diff options
Diffstat (limited to 'ui/main_window.py')
| -rw-r--r-- | ui/main_window.py | 359 |
1 files changed, 209 insertions, 150 deletions
diff --git a/ui/main_window.py b/ui/main_window.py index efbb486..c7e4953 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 @@ -38,9 +105,8 @@ from core.profile import Profile, ProfileManager from ui.control_panel import ControlPanel from ui.strip_chart import StripChartWidget from ui.profile_manager_ui import ProfileButton -from ui.windows.devices_window import DevicesWindow -from ui.windows.channels_window import ChannelsWindow -from ui.windows.plot_window import PlotWindow, build_default_layout +from ui.windows.config_window import ConfigWindow +from ui.windows.plot_window import build_default_layout from ui.windows.settings_window import SettingsWindow from plugins.plugin_manager import PluginManager @@ -64,11 +130,8 @@ class MainWindow(QMainWindow): self._settings = load_settings(SettingsWindow._defaults) self._elapsed = 0 - self._win_devices = None - self._win_channels = None - self._win_plot = None + self._win_config = None self._win_settings = None - self._win_debug = None import sys as _sys _user_plugins = os.path.join(os.path.expanduser("~"), ".labui", "plugins") @@ -122,6 +185,40 @@ 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, tab_idx in [ + ("Devices", 0), + ("Channels", 1), + ("Plot Builder", 2), + ]: + act = QAction(label, self); act.setCheckable(True) + act.triggered.connect( + lambda checked, ti=tab_idx: + self._open_config(ti) if checked else + (self._win_config and self._win_config.hide()) + ) + self._view_menu.addAction(act) + self._act_devices = self._view_menu.actions()[-3] + self._act_channels = self._view_menu.actions()[-2] + self._act_plot = self._view_menu.actions()[-1] + + 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") @@ -141,23 +238,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) @@ -174,32 +254,43 @@ class MainWindow(QMainWindow): set_btn = QPushButton("⚙ Settings") set_btn.setObjectName("toolbarSectionBtn"); set_btn.setCheckable(True) - set_btn.clicked.connect(lambda c: self._toggle_win("settings", c, set_btn)) + set_btn.clicked.connect(lambda c: self._open_settings()) tb.addWidget(set_btn); self._btn_settings = set_btn - debug_btn = QPushButton("🐞 Debug") - debug_btn.setObjectName("toolbarSectionBtn"); debug_btn.setCheckable(True) - debug_btn.clicked.connect(lambda c: self._toggle_win("debug", c, debug_btn)) - tb.addWidget(debug_btn); self._btn_debug = debug_btn - debug_btn.setVisible(False) # shown/hidden per developer-mode setting - # ── Central ─────────────────────────────────────────────────────── 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) @@ -215,6 +306,21 @@ class MainWindow(QMainWindow): self._rec_blink = QTimer(self); self._rec_blink.setInterval(600) self._rec_blink.timeout.connect(self._tick_rec_blink) + 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) @@ -273,8 +379,8 @@ class MainWindow(QMainWindow): self._plugin_toolbar_actions[plugin.plugin_id] = tb_actions self._chart.refresh() - if self._win_plot: - self._win_plot.refresh_channels() + if self._win_config: + self._win_config.refresh_plot() self._status.setText(f"Plugin enabled: {plugin.name}") def _uninstall_plugin(self, plugin_id: str): @@ -309,8 +415,8 @@ class MainWindow(QMainWindow): self.engine.remove_device(dev_id) self._chart.refresh() - if self._win_plot: - self._win_plot.refresh_channels() + if self._win_config: + self._win_config.refresh_plot() self._status.setText(f"Plugin disabled: {plugin.name}") def plugin_enable(self, plugin_id: str): @@ -404,57 +510,29 @@ class MainWindow(QMainWindow): # ── Window management ───────────────────────────────────────────────── - def _toggle_win(self, name: str, checked: bool, btn: QPushButton): - creators = { - "devices": self._open_devices, - "channels": self._open_channels, - "plot": self._open_plot, - "settings": self._open_settings, - "debug": self._open_debug, - } - wins = { - "devices": "_win_devices", - "channels": "_win_channels", - "plot": "_win_plot", - "settings": "_win_settings", - "debug": "_win_debug", - } - if checked: - creators[name]() - else: - win = getattr(self, wins[name], None) - if win: win.hide() - - def _open_devices(self): - if self._win_devices is None: - self._win_devices = DevicesWindow(self.registry, self.engine, self) - self._win_devices.device_added.connect(self._on_device_added) - self._win_devices.device_removed.connect(self._on_device_removed) - self._win_devices.device_reconfigured.connect(self._on_device_reconfigured) - 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._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._show_win(self._win_channels, "right") - - def _open_plot(self): - if self._win_plot is None: - 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.refresh_channels() # sync any derived channels - else: - self._win_plot.cfg = self._chart._cfg - self._win_plot.refresh_channels() - self._show_win(self._win_plot, "below") + def _open_config(self, tab: int = 0): + if self._win_config is None: + self._win_config = ConfigWindow( + self.registry, self.engine, self.processor, + self._chart._cfg, self, + ) + self._win_config.device_added.connect(self._on_device_added) + self._win_config.device_removed.connect(self._on_device_removed) + self._win_config.device_reconfigured.connect(self._on_device_reconfigured) + self._win_config.channel_visibility_changed.connect(self._on_channel_visibility_changed) + self._win_config.channel_name_changed.connect(self._on_channel_name_changed) + self._win_config.channel_unit_changed.connect(self._on_channel_unit_changed) + self._win_config.pipeline_changed.connect(lambda: None) + self._win_config.derived_changed.connect(self._on_derived_changed) + self._win_config.layout_applied.connect(self._chart.apply_layout) + self._win_config.closed.connect(self._on_config_closed) + self._win_config.tabs.setCurrentIndex(tab) + self._show_win(self._win_config, "right") + + def _on_config_closed(self): + self._act_devices.setChecked(False) + self._act_channels.setChecked(False) + self._act_plot.setChecked(False) def _open_settings(self): if self._win_settings is None: @@ -468,22 +546,6 @@ class MainWindow(QMainWindow): self._win_settings.closed.connect(lambda: self._btn_settings.setChecked(False)) self._show_win(self._win_settings, "right") - def _open_debug(self): - from ui.windows.debug_window import DebugWindow - if self._win_debug is None: - self._win_debug = DebugWindow(self) - self._win_debug.closed.connect(lambda: self._btn_debug.setChecked(False)) - self._show_win(self._win_debug, "right") - - def _update_debug_btn_visibility(self): - from core.app_settings import is_developer_mode - on = is_developer_mode() - self._btn_debug.setVisible(on) - if not on: - self._btn_debug.setChecked(False) - if self._win_debug: - self._win_debug.hide() - def _show_win(self, win: QWidget, position: str = "right"): if not win.isVisible(): screen = QApplication.screenAt(self.geometry().center()) @@ -529,15 +591,13 @@ class MainWindow(QMainWindow): # Clear controls self._ctrl.clear_widgets() - # Refresh open windows - if self._win_devices: - self._win_devices.refresh() - if self._win_channels: - self._win_channels.refresh_derived() - if self._win_plot: - self._win_plot.refresh_channels() + if self._win_config: + self._win_config.refresh_devices() + self._win_config.refresh_derived() + self._win_config.refresh_plot() - 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: @@ -618,55 +678,52 @@ class MainWindow(QMainWindow): self._chart.apply_layout(plot_cfg) else: self._chart.refresh() - # Refresh open windows - if self._win_devices: - self._win_devices.refresh() - if self._win_channels: - self._win_channels.refresh_derived() - if self._win_plot: - self._win_plot.refresh_channels() + if self._win_config: + self._win_config.refresh_devices() + self._win_config.refresh_derived() + self._win_config.refresh_plot(self._chart._cfg) self._status.setText(f"Profile loaded: {profile.name}") # ── Device / signal events ──────────────────────────────────────────── def _on_device_added(self): self._chart.refresh() - if self._win_plot: self._win_plot.refresh_channels() - if self._win_channels: self._win_channels.on_device_added() + if self._win_config: + self._win_config.on_device_added() + self._win_config.refresh_plot() self._status.setText("Device added.") def _on_device_removed(self, dev_id: str): self._chart.refresh() - if self._win_channels: self._win_channels.on_device_removed(dev_id) + if self._win_config: + self._win_config.on_device_removed(dev_id) self._status.setText(f"Device '{dev_id}' removed.") def _on_device_reconfigured(self, dev_id: str): self._chart.refresh() - if self._win_plot: self._win_plot.refresh_channels() - if self._win_channels: self._win_channels.on_device_reconfigured(dev_id) + if self._win_config: + self._win_config.on_device_reconfigured(dev_id) + self._win_config.refresh_plot() self._status.setText(f"Device '{dev_id}' reconfigured.") def _on_channel_visibility_changed(self, dev_id: str, ch_id: str, enabled: bool): self._chart.on_channel_enabled_changed(dev_id, ch_id, enabled) - if self._win_channels: - self._win_channels.on_channel_enabled_changed(dev_id, ch_id, enabled) - if self._win_plot: - self._win_plot.refresh_channels() + if self._win_config: + self._win_config.on_channel_enabled_changed(dev_id, ch_id, enabled) + self._win_config.refresh_plot() def _on_channel_name_changed(self, dev_id: str, ch_id: str, name: str): - if self._win_channels: - self._win_channels.on_channel_name_changed(dev_id, ch_id, name) + if self._win_config: + self._win_config.on_channel_name_changed(dev_id, ch_id, name) def _on_channel_unit_changed(self, dev_id: str, ch_id: str, unit: str): - if self._win_channels: - self._win_channels.on_channel_unit_changed(dev_id, ch_id, unit) + if self._win_config: + self._win_config.on_channel_unit_changed(dev_id, ch_id, unit) def _on_derived_changed(self): self._chart.refresh() - if self._win_plot: - # Ensure plot window works on current chart cfg, then sync derived panes - self._win_plot.cfg = self._chart._cfg - self._win_plot.refresh_channels() + if self._win_config: + self._win_config.refresh_plot(self._chart._cfg) # ── Run / Log ───────────────────────────────────────────────────────── @@ -722,14 +779,17 @@ 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 - set_developer_mode(self._settings.get("developer_mode", True)) - self._update_debug_btn_visibility() + 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) + set_developer_mode(enabled) def _on_settings(self, cfg: dict): self._settings.update(cfg) self.engine._interval = cfg.get("poll_ms", 100) / 1000.0 - set_developer_mode(self._settings.get("developer_mode", True)) - self._update_debug_btn_visibility() + self._apply_developer_mode(cfg.get("developer_mode", False)) save_settings(self._settings) def _tick(self): @@ -738,8 +798,7 @@ class MainWindow(QMainWindow): self._time_lbl.setText(f"{h:02d}:{m:02d}:{s:02d}") def closeEvent(self, event): - for w in (self._win_devices, self._win_channels, - self._win_plot, self._win_settings): + for w in (self._win_config, self._win_settings): if w: w.close() for plugin in list(self._plugin_mgr.get_loaded()): try: |
