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>
200 lines
13 KiB
Markdown
200 lines
13 KiB
Markdown
<!-- SPDX-License-Identifier: MIT -->
|
||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||
|
||
# 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.
|