From 5d5bcaed8639a162fda85c8cd0d340c296a054b8 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Mon, 10 Aug 2026 16:12:05 -0600 Subject: Unify Signals/Channels around auto-naming, plot-by-channel, and shared vars Devices: auto-name from OS-reported port description (Device Manager / udev string) instead of a generic label; default Serial #Channels 1; consistent simulate-mode checkbox across backends; inline device rename. Channels: calibration/filters move out of Signals into a Channel "Operation" (adds Scale/Offset alongside the existing math kinds); single-physical-source Channels get an embedded Filters block; new Channels auto-name/auto-ID from their source and reject duplicate names/IDs on apply; "Create Channel" button on each Signal row; renamed "Sources" -> "Signals" in the Channels UI; Signals' READ/WRITE dir badge (was silently-broken IN/OUT) now reflects the real writable flag. Plot: default layout is one pane per Channel (not per device, not raw Signals) titled by channel name; "+ Add Channel" is a checkbox dialog instead of an inline dropdown; traces get up/down reorder; live unit changes (e.g. a Mark-10 gauge switching lb/kgF/N/ozF) propagate to an open pane's axis label without clobbering a manually-typed one. Control Panel: hardware assignment now picks from the same writable- Signals list instead of a separate device/channel combo with a hardcoded Arduino pin list; stale visual indicator when a control's target device/channel disconnects or disappears; documented the `vars` bridge (Control Panel On Action Script <-> Channel scripts) in both directions' help text and script templates. Co-Authored-By: Claude Sonnet 5 --- api_layers/arduino_layer.py | 14 +- core/acquisition.py | 19 +++ core/signal_processor.py | 4 + devices/arduino_device.py | 7 +- devices/serial_device.py | 37 +++-- ui/add_device_dialog.py | 18 ++- ui/code_templates.py | 20 +++ ui/control_editor.py | 96 +++++-------- ui/control_panel.py | 41 +++++- ui/main_window.py | 3 +- ui/strip_chart.py | 35 +++++ ui/style_dark.qss | 13 ++ ui/style_light.qss | 7 + ui/windows/channels_window.py | 305 ++++++++++++++++++++++++++++++++---------- ui/windows/config_window.py | 113 ++++++++-------- ui/windows/devices_window.py | 20 ++- ui/windows/plot_window.py | 132 +++++++++++++----- 17 files changed, 637 insertions(+), 247 deletions(-) diff --git a/api_layers/arduino_layer.py b/api_layers/arduino_layer.py index 52eeb30..a3c3584 100644 --- a/api_layers/arduino_layer.py +++ b/api_layers/arduino_layer.py @@ -87,7 +87,7 @@ class ArduinoLayer: DEFAULT_DIGITAL_PINS = ["D2", "D3", "D4", "D5", "D6", "D7"] # Firmware DIGITAL_IN[] and DIGITAL_OUT[] arrays — match arduino_layer.py firmware sketch DEFAULT_DI_PINS = ["D2", "D3", "D4"] - DEFAULT_DO_PINS = ["D5", "D6", "D7", "D9", "D10", "D11"] + DEFAULT_DO_PINS = ["D5", "D6", "D7", "D8", "D9", "D10", "D11", "D13"] def __init__( self, @@ -563,6 +563,18 @@ class ArduinoLayer: def is_pyserial_available() -> bool: return _SERIAL_AVAILABLE + @staticmethod + def describe_port(port: str) -> str: + """OS-reported device description for a port — the same string shown + in Windows Device Manager or a Linux udev/lsusb listing — so an + auto-named device mirrors what the operator already sees outside + this app. Empty string if the port isn't currently enumerable + (e.g. simulation mode, or the device was unplugged).""" + for dev, desc in ArduinoLayer.list_ports(): + if dev == port and desc: + return desc + return "" + def __repr__(self): mode = "SIM" if self.simulate else f"HW:{self.port}@{self.baud}" return f"" diff --git a/core/acquisition.py b/core/acquisition.py index 4fffb8b..79bdb79 100644 --- a/core/acquisition.py +++ b/core/acquisition.py @@ -73,12 +73,15 @@ class AcquisitionEngine(QObject): new_data(timestamp, readings) # readings: list[(device_id, channel_id, value)] — # one batched emit per poll tick, not one per channel device_status_changed(device_id, status_str) + channel_unit_changed(device_id, channel_id, new_unit) # e.g. Mark-10 gauge + # switched lb/kgF/N/ozF on its own front panel log_started(filepath) log_stopped(filepath) """ new_data = pyqtSignal(float, list) device_status_changed = pyqtSignal(str, str) + channel_unit_changed = pyqtSignal(str, str, str) log_started = pyqtSignal(str) log_stopped = pyqtSignal(str) @@ -87,6 +90,8 @@ class AcquisitionEngine(QObject): self._interval = poll_interval_ms / 1000.0 self._devices: List[BaseDevice] = [] self._buffers: Dict[str, Dict[str, ChannelBuffer]] = {} + self._last_units: Dict[Tuple[str, str], str] = {} + self._last_status: Dict[str, str] = {} self._running = False self._thread: Optional[threading.Thread] = None self._t0 = 0.0 @@ -104,10 +109,15 @@ class AcquisitionEngine(QObject): ch.channel_id: ChannelBuffer() for ch in dev.info.channels } + for ch in dev.info.channels: + self._last_units[(dev.info.device_id, ch.channel_id)] = ch.unit def remove_device(self, device_id: str): self._devices = [d for d in self._devices if d.info.device_id != device_id] self._buffers.pop(device_id, None) + self._last_status.pop(device_id, None) + for key in [k for k in self._last_units if k[0] == device_id]: + self._last_units.pop(key, None) def get_buffer(self, device_id: str, channel_id: str) -> Optional[ChannelBuffer]: return self._buffers.get(device_id, {}).get(channel_id) @@ -186,6 +196,10 @@ class AcquisitionEngine(QObject): batch: List[Tuple[str, str, float]] = [] for dev in list(self._devices): + if self._last_status.get(dev.info.device_id) != dev.status.value: + self._last_status[dev.info.device_id] = dev.status.value + self.device_status_changed.emit(dev.info.device_id, dev.status.value) + active = dev.status in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED) try: readings = dev.read_channels() if active else {} @@ -194,6 +208,11 @@ class AcquisitionEngine(QObject): readings = {} for ch in dev.info.channels: + key = (dev.info.device_id, ch.channel_id) + if self._last_units.get(key) != ch.unit: + self._last_units[key] = ch.unit + self.channel_unit_changed.emit(dev.info.device_id, ch.channel_id, ch.unit) + val = readings.get(ch.channel_id) if val is None or not ch.enabled: log_row.append("") diff --git a/core/signal_processor.py b/core/signal_processor.py index 8ab3937..c00df73 100644 --- a/core/signal_processor.py +++ b/core/signal_processor.py @@ -598,4 +598,8 @@ class SignalProcessor(QObject): elif dc.kind == "none": return inputs[0] if inputs else None + elif dc.kind == "scale_offset": + if not inputs: return None + return inputs[0] * dc.params.get("scale", 1.0) + dc.params.get("offset", 0.0) + return None diff --git a/devices/arduino_device.py b/devices/arduino_device.py index 2f5e8e6..3ad0c7b 100644 --- a/devices/arduino_device.py +++ b/devices/arduino_device.py @@ -112,9 +112,14 @@ class ArduinoDevice(BaseDevice): color=_DO_COLORS[i % len(_DO_COLORS)], )) + _desc = ArduinoLayer.describe_port(port) if not simulate else "" + if _desc: + _name = _desc if port in _desc else f"{_desc} ({port})" + else: + _name = f"Arduino ({port})" info = DeviceInfo( device_id=device_id, - name="Arduino", + name=_name, device_type=self.DEVICE_TYPE, description=(f"Arduino — {len(self._analog_pins)} analog, " f"{len(self._di_pins)} DI, {len(self._do_pins)} DO"), diff --git a/devices/serial_device.py b/devices/serial_device.py index fb131aa..61c3a64 100644 --- a/devices/serial_device.py +++ b/devices/serial_device.py @@ -52,6 +52,27 @@ _LABEL_FOR: Dict[str, str] = {v: k for k, v in _FORMAT_LABELS.items()} _GENERIC_FORMATS = {"key:val", "csv", "json"} _PROTOCOL_FORMATS = {"scpi", "modbus_rtu", "mark10", "cml"} +# parse_format → auto-name label — fallback only, used when the OS can't +# describe the port (simulation mode, or the device isn't enumerable) +_AUTO_NAME_LABEL: Dict[str, str] = { + "mark10": "Mark-10 USB Device", + "scpi": "SCPI Serial Device", + "modbus_rtu": "Modbus Serial Device", + "cml": "CML Serial Device", +} + + +def _auto_name(parse_format: str, port: str, simulate: bool = False) -> str: + """Prefer the OS-reported device description (same string Windows Device + Manager / Linux udev shows for this port) over a generic protocol label, + so the auto-assigned name matches what the operator sees outside the app.""" + if not simulate: + desc = ArduinoLayer.describe_port(port) + if desc: + return desc if port in desc else f"{desc} ({port})" + label = _AUTO_NAME_LABEL.get(parse_format, "USB Serial Device") + return f"{label} ({port})" + # Index into the config-widget stacked panel _STACK_IDX = { "key:val": 0, @@ -78,7 +99,7 @@ class SerialDevice(BaseDevice): parse_format: str = "key:val", simulate: bool = True, # ── Generic stream options ────────────────────────────────────── - num_channels: int = 4, + num_channels: int = 1, channel_names: List[str] = None, units: List[str] = None, # ── SCPI ─────────────────────────────────────────────────────── @@ -116,7 +137,7 @@ class SerialDevice(BaseDevice): channels = self._build_channel_configs() info = DeviceInfo( device_id=device_id, - name="Serial / UART", + name=_auto_name(parse_format, port, simulate), device_type=self.DEVICE_TYPE, description=self._description(), icon=self.ICON, @@ -459,14 +480,12 @@ class SerialConfigWidget(QWidget): self._fmt_cb.currentIndexChanged.connect(self._on_fmt_changed) conn_form.addRow("Protocol / Format:", self._fmt_cb) - self._sim_cb = QComboBox() - self._sim_cb.addItems(["Simulate", "Real Hardware"]) - self._sim_cb.setCurrentIndex(0 if self.device.simulate else 1) + self._sim_cb = QCheckBox("Simulation Mode (no hardware)") + self._sim_cb.setChecked(self.device.simulate) from core.app_settings import is_developer_mode - mode_lbl = QLabel("Mode:") dev_mode = is_developer_mode() - mode_lbl.setVisible(dev_mode); self._sim_cb.setVisible(dev_mode) - conn_form.addRow(mode_lbl, self._sim_cb) + self._sim_cb.setVisible(dev_mode) + conn_form.addRow(self._sim_cb) root.addWidget(conn_grp) @@ -535,7 +554,7 @@ class SerialConfigWidget(QWidget): port=self._port_edit.text().strip() or "COM3", baud=int(self._baud_cb.currentText()), fmt=fmt, - simulate=(self._sim_cb.currentIndex() == 0), + simulate=self._sim_cb.isChecked(), ) if fmt in _GENERIC_FORMATS: kwargs.update(self._generic_panel.collect()) diff --git a/ui/add_device_dialog.py b/ui/add_device_dialog.py index 2bf0233..719c5a2 100644 --- a/ui/add_device_dialog.py +++ b/ui/add_device_dialog.py @@ -152,13 +152,20 @@ class _DeviceListScanner(QWidget): class SerialPanel(QWidget): def __init__(self): super().__init__() - lay = QVBoxLayout(self) + lay = QFormLayout(self) lay.setContentsMargins(0, 4, 0, 4) - note = QLabel("Additional protocol settings available in the device config dialog.") + + self.ch_spin = QSpinBox() + self.ch_spin.setRange(1, 32) + self.ch_spin.setValue(1) + lay.addRow("#Channels:", self.ch_spin) + + note = QLabel("Applies to Key:Value / CSV / JSON formats. " + "SCPI / Modbus / Mark-10 / CML define channels via their own protocol tables " + "(available in the device config dialog after adding).") note.setObjectName("traceSource") note.setWordWrap(True) - lay.addWidget(note) - lay.addStretch() + lay.addRow(note) class ArduinoPanel(QWidget): @@ -302,7 +309,7 @@ class AddDeviceDialog(QDialog): cfg_form.addRow("Device ID:", self._id_edit) self._name_edit = QLineEdit() - self._name_edit.setPlaceholderText("Leave blank to use the default type name") + self._name_edit.setPlaceholderText("Leave blank to auto-name from device + port") cfg_form.addRow("Display Name:", self._name_edit) self._fmt_cb = QComboBox() @@ -429,6 +436,7 @@ class AddDeviceDialog(QDialog): dev = SerialDevice( device_id=dev_id, port=port, baud_rate=baud, parse_format=fmt, simulate=sim, + num_channels=panel.ch_spin.value(), ) elif type_name == "Arduino": from devices.arduino_device import _parse_pin_edit, _parse_analog_pin_edit diff --git a/ui/code_templates.py b/ui/code_templates.py index ffa1662..2a3ff09 100644 --- a/ui/code_templates.py +++ b/ui/code_templates.py @@ -19,12 +19,29 @@ Namespace available in all kinds: math — Python math module vars — shared SignalProcessor._script_vars dict (read/write) state — per-channel persistent dict (survives between evaluations) + +`vars` is the bridge from the Control Panel into Channel math: a button's +"On Action Script" (ui/control_editor.py) and every Channel script here see +the *same* `vars` dict. e.g. a "Zero" button sets + vars['zero'] = channels.get('force_raw', 0) +and a Channel reads it back with `vars.get('zero', 0)` — see the +"custom_script" template below. Same pattern for an operator-entered +constant like specimen length: a Setpoint control's On Action Script sets +`vars['specimen_length_cm'] = value`, and a Channel divides by it. """ BUILTIN_TEMPLATES: dict = { "none": """\ result = x[0] +""", + + "scale_offset": """\ +# scale/offset also settable from a Control Panel button's On Action Script, +# e.g. vars["offset"] = -x[0] to zero the reading on press +scale = vars.get("scale", 1.0) +offset = vars.get("offset", 0.0) +result = x[0] * scale + offset """, "derivative": """\ @@ -99,11 +116,14 @@ SCRIPT_TEMPLATES: dict = { "expression": "x[0]", "function": """\ +# "offset" can be set here or from a Control Panel button's On Action Script +# (vars is the same dict on both sides) offset = vars.get("offset", 0) return x[0] - offset """, "custom_script": """\ +# Wire a "Zero" button to this: On Action Script -> vars["zero"] = channels.get("", 0) zero = vars.get("zero", 0) result = x[0] - zero """, diff --git a/ui/control_editor.py b/ui/control_editor.py index 111a71f..d167517 100644 --- a/ui/control_editor.py +++ b/ui/control_editor.py @@ -204,31 +204,26 @@ class ControlEditorDialog(QDialog): root.addWidget(id_grp) - # ── Device & channel ──────────────────────────────────────────── + # ── Target signal ──────────────────────────────────────────────── + # Sourced from the same writable-channel set Configuration > Channels > + # Signals shows as "OUT" — the Signals tab is the central place hardware + # endpoints (including button targets) are registered. 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="") + self._target_cb = QComboBox() + self._target_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) + for ch in dev.info.channels: + if not ch.enabled or not ch.writable: + continue + label = f"{dev.info.device_id} / {ch.name} ({ch.channel_id})" + self._target_cb.addItem(label, userData=(dev.info.device_id, ch.channel_id)) + hw_form.addRow("Target Signal:", self._target_cb) self._active_low_chk = QCheckBox("Active-low output (ON sends LOW)") hw_form.addRow("Polarity:", self._active_low_chk) - 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 ──────────────────────────────────────── @@ -256,7 +251,10 @@ class ControlEditorDialog(QDialog): _help = QLabel( "Runs every time this control changes value.\n" "Available: value, channel_id, device_id, vars (shared dict), " - "channels (latest values by channel_id), math" + "channels (latest values by channel_id), math\n" + "vars set here are readable from any Channel's script " + "(Configuration > Channels) via vars.get('key', default) — " + "this is how a button feeds a value into Channel math." ) _help.setObjectName("traceSource"); _help.setWordWrap(True) script_lay.addWidget(_help) @@ -316,21 +314,22 @@ class ControlEditorDialog(QDialog): 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() + # Target signal found = False - for i in range(self._ch_cb.count()): - if self._ch_cb.itemData(i) == self.spec.channel_id: - self._ch_cb.setCurrentIndex(i) + target = (self.spec.device_id, self.spec.channel_id) + for i in range(self._target_cb.count()): + if self._target_cb.itemData(i) == target: + self._target_cb.setCurrentIndex(i) found = True break - if not found: - self._ch_cb.setCurrentText(self.spec.channel_id) + if not found and self.spec.device_id: + # Not in the current writable-signal list (e.g. a legacy raw pin + # target, or the channel/device is now disabled/removed) — keep it + # as a synthetic entry so editing this control doesn't silently + # clear its saved hardware assignment. + label = f"{self.spec.device_id} / {self.spec.channel_id} (not currently available)" + self._target_cb.addItem(label, userData=target) + self._target_cb.setCurrentIndex(self._target_cb.count() - 1) self._active_low_chk.setChecked(bool(self.spec.active_low)) # Params @@ -351,50 +350,17 @@ class ControlEditorDialog(QDialog): 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 (skip disabled — can't be driven while switched - # off — and skip read-only channels — a control writes, so a channel - # with no write mapping should never be offered as a target) - for ch in dev.info.channels: - if not ch.enabled or not ch.writable: - continue - self._ch_cb.addItem(f"{ch.name} ({ch.channel_id})", - 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 + dev_id, channel_id = self._target_cb.currentData() or ("", "") spec = ControlSpec( control_type=ctype, title=title, icon=CONTROL_ICONS.get(ctype, "⚙"), - device_id=self._dev_cb.currentData() or "", + device_id=dev_id, channel_id=channel_id, active_low=self._active_low_chk.isChecked(), on_action_script=self._script_edit.toPlainText().strip(), diff --git a/ui/control_panel.py b/ui/control_panel.py index 3499a44..adf48e6 100644 --- a/ui/control_panel.py +++ b/ui/control_panel.py @@ -88,6 +88,8 @@ class ControlWidget(QFrame): self._compile_action(on_action_script) self.setObjectName("controlWidget") self._build_frame() + self._stale = None + self._check_stale() def _compile_action(self, script: str): try: @@ -96,6 +98,31 @@ class ControlWidget(QFrame): except SyntaxError as e: print(f"[Control '{self.title}'] script syntax error: {e}") + def _check_stale(self): + """Re-evaluate whether this control's target signal is currently + reachable — unbound controls (no device/channel assigned) are never + stale, only ones pointing at a signal that's since gone missing.""" + stale = False + if self.device_id and self.channel_id: + dev = self.registry.get_instance(self.device_id) if self.registry else None + if dev is None: + stale = True + else: + ch = dev.get_channel(self.channel_id) + if ch is None or not ch.enabled: + stale = True + elif dev.status not in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED): + stale = True + self._set_stale(stale) + + def _set_stale(self, stale: bool): + if self._stale == stale: + return + self._stale = stale + self._stale_lbl.setVisible(stale) + self.setProperty("stale", "true" if stale else "false") + self.style().unpolish(self); self.style().polish(self) + def _logic_level(self, enabled: bool) -> float: """Map logical ON/OFF to electrical level, respecting active-low outputs.""" if self.active_low: @@ -123,6 +150,11 @@ class ControlWidget(QFrame): title_lbl.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) hdr_lay.addWidget(icon_lbl) hdr_lay.addWidget(title_lbl, 1) + self._stale_lbl = QLabel("⚠") + self._stale_lbl.setObjectName("controlWidgetStale") + self._stale_lbl.setToolTip("Target signal not connected") + self._stale_lbl.setVisible(False) + hdr_lay.addWidget(self._stale_lbl) self._header = hdr self._header_layout = hdr_lay outer.addWidget(hdr) @@ -635,13 +667,20 @@ class ControlPanel(QWidget): controls_changed = pyqtSignal() # emitted whenever widgets are added/edited/removed - def __init__(self, registry: DeviceRegistry, processor=None): + def __init__(self, registry: DeviceRegistry, processor=None, engine=None): super().__init__() self.registry = registry self.processor = processor + self.engine = engine self._widgets: list[ControlWidget] = [] self._specs: list = [] # parallel list of ControlSpec self._build() + if engine is not None: + engine.device_status_changed.connect(self._on_device_status_changed) + + def _on_device_status_changed(self, device_id: str, status: str): + for w in self._widgets: + w._check_stale() # ── Layout ─────────────────────────────────────────────────────────────── diff --git a/ui/main_window.py b/ui/main_window.py index 616f16d..c258424 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -255,7 +255,7 @@ class MainWindow(QMainWindow): root.addWidget(self._chart, 1) # ── Control Panel dock ──────────────────────────────────────────── - self._ctrl = ControlPanel(self.registry, processor=self.processor) + self._ctrl = ControlPanel(self.registry, processor=self.processor, engine=self.engine) self._ctrl.setMinimumWidth(200); self._ctrl.setMaximumWidth(340) self._ctrl._add_demo_widgets() @@ -306,6 +306,7 @@ class MainWindow(QMainWindow): def _connect_signals(self): self.engine.new_data.connect(self.processor.on_raw_batch) self.processor.processed_data.connect(self._chart.on_new_data) + self.engine.channel_unit_changed.connect(self._chart.refresh_pane_unit) self.engine.log_started.connect(lambda p: self._log_lbl.setText(f"● {p}")) self.engine.log_stopped.connect(lambda p: self._log_lbl.setText(f"✓ {p}")) diff --git a/ui/strip_chart.py b/ui/strip_chart.py index a212e5c..6c644be 100644 --- a/ui/strip_chart.py +++ b/ui/strip_chart.py @@ -79,6 +79,9 @@ class StripChartWidget(QWidget): self._cfg: Optional[LayoutConfig] = None # (dev_id, ch_id) → [{curve, plot, spec}] self._curves: Dict[Tuple, List[dict]] = {} + # (dev_id, ch_id) → last unit seen — lets refresh_pane_unit() tell an + # auto-seeded pane label apart from one the operator typed by hand + self._known_units: Dict[Tuple[str, str], str] = {} # Time-axis plots, scrolled continuously by _scroll_timer rather than # only when a new sample lands — decouples the visible scroll rate # from the (much slower) poll rate so it doesn't look stepped. @@ -179,6 +182,10 @@ class StripChartWidget(QWidget): key = (tr.device_id, tr.channel_id) self._curves.setdefault(key, []).append( {"curve": curve, "plot": plot, "spec": spec, "pane": spec}) + # Seed with the channel's true current unit (not spec.y_label, + # which may be pane title text or a custom label) so the first + # live unit-change after a layout rebuild is still detected. + self._known_units[key] = self._lookup_channel_unit(*key) def set_theme(self, theme: str): """Hot-swap pyqtgraph colours when the user changes theme.""" @@ -196,6 +203,34 @@ class StripChartWidget(QWidget): for e in self._curves.get(key, []): e["curve"].setVisible(enabled) + def _lookup_channel_unit(self, device_id: str, channel_id: str) -> str: + if device_id == "derived": + for dc in self.processor.get_derived(): + if dc.channel_id == channel_id: + return dc.unit + return "" + dev = self.registry.get_instance(device_id) + ch = dev.get_channel(channel_id) if dev else None + return ch.unit if ch else "" + + @pyqtSlot(str, str, str) + def refresh_pane_unit(self, device_id: str, channel_id: str, new_unit: str): + """A channel's unit changed live (e.g. a Mark-10 gauge switched + lb/kgF/N/ozF on its own front panel). Update any open pane's Y-axis + label that's still showing the *previous* unit verbatim — but never + touch a label the operator typed by hand.""" + key = (device_id, channel_id) + old_unit = self._known_units.get(key) + self._known_units[key] = new_unit + if old_unit == new_unit: + return + for entry in self._curves.get(key, []): + spec = entry["spec"] + if spec.y_label != old_unit: + continue + spec.y_label = new_unit + entry["plot"].setLabel("left", new_unit or spec.title) + @pyqtSlot(str, str, float, float) def on_new_data(self, device_id: str, channel_id: str, ts: float, val: float): if self._paused or not _HAS_PG or not self._cfg: return diff --git a/ui/style_dark.qss b/ui/style_dark.qss index cfb127d..06c70d9 100644 --- a/ui/style_dark.qss +++ b/ui/style_dark.qss @@ -125,6 +125,12 @@ QFrame#deviceCard:hover { border-color: #3b82f6; background-color: #1c2540; } QLabel#deviceIcon { font-size: 18px; } QLabel#deviceName { font-size: 13px; font-weight: 600; color: #e2e8f0; } +QLineEdit#deviceName { + font-size: 13px; font-weight: 600; color: #e2e8f0; + background: transparent; border: 1px solid transparent; border-radius: 3px; padding: 0 2px; +} +QLineEdit#deviceName:hover { border-color: #2a3558; } +QLineEdit#deviceName:focus { border-color: #3b82f6; background-color: #161d2e; } QLabel#deviceSub { font-family: "IBM Plex Mono", monospace; font-size: 11px; color: #8b9dc3; } QLabel#deviceChannelCount { font-size: 11px; color: #3b82f6; } @@ -222,6 +228,10 @@ QTableWidget#channelTable { color: #e2e8f0; font-size: 12px; } +QTableWidget#channelTable::item:selected { + background-color: #3b82f6; + color: #0b0e13; +} QHeaderView::section { background-color: #161d2e; color: #8b9dc3; @@ -404,10 +414,13 @@ QPushButton#applyButton:hover { background-color: #1d4ed8; color: #eff6ff; } /* ── Control widgets ─────────────────────────────────────────────── */ QFrame#controlWidget { background-color: #161d2e; border: 1px solid #2a3558; border-radius: 6px; } +QFrame#controlWidget[stale="true"] { border: 1px solid #f59e0b; } QWidget#controlWidgetHeader { background-color: #0f1521; border-radius: 5px 5px 0 0; border-bottom: 1px solid #2a3558; } QLabel#controlWidgetIcon { font-size: 14px; color: #3b82f6; padding-right: 4px; } QLabel#controlWidgetTitle { font-family: "IBM Plex Mono", monospace; font-size: 11px; font-weight: 700; letter-spacing: 1.5px; color: #8b9dc3; } +QLabel#controlWidgetStale { color: #f59e0b; font-size: 12px; padding: 0 2px; } QWidget#controlWidgetBody { background-color: #161d2e; border-radius: 0 0 5px 5px; } +QFrame#controlWidget[stale="true"] QWidget#controlWidgetBody { background-color: #1c1710; } QLabel#motorSpeedLbl { font-family: "IBM Plex Mono", monospace; font-size: 28px; font-weight: 700; color: #00d4ff; } QLabel#motorUnitLbl { font-family: "IBM Plex Mono", monospace; font-size: 12px; color: #64748b; padding-top: 12px; } diff --git a/ui/style_light.qss b/ui/style_light.qss index b98dc52..f75e7cb 100644 --- a/ui/style_light.qss +++ b/ui/style_light.qss @@ -17,6 +17,9 @@ QLabel#panelHeader { background:#f1f5f9; color:#64748b; font-family:"IBM Plex Mo QFrame#deviceCard { background:#ffffff; border:1px solid #e2e8f0; border-radius:6px; } QFrame#deviceCard:hover { border-color:#3b82f6; background:#f0f9ff; } QLabel#deviceName { font-size:13px; font-weight:600; color:#1e293b; } +QLineEdit#deviceName { font-size:13px; font-weight:600; color:#1e293b; background:transparent; border:1px solid transparent; border-radius:3px; padding:0 2px; } +QLineEdit#deviceName:hover { border-color:#cbd5e1; } +QLineEdit#deviceName:focus { border-color:#3b82f6; background:#ffffff; } QLabel#deviceSub { font-family:"IBM Plex Mono",monospace; font-size:10px; color:#94a3b8; } QLabel#deviceChannelCount { font-size:11px; color:#3b82f6; } QPushButton#configButton { background:#f1f5f9; color:#64748b; border:1px solid #cbd5e1; border-radius:3px; padding:3px 10px; font-size:11px; } @@ -60,6 +63,7 @@ QFrame#devWindowDivider { color:#e2e8f0; max-height:1px; } QWidget#cfgGlobalBar { background:#f8fafc; border-bottom:1px solid #e2e8f0; } QWidget#cfgBottomBar { background:#f8fafc; border-top:1px solid #e2e8f0; } QTableWidget#channelTable { background:#ffffff; gridline-color:#f1f5f9; border:1px solid #e2e8f0; alternate-background-color:#f8fafc; } +QTableWidget#channelTable::item:selected { background-color:#93c5fd; color:#0f172a; } QHeaderView::section { background:#f1f5f9; color:#475569; border:none; border-bottom:1px solid #e2e8f0; padding:4px 8px; font-weight:600; } /* Port scanner list — light theme */ @@ -73,10 +77,13 @@ QListWidget#portList::item:selected { background:#dbeafe; color:#1d4ed8; border- /* ── Control panel widgets ───────────────────────────────────────── */ QFrame#controlWidget { background:#ffffff; border:1px solid #cbd5e1; border-radius:6px; } +QFrame#controlWidget[stale="true"] { border:1px solid #d97706; } QWidget#controlWidgetHeader { background:#f1f5f9; border-radius:5px 5px 0 0; border-bottom:1px solid #e2e8f0; } QLabel#controlWidgetIcon { font-size:14px; color:#3b82f6; padding-right:4px; } QLabel#controlWidgetTitle { font-family:"IBM Plex Mono",monospace; font-size:11px; font-weight:700; letter-spacing:1.5px; color:#64748b; } +QLabel#controlWidgetStale { color:#d97706; font-size:12px; padding:0 2px; } QWidget#controlWidgetBody { background:#ffffff; border-radius:0 0 5px 5px; } +QFrame#controlWidget[stale="true"] QWidget#controlWidgetBody { background:#fffbeb; } /* Motor */ QLabel#motorSpeedLbl { font-family:"IBM Plex Mono",monospace; font-size:28px; font-weight:700; color:#1d4ed8; } diff --git a/ui/windows/channels_window.py b/ui/windows/channels_window.py index 38d477b..2388b83 100644 --- a/ui/windows/channels_window.py +++ b/ui/windows/channels_window.py @@ -215,7 +215,8 @@ class ChannelPipelineBlock(QFrame): removed = pyqtSignal(object) def __init__(self, dev_id: str, ch_id: str, ch_name: str, unit: str, - processor: SignalProcessor): + processor: SignalProcessor, show_calibration: bool = True, + show_header: bool = True): super().__init__() self.dev_id = dev_id self.ch_id = ch_id @@ -224,6 +225,8 @@ class ChannelPipelineBlock(QFrame): self._cal_rows: List[FilterRow] = [] self._cur_pipeline: Optional[ChannelPipeline] = None self._expanded = False + self._show_calibration = show_calibration + self._show_header = show_header self.setObjectName("plotBlock") self._build(ch_name, unit) self._load() @@ -233,26 +236,28 @@ class ChannelPipelineBlock(QFrame): self._unit = unit outer = QVBoxLayout(self); outer.setContentsMargins(0, 0, 0, 0); outer.setSpacing(0) - # Header - hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32) - hl = QHBoxLayout(hdr); hl.setContentsMargins(8, 0, 6, 0) - title = f"{ch_name} ({self.dev_id}/{self.ch_id})" - if unit: - title += f" [{unit}]" - self._title_lbl = QLabel(title); self._title_lbl.setObjectName("traceSource") - hl.addWidget(self._title_lbl, 1) - - rm_btn = QToolButton(); rm_btn.setText("✕") - rm_btn.setObjectName("traceRemoveBtn"); rm_btn.setFixedSize(22, 22) - rm_btn.clicked.connect(lambda: self.removed.emit(self)) - hl.addWidget(rm_btn) - - self._toggle_btn = QToolButton(); self._toggle_btn.setText("▶") - self._toggle_btn.setObjectName("traceRemoveBtn"); self._toggle_btn.setFixedSize(22, 22) - self._toggle_btn.clicked.connect(self._toggle) - hl.addWidget(self._toggle_btn) - outer.addWidget(hdr) - hdr.mousePressEvent = lambda e: self._toggle() + # Header (omitted when embedded in another block, e.g. DerivedBlock's + # Filters section — the parent already provides a header/toggle) + if self._show_header: + hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32) + hl = QHBoxLayout(hdr); hl.setContentsMargins(8, 0, 6, 0) + title = f"{ch_name} ({self.dev_id}/{self.ch_id})" + if unit: + title += f" [{unit}]" + self._title_lbl = QLabel(title); self._title_lbl.setObjectName("traceSource") + hl.addWidget(self._title_lbl, 1) + + rm_btn = QToolButton(); rm_btn.setText("✕") + rm_btn.setObjectName("traceRemoveBtn"); rm_btn.setFixedSize(22, 22) + rm_btn.clicked.connect(lambda: self.removed.emit(self)) + hl.addWidget(rm_btn) + + self._toggle_btn = QToolButton(); self._toggle_btn.setText("▶") + self._toggle_btn.setObjectName("traceRemoveBtn"); self._toggle_btn.setFixedSize(22, 22) + self._toggle_btn.clicked.connect(self._toggle) + hl.addWidget(self._toggle_btn) + outer.addWidget(hdr) + hdr.mousePressEvent = lambda e: self._toggle() # Body self._body = QWidget(); self._body.setObjectName("plotBlockSettings") @@ -281,21 +286,25 @@ class ChannelPipelineBlock(QFrame): div.setObjectName("devWindowDivider") bl.addWidget(div) - # ── Calibration section ─────────────────────────────────────────────── - cal_lbl = QLabel("Calibration"); cal_lbl.setObjectName("pwmLabel") - bl.addWidget(cal_lbl) - + # ── Calibration section — hidden when embedded (calibration is now + # configured as a Channel "Scale/Offset" Operation instead), but the + # widgets still exist so a legacy scale_offset filter already saved + # on this pipeline (from an older profile) is preserved by _push() + # rather than silently dropped the next time a filter is edited. self._cal_area = QWidget() self._cal_lay = QVBoxLayout(self._cal_area) self._cal_lay.setContentsMargins(0, 0, 0, 0); self._cal_lay.setSpacing(3) - bl.addWidget(self._cal_area) - - cal_add_bar = QHBoxLayout() - cal_add_btn = QPushButton("+ Add Calibration"); cal_add_btn.setObjectName("addTraceBtn") - cal_add_btn.clicked.connect(self._add_calibrate) - cal_add_bar.addWidget(cal_add_btn) - cal_add_bar.addStretch() - bl.addLayout(cal_add_bar) + if self._show_calibration: + cal_lbl = QLabel("Calibration"); cal_lbl.setObjectName("pwmLabel") + bl.addWidget(cal_lbl) + bl.addWidget(self._cal_area) + + cal_add_bar = QHBoxLayout() + cal_add_btn = QPushButton("+ Add Calibration"); cal_add_btn.setObjectName("addTraceBtn") + cal_add_btn.clicked.connect(self._add_calibrate) + cal_add_bar.addWidget(cal_add_btn) + cal_add_bar.addStretch() + bl.addLayout(cal_add_bar) ap_row = QHBoxLayout(); ap_row.addStretch() ap_btn = QPushButton("✓ Apply"); ap_btn.setObjectName("applyButton") @@ -304,9 +313,12 @@ class ChannelPipelineBlock(QFrame): bl.addLayout(ap_row) outer.addWidget(self._body) - self._body.setVisible(False) + self._body.setVisible(self._show_header is False) + self._expanded = not self._show_header def _refresh_title(self): + if not self._show_header: + return title = f"{self._ch_name} ({self.dev_id}/{self.ch_id})" if self._unit: title += f" [{self._unit}]" @@ -447,7 +459,7 @@ class PipelineTab(QWidget): vb_lay.addWidget(vb_lbl) self._src_cb = _channel_combo(self.registry, self.processor, include_derived=True) self._src_cb.setObjectName("channelPickerCb") - self._src_cb.insertItem(0, "Source: none (empty channel)", userData=None) + self._src_cb.insertItem(0, "Signal: none (empty channel)", userData=None) self._src_cb.setCurrentIndex(0) vb_lay.addWidget(self._src_cb, 1) add_virt = QPushButton("+ Add Channel"); add_virt.setObjectName("addTraceBtn") @@ -521,6 +533,7 @@ class PipelineTab(QWidget): block = self._blocks.get(key) if block: self._on_remove_block(block) + self._refresh_src_cb() def on_device_reconfigured(self, dev_id: str): dev = self.registry.get_instance(dev_id) @@ -534,9 +547,10 @@ class PipelineTab(QWidget): block = self._blocks.get(key) if block: self._on_remove_block(block) + self._refresh_src_cb() def on_device_added(self): - pass + self._refresh_src_cb() def on_channel_name_changed(self, dev_id: str, ch_id: str, ch_name: str): key = (dev_id, ch_id) @@ -548,12 +562,42 @@ class PipelineTab(QWidget): ch = dev.get_channel(ch_id) if ch and ch.enabled: self._add_block(dev_id, ch_id, ch_name, ch.unit) + self._refresh_src_cb() def on_channel_unit_changed(self, dev_id: str, ch_id: str, unit: str): key = (dev_id, ch_id) if key in self._blocks: self._blocks[key].update_unit(unit) + def _refresh_src_cb(self): + """Rebuild the 'Source: …' picker at the top of the Channels pane — + it's a persistent combo (not a dialog), so it must be kept in sync + whenever devices/channels are added, removed, or renamed, not just + built once at construction time.""" + current = self._src_cb.currentData() + self._src_cb.blockSignals(True) + self._src_cb.clear() + self._src_cb.insertItem(0, "Signal: none (empty channel)", userData=None) + for dev in self.registry.all_instances(): + for ch in dev.info.channels: + if not ch.enabled: + continue + label = f"{ch.name} ({dev.info.device_id}/{ch.channel_id})" + self._src_cb.addItem(label, userData=(dev.info.device_id, ch.channel_id)) + for dc in self.processor.get_derived(): + label = f"[derived] {dc.channel_id} ({dc.name})" + self._src_cb.addItem(label, userData=("derived", dc.channel_id)) + if current: + for i in range(self._src_cb.count()): + if self._src_cb.itemData(i) == current: + self._src_cb.setCurrentIndex(i) + break + else: + self._src_cb.setCurrentIndex(0) + else: + self._src_cb.setCurrentIndex(0) + self._src_cb.blockSignals(False) + def _on_add_channel(self): dlg = ChannelPickerDialog(self.registry, self._shown, self) if dlg.exec() != QDialog.DialogCode.Accepted: @@ -579,14 +623,21 @@ class PipelineTab(QWidget): def _add_virtual(self): color = _DERIVED_COLORS[self._color_idx % len(_DERIVED_COLORS)] self._color_idx += 1 - n = len(self._derived_blocks) + 1 - dc = DerivedChannel(channel_id=f"CH_{n}", name=f"Channel {n}", + existing_names = {d.name for d in self.processor.get_derived()} + existing_ids = {d.channel_id for d in self.processor.get_derived()} + # Pre-seed the source picked in the bar above, if any — otherwise the + # channel is created empty and signals can be added manually. + src = self._src_cb.currentData() + if src: + src_name = _channel_display_name(self.registry, self.processor, src[0], src[1]) + else: + src_name = f"Channel {len(self._derived_blocks) + 1}" + name = _dedupe_name(src_name, existing_names) + channel_id = _slug_id(name, existing_ids) + dc = DerivedChannel(channel_id=channel_id, name=name, kind="expression", color=color) blk = self._make_derived_block(dc) self._virt_inner.insertWidget(self._virt_inner.count() - 1, blk) - # Pre-seed the source picked in the bar above, if any — otherwise the - # channel is created empty and sources can be added manually. - src = self._src_cb.currentData() if src: blk._add_src(src) @@ -615,45 +666,85 @@ class PipelineTab(QWidget): self._virt_inner.insertWidget(self._virt_inner.count() - 1, blk) self._color_idx = len(self._derived_blocks) + self._refresh_src_cb() # ══════════════════════════════════════════════════════════════════════════════ # Derived editor for one DerivedChannel # ══════════════════════════════════════════════════════════════════════════════ -_BUILTIN = ["none", "derivative", "second_derivative", "power", "rms", "difference", "sum"] +_BUILTIN = ["none", "scale_offset", "derivative", "second_derivative", "power", "rms", "difference", "sum"] _SCRIPT = ["expression", "function", "custom_script"] _ALL_KINDS = _BUILTIN + _SCRIPT _KIND_DISPLAY = {k: k.replace("_", " ").title() for k in _ALL_KINDS} _KIND_DISPLAY["none"] = "None" +_KIND_DISPLAY["scale_offset"] = "Scale/Offset (Calibration)" _KIND_HELP = { - "none": "Pass source through unchanged", - "derivative": "Backward difference dy/dt from 1 source", - "second_derivative": "Second derivative d²y/dt² from 1 source", - "power": "V × I from 2 sources (voltage, current)", - "rms": "Rolling RMS from 1 source (set window below)", - "difference": "source[0] − source[1] from 2 sources", - "sum": "Σ all source values", + "none": "Pass signal through unchanged", + "scale_offset": "Linear calibration from 1 signal: result = value × scale + offset", + "derivative": "Backward difference dy/dt from 1 signal", + "second_derivative": "Second derivative d²y/dt² from 1 signal", + "power": "V × I from 2 signals (voltage, current)", + "rms": "Rolling RMS from 1 signal (set window below)", + "difference": "signal[0] − signal[1] from 2 signals", + "sum": "Σ all signal values", "expression": ( "Single-line Python expression.\n" "Available: x[0]… or use signal names directly (e.g. A0, encoder_0)\n" " t = elapsed time (s), math, vars, state\n" - "Example: A0 * 3.14159 / 180" + "Example: A0 * 3.14159 / 180\n" + "Tip: vars is shared with Control Panel 'On Action Script' — a button\n" + " can set vars['specimen_length'] = value; read it here as\n" + " vars.get('specimen_length', 1.0)." ), "function": ( "Multi-line function body — auto-wrapped as def compute(x, t):\n" "Available globals: x, signal names, t, math, vars, state\n" - "Example:\n offset = vars.get('zero', 0)\n return x[0] - offset" + "Example:\n offset = vars.get('zero', 0)\n return x[0] - offset\n" + "Tip: vars is shared with Control Panel 'On Action Script' — a 'Zero'\n" + " button can set vars['zero'] = value while this reads it back." ), "custom_script": ( "Multi-line Python block. Assign computed value to result.\n" - "Available: x, signal names, t, math, vars, state" + "Available: x, signal names, t, math, vars, state\n" + "Tip: vars is shared with Control Panel 'On Action Script' — a button\n" + " can set vars['key'] = value; read it here as vars.get('key', default)." ), } +def _slug_id(name: str, existing: Set[str]) -> str: + """Short-hand channel_id derived from a display name, deduplicated against + existing derived channel IDs so a new Channel never silently overwrites + (goes stale on top of) an existing one.""" + words = re.findall(r"[A-Za-z0-9]+", name) + if not words: + base = "CH" + elif len(words) == 1: + base = words[0][:4].upper() + else: + base = "".join(w[0] for w in words[:4]).upper() + slug = base + i = 2 + while slug in existing: + slug = f"{base}_{i}" + i += 1 + return slug + + +def _dedupe_name(name: str, existing: Set[str]) -> str: + """Two channels must never share a display name — append ' (2)', ' (3)'... + until unique.""" + if name not in existing: + return name + i = 2 + while f"{name} ({i})" in existing: + i += 1 + return f"{name} ({i})" + + def _sub_xrefs(code: str, names: list) -> str: """Replace x[0], x[1], ... with named source vars where index is in range.""" def repl(m): @@ -712,6 +803,22 @@ class DerivedBlock(QFrame): meta.addWidget(self._unit); meta.addStretch() bl.addLayout(meta) + # Sources — include derived channels as valid sources + src_grp = QGroupBox("Signals"); src_grp.setObjectName("cfgGlobalBar") + src_lay = QVBoxLayout(src_grp); src_lay.setContentsMargins(6, 4, 6, 4); src_lay.setSpacing(3) + self._src_cont = QWidget(); self._src_vlay = QVBoxLayout(self._src_cont) + self._src_vlay.setContentsMargins(0, 0, 0, 0); self._src_vlay.setSpacing(3) + _is_deriv = self.dc.kind in ("derivative", "second_derivative") + if _is_deriv: + self._add_src(self.dc.sources[0] if self.dc.sources else None) + else: + for _s in self.dc.sources: + self._add_src(_s) + self._add_src_btn = QPushButton("+ Add Signal"); self._add_src_btn.setObjectName("addTraceBtn") + self._add_src_btn.clicked.connect(lambda: self._add_src()) + src_lay.addWidget(self._src_cont); src_lay.addWidget(self._add_src_btn) + bl.addWidget(src_grp) + # Kind combo + help k_row = QHBoxLayout(); k_row.addWidget(QLabel("Operation:")) self._kind_cb = QComboBox(); self._kind_cb.setObjectName("channelPickerCb") @@ -736,21 +843,30 @@ class DerivedBlock(QFrame): self._win_widget = QWidget(); self._win_widget.setLayout(self._win_row) bl.addWidget(self._win_widget) - # Sources — include derived channels as valid sources - src_grp = QGroupBox("Sources"); src_grp.setObjectName("cfgGlobalBar") - src_lay = QVBoxLayout(src_grp); src_lay.setContentsMargins(6, 4, 6, 4); src_lay.setSpacing(3) - self._src_cont = QWidget(); self._src_vlay = QVBoxLayout(self._src_cont) - self._src_vlay.setContentsMargins(0, 0, 0, 0); self._src_vlay.setSpacing(3) - _is_deriv = self.dc.kind in ("derivative", "second_derivative") - if _is_deriv: - self._add_src(self.dc.sources[0] if self.dc.sources else None) - else: - for _s in self.dc.sources: - self._add_src(_s) - self._add_src_btn = QPushButton("+ Add Source"); self._add_src_btn.setObjectName("addTraceBtn") - self._add_src_btn.clicked.connect(lambda: self._add_src()) - src_lay.addWidget(self._src_cont); src_lay.addWidget(self._add_src_btn) - bl.addWidget(src_grp) + # Scale/Offset params + self._so_row = QHBoxLayout() + self._so_row.addWidget(QLabel("Scale:")) + self._scale_sp = QDoubleSpinBox(); self._scale_sp.setRange(-1e6, 1e6); self._scale_sp.setDecimals(6) + self._scale_sp.setValue(self.dc.params.get("scale", 1.0)) + self._scale_sp.setObjectName("traceWidthSpin"); self._scale_sp.setFixedWidth(90) + self._scale_sp.valueChanged.connect(lambda v: self.dc.params.update({"scale": v})) + self._so_row.addWidget(self._scale_sp) + self._so_row.addWidget(QLabel("Offset:")) + self._offset_sp = QDoubleSpinBox(); self._offset_sp.setRange(-1e9, 1e9); self._offset_sp.setDecimals(6) + self._offset_sp.setValue(self.dc.params.get("offset", 0.0)) + self._offset_sp.setObjectName("traceWidthSpin"); self._offset_sp.setFixedWidth(90) + self._offset_sp.valueChanged.connect(lambda v: self.dc.params.update({"offset": v})) + self._so_row.addWidget(self._offset_sp); self._so_row.addStretch() + self._so_widget = QWidget(); self._so_widget.setLayout(self._so_row) + bl.addWidget(self._so_widget) + + # Filters — only for a single-physical-source passthrough channel + # (kind="none"); the embedded block is (re)built by _refresh_filters() + self._filters_grp = QGroupBox("Filters"); self._filters_grp.setObjectName("cfgGlobalBar") + self._filters_lay = QVBoxLayout(self._filters_grp) + self._filters_lay.setContentsMargins(6, 4, 6, 4) + self._filters_block: Optional[ChannelPipelineBlock] = None + bl.addWidget(self._filters_grp) # W.r.t. — only for derivative/second_derivative self._wrt_grp = QGroupBox("With Respect To"); self._wrt_grp.setObjectName("cfgGlobalBar") @@ -876,8 +992,10 @@ class DerivedBlock(QFrame): if wrt_src and wrt_src not in sources: sources.append(wrt_src) + self._refresh_filters() + if not sources: - self._vars_lbl.setText("No sources — add sources above to use named variables") + self._vars_lbl.setText("No signals — add signals above to use named variables") return display_names = [_channel_display_name(self.registry, self.processor, d, c) @@ -903,6 +1021,36 @@ class DerivedBlock(QFrame): self._code.setTextCursor(c) self._code.blockSignals(False) + def _refresh_filters(self): + """(Re)build the embedded single-source Filters block when eligible: + kind is a plain passthrough ("none") with exactly one physical source.""" + if not hasattr(self, "_filters_grp"): + return # called during initial Sources construction, too early + sources = [cb.currentData() for cb in self._src_combos if cb.currentData()] + eligible = (self.dc.kind == "none" and len(sources) == 1 + and sources[0][0] != "derived") + if not eligible: + self._filters_grp.setVisible(False) + if self._filters_block is not None: + self._filters_lay.removeWidget(self._filters_block) + self._filters_block.deleteLater() + self._filters_block = None + return + dev_id, ch_id = sources[0] + if (self._filters_block is not None + and (self._filters_block.dev_id, self._filters_block.ch_id) != (dev_id, ch_id)): + self._filters_lay.removeWidget(self._filters_block) + self._filters_block.deleteLater() + self._filters_block = None + if self._filters_block is None: + ch_name = _channel_display_name(self.registry, self.processor, dev_id, ch_id) + self._filters_block = ChannelPipelineBlock( + dev_id, ch_id, ch_name, "", self.processor, + show_calibration=False, show_header=False, + ) + self._filters_lay.addWidget(self._filters_block) + self._filters_grp.setVisible(True) + def _on_kind(self, idx: int): from ui.code_templates import BUILTIN_TEMPLATES, SCRIPT_TEMPLATES k = _ALL_KINDS[idx] @@ -958,12 +1106,33 @@ class DerivedBlock(QFrame): self._code.setReadOnly(is_builtin) self._edit_as_script_btn.setVisible(is_builtin) self._win_widget.setVisible(self.dc.kind == "rms") + self._so_widget.setVisible(self.dc.kind == "scale_offset") + self._refresh_filters() self._wrt_grp.setVisible(is_deriv) self._add_src_btn.setVisible(not is_deriv) if is_deriv: self._wrt_x_row.setVisible(self._wrt_cb.currentIndex() == 1) def _apply(self): + name = self.dc.name.strip() + channel_id = self.dc.channel_id.strip() + if not name: + self._st.setText("⚠ Name cannot be empty") + self._st.setStyleSheet("color:#ef4444;") + return + if not channel_id: + self._st.setText("⚠ ID cannot be empty") + self._st.setStyleSheet("color:#ef4444;") + return + others = [d for d in self.processor.get_derived() if d is not self.dc] + if any(d.name == name for d in others): + self._st.setText(f"⚠ Name '{name}' is already used by another channel") + self._st.setStyleSheet("color:#ef4444;") + return + if any(d.channel_id == channel_id for d in others): + self._st.setText(f"⚠ ID '{channel_id}' is already used by another channel") + self._st.setStyleSheet("color:#ef4444;") + return sources = [cb.currentData() for cb in self._src_combos if cb.currentData()] if self.dc.kind in ("derivative", "second_derivative"): wrt = "channel" if self._wrt_cb.currentIndex() == 1 else "time" diff --git a/ui/windows/config_window.py b/ui/windows/config_window.py index 05cc1fb..64c669d 100644 --- a/ui/windows/config_window.py +++ b/ui/windows/config_window.py @@ -5,8 +5,8 @@ Unified Configuration window — Devices | Channels | Plot in one tabbed window. Channels tab layout (2 columns): Left — signal table (Device / Signal ID / Dir / On / Name / Unit / Min / Max) - ⚙ button per row opens that channel's filter/calibration pipeline dialog - Right — derived / virtual channels + Right — derived / virtual channels — operations (filters/calibration/math/script) + are configured per Channel here, not on the Signals side. """ from __future__ import annotations @@ -17,55 +17,30 @@ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QToolButton, QScrollArea, QFrame, QTabWidget, QTableWidget, QTableWidgetItem, QHeaderView, QCheckBox, QMessageBox, QAbstractItemView, QSplitter, - QDoubleSpinBox, QDialog, QDialogButtonBox, + QDoubleSpinBox, ) from PyQt6.QtCore import Qt, pyqtSignal, QTimer from PyQt6.QtGui import QCloseEvent, QColor from devices.device_registry import DeviceRegistry from core.acquisition import AcquisitionEngine -from core.signal_processor import SignalProcessor +from core.signal_processor import SignalProcessor, DerivedChannel from ui.windows.devices_window import DeviceRow, ChannelsTab -from ui.windows.channels_window import PipelineTab, ChannelPipelineBlock +from ui.windows.channels_window import PipelineTab, _slug_id, _dedupe_name from ui.windows.plot_window import ( PlotWindow, LayoutConfig, LayoutCanvas, PaneBlock, build_default_layout, ) -# ══════════════════════════════════════════════════════════════════════════════ -# Pipeline dialog — per-channel filter/calibration popup -# ══════════════════════════════════════════════════════════════════════════════ - -class _PipelineDialog(QDialog): - """Shows one ChannelPipelineBlock inside a dialog for a single signal.""" - - def __init__(self, dev_id: str, ch_id: str, ch_name: str, unit: str, - processor: SignalProcessor, parent=None): - super().__init__(parent) - self.setWindowTitle(f"Pipeline — {dev_id} / {ch_id}") - self.setMinimumWidth(460) - self.resize(500, 420) - - lay = QVBoxLayout(self) - lay.setContentsMargins(0, 0, 0, 12) - - self._block = ChannelPipelineBlock(dev_id, ch_id, ch_name, unit, processor) - self._block._toggle() # start expanded - lay.addWidget(self._block, 1) - - btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) - btns.rejected.connect(self.accept) - lay.addWidget(btns) - - # ══════════════════════════════════════════════════════════════════════════════ # Enhanced signals table — adds Dir badge and pipeline ⚙ button # ══════════════════════════════════════════════════════════════════════════════ class _SignalsTab(ChannelsTab): - """ChannelsTab with Dir (IN/OUT) badge and per-row ⚙ pipeline button.""" + """ChannelsTab with a Dir (READ/WRITE) badge. Operations (filters/calibration/math) + are configured per Channel in the Channels pane, not here.""" _C_DEVICE = 0 _C_CH_ID = 1 @@ -75,7 +50,9 @@ class _SignalsTab(ChannelsTab): _C_UNIT = 5 _C_MIN = 6 _C_MAX = 7 - _C_PIPE = 8 # ⚙ pipeline button + _C_MKCH = 8 # "+ Channel" button + + channel_created = pyqtSignal() def __init__(self, registry: DeviceRegistry, processor: SignalProcessor): self._processor = processor @@ -97,7 +74,7 @@ class _SignalsTab(ChannelsTab): hdr.setSectionResizeMode(self._C_CH_ID, QHeaderView.ResizeMode.ResizeToContents) hdr.setSectionResizeMode(self._C_DIR, QHeaderView.ResizeMode.ResizeToContents) hdr.setSectionResizeMode(self._C_ENABLED, QHeaderView.ResizeMode.ResizeToContents) - hdr.setSectionResizeMode(self._C_PIPE, QHeaderView.ResizeMode.ResizeToContents) + hdr.setSectionResizeMode(self._C_MKCH, QHeaderView.ResizeMode.ResizeToContents) self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self._table.setAlternatingRowColors(True) self._table.verticalHeader().setVisible(False) @@ -122,8 +99,8 @@ class _SignalsTab(ChannelsTab): ch_item.setForeground(QColor(ch.color)) self._table.setItem(row, self._C_CH_ID, ch_item) - is_out = getattr(ch, "is_output", False) - dir_item = QTableWidgetItem("OUT" if is_out else "IN") + is_out = getattr(ch, "writable", False) + dir_item = QTableWidgetItem("WRITE" if is_out else "READ") dir_item.setFlags(dir_item.flags() & ~Qt.ItemFlag.ItemIsEditable) dir_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) dir_item.setForeground(QColor("#f59e0b") if is_out else QColor("#22c55e")) @@ -156,33 +133,47 @@ class _SignalsTab(ChannelsTab): self._table.setItem(row, self._C_MIN, QTableWidgetItem(str(ch.min_value))) self._table.setItem(row, self._C_MAX, QTableWidgetItem(str(ch.max_value))) - # Pipeline ⚙ button - pipe_w = QWidget() - pipe_l = QHBoxLayout(pipe_w) - pipe_l.setContentsMargins(2, 0, 2, 0) - pipe_l.setAlignment(Qt.AlignmentFlag.AlignCenter) - pipe_btn = QToolButton() - pipe_btn.setText("⚙") - pipe_btn.setObjectName("traceRemoveBtn") - pipe_btn.setFixedSize(22, 22) - pipe_btn.setToolTip("Configure filters / calibration") - d_cap, c_cap, n_cap, u_cap = ( - dev.info.device_id, ch.channel_id, ch.name, ch.unit + mkch_w = QWidget() + mkch_l = QHBoxLayout(mkch_w) + mkch_l.setContentsMargins(2, 0, 2, 0) + mkch_l.setAlignment(Qt.AlignmentFlag.AlignCenter) + mkch_btn = QToolButton() + mkch_btn.setText("⊕") + mkch_btn.setObjectName("addTraceBtn") + mkch_btn.setFixedSize(24, 22) + mkch_btn.setToolTip("Create a Channel from this Signal") + d_cap, c_cap, n_cap, u_cap, col_cap = ( + dev.info.device_id, ch.channel_id, ch.name, ch.unit, ch.color ) - pipe_btn.clicked.connect( - lambda _=False, d=d_cap, c=c_cap, n=n_cap, u=u_cap: - self._open_pipeline(d, c, n, u) + mkch_btn.clicked.connect( + lambda _=False, d=d_cap, c=c_cap, n=n_cap, u=u_cap, col=col_cap: + self._on_create_channel(d, c, n, u, col) ) - pipe_l.addWidget(pipe_btn) - self._table.setCellWidget(row, self._C_PIPE, pipe_w) + mkch_l.addWidget(mkch_btn) + self._table.setCellWidget(row, self._C_MKCH, mkch_w) row += 1 self._table.blockSignals(False) - def _open_pipeline(self, dev_id: str, ch_id: str, ch_name: str, unit: str): - dlg = _PipelineDialog(dev_id, ch_id, ch_name, unit, self._processor, self) - dlg.exec() + def _on_create_channel(self, dev_id: str, ch_id: str, ch_name: str, unit: str, color: str): + """Create a passthrough Channel (kind="none") from this Signal, + named after it — the "1 Signal -> 1 Channel" quick path. No-ops if + a passthrough Channel for this exact Signal already exists.""" + existing = self._processor.get_derived() + for dc in existing: + if dc.kind == "none" and dc.sources == [(dev_id, ch_id)]: + return + existing_names = {d.name for d in existing} + existing_ids = {d.channel_id for d in existing} + name = _dedupe_name(ch_name, existing_names) + channel_id = _slug_id(name, existing_ids) + dc = DerivedChannel(channel_id=channel_id, name=name, kind="none", + sources=[(dev_id, ch_id)], source_names=[ch_name], + unit=unit, color=color or "#00d4ff") + err = self._processor.add_derived(dc) + if err is None: + self.channel_created.emit() # ══════════════════════════════════════════════════════════════════════════════ @@ -353,6 +344,7 @@ class ConfigWindow(QWidget): row = DeviceRow(dev) row.configure_clicked.connect(self._on_configure_device) row.remove_clicked.connect(self._on_remove_device) + row.name_changed.connect(self._on_device_renamed) self._dev_inner.insertWidget(self._dev_inner.count() - 1, row) self._dev_rows[dev.info.device_id] = row @@ -371,14 +363,20 @@ class ConfigWindow(QWidget): self.engine.add_device(dev) self.refresh_devices() self._signals_tab.refresh() + self._pipeline_tab.on_device_added() self.device_added.emit() + def _on_device_renamed(self, device_id: str): + self._signals_tab.refresh() + self.device_reconfigured.emit(device_id) + def _on_configure_device(self, device_id: str): from ui.config_dialog import DeviceConfigDialog dev = self.registry.get_instance(device_id) if dev: DeviceConfigDialog(dev, self).exec() self._signals_tab.refresh() + self._pipeline_tab.on_device_reconfigured(device_id) self.device_reconfigured.emit(device_id) def _on_remove_device(self, device_id: str): @@ -412,7 +410,7 @@ class ConfigWindow(QWidget): sig_l = QVBoxLayout(sig_w); sig_l.setContentsMargins(0, 0, 0, 0); sig_l.setSpacing(0) sig_bar = QWidget(); sig_bar.setObjectName("cfgGlobalBar") - sb_lay = QHBoxLayout(sig_bar); sb_lay.setContentsMargins(10, 7, 10, 7) + sb_lay = QHBoxLayout(sig_bar); sb_lay.setContentsMargins(10, 7, 10, 7); sb_lay.setSpacing(6) sb_lbl = QLabel("Signals"); sb_lbl.setObjectName("devWindowTitle") sb_lay.addWidget(sb_lbl, 1) sig_l.addWidget(sig_bar) @@ -435,6 +433,7 @@ class ConfigWindow(QWidget): if inner_splitters: inner_splitters[0].widget(0).setVisible(False) splitter.addWidget(self._pipeline_tab) + self._signals_tab.channel_created.connect(self._pipeline_tab.refresh_derived) splitter.setStretchFactor(0, 2) splitter.setStretchFactor(1, 1) diff --git a/ui/windows/devices_window.py b/ui/windows/devices_window.py index eec65c8..ae88c6b 100644 --- a/ui/windows/devices_window.py +++ b/ui/windows/devices_window.py @@ -41,6 +41,7 @@ _STATUS_TEXT = { class DeviceRow(QFrame): configure_clicked = pyqtSignal(str) remove_clicked = pyqtSignal(str) + name_changed = pyqtSignal(str) def __init__(self, device): super().__init__() @@ -66,11 +67,14 @@ class DeviceRow(QFrame): info_col = QVBoxLayout() info_col.setSpacing(1) - name = QLabel(self.device.info.name) - name.setObjectName("deviceName") + self._name_edit = QLineEdit(self.device.info.name) + self._name_edit.setObjectName("deviceName") + self._name_edit.setFrame(False) + self._name_edit.setToolTip("Click to rename") + self._name_edit.editingFinished.connect(self._on_name_edited) id_lbl = QLabel(f"{self.device.info.device_id} · {self.device.info.device_type}") id_lbl.setObjectName("deviceSub") - info_col.addWidget(name) + info_col.addWidget(self._name_edit) info_col.addWidget(id_lbl) lay.addLayout(info_col, 1) @@ -103,6 +107,16 @@ class DeviceRow(QFrame): self._status_lbl.setText(_STATUS_TEXT.get(self.device.status, "?")) self._set_status_style() + def _on_name_edited(self): + new_name = self._name_edit.text().strip() + if not new_name: + self._name_edit.setText(self.device.info.name) + return + if new_name == self.device.info.name: + return + self.device.info.name = new_name + self.name_changed.emit(self.device.info.device_id) + def _set_dot_color(self): c = _DOT_COLOR.get(self.device.status, "#475569") self._dot.setStyleSheet(f"color:{c}; font-size:10px;") diff --git a/ui/windows/plot_window.py b/ui/windows/plot_window.py index f311e9e..119efab 100644 --- a/ui/windows/plot_window.py +++ b/ui/windows/plot_window.py @@ -26,7 +26,7 @@ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea, QFrame, QLineEdit, QDoubleSpinBox, QSpinBox, QCheckBox, QComboBox, QColorDialog, QToolButton, - QFileDialog, QMessageBox, + QFileDialog, QMessageBox, QDialog, QDialogButtonBox, ) from PyQt6.QtCore import Qt, pyqtSignal, QPoint, QRect from PyQt6.QtGui import QColor, QCloseEvent, QPainter, QPen @@ -89,18 +89,12 @@ class LayoutConfig: def build_default_layout(registry: DeviceRegistry, processor: SignalProcessor) -> LayoutConfig: + """One pane per Channel (processor.get_derived()), titled by channel + name — raw Signals (physical device channels) are not auto-plotted. + Signals are just hardware endpoints; a Channel is what the operator has + explicitly chosen to process/observe, so that's what the default plot + shows. Use the "+ Add Channel" button on a Signal row to promote it.""" cfg = LayoutConfig() - for dev in registry.all_instances(): - enabled = [ch for ch in dev.info.channels if ch.enabled] - if not enabled: continue - pane = PaneSpec(title=dev.info.name, - y_label=enabled[0].unit if enabled else "") - for ch in enabled: - pane.traces.append(TraceSpec( - device_id=dev.info.device_id, channel_id=ch.channel_id, - label=ch.name, color=ch.color, - )) - if pane.traces: cfg.panes.append(pane) for dc in processor.get_derived(): pane = PaneSpec(title=dc.name, y_label=dc.unit) pane.traces.append(TraceSpec( @@ -281,8 +275,9 @@ class ColorSwatch(QPushButton): # ══════════════════════════════════════════════════════════════════════════════ class TraceRow(QFrame): - removed = pyqtSignal(object) - changed = pyqtSignal() + removed = pyqtSignal(object) + changed = pyqtSignal() + move_requested = pyqtSignal(object, int) # (self, direction: -1 up / +1 down) def __init__(self, tr: TraceSpec): super().__init__(); self.tr = tr; self.setObjectName("traceRow"); self._build() def _build(self): @@ -309,11 +304,76 @@ class TraceRow(QFrame): self._w.setValue(self.tr.width); self._w.setFixedWidth(56) self._w.valueChanged.connect(lambda v: setattr(self.tr,"width",v)) lay.addWidget(self._w) + up = QToolButton(); up.setText("▲"); up.setObjectName("traceRemoveBtn") + up.setFixedSize(18,18); up.setToolTip("Move up") + up.clicked.connect(lambda: self.move_requested.emit(self, -1)) + lay.addWidget(up) + dn = QToolButton(); dn.setText("▼"); dn.setObjectName("traceRemoveBtn") + dn.setFixedSize(18,18); dn.setToolTip("Move down") + dn.clicked.connect(lambda: self.move_requested.emit(self, 1)) + lay.addWidget(dn) rm = QToolButton(); rm.setText("✕"); rm.setObjectName("traceRemoveBtn") rm.setFixedSize(22,22); rm.clicked.connect(lambda: self.removed.emit(self)) lay.addWidget(rm) +# ══════════════════════════════════════════════════════════════════════════════ +# Trace channel picker — checkbox dialog, replaces the old inline dropdown +# ══════════════════════════════════════════════════════════════════════════════ + +class TraceChannelPickerDialog(QDialog): + """Checkbox list of enabled device + derived channels — same pattern as + channels_window.ChannelPickerDialog, so adding traces to a pane is + consistent with how channels are added elsewhere in the app.""" + + def __init__(self, registry: DeviceRegistry, processor: SignalProcessor, parent=None): + super().__init__(parent) + self.setWindowTitle("Add Channels to Pane") + self.setMinimumWidth(380) + self.resize(420, 320) + self._checks: List[Tuple] = [] # (QCheckBox, dev_id, ch_id, name, color) + + lay = QVBoxLayout(self) + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + cont = QWidget() + cl = QVBoxLayout(cont); cl.setContentsMargins(8, 8, 8, 8); cl.setSpacing(4) + + any_available = False + for dev in registry.all_instances(): + for ch in dev.info.channels: + if not ch.enabled: + continue + any_available = True + chk = QCheckBox(f"{ch.name} ({dev.info.device_id}/{ch.channel_id})") + self._checks.append((chk, dev.info.device_id, ch.channel_id, ch.name, ch.color)) + cl.addWidget(chk) + for dc in processor.get_derived(): + if not dc.enabled: + continue + any_available = True + chk = QCheckBox(f"{dc.name} ([virtual]/{dc.channel_id})") + self._checks.append((chk, "derived", dc.channel_id, dc.name, dc.color)) + cl.addWidget(chk) + + if not any_available: + cl.addWidget(QLabel("No channels available.")) + + cl.addStretch() + scroll.setWidget(cont) + lay.addWidget(scroll, 1) + + btns = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + btns.accepted.connect(self.accept) + btns.rejected.connect(self.reject) + lay.addWidget(btns) + + def selected(self) -> List[Tuple[str, str, str, str]]: + return [(d, c, n, col) for chk, d, c, n, col in self._checks if chk.isChecked()] + + # ══════════════════════════════════════════════════════════════════════════════ # Pane block # ══════════════════════════════════════════════════════════════════════════════ @@ -411,30 +471,15 @@ class PaneBlock(QFrame): for tr in self.spec.traces: self._add_row(tr) pick_row = QHBoxLayout() - self._picker = self._make_picker(); pick_row.addWidget(self._picker,1) ab = QPushButton("+ Add Channel"); ab.setObjectName("addTraceBtn") - ab.clicked.connect(self._on_add); pick_row.addWidget(ab) + ab.clicked.connect(self._on_add); pick_row.addWidget(ab); pick_row.addStretch() self._tlay.addLayout(pick_row) outer.addWidget(tr_w) - def _make_picker(self): - cb = QComboBox(); cb.setObjectName("channelPickerCb") - for dev in self.registry.all_instances(): - for ch in dev.info.channels: - if not ch.enabled: - continue - cb.addItem(f"{ch.name} ({dev.info.device_id}/{ch.channel_id})", - userData=(dev.info.device_id, ch.channel_id, ch.name, ch.color)) - for dc in self.processor.get_derived(): - if not dc.enabled: - continue - cb.addItem(f"{dc.name} ([virtual]/{dc.channel_id})", - userData=("derived", dc.channel_id, dc.name, dc.color)) - return cb - def _add_row(self, tr: TraceSpec): row = TraceRow(tr); row.removed.connect(self._rm_row) row.changed.connect(self.changed) + row.move_requested.connect(self._move_row) self._rows.append(row) self._tlay.insertWidget(self._tlay.count()-1, row) @@ -443,12 +488,27 @@ class PaneBlock(QFrame): self._rows.remove(row); self._tlay.removeWidget(row); row.deleteLater() self.changed.emit() + def _move_row(self, row: TraceRow, direction: int): + if row not in self._rows: + return + idx = self._rows.index(row) + new_idx = idx + direction + if new_idx < 0 or new_idx >= len(self._rows): + return + self._rows[idx], self._rows[new_idx] = self._rows[new_idx], self._rows[idx] + self.spec.traces[idx], self.spec.traces[new_idx] = self.spec.traces[new_idx], self.spec.traces[idx] + self._tlay.removeWidget(row) + self._tlay.insertWidget(new_idx, row) + self.changed.emit() + def _on_add(self): - data = self._picker.currentData() - if not data: return - d,c,n,col = data - tr = TraceSpec(device_id=d,channel_id=c,label=n,color=col) - self.spec.traces.append(tr); self._add_row(tr); self.changed.emit() + dlg = TraceChannelPickerDialog(self.registry, self.processor, self) + if dlg.exec() != QDialog.DialogCode.Accepted: + return + for d, c, n, col in dlg.selected(): + tr = TraceSpec(device_id=d, channel_id=c, label=n, color=col) + self.spec.traces.append(tr); self._add_row(tr) + self.changed.emit() def _on_auto(self, v): self.spec.y_auto = v; self._ymin.setEnabled(not v); self._ymax.setEnabled(not v) -- cgit v1.2.3