summaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-08-01 22:55:49 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-08-01 23:02:14 -0600
commite1b951db7714a9496ebf7f861a6741598d4c7e67 (patch)
tree2c10266de94b9649e5fab65aac1fd33a87de2528 /ui
parent91b0b613f0b58498315ef91f6a541dd1089b88de (diff)
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
Diffstat (limited to 'ui')
-rw-r--r--ui/control_panel.py13
-rw-r--r--ui/main_window.py183
-rw-r--r--ui/style_dark.qss31
-rw-r--r--ui/style_light.qss8
-rw-r--r--ui/windows/settings_window.py41
5 files changed, 221 insertions, 55 deletions
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))