"""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)