Files
radar_system/python_app/gui/controllers/app_window_ui_mixin.py
T
2026-06-11 13:02:02 +03:00

308 lines
13 KiB
Python

"""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,
)
from python_app.logging_setup import LOG_LEVELS
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 = {}
# Cap rendered points per trace (magnitude and phase alike): beyond ~1
# point/pixel there is no visual gain, and decimation keeps pass-through
# responsive with many combos on screen.
self._trace_render_max_points = 800
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")
right_layout.addWidget(self._status_label)
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())
# Log-level selector: live verbosity control, persisted to run_config.
self._log_level_label = QLabel("Level", self._settings_panel)
self._log_level_label.setObjectName("hintLabel")
self._log_level_combo = QComboBox(self._settings_panel)
self._log_level_combo.setObjectName("logLevelCombo")
self._log_level_combo.addItems([name.capitalize() for name in LOG_LEVELS])
self._log_level_combo.setToolTip("Logging verbosity — applied live and saved to run_config")
current_level = self._defaults_config.logging.level.capitalize()
current_index = self._log_level_combo.findText(current_level)
if current_index >= 0:
self._log_level_combo.setCurrentIndex(current_index)
# Connect AFTER seeding the value so reflecting the config does not save it back.
self._log_level_combo.currentTextChanged.connect(self._on_log_level_selected)
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_level_label)
header_layout.addWidget(self._log_level_combo)
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)