"""UI construction mixin for the main radar control window. Layout is intentionally split into two independent plot surfaces: - single `PlotWidget` for B-scan heatmap rendering; - single `PlotWidget` for GPR accumulator/annotation rendering; - stacked magnitude/phase `PlotWidget`s for pass-through traces. `_set_plot_mode()` switches between these surfaces via `QStackedWidget`. """ from __future__ import annotations from PyQt6.QtWidgets import ( QComboBox, QFrame, QGroupBox, QHBoxLayout, QLabel, QPushButton, QScrollArea, QStackedWidget, QTextEdit, QVBoxLayout, QWidget, ) import pyqtgraph as pg from python_app.gui.controllers.sections import ( build_data_actions_group, build_primary_actions_group, build_preprocess_summary_group, build_processing_group, build_radar_group, build_switch_group, ) class AppWindowUiMixin: """Build and wire all static widgets used by :class:`AppWindow`. This mixin only creates UI objects and connects lightweight UI signals. Runtime logic (pipeline start/stop, polling, drawing, snapshot saving) is implemented in other mixins. """ def _build_ui(self) -> None: """Build complete main-window layout. High-level structure: 1. Left: plot stack (`B-scan` page + `trace magnitude/phase` page). 2. Middle: narrow toggle button to collapse/expand settings. 3. Right: scrollable settings panel + status/history + runtime log. """ self.setWindowTitle("Radar System Control") root = QWidget(self) self.setCentralWidget(root) layout = QHBoxLayout(root) layout.setContentsMargins(12, 12, 12, 12) layout.setSpacing(14) self._build_plot_area(layout, root) self._build_settings_toggle(layout) self._build_settings_panel(layout, root) self._toggle_settings_panel(visible=True) self.resize(1650, 940) def _build_plot_area(self, root_layout: QHBoxLayout, root: QWidget) -> None: """Build left plot area with stacked B-scan and trace pages.""" # Plot stack holds mutually exclusive visualization surfaces. # We create both upfront and only switch active page at runtime. self._plot_stack = QStackedWidget(root) self._build_bscan_plot_page() self._build_gpr_plot_page() self._build_trace_plot_page() # Default view on startup is pass-through traces. self._plot_stack.setCurrentWidget(self._trace_plots_container) root_layout.addWidget(self._plot_stack, stretch=11) @staticmethod def _create_plot_widget(*, background: str) -> pg.PlotWidget: """Create PlotWidget with pyqtgraph context menu disabled for PyQt6 compatibility.""" return pg.PlotWidget(background=background, enableMenu=False) def _build_bscan_plot_page(self) -> None: """Create B-scan page in plot stack.""" # B-scan surface: one PlotWidget used as canvas for ImageItem heatmap. self._bscan_plot = self._create_plot_widget(background="#0f141c") self._bscan_plot.showGrid(x=True, y=True, alpha=0.2) self._configure_bscan_plot_axes() self._plot_stack.addWidget(self._bscan_plot) def _build_trace_plot_page(self) -> None: """Create pass-through page with magnitude and phase plots.""" # Pass-through surface: container with two synchronized line plots. # Upper plot shows magnitude, lower plot shows phase. self._trace_plots_container = QWidget() trace_layout = QVBoxLayout(self._trace_plots_container) trace_layout.setContentsMargins(0, 0, 0, 0) trace_layout.setSpacing(6) self._trace_magnitude_plot = self._create_plot_widget(background="#0f141c") self._trace_magnitude_plot.showGrid(x=True, y=True, alpha=0.2) self._trace_magnitude_plot.setLabel("left", "Magnitude", units="dB") self._trace_magnitude_plot.getPlotItem().showAxis("bottom", show=False) self._trace_magnitude_plot.getPlotItem().setDownsampling(mode="peak") self._trace_magnitude_plot.getPlotItem().setClipToView(True) trace_layout.addWidget(self._trace_magnitude_plot, stretch=1) self._trace_phase_plot = self._create_plot_widget(background="#0f141c") self._trace_phase_plot.showGrid(x=True, y=True, alpha=0.2) self._trace_phase_plot.setLabel("left", "Phase", units="deg") self._trace_phase_plot.setLabel("bottom", "Frequency", units="Hz") self._trace_phase_plot.getPlotItem().setDownsampling(mode="peak") self._trace_phase_plot.getPlotItem().setClipToView(True) trace_layout.addWidget(self._trace_phase_plot, stretch=1) # Legends are created lazily only when trace mode draws line series. self._trace_magnitude_legend = None self._trace_phase_legend = None self._trace_magnitude_legend_combo_keys = set() self._trace_phase_legend_combo_keys = set() self._trace_magnitude_curves = {} self._trace_phase_curves = {} self._trace_phase_render_max_points = 400 self._plot_stack.addWidget(self._trace_plots_container) def _build_gpr_plot_page(self) -> None: """Create GPR page in plot stack.""" self._gpr_plot = self._create_plot_widget(background="#0f141c") self._gpr_plot.showGrid(x=True, y=True, alpha=0.2) self._configure_gpr_plot_axes() self._plot_stack.addWidget(self._gpr_plot) def _build_settings_toggle(self, root_layout: QHBoxLayout) -> None: """Create narrow button used to collapse or show settings panel.""" self._settings_toggle_button = QPushButton("<") self._settings_toggle_button.setObjectName("settingsToggleButton") self._settings_toggle_button.setFixedWidth(26) self._settings_toggle_button.clicked.connect(lambda: self._toggle_settings_panel()) root_layout.addWidget(self._settings_toggle_button, stretch=0) def _build_settings_panel(self, root_layout: QHBoxLayout, root: QWidget) -> None: """Build right settings panel with controls, history summary, and log.""" self._settings_panel = QWidget(root) self._settings_panel.setMinimumWidth(610) right_layout = QVBoxLayout(self._settings_panel) right_layout.setContentsMargins(0, 0, 0, 0) right_layout.setSpacing(10) # Build log early so `_show_error()` can append text even during # subsequent group construction if something fails. self._log_panel = self._build_log_panel() right_layout.addWidget(build_primary_actions_group(self), stretch=0) right_layout.addWidget(self._build_settings_scroll(), stretch=1) self._status_label = QLabel("Status: idle", self._settings_panel) self._status_label.setObjectName("statusLabel") self._status_label.hide() self._history_label = QLabel("History: raw=0, preprocessed=0, results=0", self._settings_panel) self._history_label.setObjectName("hintLabel") right_layout.addWidget(self._history_label) right_layout.addWidget(self._log_panel, stretch=0) root_layout.addWidget(self._settings_panel, stretch=7) def _build_log_panel(self) -> QWidget: """Build runtime log block with collapsible log body.""" self._log_panel_title = QLabel("Runtime Log", self._settings_panel) self._log_panel_title.setObjectName("hintLabel") self._log_toggle_button = QPushButton(self._settings_panel) self._log_toggle_button.setObjectName("sectionToggleButton") self._log_toggle_button.clicked.connect(lambda: self._toggle_log_panel()) self._log_box = QTextEdit(self._settings_panel) self._log_box.setObjectName("runtimeLogBox") self._log_box.setReadOnly(True) self._log_box.setUndoRedoEnabled(False) self._log_box.setMinimumHeight(170) self._log_box.document().setMaximumBlockCount(1200) panel = QWidget(self._settings_panel) panel_layout = QVBoxLayout(panel) panel_layout.setContentsMargins(0, 0, 0, 0) panel_layout.setSpacing(6) header_row = QWidget(panel) header_layout = QHBoxLayout(header_row) header_layout.setContentsMargins(0, 0, 0, 0) header_layout.setSpacing(8) header_layout.addWidget(self._log_panel_title) header_layout.addStretch(1) header_layout.addWidget(self._log_toggle_button) panel_layout.addWidget(header_row) panel_layout.addWidget(self._log_box) self._toggle_log_panel(visible=True) return panel def _build_settings_scroll(self) -> QScrollArea: """Build scroll area with all control groups in display order.""" # All control groups are placed into a scroll area so right panel # remains usable on smaller screens and with future extra controls. controls = QWidget(self._settings_panel) controls_layout = QVBoxLayout(controls) controls_layout.setContentsMargins(0, 0, 12, 0) controls_layout.setSpacing(10) for group in self._build_control_groups(): controls_layout.addWidget(group) controls_layout.addStretch(1) scroll = QScrollArea(self._settings_panel) scroll.setWidgetResizable(True) scroll.setFrameShape(QFrame.Shape.NoFrame) scroll.setWidget(controls) return scroll def _build_control_groups(self) -> list[QGroupBox]: """Create all settings groups in top-to-bottom order.""" return [ build_data_actions_group(self), build_switch_group(self), build_preprocess_summary_group(self), build_processing_group(self), build_radar_group(self), ] def _toggle_settings_panel(self, *, visible: bool | None = None) -> None: """Toggle settings panel visibility or force a specific state. When `visible` is `None`, state is toggled. When `visible` is set, panel visibility is forced to that value. """ if visible is None: visible = not self._settings_panel.isVisible() self._settings_panel.setVisible(visible) if visible: self._settings_toggle_button.setText(">") self._settings_toggle_button.setToolTip("Hide settings panel") else: self._settings_toggle_button.setText("<") self._settings_toggle_button.setToolTip("Show settings panel") def _toggle_log_panel(self, *, visible: bool | None = None) -> None: """Toggle runtime log body visibility or force a specific state.""" if visible is None: visible = not self._log_box.isVisible() self._log_box.setVisible(visible) if visible: self._log_toggle_button.setText("Collapse") self._log_toggle_button.setToolTip("Hide runtime log entries") else: self._log_toggle_button.setText("Expand") self._log_toggle_button.setToolTip("Show runtime log entries") def _set_plot_mode(self, mode: str) -> None: """Switch visible plot page according to processing mode. `bscan` -> show `self._bscan_plot` `gpr` and `legacy_gpr` -> show `self._gpr_plot` otherwise -> show `self._trace_plots_container` """ if mode == "bscan": self._plot_stack.setCurrentWidget(self._bscan_plot) return if mode in {"gpr", "legacy_gpr"}: self._plot_stack.setCurrentWidget(self._gpr_plot) return self._plot_stack.setCurrentWidget(self._trace_plots_container) @staticmethod def _set_combo_current_text(combo: QComboBox, value: str) -> None: """Select combo item by text, appending it when missing.""" index = combo.findText(value) if index >= 0: combo.setCurrentIndex(index) return combo.addItem(value) combo.setCurrentIndex(combo.count() - 1)