209 lines
7.4 KiB
Python
209 lines
7.4 KiB
Python
"""Menu bar — camera, video format, FPS and debug controls."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
|
||
from PySide6.QtCore import Signal
|
||
from PySide6.QtGui import QAction, QActionGroup
|
||
from PySide6.QtWidgets import QMenuBar, QWidget
|
||
|
||
from app.camera.camera_enumerator import CameraInfo
|
||
from app.logging_setup import set_console_level
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class AppMenuBar(QMenuBar):
|
||
"""
|
||
Application menu bar.
|
||
|
||
Signals:
|
||
camera_selected(CameraInfo) — user picked a camera
|
||
resolution_selected(int, int) — user picked (width, height)
|
||
fps_selected(float) — user picked a target FPS
|
||
reconnect_requested() — user hit Reconnect
|
||
overlay_toggled(bool) — overlay show/hide
|
||
log_toggled(bool) — console logging on/off
|
||
"""
|
||
|
||
camera_selected = Signal(object) # CameraInfo
|
||
resolution_selected = Signal(int, int)
|
||
fps_selected = Signal(float)
|
||
reconnect_requested = Signal()
|
||
overlay_toggled = Signal(bool)
|
||
log_toggled = Signal(bool)
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
|
||
self._camera_group: QActionGroup | None = None
|
||
self._resolution_group: QActionGroup | None = None
|
||
self._fps_group: QActionGroup | None = None
|
||
self._cameras: list[CameraInfo] = []
|
||
|
||
self._build_menus()
|
||
|
||
# ------------------------------------------------------------------
|
||
# Public API
|
||
# ------------------------------------------------------------------
|
||
|
||
def populate_cameras(self, cameras: list[CameraInfo]) -> None:
|
||
"""Populate the Camera menu with discovered devices."""
|
||
self._cameras = cameras
|
||
menu = self._camera_menu
|
||
|
||
for action in list(menu.actions()):
|
||
if action not in (self._reconnect_action, self._cam_separator):
|
||
menu.removeAction(action)
|
||
|
||
self._camera_group = QActionGroup(self)
|
||
self._camera_group.setExclusive(True)
|
||
|
||
for cam in cameras:
|
||
action = QAction(cam.name, self)
|
||
action.setCheckable(True)
|
||
action.setData(cam)
|
||
self._camera_group.addAction(action)
|
||
menu.insertAction(self._cam_separator, action)
|
||
action.triggered.connect(self._on_camera_action)
|
||
|
||
if cameras:
|
||
self._camera_group.actions()[0].setChecked(True)
|
||
|
||
def populate_formats(self, camera_info: CameraInfo) -> None:
|
||
"""Populate Resolution and FPS menus based on a camera's supported formats."""
|
||
self._populate_resolutions(camera_info)
|
||
self._populate_fps(camera_info)
|
||
|
||
def set_active_camera(self, camera_info: CameraInfo) -> None:
|
||
if self._camera_group is None:
|
||
return
|
||
for action in self._camera_group.actions():
|
||
if action.data() is camera_info:
|
||
action.setChecked(True)
|
||
return
|
||
|
||
def set_log_file_path(self, path: str) -> None:
|
||
"""Display the log file path as a disabled menu item in Debug menu."""
|
||
# Truncate long paths for display
|
||
display = path if len(path) <= 60 else "…" + path[-57:]
|
||
self._log_file_action.setText(f"Log: {display}")
|
||
self._log_file_action.setToolTip(path)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Menu construction
|
||
# ------------------------------------------------------------------
|
||
|
||
def _build_menus(self) -> None:
|
||
# Camera menu
|
||
self._camera_menu = self.addMenu("Camera")
|
||
self._cam_separator = self._camera_menu.addSeparator()
|
||
self._reconnect_action = QAction("Reconnect", self)
|
||
self._reconnect_action.triggered.connect(self.reconnect_requested)
|
||
self._camera_menu.addAction(self._reconnect_action)
|
||
|
||
# Video menu
|
||
self._video_menu = self.addMenu("Video")
|
||
self._res_menu = self._video_menu.addMenu("Resolution")
|
||
self._fps_menu = self._video_menu.addMenu("FPS")
|
||
|
||
# Debug menu
|
||
debug_menu = self.addMenu("Debug")
|
||
|
||
self._overlay_action = QAction("Show Overlay", self)
|
||
self._overlay_action.setCheckable(True)
|
||
self._overlay_action.setChecked(True)
|
||
self._overlay_action.toggled.connect(self.overlay_toggled)
|
||
debug_menu.addAction(self._overlay_action)
|
||
|
||
self._log_action = QAction("Console Logging", self)
|
||
self._log_action.setCheckable(True)
|
||
self._log_action.setChecked(False)
|
||
self._log_action.toggled.connect(self._on_log_toggled)
|
||
debug_menu.addAction(self._log_action)
|
||
|
||
debug_menu.addSeparator()
|
||
|
||
self._log_file_action = QAction("Log: (not started)", self)
|
||
self._log_file_action.setEnabled(False)
|
||
debug_menu.addAction(self._log_file_action)
|
||
|
||
def _populate_resolutions(self, camera_info: CameraInfo) -> None:
|
||
self._res_menu.clear()
|
||
self._resolution_group = QActionGroup(self)
|
||
self._resolution_group.setExclusive(True)
|
||
|
||
seen: set[tuple[int, int]] = set()
|
||
for fmt in camera_info.formats:
|
||
key = (fmt.width, fmt.height)
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
action = QAction(f"{fmt.width} × {fmt.height}", self)
|
||
action.setCheckable(True)
|
||
action.setData((fmt.width, fmt.height))
|
||
self._resolution_group.addAction(action)
|
||
self._res_menu.addAction(action)
|
||
action.triggered.connect(self._on_resolution_action)
|
||
|
||
actions = self._resolution_group.actions()
|
||
if actions:
|
||
actions[0].setChecked(True)
|
||
|
||
def _populate_fps(self, camera_info: CameraInfo) -> None:
|
||
self._fps_menu.clear()
|
||
self._fps_group = QActionGroup(self)
|
||
self._fps_group.setExclusive(True)
|
||
|
||
seen: set[int] = set()
|
||
for fmt in camera_info.formats:
|
||
key = round(fmt.max_fps)
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
action = QAction(f"{key} fps", self)
|
||
action.setCheckable(True)
|
||
action.setData(float(fmt.max_fps))
|
||
self._fps_group.addAction(action)
|
||
self._fps_menu.addAction(action)
|
||
action.triggered.connect(self._on_fps_action)
|
||
|
||
actions = self._fps_group.actions()
|
||
if actions:
|
||
actions[0].setChecked(True)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Slots
|
||
# ------------------------------------------------------------------
|
||
|
||
def _on_camera_action(self) -> None:
|
||
action = self.sender()
|
||
if action is None:
|
||
return
|
||
cam: CameraInfo = action.data()
|
||
logger.debug("Camera selected: %s", cam.name)
|
||
self.camera_selected.emit(cam)
|
||
self._populate_resolutions(cam)
|
||
self._populate_fps(cam)
|
||
|
||
def _on_resolution_action(self) -> None:
|
||
action = self.sender()
|
||
if action is None:
|
||
return
|
||
w, h = action.data()
|
||
logger.debug("Resolution selected: %dx%d", w, h)
|
||
self.resolution_selected.emit(w, h)
|
||
|
||
def _on_fps_action(self) -> None:
|
||
action = self.sender()
|
||
if action is None:
|
||
return
|
||
fps: float = action.data()
|
||
logger.debug("FPS selected: %.1f", fps)
|
||
self.fps_selected.emit(fps)
|
||
|
||
def _on_log_toggled(self, enabled: bool) -> None:
|
||
set_console_level(enabled)
|
||
self.log_toggled.emit(enabled)
|