"""Main application window.""" from __future__ import annotations import logging from PySide6.QtCore import QTimer from PySide6.QtWidgets import QLabel, QMainWindow, QSizePolicy, QStatusBar from app.camera.camera_enumerator import CameraEnumerator, CameraInfo from app.camera.camera_service import CameraService from app.config import APP_NAME, APP_VERSION from app.pipeline.frame_dispatcher import FrameDispatcher from app.telemetry.telemetry_collector import TelemetryCollector from app.ui.menu_bar import AppMenuBar from app.ui.video_widget import VideoWidget logger = logging.getLogger(__name__) class MainWindow(QMainWindow): """ Top-level application window. Rendering architecture: QVideoWidget is intentionally NOT used. On Windows its native HWND surface occludes all sibling/child QWidgets regardless of z-order, making overlay rendering impossible. Instead, frames are received as QVideoFrame via CameraService.frame_ready, converted to QImage inside VideoWidget.on_frame(), and drawn together with the metrics overlay in a single paintEvent pass. Signal flow: CameraService.frame_ready → FrameDispatcher.dispatch → VideoWidget.on_frame (render) → TelemetryCollector.on_frame (metrics) → VideoWidget.update_metrics (overlay data) """ def __init__(self) -> None: super().__init__() self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}") self.setMinimumSize(640, 480) self.resize(1280, 720) # --- Core components --- self._camera_service = CameraService(self) self._dispatcher = FrameDispatcher(self) self._telemetry = TelemetryCollector(parent=self) # --- Video widget (central widget) --- # Plain QWidget — no native surface, overlay rendered in same paintEvent. self._video_widget = VideoWidget(self) self._video_widget.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding ) self.setCentralWidget(self._video_widget) # --- Menu bar --- self._menu = AppMenuBar(self) self.setMenuBar(self._menu) # --- Status bar --- self._status_bar = QStatusBar(self) self.setStatusBar(self._status_bar) self._status_label = QLabel("Initialising…") self._status_bar.addWidget(self._status_label) # --- Wire signals --- self._wire_signals() # --- Enumerate cameras and start --- QTimer.singleShot(0, self._initialise_cameras) # ------------------------------------------------------------------ # Initialisation # ------------------------------------------------------------------ def _initialise_cameras(self) -> None: cameras = CameraEnumerator.list_cameras() if not cameras: self._status_label.setText("No cameras found") logger.warning("No cameras detected") return self._menu.populate_cameras(cameras) default = CameraEnumerator.default_camera() start_cam = default if default is not None else cameras[0] self._menu.populate_formats(start_cam) self._start_camera(start_cam) def _start_camera(self, cam: CameraInfo) -> None: self._telemetry.reset_counters() self._camera_service.start(cam) self._menu.set_active_camera(cam) self._status_label.setText(f"Opening: {cam.name}") # ------------------------------------------------------------------ # Signal wiring # ------------------------------------------------------------------ def _wire_signals(self) -> None: # CameraService → FrameDispatcher self._camera_service.frame_ready.connect(self._dispatcher.dispatch) # FrameDispatcher → VideoWidget (render) — drop if busy: skip frame, keep UI fluid self._dispatcher.subscribe(self._video_widget.on_frame, drop_if_busy=True) # FrameDispatcher → TelemetryCollector — never drop, count every frame self._dispatcher.subscribe(self._telemetry.on_frame, drop_if_busy=False) # TelemetryCollector → VideoWidget overlay self._telemetry.metrics_updated.connect(self._video_widget.update_metrics) # CameraService status self._camera_service.camera_started.connect(self._on_camera_started) self._camera_service.camera_stopped.connect(self._on_camera_stopped) self._camera_service.camera_error.connect(self._on_camera_error) # Menu signals self._menu.camera_selected.connect(self._on_camera_selected) self._menu.resolution_selected.connect(self._on_resolution_selected) self._menu.fps_selected.connect(self._on_fps_selected) self._menu.reconnect_requested.connect(self._camera_service.reconnect) self._menu.overlay_toggled.connect(self._video_widget.set_overlay_visible) # ------------------------------------------------------------------ # Camera status slots # ------------------------------------------------------------------ def _on_camera_started(self) -> None: cam = self._camera_service.current_camera name = cam.name if cam else "Unknown" self._status_label.setText(f"Streaming: {name}") logger.info("Camera streaming: %s", name) def _on_camera_stopped(self) -> None: self._status_label.setText("Camera stopped") def _on_camera_error(self, message: str) -> None: self._status_label.setText(f"Error: {message}") logger.error("Camera error: %s", message) # ------------------------------------------------------------------ # Menu action slots # ------------------------------------------------------------------ def _on_camera_selected(self, cam: CameraInfo) -> None: self._start_camera(cam) def _on_resolution_selected(self, width: int, height: int) -> None: self._camera_service.set_resolution(width, height) def _on_fps_selected(self, fps: float) -> None: self._camera_service.set_fps(fps) # ------------------------------------------------------------------ # Qt overrides # ------------------------------------------------------------------ def closeEvent(self, event) -> None: # noqa: N802 self._camera_service.stop() super().closeEvent(event)