diff options
Diffstat (limited to 'ui')
32 files changed, 777 insertions, 347 deletions
diff --git a/ui/__pycache__/__init__.cpython-312.pyc b/ui/__pycache__/__init__.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..6ed2349 --- /dev/null +++ b/ui/__pycache__/__init__.cpython-312.pyc diff --git a/ui/__pycache__/__init__.cpython-314.pyc b/ui/__pycache__/__init__.cpython-314.pyc Binary files differdeleted file mode 100644 index 3af9695..0000000 --- a/ui/__pycache__/__init__.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/add_device_dialog.cpython-314.pyc b/ui/__pycache__/add_device_dialog.cpython-314.pyc Binary files differdeleted file mode 100644 index e74884b..0000000 --- a/ui/__pycache__/add_device_dialog.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/alarm_panel.cpython-314.pyc b/ui/__pycache__/alarm_panel.cpython-314.pyc Binary files differdeleted file mode 100644 index a1811f7..0000000 --- a/ui/__pycache__/alarm_panel.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/config_dialog.cpython-314.pyc b/ui/__pycache__/config_dialog.cpython-314.pyc Binary files differdeleted file mode 100644 index 788f4a0..0000000 --- a/ui/__pycache__/config_dialog.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/control_editor.cpython-312.pyc b/ui/__pycache__/control_editor.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..f1716c6 --- /dev/null +++ b/ui/__pycache__/control_editor.cpython-312.pyc diff --git a/ui/__pycache__/control_panel.cpython-312.pyc b/ui/__pycache__/control_panel.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..c54cd25 --- /dev/null +++ b/ui/__pycache__/control_panel.cpython-312.pyc diff --git a/ui/__pycache__/control_panel.cpython-314.pyc b/ui/__pycache__/control_panel.cpython-314.pyc Binary files differdeleted file mode 100644 index a534649..0000000 --- a/ui/__pycache__/control_panel.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/device_panel.cpython-314.pyc b/ui/__pycache__/device_panel.cpython-314.pyc Binary files differdeleted file mode 100644 index 07eb750..0000000 --- a/ui/__pycache__/device_panel.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/main_window.cpython-312.pyc b/ui/__pycache__/main_window.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..b99d7c2 --- /dev/null +++ b/ui/__pycache__/main_window.cpython-312.pyc diff --git a/ui/__pycache__/main_window.cpython-314.pyc b/ui/__pycache__/main_window.cpython-314.pyc Binary files differdeleted file mode 100644 index d933966..0000000 --- a/ui/__pycache__/main_window.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/profile_manager_ui.cpython-312.pyc b/ui/__pycache__/profile_manager_ui.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..e66a754 --- /dev/null +++ b/ui/__pycache__/profile_manager_ui.cpython-312.pyc diff --git a/ui/__pycache__/readout_panel.cpython-314.pyc b/ui/__pycache__/readout_panel.cpython-314.pyc Binary files differdeleted file mode 100644 index 9258b10..0000000 --- a/ui/__pycache__/readout_panel.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/strip_chart.cpython-312.pyc b/ui/__pycache__/strip_chart.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..ba3ca5d --- /dev/null +++ b/ui/__pycache__/strip_chart.cpython-312.pyc diff --git a/ui/__pycache__/strip_chart.cpython-314.pyc b/ui/__pycache__/strip_chart.cpython-314.pyc Binary files differdeleted file mode 100644 index af41096..0000000 --- a/ui/__pycache__/strip_chart.cpython-314.pyc +++ /dev/null diff --git a/ui/alarm_panel.py b/ui/alarm_panel.py deleted file mode 100644 index 1270aff..0000000 --- a/ui/alarm_panel.py +++ /dev/null @@ -1,85 +0,0 @@ -""" -ui/alarm_panel.py — Alarm event log. -""" - -from datetime import datetime -from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QLabel, - QScrollArea, QFrame, QPushButton, -) -from PyQt6.QtCore import Qt - - -class AlarmRow(QFrame): - def __init__(self, dev_id: str, ch_id: str, value: float, kind: str): - super().__init__() - self.setObjectName("alarmEntry") - color = "#ef4444" if kind == "high" else "#f59e0b" - arrow = "▲" if kind == "high" else "▼" - self.setStyleSheet(f"QFrame#alarmEntry {{ border-left: 3px solid {color}; }}") - - ts_str = datetime.now().strftime("%H:%M:%S.%f")[:11] - layout = QHBoxLayout(self) - layout.setContentsMargins(8, 3, 8, 3) - - msg = QLabel(f"{arrow} {dev_id} / {ch_id} = {value:.4f} [{kind.upper()}]") - msg.setObjectName("alarmMsg") - ts = QLabel(ts_str) - ts.setObjectName("alarmTs") - - layout.addWidget(msg, 1) - layout.addWidget(ts) - - -class AlarmPanel(QWidget): - def __init__(self): - super().__init__() - self._count = 0 - self._build() - - def _build(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - hdr_w = QWidget() - hdr_w.setObjectName("alarmHeaderWidget") - hdr_lay = QHBoxLayout(hdr_w) - hdr_lay.setContentsMargins(0, 0, 4, 0) - hdr = QLabel(" ALARMS") - hdr.setObjectName("panelHeader") - hdr.setMinimumHeight(28) - hdr_lay.addWidget(hdr, 1) - clr = QPushButton("Clear") - clr.setObjectName("clearAlarmsBtn") - clr.clicked.connect(self.clear) - hdr_lay.addWidget(clr) - layout.addWidget(hdr_w) - - self._scroll = QScrollArea() - self._scroll.setWidgetResizable(True) - self._container = QWidget() - self._inner = QVBoxLayout(self._container) - self._inner.setContentsMargins(4, 4, 4, 4) - self._inner.setSpacing(2) - self._inner.addStretch() - self._scroll.setWidget(self._container) - layout.addWidget(self._scroll) - - def add_alarm(self, dev_id: str, ch_id: str, value: float, kind: str): - row = AlarmRow(dev_id, ch_id, value, kind) - self._inner.insertWidget(0, row) - self._count += 1 - # Cap at 300 entries - if self._count > 300: - item = self._inner.takeAt(self._inner.count() - 2) - if item and item.widget(): - item.widget().deleteLater() - self._count -= 1 - - def clear(self): - while self._inner.count() > 1: - item = self._inner.takeAt(0) - if item and item.widget(): - item.widget().deleteLater() - self._count = 0 diff --git a/ui/control_editor.py b/ui/control_editor.py new file mode 100644 index 0000000..93b8110 --- /dev/null +++ b/ui/control_editor.py @@ -0,0 +1,319 @@ +""" +ui/control_editor.py + +Dialog for adding or editing a control widget. +Lets the operator choose: + • Control type (On/Off, Motor, PWM, Setpoint, Analog Out) + • Title & icon + • Target device + channel + • Type-specific parameters (max RPM, unit, min/max, step...) +""" + +from __future__ import annotations +from dataclasses import dataclass, field, asdict +from typing import Any, Dict, Optional + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, + QLabel, QLineEdit, QComboBox, QDoubleSpinBox, + QSpinBox, QPushButton, QGroupBox, QWidget, + QStackedWidget, QFrame, QCheckBox, +) +from PyQt6.QtCore import Qt + +from devices.device_registry import DeviceRegistry + + +# ── Data model ──────────────────────────────────────────────────────────────── + +CONTROL_TYPES = [ + "On/Off Switch", + "Motor Control", + "PWM Output", + "Setpoint", + "Analog Output", +] + +CONTROL_ICONS = { + "On/Off Switch": "⏻", + "Motor Control": "⟳", + "PWM Output": "⊓", + "Setpoint": "◎", + "Analog Output": "↗", +} + + +@dataclass +class ControlSpec: + """Serialisable description of one control widget.""" + control_type: str = "On/Off Switch" + title: str = "Control" + icon: str = "⏻" + device_id: str = "" + channel_id: str = "" + # Type-specific params + unit: str = "" + min_val: float = 0.0 + max_val: float = 100.0 + step: float = 1.0 + max_rpm: int = 3000 + + def to_dict(self) -> dict: + return asdict(self) + + @staticmethod + def from_dict(d: dict) -> "ControlSpec": + valid = {k: v for k, v in d.items() if k in ControlSpec.__dataclass_fields__} + return ControlSpec(**valid) + + +# ── Per-type parameter panels ───────────────────────────────────────────────── + +class OnOffParams(QWidget): + def __init__(self): super().__init__() # no extra params needed + + def load(self, spec: ControlSpec): pass + def save(self, spec: ControlSpec): pass + + +class MotorParams(QWidget): + def __init__(self): + super().__init__() + lay = QFormLayout(self); lay.setContentsMargins(0, 4, 0, 4) + self._rpm = QSpinBox(); self._rpm.setRange(1, 100000); self._rpm.setValue(3000) + self._rpm.setSuffix(" RPM") + lay.addRow("Max RPM:", self._rpm) + + def load(self, spec: ControlSpec): self._rpm.setValue(spec.max_rpm) + def save(self, spec: ControlSpec): spec.max_rpm = self._rpm.value() + + +class PwmParams(QWidget): + def __init__(self): super().__init__() # duty handled by slider, no extra params + + def load(self, spec: ControlSpec): pass + def save(self, spec: ControlSpec): pass + + +class SetpointParams(QWidget): + def __init__(self): + super().__init__() + lay = QFormLayout(self); lay.setContentsMargins(0, 4, 0, 4) + self._unit = QLineEdit(); self._unit.setPlaceholderText("e.g. °C, bar, rpm") + self._min = QDoubleSpinBox(); self._min.setRange(-1e9, 1e9); self._min.setValue(0.0) + self._max = QDoubleSpinBox(); self._max.setRange(-1e9, 1e9); self._max.setValue(100.0) + self._step = QDoubleSpinBox(); self._step.setRange(0.001, 1e6); self._step.setValue(1.0) + lay.addRow("Unit:", self._unit) + lay.addRow("Min:", self._min) + lay.addRow("Max:", self._max) + lay.addRow("Step:", self._step) + + def load(self, spec: ControlSpec): + self._unit.setText(spec.unit) + self._min.setValue(spec.min_val) + self._max.setValue(spec.max_val) + self._step.setValue(spec.step) + + def save(self, spec: ControlSpec): + spec.unit = self._unit.text().strip() + spec.min_val = self._min.value() + spec.max_val = self._max.value() + spec.step = self._step.value() + + +class AnalogOutParams(QWidget): + def __init__(self): + super().__init__() + lay = QFormLayout(self); lay.setContentsMargins(0, 4, 0, 4) + self._unit = QLineEdit("V"); self._unit.setPlaceholderText("V, mA, …") + self._min = QDoubleSpinBox(); self._min.setRange(-1e9, 1e9); self._min.setValue(0.0) + self._max = QDoubleSpinBox(); self._max.setRange(-1e9, 1e9); self._max.setValue(10.0) + lay.addRow("Unit:", self._unit) + lay.addRow("Min:", self._min) + lay.addRow("Max:", self._max) + + def load(self, spec: ControlSpec): + self._unit.setText(spec.unit) + self._min.setValue(spec.min_val) + self._max.setValue(spec.max_val) + + def save(self, spec: ControlSpec): + spec.unit = self._unit.text().strip() + spec.min_val = self._min.value() + spec.max_val = self._max.value() + + +_PARAM_PANELS = { + "On/Off Switch": OnOffParams, + "Motor Control": MotorParams, + "PWM Output": PwmParams, + "Setpoint": SetpointParams, + "Analog Output": AnalogOutParams, +} + + +# ── Editor dialog ───────────────────────────────────────────────────────────── + +class ControlEditorDialog(QDialog): + """Add or edit a control widget.""" + + def __init__(self, registry: DeviceRegistry, + spec: Optional[ControlSpec] = None, + parent=None): + super().__init__(parent) + self.registry = registry + self.spec = spec or ControlSpec() + self.result_spec: Optional[ControlSpec] = None + + self.setWindowTitle("Edit Control" if spec else "Add Control") + self.setMinimumSize(440, 460) + self.resize(460, 500) + self._build() + self._load_spec() + + def _build(self): + root = QVBoxLayout(self); root.setSpacing(10) + + # ── Identity ──────────────────────────────────────────────────── + id_grp = QGroupBox("Identity") + id_form = QFormLayout(id_grp); id_form.setContentsMargins(10, 16, 10, 10) + + self._type_cb = QComboBox() + self._type_cb.addItems(CONTROL_TYPES) + self._type_cb.currentTextChanged.connect(self._on_type_changed) + id_form.addRow("Control Type:", self._type_cb) + + self._title_edit = QLineEdit() + self._title_edit.setPlaceholderText("e.g. Heater, Pump, Mixer") + id_form.addRow("Label:", self._title_edit) + + root.addWidget(id_grp) + + # ── Device & channel ──────────────────────────────────────────── + hw_grp = QGroupBox("Hardware Assignment") + hw_form = QFormLayout(hw_grp); hw_form.setContentsMargins(10, 16, 10, 10) + + self._dev_cb = QComboBox() + self._dev_cb.addItem("— none —", userData="") + for dev in self.registry.all_instances(): + label = f"{dev.info.device_id} ({dev.info.name})" + self._dev_cb.addItem(label, userData=dev.info.device_id) + self._dev_cb.currentIndexChanged.connect(self._on_dev_changed) + hw_form.addRow("Device:", self._dev_cb) + + self._ch_cb = QComboBox() + self._ch_cb.setEditable(True) # allow typing custom pin like "D7" + self._ch_cb.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) + hw_form.addRow("Channel / Pin:", self._ch_cb) + + self._ch_hint = QLabel("") + self._ch_hint.setObjectName("traceSource") + self._ch_hint.setWordWrap(True) + hw_form.addRow(self._ch_hint) + + root.addWidget(hw_grp) + + # ── Type-specific params ──────────────────────────────────────── + self._params_grp = QGroupBox("Parameters") + params_lay = QVBoxLayout(self._params_grp) + params_lay.setContentsMargins(10, 16, 10, 10) + + self._stack = QStackedWidget() + self._param_panels: Dict[str, QWidget] = {} + for name, cls in _PARAM_PANELS.items(): + panel = cls() + self._param_panels[name] = panel + self._stack.addWidget(panel) + params_lay.addWidget(self._stack) + root.addWidget(self._params_grp) + + # ── Buttons ──────────────────────────────────────────────────── + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider"); root.addWidget(div) + + btn_row = QHBoxLayout(); btn_row.addStretch() + cancel = QPushButton("Cancel"); cancel.clicked.connect(self.reject) + ok = QPushButton("Save Control") + ok.setObjectName("applyButton"); ok.setDefault(True) + ok.clicked.connect(self._on_ok) + btn_row.addWidget(cancel); btn_row.addWidget(ok) + root.addLayout(btn_row) + + def _load_spec(self): + """Populate fields from self.spec.""" + # Type + idx = CONTROL_TYPES.index(self.spec.control_type) \ + if self.spec.control_type in CONTROL_TYPES else 0 + self._type_cb.setCurrentIndex(idx) + self._title_edit.setText(self.spec.title) + + # Device + for i in range(self._dev_cb.count()): + if self._dev_cb.itemData(i) == self.spec.device_id: + self._dev_cb.setCurrentIndex(i); break + + # Channel (populated after device selection) + self._on_dev_changed() + self._ch_cb.setCurrentText(self.spec.channel_id) + + # Params + for name, panel in self._param_panels.items(): + panel.load(self.spec) + + def _on_type_changed(self, type_name: str): + idx = list(_PARAM_PANELS.keys()).index(type_name) + self._stack.setCurrentIndex(idx) + # Auto-set title if still default + if not self._title_edit.text() or \ + self._title_edit.text() in CONTROL_TYPES: + self._title_edit.setText(type_name) + + def _on_dev_changed(self): + self._ch_cb.clear() + dev_id = self._dev_cb.currentData() + if not dev_id: + self._ch_hint.setText("No device selected — type a channel manually") + return + dev = self.registry.get_instance(dev_id) + if not dev: + return + # Add actual channels + for ch in dev.info.channels: + self._ch_cb.addItem(f"{ch.channel_id} ({ch.name})", + userData=ch.channel_id) + # For Arduino backends also suggest digital pins for output + if hasattr(dev, "backend") and dev.backend == "arduino": + self._ch_cb.insertSeparator(self._ch_cb.count()) + for pin in ["D5", "D6", "D7", "D8", "D9", "D10", "D11", "D13"]: + self._ch_cb.addItem(f"{pin} (digital out)", userData=pin) + self._ch_hint.setText( + "Analog pins (A0…) for reading.\n" + "Digital pins (D5…) for on/off output.\n" + "D9, D10, D11 support PWM." + ) + else: + self._ch_hint.setText("") + + def _on_ok(self): + ctype = self._type_cb.currentText() + title = self._title_edit.text().strip() or ctype + + # Resolve channel_id — prefer userData if it's a real combo item + ch_text = self._ch_cb.currentText().strip() + ch_data = self._ch_cb.currentData() + channel_id = ch_data if ch_data else ch_text.split()[0] # strip "(name)" part + + spec = ControlSpec( + control_type=ctype, + title=title, + icon=CONTROL_ICONS.get(ctype, "⚙"), + device_id=self._dev_cb.currentData() or "", + channel_id=channel_id, + ) + # Save type-specific params + panel = self._param_panels.get(ctype) + if panel: + panel.save(spec) + + self.result_spec = spec + self.accept() diff --git a/ui/control_panel.py b/ui/control_panel.py index 98f0637..d0f8637 100644 --- a/ui/control_panel.py +++ b/ui/control_panel.py @@ -83,11 +83,25 @@ class ControlWidget(QFrame): outer.addWidget(self._body) def _write(self, value: float): - """Write value to the linked device channel (if any).""" + """ + Write value to the linked device channel. + + Routing: + DigitalIODevice → write_channel() → ArduinoLayer.digital_write() → W:Dxx:val + AnalogInputDevice (arduino backend) → write_channel() → ArduinoLayer.digital_write() + Any device with no write support → logs a warning, emits signal only + """ + written = False if self.registry and self.device_id and self.channel_id: dev = self.registry.get_instance(self.device_id) if dev: - dev.write_channel(self.channel_id, value) + ok = dev.write_channel(self.channel_id, value) + if ok: + written = True + else: + print(f"[Control] write_channel({self.channel_id}, {value}) " + f"returned False on {self.device_id} — " + f"check device type and channel ID") self.value_changed.emit(self.channel_id, value) @@ -98,8 +112,8 @@ class ControlWidget(QFrame): class OnOffSwitch(ControlWidget): """Large latching power switch with green/red indicator.""" - def __init__(self, title: str = "Power", **kw): - super().__init__(title, icon="⏻", **kw) + def __init__(self, title: str = "Power", icon: str = "⏻", **kw): + super().__init__(title, icon=icon, **kw) self._state = False self._build_body() @@ -151,8 +165,8 @@ class OnOffSwitch(ControlWidget): class MotorControl(ControlWidget): """Motor speed (0–100 %), direction toggle, start/stop.""" - def __init__(self, title: str = "Motor", max_rpm: int = 3000, **kw): - super().__init__(title, icon="⟳", **kw) + def __init__(self, title: str = "Motor", icon: str = "⟳", max_rpm: int = 3000, **kw): + super().__init__(title, icon=icon, **kw) self.max_rpm = max_rpm self._running = False self._fwd = True @@ -238,10 +252,10 @@ class MotorControl(ControlWidget): class SetpointControl(ControlWidget): """Numeric setpoint with ± step buttons and live process-value readback.""" - def __init__(self, title: str = "Setpoint", unit: str = "", + def __init__(self, title: str = "Setpoint", icon: str = "◎", unit: str = "", min_val: float = 0.0, max_val: float = 100.0, step: float = 1.0, **kw): - super().__init__(title, icon="◎", **kw) + super().__init__(title, icon=icon, **kw) self.unit = unit self.min_val = min_val self.max_val = max_val @@ -330,8 +344,8 @@ class SetpointControl(ControlWidget): class PwmControl(ControlWidget): """PWM duty cycle slider + frequency setting.""" - def __init__(self, title: str = "PWM Output", **kw): - super().__init__(title, icon="⊓", **kw) + def __init__(self, title: str = "PWM Output", icon: str = "⊓", **kw): + super().__init__(title, icon=icon, **kw) self._build_body() def _build_body(self): @@ -399,9 +413,9 @@ class PwmControl(ControlWidget): class AnalogOutputControl(ControlWidget): """Generic voltage/current analog output with spinbox + send button.""" - def __init__(self, title: str = "Analog Out", unit: str = "V", + def __init__(self, title: str = "Analog Out", icon: str = "↗", unit: str = "V", min_val: float = 0.0, max_val: float = 10.0, **kw): - super().__init__(title, icon="↗", **kw) + super().__init__(title, icon=icon, **kw) self.unit = unit self.min_val = min_val self.max_val = max_val @@ -451,24 +465,39 @@ class AnalogOutputControl(ControlWidget): # ══════════════════════════════════════════════════════════════════════════════ class ControlPanel(QWidget): - """Left panel — output control widgets only. No device management here.""" + """ + Left panel — output control widgets. + Header has Add button. Each widget has Edit and Remove buttons overlaid. + """ + + controls_changed = pyqtSignal() # emitted whenever widgets are added/edited/removed def __init__(self, registry: DeviceRegistry): super().__init__() self.registry = registry - self._widgets: list[ControlWidget] = [] + self._widgets: list[ControlWidget] = [] + self._specs: list = [] # parallel list of ControlSpec self._build() - self._add_demo_widgets() + + # ── Layout ─────────────────────────────────────────────────────────────── def _build(self): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) - hdr = QLabel(" CONTROLS") - hdr.setObjectName("panelHeader") - hdr.setMinimumHeight(28) - layout.addWidget(hdr) + # 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) @@ -477,83 +506,159 @@ class ControlPanel(QWidget): self._container = QWidget() self._inner = QVBoxLayout(self._container) - self._inner.setContentsMargins(8, 8, 8, 8) - self._inner.setSpacing(10) + self._inner.setContentsMargins(6, 6, 6, 6) + self._inner.setSpacing(8) self._inner.addStretch() scroll.setWidget(self._container) layout.addWidget(scroll) - def add_widget(self, widget: ControlWidget): - """Add a control widget to the panel.""" + # ── Widget management ───────────────────────────────────────────────────── + + def _make_wrapper(self, widget: ControlWidget, spec) -> QFrame: + """Wrap a ControlWidget with Edit / Remove buttons in the corner.""" + wrapper = QFrame(); wrapper.setObjectName("controlWidgetWrapper") + wl = QVBoxLayout(wrapper); wl.setContentsMargins(0, 0, 0, 0); wl.setSpacing(0) + wl.addWidget(widget) + + # Button row below each widget + btn_row = QHBoxLayout(); btn_row.setContentsMargins(2, 1, 2, 1) + btn_row.addStretch() + + edit_btn = QPushButton("✎ Edit"); edit_btn.setObjectName("configButton") + edit_btn.setFixedHeight(20) + rm_btn = QPushButton("✕"); rm_btn.setObjectName("traceRemoveBtn") + rm_btn.setFixedSize(20, 20) + + edit_btn.clicked.connect(lambda: self._on_edit(widget, spec, wrapper)) + rm_btn.clicked.connect( lambda: self._on_remove(widget, spec, wrapper)) + + btn_row.addWidget(edit_btn) + btn_row.addWidget(rm_btn) + wl.addLayout(btn_row) + return wrapper + + def _add_widget_from_spec(self, spec): + """Instantiate a ControlWidget from a ControlSpec and add to panel.""" + from ui.control_editor import ControlSpec as CS + widget = _build_widget_from_spec(spec, self.registry) + if widget is None: + return + wrapper = self._make_wrapper(widget, spec) self._widgets.append(widget) - self._inner.insertWidget(self._inner.count() - 1, widget) + self._specs.append(spec) + self._inner.insertWidget(self._inner.count() - 1, wrapper) + self.controls_changed.emit() + + def add_widget(self, widget: ControlWidget, spec=None): + """Legacy API — add a pre-built widget directly.""" + from ui.control_editor import ControlSpec + if spec is None: + spec = ControlSpec( + title=widget.title, icon=widget.icon, + device_id=widget.device_id, channel_id=widget.channel_id, + ) + wrapper = self._make_wrapper(widget, spec) + self._widgets.append(widget) + self._specs.append(spec) + self._inner.insertWidget(self._inner.count() - 1, wrapper) def clear_widgets(self): - """Remove all control widgets.""" - for w in self._widgets: - self._inner.removeWidget(w) - w.deleteLater() + while self._inner.count() > 1: + item = self._inner.takeAt(0) + if item and item.widget(): + item.widget().deleteLater() self._widgets.clear() + self._specs.clear() + + def get_specs(self) -> list: + """Return list of ControlSpec for all current widgets (for profile save).""" + return list(self._specs) + + def load_specs(self, specs: list): + """Load a list of ControlSpec objects (from profile restore).""" + self.clear_widgets() + for spec in specs: + self._add_widget_from_spec(spec) + + # ── Slots ───────────────────────────────────────────────────────────────── + + def _on_add(self): + from ui.control_editor import ControlEditorDialog + dlg = ControlEditorDialog(self.registry, parent=self) + if dlg.exec() and dlg.result_spec: + self._add_widget_from_spec(dlg.result_spec) + + def _on_edit(self, widget: ControlWidget, spec, wrapper: QFrame): + from ui.control_editor import ControlEditorDialog + dlg = ControlEditorDialog(self.registry, spec=spec, parent=self) + if not (dlg.exec() and dlg.result_spec): + return + new_spec = dlg.result_spec + idx = self._specs.index(spec) + + # Remove old wrapper + self._inner.removeWidget(wrapper); wrapper.deleteLater() + self._widgets.pop(idx); self._specs.pop(idx) + + # Insert new one at same position + new_widget = _build_widget_from_spec(new_spec, self.registry) + if new_widget is None: + return + new_wrapper = self._make_wrapper(new_widget, new_spec) + self._widgets.insert(idx, new_widget) + self._specs.insert(idx, new_spec) + self._inner.insertWidget(idx, new_wrapper) + self.controls_changed.emit() + + def _on_remove(self, widget: ControlWidget, spec, wrapper: QFrame): + idx = self._specs.index(spec) + self._inner.removeWidget(wrapper); wrapper.deleteLater() + self._widgets.pop(idx); self._specs.pop(idx) + self.controls_changed.emit() def _add_demo_widgets(self): - """Default demo configuration — replace with your lab setup.""" - - # Pump power switch - pump = OnOffSwitch( - title="Pump Power", - registry=self.registry, - device_id="dio_0", channel_id="do0", - ) - self.add_widget(pump) - - # Heater switch - heater = OnOffSwitch( - title="Heater", - registry=self.registry, - device_id="dio_0", channel_id="do1", - ) - self.add_widget(heater) - - # Motor controller - motor = MotorControl( - title="Drive Motor", - max_rpm=3000, - registry=self.registry, - device_id="ard_0", channel_id="A0", - ) - self.add_widget(motor) - - # Temperature setpoint - temp_sp = SetpointControl( - title="Temp Setpoint", - unit="°C", - min_val=0.0, max_val=300.0, - step=0.5, - ) - self.add_widget(temp_sp) - - # Flow setpoint - flow_sp = SetpointControl( - title="Flow Rate", - unit="mL/min", - min_val=0.0, max_val=500.0, - step=5.0, - ) - self.add_widget(flow_sp) - - # PWM output - pwm = PwmControl( - title="PWM Ch 1", - registry=self.registry, - device_id="dio_0", channel_id="do2", - ) - self.add_widget(pwm) - - # Analog output - ao = AnalogOutputControl( - title="Analog Out", - unit="V", - min_val=0.0, max_val=10.0, - ) - self.add_widget(ao) + """Default demo configuration.""" + from ui.control_editor import ControlSpec + demos = [ + ControlSpec("On/Off Switch", "Pump Power", "⏻", "dio_0", "do0"), + ControlSpec("On/Off Switch", "Heater", "⏻", "ard_0", "D7"), + ControlSpec("Motor Control", "Drive Motor", "⟳", "ard_0", "A0", max_rpm=3000), + ControlSpec("Setpoint", "Temp Setpoint","◎", "", "", unit="°C", min_val=0, max_val=300, step=0.5), + ControlSpec("Setpoint", "Flow Rate", "◎", "", "", unit="mL/min",min_val=0, max_val=500, step=5.0), + ControlSpec("PWM Output", "PWM Ch 1", "⊓", "ard_0", "D9"), + ControlSpec("Analog Output", "Analog Out", "↗", "", "", unit="V", min_val=0, max_val=10), + ] + for spec in demos: + self._add_widget_from_spec(spec) + + +# ── Factory — build a ControlWidget from a ControlSpec ───────────────────────── + +def _build_widget_from_spec(spec, registry: DeviceRegistry) -> Optional[ControlWidget]: + """Instantiate the right ControlWidget subclass from a ControlSpec.""" + # Base kwargs — icon is NOT included here; it's passed explicitly below + # so subclasses never get a double-value collision. + base_kw = dict( + device_id=spec.device_id, + channel_id=spec.channel_id, + registry=registry, + ) + t = spec.control_type + ttl = spec.title + ico = spec.icon + if t == "On/Off Switch": + return OnOffSwitch(title=ttl, icon=ico, **base_kw) + elif t == "Motor Control": + return MotorControl(title=ttl, icon=ico, max_rpm=spec.max_rpm, **base_kw) + elif t == "PWM Output": + return PwmControl(title=ttl, icon=ico, **base_kw) + elif t == "Setpoint": + return SetpointControl(title=ttl, icon=ico, unit=spec.unit, + min_val=spec.min_val, max_val=spec.max_val, + step=spec.step, **base_kw) + elif t == "Analog Output": + return AnalogOutputControl(title=ttl, icon=ico, unit=spec.unit, + min_val=spec.min_val, max_val=spec.max_val, + **base_kw) + return None diff --git a/ui/main_window.py b/ui/main_window.py index e69ab1a..a82a389 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -1,21 +1,15 @@ """ ui/main_window.py -Toolbar sections (left→right): - [▶ RUN] [⬤ LOG] | [⚙ DEVICES] | [⚗ SIGNALS] | [📐 PLOT] | [⚙ SETTINGS] - spacer | timer - -Central: - Left: ControlPanel (output widgets only) - Center: StripChartWidget - -All secondary features live in floating windows. +Toolbar (left→right): + [▶ RUN] [⬤ LOG] | [⊞ Devices] | [⚗ Signals] | [📐 Plot] | [⚙ Settings] + spacer | timer | [📁 File] """ from PyQt6.QtWidgets import ( QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QSplitter, QStatusBar, QLabel, QPushButton, - QToolBar, QSizePolicy, QApplication, + QToolBar, QSizePolicy, QApplication, QFrame, ) from PyQt6.QtCore import Qt, QTimer, pyqtSlot import os @@ -26,13 +20,15 @@ from devices.serial_device import SerialDevice from devices.device_registry import DeviceRegistry from core.acquisition import AcquisitionEngine from core.signal_processor import SignalProcessor +from core.profile import Profile, ProfileManager -from ui.control_panel import ControlPanel -from ui.strip_chart import StripChartWidget -from ui.windows.devices_window import DevicesWindow -from ui.windows.signals_window import SignalsWindow -from ui.windows.plot_window import PlotWindow, build_default_layout -from ui.windows.settings_window import SettingsWindow +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.signals_window import SignalsWindow +from ui.windows.plot_window import PlotWindow, build_default_layout +from ui.windows.settings_window import SettingsWindow _DARK_QSS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "style_dark.qss") @@ -50,11 +46,11 @@ class MainWindow(QMainWindow): self.processor = SignalProcessor() self._settings = dict(SettingsWindow._defaults) - self._elapsed = 0 - self._win_devices = None - self._win_signals = None - self._win_plot = None - self._win_settings = None + self._elapsed = 0 + self._win_devices = None + self._win_signals = None + self._win_plot = None + self._win_settings = None self._init_demo_devices() self._build_ui() @@ -76,7 +72,6 @@ class MainWindow(QMainWindow): # ── UI ──────────────────────────────────────────────────────────────── def _build_ui(self): - # ── Toolbar ────────────────────────────────────────────────────── tb = QToolBar(); tb.setObjectName("mainToolbar"); tb.setMovable(False) self.addToolBar(tb) @@ -84,7 +79,6 @@ class MainWindow(QMainWindow): s = QFrame(); s.setFrameShape(QFrame.Shape.VLine) s.setObjectName("toolbarSep"); return s - # RUN / LOG self._run_btn = QPushButton("▶ RUN") self._run_btn.setObjectName("runButton"); self._run_btn.setCheckable(True) self._run_btn.clicked.connect(self._toggle_run); tb.addWidget(self._run_btn) @@ -96,46 +90,50 @@ class MainWindow(QMainWindow): tb.addWidget(_sep()) - # DEVICES dev_btn = QPushButton("⊞ Devices") - dev_btn.setObjectName("toolbarSectionBtn") - dev_btn.setCheckable(True) + 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 tb.addWidget(_sep()) - # SIGNALS sig_btn = QPushButton("⚗ Signals") - sig_btn.setObjectName("toolbarSectionBtn") - sig_btn.setCheckable(True) + sig_btn.setObjectName("toolbarSectionBtn"); sig_btn.setCheckable(True) sig_btn.clicked.connect(lambda c: self._toggle_win("signals", c, sig_btn)) tb.addWidget(sig_btn); self._btn_signals = sig_btn tb.addWidget(_sep()) - # PLOT plot_btn = QPushButton("📐 Plot") - plot_btn.setObjectName("toolbarSectionBtn") - plot_btn.setCheckable(True) + 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()) - # SETTINGS set_btn = QPushButton("⚙ Settings") - set_btn.setObjectName("toolbarSectionBtn") - set_btn.setCheckable(True) + set_btn.setObjectName("toolbarSectionBtn"); set_btn.setCheckable(True) set_btn.clicked.connect(lambda c: self._toggle_win("settings", c, set_btn)) tb.addWidget(set_btn); self._btn_settings = set_btn - spacer = QWidget(); spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + # Spacer + clock + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) tb.addWidget(spacer) self._time_lbl = QLabel("00:00:00"); self._time_lbl.setObjectName("timeLabel") tb.addWidget(self._time_lbl) + tb.addWidget(_sep()) + + # ── 📁 File button (profiles) ───────────────────────────────────── + self._file_btn = ProfileButton( + on_new=self._profile_new, + get_profile=self._profile_capture, + apply_profile=self._profile_apply, + ) + tb.addWidget(self._file_btn) + # ── Central ─────────────────────────────────────────────────────── central = QWidget(); self.setCentralWidget(central) root = QHBoxLayout(central); root.setContentsMargins(0,0,0,0); root.setSpacing(0) @@ -143,16 +141,16 @@ class MainWindow(QMainWindow): hsplit = QSplitter(Qt.Orientation.Horizontal); hsplit.setHandleWidth(3) self._ctrl = ControlPanel(self.registry) - self._ctrl.setMinimumWidth(200); self._ctrl.setMaximumWidth(320) + 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([240, 1000]) + hsplit.setSizes([260, 1000]) root.addWidget(hsplit) - # ── Status bar ───────────────────────────────────────────────── sb = QStatusBar(); self.setStatusBar(sb) self._status = QLabel("Ready"); sb.addWidget(self._status) self._log_lbl = QLabel(""); sb.addPermanentWidget(self._log_lbl) @@ -229,11 +227,51 @@ class MainWindow(QMainWindow): if not win.isVisible(): if position == "right": win.move(geo.right() + 8, geo.top() + 40) - elif position == "below": + else: win.move(geo.left(), geo.bottom() + 8) win.show(); win.raise_(); win.activateWindow() - # ── Device/signal events ────────────────────────────────────────────── + # ── Profile callbacks ───────────────────────────────────────────────── + + def _profile_new(self): + """Reset to a blank slate.""" + self._ctrl.clear_widgets() + # Clear derived channels + for dc in self.processor.get_derived(): + self.processor.remove_derived(dc.channel_id) + self._chart.refresh() + self._status.setText("New profile — blank slate.") + + def _profile_capture(self, name: str = "Profile") -> Profile: + """Serialise current state into a Profile object.""" + return ProfileManager.capture( + registry=self.registry, + processor=self.processor, + plot_cfg=self._chart._cfg, + control_specs=self._ctrl.get_specs(), + settings=self._settings, + profile_name=name, + ) + + def _profile_apply(self, profile: Profile): + """Restore state from a Profile object.""" + plot_cfg = ProfileManager.apply( + profile=profile, + registry=self.registry, + processor=self.processor, + control_panel=self._ctrl, + settings_ref=self._settings, + ) + if plot_cfg: + self._chart.apply_layout(plot_cfg) + else: + self._chart.refresh() + # Refresh open windows + if self._win_plot: + self._win_plot.refresh_channels() + self._status.setText(f"Profile loaded: {profile.name}") + + # ── Device / signal events ──────────────────────────────────────────── def _on_device_added(self): self._chart.refresh() @@ -245,10 +283,8 @@ class MainWindow(QMainWindow): self._status.setText(f"Device '{dev_id}' removed.") def _on_device_reconfigured(self, dev_id: str): - """Called after Configure dialog closes — rebuild chart curves.""" self._chart.refresh() - if self._win_plot: - self._win_plot.refresh_channels() + if self._win_plot: self._win_plot.refresh_channels() self._status.setText(f"Device '{dev_id}' reconfigured.") def _on_derived_changed(self): @@ -271,22 +307,20 @@ class MainWindow(QMainWindow): def _toggle_log(self, c: bool): if c: - os.makedirs(self._settings.get("log_dir","logs"), exist_ok=True) p = self.engine.start_logging( - os.path.join(self._settings.get("log_dir","logs"), "")) + os.path.join(self._settings.get("log_dir", "logs"), "")) self._log_btn.setText("⏹ LOGGING") self._status.setText(f"Logging → {p}") else: self.engine.stop_logging(); self._log_btn.setText("⬤ LOG") - # ── Settings ────────────────────────────────────────────────────────── + # ── Theme / settings ────────────────────────────────────────────────── def _apply_theme(self, theme: str): qss_file = _DARK_QSS if theme == "dark" else _LIGHT_QSS if os.path.exists(qss_file): with open(qss_file) as f: QApplication.instance().setStyleSheet(f.read()) - # Update pyqtgraph plot colours independently of QSS self._chart.set_theme(theme) def _on_settings(self, cfg: dict): @@ -299,9 +333,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_signals,self._win_plot,self._win_settings): + for w in (self._win_devices, self._win_signals, + self._win_plot, self._win_settings): if w: w.close() self.engine.stop(); event.accept() - - -from PyQt6.QtWidgets import QFrame diff --git a/ui/profile_manager_ui.py b/ui/profile_manager_ui.py new file mode 100644 index 0000000..8966f8f --- /dev/null +++ b/ui/profile_manager_ui.py @@ -0,0 +1,152 @@ +""" +ui/profile_manager_ui.py + +Profile menu button (📁) in the top-right toolbar. +Handles New / Load / Save / Save As via file dialogs. +Thin UI layer — all serialisation is in core/profile.py. +""" + +import os +from PyQt6.QtWidgets import ( + QMenu, QPushButton, QFileDialog, QInputDialog, + QMessageBox, QApplication, +) +from PyQt6.QtCore import QPoint +from PyQt6.QtGui import QAction + +from core.profile import Profile, ProfileManager + + +PROFILE_EXT = ".labdaq" +PROFILE_FILTER = f"LabDAQ Profile (*{PROFILE_EXT});;All files (*)" + + +class ProfileButton(QPushButton): + """ + A '📁 File' button that shows New / Load / Save / Save As. + Placed in the toolbar top-right. + + Caller provides callbacks: + on_new() — reset to blank state + get_profile() — return a Profile representing current state + apply_profile(p) — restore state from a Profile + """ + + def __init__(self, on_new, get_profile, apply_profile, parent=None): + super().__init__("📁 File", parent) + self.setObjectName("toolbarSectionBtn") + self._on_new = on_new + self._get_profile = get_profile + self._apply_profile = apply_profile + self._current_path: str = "" + self.clicked.connect(self._show_menu) + + # ── Menu ────────────────────────────────────────────────────────────────── + + def _show_menu(self): + menu = QMenu(self) + menu.setObjectName("profileMenu") + + a_new = menu.addAction("📄 New Profile") + menu.addSeparator() + a_open = menu.addAction("📂 Load Profile…") + menu.addSeparator() + a_save = menu.addAction("💾 Save") + a_save_as = menu.addAction("💾 Save As…") + + a_save.setEnabled(bool(self._current_path)) + + a_new.triggered.connect(self._do_new) + a_open.triggered.connect(self._do_load) + a_save.triggered.connect(self._do_save) + a_save_as.triggered.connect(self._do_save_as) + + # Show below the button + pos = self.mapToGlobal(QPoint(0, self.height())) + menu.exec(pos) + + # ── Actions ─────────────────────────────────────────────────────────────── + + def _do_new(self): + reply = QMessageBox.question( + self, "New Profile", + "Discard current configuration and start a blank profile?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + self._current_path = "" + self._on_new() + self._update_title() + + def _do_load(self): + path, _ = QFileDialog.getOpenFileName( + self, "Load Profile", self._default_dir(), + PROFILE_FILTER, + ) + if not path: + return + try: + profile = Profile.load(path) + self._apply_profile(profile) + self._current_path = path + self._update_title(profile.name) + except Exception as e: + QMessageBox.critical(self, "Load Failed", str(e)) + + def _do_save(self): + if not self._current_path: + self._do_save_as() + return + self._save_to(self._current_path) + + def _do_save_as(self): + # Ask for profile name + name, ok = QInputDialog.getText( + self, "Profile Name", "Profile name:", + text=os.path.splitext(os.path.basename(self._current_path))[0] + if self._current_path else "My Profile", + ) + if not ok or not name.strip(): + return + name = name.strip() + + path, _ = QFileDialog.getSaveFileName( + self, "Save Profile As", + os.path.join(self._default_dir(), name + PROFILE_EXT), + PROFILE_FILTER, + ) + if not path: + return + if not path.endswith(PROFILE_EXT): + path += PROFILE_EXT + self._save_to(path, name) + + def _save_to(self, path: str, name: str = ""): + try: + profile = self._get_profile(name or os.path.splitext( + os.path.basename(path))[0]) + profile.save(path) + self._current_path = path + self._update_title(profile.name) + except Exception as e: + QMessageBox.critical(self, "Save Failed", str(e)) + + # ── Helpers ─────────────────────────────────────────────────────────────── + + def _default_dir(self) -> str: + d = os.path.join(os.path.expanduser("~"), "labdaq_profiles") + os.makedirs(d, exist_ok=True) + return d + + def _update_title(self, name: str = ""): + app = QApplication.instance() + if app and hasattr(app, "topLevelWidgets"): + for w in app.topLevelWidgets(): + if hasattr(w, "setWindowTitle") and "LabDAQ" in (w.windowTitle() or ""): + title = "LabDAQ" + if name: + title += f" — {name}" + if self._current_path: + title += f" [{os.path.basename(self._current_path)}]" + w.setWindowTitle(title) + break diff --git a/ui/readout_panel.py b/ui/readout_panel.py deleted file mode 100644 index ef04e18..0000000 --- a/ui/readout_panel.py +++ /dev/null @@ -1,122 +0,0 @@ -""" -ui/readout_panel.py — Right-side numeric readout panel. -""" - -from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QLabel, - QScrollArea, QFrame, QProgressBar, -) -from PyQt6.QtCore import Qt, pyqtSlot -from devices.device_registry import DeviceRegistry - - -class ChannelReadout(QFrame): - def __init__(self, ch_config): - super().__init__() - self.ch = ch_config - self.setObjectName("channelReadout") - self._alarm = False - self._build() - - def _build(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(8, 6, 8, 6) - layout.setSpacing(2) - - top = QHBoxLayout() - self._name = QLabel(self.ch.name) - self._name.setObjectName("readoutName") - top.addWidget(self._name, 1) - - self._val = QLabel("— — —") - self._val.setObjectName("readoutValue") - self._val.setStyleSheet(f"color:{self.ch.color};") - top.addWidget(self._val) - - unit = QLabel(f" {self.ch.unit}") - unit.setObjectName("readoutUnit") - top.addWidget(unit) - layout.addLayout(top) - - self._bar = QProgressBar() - self._bar.setObjectName("readoutBar") - self._bar.setRange(0, 1000) - self._bar.setValue(500) - self._bar.setTextVisible(False) - self._bar.setMaximumHeight(3) - self._bar.setStyleSheet( - f"QProgressBar::chunk {{ background:{self.ch.color}; border-radius:1px; }}" - ) - layout.addWidget(self._bar) - - def update_value(self, v: float): - self._val.setText(f"{v:>10.4f}".strip()) - rng = self.ch.max_value - self.ch.min_value - norm = int(((v - self.ch.min_value) / rng) * 1000) if rng else 500 - self._bar.setValue(max(0, min(1000, norm))) - - alarm = ( - (self.ch.alarm_low is not None and v < self.ch.alarm_low) or - (self.ch.alarm_high is not None and v > self.ch.alarm_high) - ) - if alarm != self._alarm: - self._alarm = alarm - self.setProperty("alarm", alarm) - self.style().unpolish(self) - self.style().polish(self) - - -class ReadoutPanel(QWidget): - def __init__(self, registry: DeviceRegistry): - super().__init__() - self.registry = registry - self._readouts = {} # (dev_id, ch_id) -> ChannelReadout - self._build() - self.refresh() - - def _build(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - hdr = QLabel(" CHANNELS") - hdr.setObjectName("panelHeader") - hdr.setMinimumHeight(28) - layout.addWidget(hdr) - - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - - self._container = QWidget() - self._inner = QVBoxLayout(self._container) - self._inner.setContentsMargins(6, 6, 6, 6) - self._inner.setSpacing(4) - self._inner.addStretch() - - scroll.setWidget(self._container) - layout.addWidget(scroll) - - def refresh(self): - for w in self._readouts.values(): - w.deleteLater() - self._readouts.clear() - # Clear layout - while self._inner.count() > 1: - item = self._inner.takeAt(0) - if item and item.widget(): - item.widget().deleteLater() - - for dev in self.registry.all_instances(): - for ch in dev.info.channels: - if not ch.enabled: - continue - ro = ChannelReadout(ch) - self._inner.insertWidget(self._inner.count() - 1, ro) - self._readouts[(dev.info.device_id, ch.channel_id)] = ro - - @pyqtSlot(str, str, float, float) - def on_new_data(self, dev_id: str, ch_id: str, _ts: float, value: float): - ro = self._readouts.get((dev_id, ch_id)) - if ro: - ro.update_value(value) diff --git a/ui/style_dark.qss b/ui/style_dark.qss index 4043ff6..df48330 100644 --- a/ui/style_dark.qss +++ b/ui/style_dark.qss @@ -464,3 +464,32 @@ QLabel#traceSource { font-family: "IBM Plex Mono", monospace; font-size: 12px; } + +/* ── Profile / File menu ─────────────────────────────────────────── */ +QMenu#profileMenu { + background-color: #111620; + border: 1px solid #2a3558; + border-radius: 4px; + padding: 4px 0; + color: #e2e8f0; + font-size: 13px; +} +QMenu#profileMenu::item { + padding: 7px 20px; + color: #c7d2fe; +} +QMenu#profileMenu::item:selected { + background-color: #1e3a5f; + color: #e0f2fe; +} +QMenu#profileMenu::separator { + height: 1px; + background: #2a3558; + margin: 3px 8px; +} + +/* ── Control widget wrapper (edit/remove row) ────────────────────── */ +QFrame#controlWidgetWrapper { + background: transparent; + border: none; +} diff --git a/ui/windows/__pycache__/__init__.cpython-312.pyc b/ui/windows/__pycache__/__init__.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..244f840 --- /dev/null +++ b/ui/windows/__pycache__/__init__.cpython-312.pyc diff --git a/ui/windows/__pycache__/__init__.cpython-314.pyc b/ui/windows/__pycache__/__init__.cpython-314.pyc Binary files differdeleted file mode 100644 index 0f65045..0000000 --- a/ui/windows/__pycache__/__init__.cpython-314.pyc +++ /dev/null diff --git a/ui/windows/__pycache__/devices_window.cpython-312.pyc b/ui/windows/__pycache__/devices_window.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..f2ce20a --- /dev/null +++ b/ui/windows/__pycache__/devices_window.cpython-312.pyc diff --git a/ui/windows/__pycache__/devices_window.cpython-314.pyc b/ui/windows/__pycache__/devices_window.cpython-314.pyc Binary files differdeleted file mode 100644 index f13d7e0..0000000 --- a/ui/windows/__pycache__/devices_window.cpython-314.pyc +++ /dev/null diff --git a/ui/windows/__pycache__/plot_window.cpython-312.pyc b/ui/windows/__pycache__/plot_window.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..5c957ed --- /dev/null +++ b/ui/windows/__pycache__/plot_window.cpython-312.pyc diff --git a/ui/windows/__pycache__/plot_window.cpython-314.pyc b/ui/windows/__pycache__/plot_window.cpython-314.pyc Binary files differdeleted file mode 100644 index a76c9b5..0000000 --- a/ui/windows/__pycache__/plot_window.cpython-314.pyc +++ /dev/null diff --git a/ui/windows/__pycache__/settings_window.cpython-312.pyc b/ui/windows/__pycache__/settings_window.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..7da4e1a --- /dev/null +++ b/ui/windows/__pycache__/settings_window.cpython-312.pyc diff --git a/ui/windows/__pycache__/settings_window.cpython-314.pyc b/ui/windows/__pycache__/settings_window.cpython-314.pyc Binary files differdeleted file mode 100644 index 38f2969..0000000 --- a/ui/windows/__pycache__/settings_window.cpython-314.pyc +++ /dev/null diff --git a/ui/windows/__pycache__/signals_window.cpython-312.pyc b/ui/windows/__pycache__/signals_window.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..6b423d8 --- /dev/null +++ b/ui/windows/__pycache__/signals_window.cpython-312.pyc diff --git a/ui/windows/__pycache__/signals_window.cpython-314.pyc b/ui/windows/__pycache__/signals_window.cpython-314.pyc Binary files differdeleted file mode 100644 index 1ca83d8..0000000 --- a/ui/windows/__pycache__/signals_window.cpython-314.pyc +++ /dev/null |
