diff --git a/docs/superpowers/specs/2026-10-14-sidebar-tabs-design.md b/docs/superpowers/specs/2026-10-14-sidebar-tabs-design.md new file mode 100644 index 0000000..38780f6 --- /dev/null +++ b/docs/superpowers/specs/2026-10-14-sidebar-tabs-design.md @@ -0,0 +1,174 @@ + + + +# 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. + +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 + +`rebuild_signal_list()` (`ui_signals.cpp:139-301`) gains a tail: a `MATH` divider row carrying +the `+`, then one row per math channel, built with the same widgets as a signal row — tick, +colour chip, name, unit, menu. + +| | signal row | math row | +| --- | --- | --- | +| tick | acquire (hidden in a file view) | enabled | +| colour chip | clickable while plotted | clickable while plotted | +| unit | from CONFIG | the channel's own | +| menu | Information, Calibration | Edit graph…, Delete | + +No rename item: the node editor owns a math channel's name and unit already +(`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. + +The colour chip and the tick need no special case: `ScopeModel::set_math_channels()` already +appends a synthetic `SignalInfo` with `math=true`, which is why measurements, export and the +plots never knew the difference. + +Deleted: `build_math_panel()` (`ui_math.cpp:1044`), `rebuild_math_pane()` (`ui.cpp:2251-2319`), +`src/ui/math-panel.ui` and its GResource entry, `App::math_revealer`, `App::math_toggle`. 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`): + +```toml +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 | + +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.