Files
udpscope/docs/superpowers/specs/2026-10-14-sidebar-tabs-design.md
T
Martino FerrariandClaude Opus 4.6 e10a92fc52 docs: correct the sidebar spec, and plan its implementation
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>
2026-10-14 10:48:21 +02:00

13 KiB
Raw Blame History

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:

  1. 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.
  2. 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 MATH divider 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 MATH divider 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.