diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-09-20 23:45:18 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-09-20 23:45:18 -0600 |
| commit | a0473245f1f453bfc87ea1bc9bd479311e8a944b (patch) | |
| tree | 5c317d6b39c436fb58824e0553d7ba4603cc0829 /ui/windows | |
| parent | 006366ef8099c8313ae596bf6267e9f98dfbeb5e (diff) | |
Persist dock layout to profiles; dedupe port names; Channels-tab Create button
- Fix duplicated device names ("FT231X USB UART - FT231X USB UART (...)")
— pyserial on Linux often reports the same udev model string twice for
cheap FTDI/CH340-style adapters. Collapse it at the list_ports() source
so every consumer (device auto-name, port picker) benefits.
- Save Control Panel / Namespace dock area, floating geometry, visibility,
size and order to .labui profiles via QMainWindow.saveState()/
restoreState(), round-tripped through the existing Profile.settings
passthrough (self._settings) — no profile schema change needed.
- Channels tab: replace the per-row Signal ⊕ button (and its pinned/
frozen-column overlay) with a single "Create Channel" button below the
Namespace panel. Select one or more Signal/Channel rows (multi-select
now supported) and click it — selected rows become the new Channel's
sources, or an empty Channel is created if nothing's selected. Fixes a
bug in the process: the multi/zero-source path defaulted to
kind="expression" with no expression text, which fails to compile and
silently dropped the channel; use kind="none" instead, which the user
then edits in the Channels pane.
NamespacePanel itself (ui/namespace_panel.py) goes back to a plain
read-only reference widget — the create-channel button and its logic
live entirely in ConfigWindow (config_window.py), so the shared
dockable Namespace panel on the main window is unaffected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diffstat (limited to 'ui/windows')
| -rw-r--r-- | ui/windows/config_window.py | 104 |
1 files changed, 78 insertions, 26 deletions
diff --git a/ui/windows/config_window.py b/ui/windows/config_window.py index 7ec0355..9b864bf 100644 --- a/ui/windows/config_window.py +++ b/ui/windows/config_window.py @@ -8,12 +8,13 @@ Devices tab layout (2 columns): Right — signal table (Device / Signal ID / Dir / On / Label / Unit / Min / Max) Channels tab layout (2 columns): - Left — Namespace browser (Signals / Channels / vars). Mostly a read-only - reference, with one exception: each Signal row has a ⊕ button to - create a passthrough Channel from it directly — the quick "make - this Signal a Channel" path. See ui/namespace_panel.py — only - this instance opts into that button (on_create_channel), not the - read-only dockable Namespace panel on the main window. + Left — Namespace browser (Signals / Channels / vars, ui/namespace_panel.py + — plain read-only reference, reused as-is for the dockable + Namespace panel on the main window too) plus a "Create Channel" + button below it, local to this tab only: uses whichever rows are + selected (multi-select) as the new Channel's sources, or creates + an empty Channel if nothing's selected. See _on_create_channel_ + clicked(). Right — derived / virtual channels — operations (filters/calibration/math/script) are configured per Channel here, not on the Signals side. """ @@ -471,10 +472,20 @@ class ConfigWindow(QWidget): ns_div = QFrame(); ns_div.setFrameShape(QFrame.Shape.HLine) ns_div.setObjectName("devWindowDivider"); ns_l.addWidget(ns_div) - self._namespace_panel = NamespacePanel(self.registry, self.processor, - on_create_channel=self._on_create_channel) + self._namespace_panel = NamespacePanel(self.registry, self.processor) self.processor.processed_data.connect(self._namespace_panel.on_processed_data) ns_l.addWidget(self._namespace_panel, 1) + + ns_create_bar = QWidget(); ns_create_bar.setObjectName("cfgGlobalBar") + nc_lay = QHBoxLayout(ns_create_bar); nc_lay.setContentsMargins(10, 7, 10, 7); nc_lay.setSpacing(6) + nc_lay.addStretch() + create_btn = QPushButton("+ Create Channel"); create_btn.setObjectName("addTraceBtn") + create_btn.setToolTip("Selected rows become the new Channel's sources — " + "none selected creates an empty Channel") + create_btn.clicked.connect(self._on_create_channel_clicked) + nc_lay.addWidget(create_btn) + ns_l.addWidget(ns_create_bar) + splitter.addWidget(ns_w) # Right: derived channels only @@ -489,29 +500,70 @@ class ConfigWindow(QWidget): inner_splitters[0].widget(0).setVisible(False) splitter.addWidget(self._pipeline_tab) - # Channels side larger by default - splitter.setStretchFactor(0, 1) - splitter.setStretchFactor(1, 3) + # Namespace starts at its content-driven natural width (see + # NamespacePanel.natural_width() — a query only, not an enforced + # minimum: a hard setMinimumWidth() here used to fight QMainWindow's + # dock layout when this same NamespacePanel class is also docked + # stacked/tabbed with the Control Panel dock, ui/main_window.py). + # Stretch factor 0 means Channels absorbs window-resize growth; + # dragging the divider narrower than natural_width() just lets + # Source etc. scroll — the user's call. + splitter.setStretchFactor(0, 0) + splitter.setStretchFactor(1, 1) + + def _init_ns_split(): + ns_w0 = self._namespace_panel.natural_width() + total = splitter.width() + splitter.setSizes([ns_w0, max(1, total - ns_w0)]) + + QTimer.singleShot(0, _init_ns_split) self.tabs.addTab(w, " Channels ") - 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, triggered - from the ⊕ button on a Signal row in the Channels tab's Namespace - panel (ui/namespace_panel.py). 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 + def _on_create_channel_clicked(self): + """Create Channel button below the Channels tab's Namespace panel — + local to this tab (see module docstring). Uses whatever Signal/ + Channel rows are currently selected (ui/namespace_panel.py:: + NamespacePanel.selected_references(), multi-select) as the new + Channel's sources; none selected creates an empty Channel.""" + refs = self._namespace_panel.selected_references() + existing = self.processor.get_derived() 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 = Channel(channel_id=channel_id, name=name, kind="none", - sources=[(dev_id, ch_id)], source_names=[ch_name], - unit=unit, color=color or "#00d4ff") + + if len(refs) == 1: + # Exactly one Signal/Channel selected — the "1 -> 1 passthrough" + # quick path. No-ops if one already exists for this exact source. + dev_id, ch_id, ch_name, unit, color = refs[0] + for dc in existing: + if dc.kind == "none" and dc.sources == [(dev_id, ch_id)]: + return + name = _dedupe_name(ch_name, existing_names) + channel_id = _slug_id(name, existing_ids) + dc = Channel(channel_id=channel_id, name=name, kind="none", + sources=[(dev_id, ch_id)], source_names=[ch_name], + unit=unit, color=color or "#00d4ff") + else: + # Zero selected -> empty Channel, same naming convention + # PipelineTab._add_virtual() uses for one. Two+ selected -> one + # Channel sourced from all of them. kind="none" here (not + # "expression") because it's the only kind that compiles + # trivially with no expression/script text set yet — Channel. + # compile() does `compile(f"__result__ = {self.expression}")` + # for "expression", which is a SyntaxError on the default blank + # string, so processor.add_derived() below would silently fail + # and never add the channel. _add_virtual() gets away with + # defaulting to "expression" because it never calls + # add_derived() itself — it hands the Channel to the editor and + # waits for the user's own Apply. This path calls add_derived() + # immediately, so it needs a kind that survives that. The user + # picks the real kind/expression afterward in the editor. + name = _dedupe_name(f"Channel {len(existing) + 1}", existing_names) + channel_id = _slug_id(name, existing_ids) + dc = Channel(channel_id=channel_id, name=name, kind="none", + sources=[(d, c) for d, c, *_ in refs], + source_names=[n for _, _, n, _, _ in refs]) + err = self.processor.add_derived(dc) if err is None: self._pipeline_tab.refresh_derived() |
