From e1b951db7714a9496ebf7f861a6741598d4c7e67 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Sat, 1 Aug 2026 22:55:49 -0600 Subject: Major control panel improvements. - Added dedicated firmware file for arduino - Added View button to consolidate windows - Control Panel changes: - Changed name fron "Controls" to "Control Panel" - Blue side bar when Panel is hidden. - Supports "popping" out contorl panel - Settings > Advanced tab added with developer settings --- ui/control_panel.py | 13 --- ui/main_window.py | 183 +++++++++++++++++++++++++++++++++++------- ui/style_dark.qss | 31 ++++++- ui/style_light.qss | 8 ++ ui/windows/settings_window.py | 41 +++++++--- 5 files changed, 221 insertions(+), 55 deletions(-) (limited to 'ui') diff --git a/ui/control_panel.py b/ui/control_panel.py index 6e78e89..61e1807 100644 --- a/ui/control_panel.py +++ b/ui/control_panel.py @@ -532,19 +532,6 @@ class ControlPanel(QWidget): layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) - # Header with Add button - hdr_widget = QWidget(); hdr_widget.setObjectName("controlPanelHeader") - hdr_widget.setFixedHeight(30) - hdr_lay = QHBoxLayout(hdr_widget) - hdr_lay.setContentsMargins(8, 0, 6, 0) - hdr_lbl = QLabel("CONTROLS"); hdr_lbl.setObjectName("panelHeader") - hdr_lay.addWidget(hdr_lbl, 1) - add_btn = QPushButton("+"); add_btn.setObjectName("devicesSmallBtn") - add_btn.setFixedSize(24, 22); add_btn.setToolTip("Add control widget") - add_btn.clicked.connect(self._on_add) - hdr_lay.addWidget(add_btn) - layout.addWidget(hdr_widget) - scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) diff --git a/ui/main_window.py b/ui/main_window.py index d0e0ac4..76c203d 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -8,11 +8,13 @@ Toolbar (left→right): from PyQt6.QtWidgets import ( QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, - QSplitter, QStatusBar, QLabel, QPushButton, + QDockWidget, QStatusBar, QLabel, QPushButton, QToolBar, QSizePolicy, QApplication, QFrame, - QAbstractSpinBox, QComboBox, + QAbstractSpinBox, QComboBox, QMenu, ) -from PyQt6.QtCore import Qt, QTimer, QObject, QEvent, pyqtSlot +from PyQt6.QtCore import Qt, QTimer, QObject, QEvent, QRect, pyqtSignal, pyqtSlot +from PyQt6.QtGui import QPainter, QColor, QFont, QAction +import logging import os @@ -27,6 +29,71 @@ class _WheelBlocker(QObject): return True return super().eventFilter(obj, event) +class _CtrlTabStrip(QWidget): + """Thin vertical strip shown on the left edge when the Control Panel dock is hidden. + Clicking it restores the dock. Safety feature — operator can never lose the panel.""" + + clicked = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("ctrlTabStrip") + self.setFixedWidth(22) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setToolTip("Show Control Panel") + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit() + super().mousePressEvent(event) + + def paintEvent(self, _event): + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + p.fillRect(self.rect(), QColor("#3b82f6")) + p.setPen(QColor("#ffffff")) + font = QFont() + font.setPointSize(8) + font.setBold(True) + p.setFont(font) + p.translate(self.width() / 2.0, self.height() / 2.0) + p.rotate(-90.0) + text_rect = QRect(-self.height() // 2, -self.width() // 2, + self.height(), self.width()) + p.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, "CONTROL PANEL ▶") + p.end() + + +class _DockTitleBar(QWidget): + """Custom title bar for the Control Panel QDockWidget. + Replaces native title bar; provides Add, Float, and Close buttons.""" + + def __init__(self, dock: QDockWidget, add_callback, parent=None): + super().__init__(parent) + self.setObjectName("controlPanelHeader") + self.setFixedHeight(30) + lay = QHBoxLayout(self) + lay.setContentsMargins(8, 0, 4, 0); lay.setSpacing(4) + + lbl = QLabel("CONTROL PANEL"); lbl.setObjectName("panelHeader") + lay.addWidget(lbl, 1) + + add_btn = QPushButton("+"); add_btn.setObjectName("devicesSmallBtn") + add_btn.setFixedSize(24, 22); add_btn.setToolTip("Add control widget") + add_btn.clicked.connect(add_callback) + lay.addWidget(add_btn) + + float_btn = QPushButton("⧉"); float_btn.setObjectName("devicesSmallBtn") + float_btn.setFixedSize(22, 22); float_btn.setToolTip("Float / dock") + float_btn.clicked.connect(lambda: dock.setFloating(not dock.isFloating())) + lay.addWidget(float_btn) + + close_btn = QPushButton("✕"); close_btn.setObjectName("devicesSmallBtn") + close_btn.setFixedSize(22, 22); close_btn.setToolTip("Hide Control Panel") + close_btn.clicked.connect(dock.hide) + lay.addWidget(close_btn) + + from devices.arduino_device import ArduinoDevice from devices.nidaqmx_device import NidaqmxDevice from devices.serial_device import SerialDevice @@ -116,6 +183,38 @@ class MainWindow(QMainWindow): apply_profile=self._profile_apply, ) tb.addWidget(self._file_btn) + + # ── ⊞ View menu ─────────────────────────────────────────────────── + view_btn = QPushButton("⊞ View ▾"); view_btn.setObjectName("toolbarSectionBtn") + self._view_menu = QMenu(self) + + self._act_ctrl_panel = QAction("Control Panel", self) + self._act_ctrl_panel.setCheckable(True); self._act_ctrl_panel.setChecked(True) + self._act_ctrl_panel.triggered.connect(self._on_view_ctrl_panel) + self._view_menu.addAction(self._act_ctrl_panel) + self._view_menu.addSeparator() + + for label, name, opener in [ + ("Devices", "devices", self._open_devices), + ("Channels", "channels", self._open_channels), + ("Plot Builder", "plot", self._open_plot), + ]: + act = QAction(label, self); act.setCheckable(True) + act.triggered.connect( + lambda checked, nm=name, op=opener: + op() if checked else + (getattr(self, f"_win_{nm}") and getattr(self, f"_win_{nm}").hide()) + ) + self._view_menu.addAction(act) + setattr(self, f"_act_{name}", act) + + self._view_plugin_sep = self._view_menu.addSeparator() + self._view_plugin_sep.setVisible(False) + + view_btn.clicked.connect( + lambda: self._view_menu.exec(view_btn.mapToGlobal(view_btn.rect().bottomLeft())) + ) + tb.addWidget(view_btn) tb.addWidget(_sep()) self._run_btn = QPushButton("▶ RUN") @@ -135,23 +234,6 @@ class MainWindow(QMainWindow): tb.addWidget(_sep()) - dev_btn = QPushButton("⊞ Devices") - dev_btn.setObjectName("toolbarSectionBtn"); dev_btn.setCheckable(True) - dev_btn.clicked.connect(lambda c: self._toggle_win("devices", c, dev_btn)) - tb.addWidget(dev_btn); self._btn_devices = dev_btn - - sig_btn = QPushButton("⚗ Channels") - sig_btn.setObjectName("toolbarSectionBtn"); sig_btn.setCheckable(True) - sig_btn.clicked.connect(lambda c: self._toggle_win("channels", c, sig_btn)) - tb.addWidget(sig_btn); self._btn_channels = sig_btn - - plot_btn = QPushButton("📐 Plot") - plot_btn.setObjectName("toolbarSectionBtn"); plot_btn.setCheckable(True) - plot_btn.clicked.connect(lambda c: self._toggle_win("plot", c, plot_btn)) - tb.addWidget(plot_btn); self._btn_plot = plot_btn - - tb.addWidget(_sep()) - # Plugin buttons are inserted here at runtime (between this sep and spacer) self._plugin_sep_action = tb.addWidget(_sep()) self._plugin_sep_action.setVisible(False) @@ -176,18 +258,35 @@ class MainWindow(QMainWindow): central = QWidget(); self.setCentralWidget(central) root = QHBoxLayout(central); root.setContentsMargins(0,0,0,0); root.setSpacing(0) - hsplit = QSplitter(Qt.Orientation.Horizontal); hsplit.setHandleWidth(3) + # Side tab strip — shown when Control Panel dock is hidden + self._ctrl_tab = _CtrlTabStrip() + self._ctrl_tab.setVisible(False) + self._ctrl_tab.clicked.connect(self._show_ctrl_panel) + root.addWidget(self._ctrl_tab) + self._chart = StripChartWidget(self.engine, self.registry, self.processor) + root.addWidget(self._chart, 1) + + # ── Control Panel dock ──────────────────────────────────────────── self._ctrl = ControlPanel(self.registry, processor=self.processor) self._ctrl.setMinimumWidth(200); self._ctrl.setMaximumWidth(340) self._ctrl._add_demo_widgets() - hsplit.addWidget(self._ctrl) - - self._chart = StripChartWidget(self.engine, self.registry, self.processor) - hsplit.addWidget(self._chart) - hsplit.setSizes([260, 1000]) - root.addWidget(hsplit) + self._ctrl_dock = QDockWidget("Control Panel", self) + self._ctrl_dock.setObjectName("controlPanelDock") + self._ctrl_dock.setWidget(self._ctrl) + self._ctrl_dock.setFeatures( + QDockWidget.DockWidgetFeature.DockWidgetMovable | + QDockWidget.DockWidgetFeature.DockWidgetFloatable | + QDockWidget.DockWidgetFeature.DockWidgetClosable + ) + self._ctrl_dock.setAllowedAreas( + Qt.DockWidgetArea.LeftDockWidgetArea | + Qt.DockWidgetArea.RightDockWidgetArea + ) + self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self._ctrl_dock) + self._ctrl_dock.setTitleBarWidget(_DockTitleBar(self._ctrl_dock, self._ctrl._on_add)) + self._ctrl_dock.visibilityChanged.connect(self._on_ctrl_dock_visibility) sb = QStatusBar(); self.setStatusBar(sb) self._status = QLabel("Ready"); sb.addWidget(self._status) @@ -196,6 +295,21 @@ class MainWindow(QMainWindow): self._clock = QTimer(self); self._clock.setInterval(1000) self._clock.timeout.connect(self._tick) + def _on_ctrl_dock_visibility(self, visible: bool): + self._ctrl_tab.setVisible(not visible) + if hasattr(self, "_act_ctrl_panel"): + self._act_ctrl_panel.setChecked(visible) + + def _on_view_ctrl_panel(self, checked: bool): + if checked: + self._ctrl_dock.show(); self._ctrl_dock.raise_() + else: + self._ctrl_dock.hide() + + def _show_ctrl_panel(self): + self._ctrl_dock.show() + self._ctrl_dock.raise_() + def _connect_signals(self): self.engine.new_data.connect(self.processor.on_raw_data) self.processor.processed_data.connect(self._chart.on_new_data) @@ -336,14 +450,14 @@ class MainWindow(QMainWindow): self._win_devices.channel_visibility_changed.connect(self._on_channel_visibility_changed) self._win_devices.channel_name_changed.connect(self._on_channel_name_changed) self._win_devices.channel_unit_changed.connect(self._on_channel_unit_changed) - self._win_devices.closed.connect(lambda: self._btn_devices.setChecked(False)) + self._win_devices.closed.connect(lambda: self._act_devices.setChecked(False)) self._show_win(self._win_devices, "right") def _open_channels(self): if self._win_channels is None: self._win_channels = ChannelsWindow(self.registry, self.processor, self) self._win_channels.derived_changed.connect(self._on_derived_changed) - self._win_channels.closed.connect(lambda: self._btn_channels.setChecked(False)) + self._win_channels.closed.connect(lambda: self._act_channels.setChecked(False)) self._show_win(self._win_channels, "right") def _open_plot(self): @@ -351,7 +465,7 @@ class MainWindow(QMainWindow): self._win_plot = PlotWindow(self.registry, self.processor, self._chart._cfg, self) self._win_plot.layout_applied.connect(self._chart.apply_layout) - self._win_plot.closed.connect(lambda: self._btn_plot.setChecked(False)) + self._win_plot.closed.connect(lambda: self._act_plot.setChecked(False)) self._win_plot.refresh_channels() # sync any derived channels else: self._win_plot.cfg = self._chart._cfg @@ -423,7 +537,8 @@ class MainWindow(QMainWindow): if self._win_plot: self._win_plot.refresh_channels() - self._chart.refresh() + self._chart.apply_layout(build_default_layout(self.registry, self.processor)) + self._clear_history() self._status.setText("New profile — blank slate.") def _profile_capture(self, name: str = "Profile") -> Profile: @@ -554,10 +669,16 @@ class MainWindow(QMainWindow): def _apply_settings_on_startup(self): self._apply_theme(self._settings.get("theme", "dark")) self.engine._interval = self._settings.get("poll_ms", 100) / 1000.0 + self._apply_developer_mode(self._settings.get("developer_mode", False)) + + def _apply_developer_mode(self, enabled: bool): + level = logging.DEBUG if enabled else logging.WARNING + logging.getLogger().setLevel(level) def _on_settings(self, cfg: dict): self._settings.update(cfg) self.engine._interval = cfg.get("poll_ms", 100) / 1000.0 + self._apply_developer_mode(cfg.get("developer_mode", False)) save_settings(self._settings) def _tick(self): diff --git a/ui/style_dark.qss b/ui/style_dark.qss index e562365..029d085 100644 --- a/ui/style_dark.qss +++ b/ui/style_dark.qss @@ -322,8 +322,8 @@ QComboBox#traceStyleCb, QDoubleSpinBox#traceWidthSpin { font-family: "IBM Plex Mono", monospace; font-size: 11px; background: #0b0e13; border: 1px solid #1c2540; border-radius: 3px; color: #8b9dc3; padding: 2px 4px; } -QToolButton#traceRemoveBtn { background: transparent; color: #334155; border: none; font-size: 13px; border-radius: 3px; } -QToolButton#traceRemoveBtn:hover { background-color: #450a0a; color: #ef4444; } +QPushButton#traceRemoveBtn { background: transparent; color: #8b9dc3; border: none; font-size: 13px; border-radius: 3px; padding: 0; } +QPushButton#traceRemoveBtn:hover { background-color: #450a0a; color: #ef4444; } QPushButton#addTraceBtn { background-color: #1c2540; color: #8b9dc3; @@ -603,3 +603,30 @@ QLabel#layoutTileLabel { color: #8b9dc3; background: transparent; } + +/* ── Control Panel Dock ──────────────────────────────────────────── */ +QDockWidget#controlPanelDock { + border: none; +} +QDockWidget#controlPanelDock::title { + background-color: #0f1521; + color: #8b9dc3; + padding: 4px 8px; + text-align: left; + font-size: 11px; + font-weight: bold; + letter-spacing: 1px; + border-bottom: 1px solid #2a3558; +} +QDockWidget#controlPanelDock::close-button, +QDockWidget#controlPanelDock::float-button { + background: transparent; + border: none; + padding: 2px; + subcontrol-position: top right; +} +QDockWidget#controlPanelDock::close-button:hover, +QDockWidget#controlPanelDock::float-button:hover { + background: #2a3558; + border-radius: 2px; +} diff --git a/ui/style_light.qss b/ui/style_light.qss index e0a1987..72619c6 100644 --- a/ui/style_light.qss +++ b/ui/style_light.qss @@ -116,6 +116,8 @@ QPushButton#digitalOutBtn:checked { background:#dcfce7; border-color:#22c55e; co QWidget#controlPanelHeader { background:#f1f5f9; border-bottom:1px solid #e2e8f0; } QPushButton#devicesSmallBtn { background:transparent; color:#94a3b8; border:1px solid #e2e8f0; border-radius:3px; font-size:13px; padding:0; } QPushButton#devicesSmallBtn:hover { background:#f0f9ff; color:#3b82f6; border-color:#3b82f6; } +QPushButton#traceRemoveBtn { background:transparent; color:#94a3b8; border:none; font-size:13px; border-radius:3px; padding:0; } +QPushButton#traceRemoveBtn:hover { background:#fee2e2; color:#ef4444; } QPushButton#plotCfgButton { background:#f0f9ff; color:#1d4ed8; border:1px solid #93c5fd; border-radius:4px; padding:5px 14px; font-weight:600; } QPushButton#plotCfgButton:hover { background:#dbeafe; border-color:#3b82f6; } QPushButton#plotCfgButton:checked { background:#dbeafe; border-color:#3b82f6; } @@ -132,3 +134,9 @@ QFrame#layoutTile { background-color:#e2e8f0; border:1px solid #94a3b8; border-r QFrame#layoutTile:hover { border-color:#4338ca; background-color:#c7d2fe; } QFrame#layoutTileDragging { background-color:#bfdbfe; border:2px solid #3b82f6; border-radius:5px; } QLabel#layoutTileLabel { font-family:"IBM Plex Mono",monospace; font-size:11px; font-weight:700; color:#334155; background:transparent; } + +/* Control Panel Dock */ +QDockWidget#controlPanelDock { border: none; } +QDockWidget#controlPanelDock::title { background-color:#f1f5f9; color:#475569; padding:4px 8px; text-align:left; font-size:11px; font-weight:bold; letter-spacing:1px; border-bottom:1px solid #e2e8f0; } +QDockWidget#controlPanelDock::close-button, QDockWidget#controlPanelDock::float-button { background:transparent; border:none; padding:2px; } +QDockWidget#controlPanelDock::close-button:hover, QDockWidget#controlPanelDock::float-button:hover { background:#e2e8f0; border-radius:2px; } diff --git a/ui/windows/settings_window.py b/ui/windows/settings_window.py index 1630efa..1afbca5 100644 --- a/ui/windows/settings_window.py +++ b/ui/windows/settings_window.py @@ -40,6 +40,7 @@ class SettingsWindow(QWidget): "show_grid": True, "font_size": 12, "antialias": True, + "developer_mode": False, } def __init__(self, registry: DeviceRegistry, @@ -77,6 +78,7 @@ class SettingsWindow(QWidget): tabs.addTab(self._acquisition_tab(), " Acquisition ") tabs.addTab(self._display_tab(), " Display ") tabs.addTab(self._plugins_tab(), " Plugins ") + tabs.addTab(self._advanced_tab(), " Advanced ") # Bottom bar btm = QWidget(); btm.setObjectName("cfgBottomBar") @@ -126,6 +128,7 @@ class SettingsWindow(QWidget): self._tw_sp.setValue(self.cfg["time_window_s"]) self._legend_chk.setChecked(self.cfg["show_legend"]) self._grid_chk.setChecked(self.cfg["show_grid"]) + self._dev_mode_chk.setChecked(self.cfg.get("developer_mode", False)) def _acquisition_tab(self): w = QWidget() @@ -256,6 +259,25 @@ class SettingsWindow(QWidget): return card + def _advanced_tab(self): + w = QWidget() + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + cont = QWidget(); lay = QFormLayout(cont) + lay.setContentsMargins(16, 14, 16, 14); lay.setSpacing(10) + + self._dev_mode_chk = QCheckBox() + self._dev_mode_chk.setChecked(self.cfg.get("developer_mode", False)) + lay.addRow("Developer mode:", self._dev_mode_chk) + + note = QLabel("Enables verbose DEBUG output in the terminal.\nNo effect when running as a packaged app.") + note.setObjectName("traceSource"); note.setWordWrap(True) + lay.addRow("", note) + + scroll.setWidget(cont) + root = QVBoxLayout(w); root.setContentsMargins(0, 0, 0, 0); root.addWidget(scroll) + return w + def _toggle_plugin(self, plugin_id: str, btn: QPushButton): if self._plugin_mgr.is_enabled(plugin_id): self.plugin_disable_requested.emit(plugin_id) @@ -285,15 +307,16 @@ class SettingsWindow(QWidget): def _apply(self): theme = self._theme_cb.currentText().lower() self.cfg.update({ - "theme": theme, - "font_size": self._font_sp.value(), - "antialias": self._aa_chk.isChecked(), - "poll_ms": self._poll_sp.value(), - "buffer_size": self._buf_sp.value(), - "log_dir": self._log_edit.text(), - "time_window_s": self._tw_sp.value(), - "show_legend": self._legend_chk.isChecked(), - "show_grid": self._grid_chk.isChecked(), + "theme": theme, + "font_size": self._font_sp.value(), + "antialias": self._aa_chk.isChecked(), + "poll_ms": self._poll_sp.value(), + "buffer_size": self._buf_sp.value(), + "log_dir": self._log_edit.text(), + "time_window_s": self._tw_sp.value(), + "show_legend": self._legend_chk.isChecked(), + "show_grid": self._grid_chk.isChecked(), + "developer_mode": self._dev_mode_chk.isChecked(), }) self.theme_changed.emit(theme) self.settings_changed.emit(dict(self.cfg)) -- cgit v1.2.3