The spec claimed the math pane and the signal list were two lists of the same channels; they are one. `append_math_signals_locked()` already puts every math channel into `signals()` and nothing filters `sig.math`, so the merge is mostly a deletion -- the list gains a `MATH` divider and a second menu, and the pane goes. §2, §3 and §6 say so now. The plan is seven tasks, each ending in a build, `make test-ui` and one short headless probe read. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
13 KiB
A tabbed side panel, and one channel list: design
The side panel stacks five sections in one scrolling column — trigger, cursors, measurements,
math, signals — each behind a GtkRevealer opened by its own toolbar toggle. With trigger,
measurements, cursors and math all open the column is unusable: every section is short of room,
and finding one means scrolling past three others.
Two changes, aimed at that one problem:
- The column becomes two tabs. Measure holds the cursors readout and the measurements; Signals holds the trigger and the channel list. One tab is on screen at a time, so no section competes with four others for height.
- The math list is folded into the signal list. A math channel already is a channel in
the model, and the two lists were the same list drawn twice. One list, with a
MATHdivider in it.
A third change falls out of the first. A tab is one-of-N, so it can no longer carry an on/off: no toggle hides a pane any more. Trigger-off and cursors-off put their pane in a disabled state and leave it where it is.
The decisions taken, and what each one rules out
| Decision | Alternative rejected |
|---|---|
AdwViewStack + AdwViewSwitcher |
GtkStack + GtkStackSwitcher: plain buttons beside an AdwOverlaySplitView, which is what the sidebar already is. GtkNotebook: draws its own tab strip, which costs width in a 320 px sidebar and reads as off-platform. libadwaita ≥ 1.8 is already a hard requirement (CMakeLists.txt:22), so the platform widget costs nothing new |
| A scroller per page, inside the stack | One shared scroller around the stack: the two pages are different heights, so a tab switch would leave the scroll position somewhere the new page did not ask for |
| The switcher sits above the scrolled area | Inside it: the one control that chooses what you are looking at must not be the first thing to scroll away |
| Two tabs, exactly as grouped | A tab per section (five tabs): that is the stack again, laid sideways, and a 320 px sidebar has no room for five switcher pills. Measurements read what the cursors bracket, and the trigger picks a channel from the list beside it — the grouping follows the way the panes are used |
| Nothing inside a tab collapses | Keeping a revealer per section inside the tab, or a GtkPaned between them: the first reintroduces exactly the state-management the tabs remove, two sections at a time instead of five; the second adds a divider position to persist per tab for a column that already scrolls |
| Trigger-off and cursors-off disable their pane | Hiding it, which is today's behaviour: a pane you can see is a pane you can read, and the settings you are about to turn back on should not vanish while you look for them |
The measurements and math toolbar toggles are deleted |
Keeping them as tab selectors: two buttons that select the same tab, whose pressed state duplicates the switcher's |
| The switcher shows titles only, and the two icons the deleted toggles used are removed | Giving each page an icon-name: the measurements icon would carry over, but Signals has no glyph — reusing the trigger icon for a page that is mostly a channel list misnames it, and drawing a new one is new artwork under the icon authoring rules for a label two words long |
| One row widget for both kinds of channel, differing only in its menu | Inline edit and delete buttons on math rows (today's math pane): math rows then carry visibly more controls than signal rows, and the list stops reading as one column |
A + on the MATH divider only |
A + on the signals header too: a signal comes from CONFIG and cannot be added, so the button would have nothing to do |
| The statistics accumulator keeps running on the hidden page | Gating update_measures() on the visible tab: the accumulator is history, so min/max/avg would gain a gap for exactly as long as you looked at the trigger |
| The probe reads the page name and sensitivity | Dropping the two revealer readings: the defect they catch — a panel that is built but not on screen — still exists, it is just spelled differently |
1. The container
The sidebar child of side_view becomes a vertical GtkBox:
AdwOverlaySplitView.side_view
sidebar: GtkBox (vertical)
AdwViewSwitcher <- never scrolls
AdwViewStack
page "measure" GtkScrolledWindow -> GtkBox: cursors, separator, measurements
page "signals" GtkScrolledWindow -> GtkBox: trigger, separator, signal list
side_scroll moves inside the pages; App::side_box becomes the stack, and the five revealer
fields in ui_app.hpp are removed with the code that sets them (ui.cpp:4728-4767).
The switcher carries the two page titles and no icons. udpscope-measure-symbolic and
udpscope-math-symbolic lose their last user with the toggles that used them, so both leave
src/icons/scalable/actions/ and udpscope.gresource.xml: the bundled set goes from six
action icons to four, which invariants-gtk-and-chrome.md counts and must be corrected with
the change.
The pages are built by the functions that build them today — build_trigger_panel(),
build_cursors_panel(), build_measures_panel(), build_signal_panel() — unchanged except
that they are appended to a page's box instead of to a revealer.
2. State instead of visibility
| Control | Today | After |
|---|---|---|
trigger_toggle |
trigger mode on/off and reveals the trigger pane (ui_trigger.cpp:198) |
trigger mode on/off; the pane's body goes sensitive/insensitive |
cursors_toggle |
cursors on/off and reveals the readout (ui_cursors.cpp:124-126) |
cursors on/off; the readout goes insensitive and its values blank to — |
cursors_reset |
hidden while cursors are off (ui_cursors.cpp:122) |
insensitive while cursors are off |
measures_toggle |
reveals the measurements (ui.cpp:4541-4550) |
deleted |
math_toggle |
reveals the math pane (ui.cpp:4551-4558) |
deleted |
sidebar_toggle, events_toggle, pause_button |
— | unchanged |
The two deleted toggles leave src/ui/header-bar.ui with their handlers, so the bar is
declared with five controls and no id resolves to a button nothing listens to.
update_side_visibility() (ui.cpp:2830-2850) keeps its job — the whole sidebar is still the
split view's to show, still gated on a CONFIG having named a signal — minus the line that showed
signal_toggle, which no longer exists.
Everything else those handlers do is untouched: tc.armed, the epoch rule and the
set_time_window() call in on_trigger_toggled(), sync_events_availability(), the
Arm/Disarm label, and rows->set_cursors_visible() with its reset_cursors().
Blanking the cursor readout to — rather than leaving the last values is the readout invariant
restated: a readout quotes a stored sample, and with the instruments off there is no sample it
quotes.
3. One channel list
The duplication is literal, and the merge is mostly a deletion.
rebuild_signal_list() is handed model.signals(), and append_math_signals_locked()
(model.cpp:252-269) has already appended one synthetic entry per math channel — "they go
through the ordinary signal path on purpose: a derived channel is a channel", says its own
comment. Nothing in the list filters sig.math, so every math channel is already a row in
the signal list, with its tick, chip, name, unit and menu. The math pane listed the same
channels a second time.
What the list gains:
- a non-selectable
MATHdivider row before the first math row, carrying the+that opens the node editor on a new channel; - two items in a math row's menu.
| signal row | math row | |
|---|---|---|
| tick | acquire (hidden in a file view) | enabled |
| colour chip | clickable while plotted | unchanged |
| unit | from CONFIG | the channel's own |
| menu | Information, Default colour, Calibrate… | Edit graph…, Default colour, Delete |
Information and Calibrate… are a streamed signal's — calibration is applied at ingest — so a
math row carries neither. No rename item either: the node editor owns a math channel's name and
unit (src/ui/math-editor.ui:37-43), so Edit graph… is where both are changed, and a second
place to change them would be a second answer to the same question.
Two things already work and must not be rebuilt: the tick is already the right switch —
enable_locked() (model.cpp:626-639) mirrors it into the MathChannel's own enabled flag —
and the row's GtkDragSource already lets a math channel be dropped onto a plot row, which is
how math1 reached row 0 in the session this work started from.
One defect to fix while merging: App::math is the copy the session file is written from, so a
tick on a math row must mirror enabled into it before schedule_layout_save(). Today the pane's
checkbox did that (ui.cpp:2276) and the list's tick did not, so the two controls disagreed
about what gets saved.
Deleted: build_math_panel() (ui_math.cpp:1044), rebuild_math_pane() (ui.cpp:2251-2319),
src/ui/math-panel.ui and its entry in src/ui/udpscope-ui.gresource.xml, App::math_revealer,
App::math_list, App::math_toggle, App::measures_toggle, App::signal_revealer, and
App::signal_toggle with on_signals_toggled() (ui_signals.cpp:303-307) and its overflow-menu
row (ui.cpp:4452-4455) — the list is a page now, so "show or hide the signal list" has nothing
to hide. The node editor dialog itself is untouched; only the button that opens it moves.
4. Persistence
One key, top level in the session file beside window_s and measure_stats
(layout_store.cpp):
side_tab = "signals" # or "measure"
Written on save; applied once where the rest of the layout is applied, so an unknown or missing
value leaves the stack on its first page. Nothing else about the panel is persisted today and
nothing else needs to be: no revealer states remain, and the 320 px width is still the .ui's.
5. What runs on a hidden page
update_cursors_pane() and update_measures(force=false, accumulate=true) are called every
frame from the tick (ui.cpp:3275-3276) and keep being called whichever page is current.
Writing a label on an unrealized widget is legal; reading its allocation is not. Every
allocation read in the sidebar is a debug capture — dbg_measures_pane_w/h
(ui.cpp:3237-3239), dbg_signal_w/h and dbg_trigger_w/h (ui_probe.cpp:77-80) — and each
reads 0 on the page that is not current. That is a change in what the reading means, which §6
handles; it is not a defect, and no functional code may start depending on those numbers.
This is the revealer trap in a new widget. invariants-gtk-and-chrome.md:174-181 records that
an unrevealed GtkRevealer measures 0×0 and is still given the leftover width; a stack's
hidden page measures 0×0 too, and the rule it teaches — ask about state, not about size —
carries over unchanged.
6. Verification
Readings that change, and what each must say instead:
| Reading | Was | Becomes |
|---|---|---|
dbg_signal_revealed (ui_probe.cpp:90) |
the signal revealer's child is revealed | the signals page is current and its list is non-empty |
dbg_trigger_revealed (ui_probe.cpp:92, :2190) |
the trigger revealer's child is revealed | the signals page is current and the trigger body is sensitive |
dbg_measures_pane_w/h |
the pane's size while revealed | the pane's size while its page is current; 0 otherwise, and read as such |
the panel-toggle reading (ui_probe.cpp:104) |
both menu toggles are visible | the switcher is visible, which is the one control those two became |
One new probe, UDPSCOPE_PROBE_SIDE_TAB_MS: select the page that is not current, then dump the
page name, the measurement row count and the accumulator's sample count. The count must have
advanced across the switch — that is the §5 rule under test, and the defect it names is a
statistics pane that silently restarts its history every time you look at the trigger.
New reading for the merged list, on the existing signal-panel probe: the row count must be
signals + 1 + math channels, and the math rows must carry the same widget shape as a signal
row. A count short by the divider is a list that lost its +.
Baselines, unchanged and re-read on every run: exactly four warnings on a clean build, no
Gtk-CRITICAL, test-ui 15/15.
Docs to update with the work: probe-readings.md (the three changed lines and the two new
ones), verification.md (a bullet for the tab switch), invariants-gtk-and-chrome.md (the
sidebar is a view stack, the hidden-page trap replaces the unrevealed-revealer one for it, the
bar carries five controls and the bundled set four action icons),
invariants-math-channels.md (the list lives in the signal panel), invariants-readouts-and-cursors.md
(the readout blanks when the instruments are off), and a README note recording the deviation
from SPECS.md where it names the panes.
Out of scope
The events pane left of the plot, export, the node editor's internals, and the status bar.