# 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`): ```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 | | 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.