docs: the badge bar, the slide-over and the dock (step 1 of the redesign)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-11-02 21:03:03 +01:00
1 parent a1672acc70
commit 0c94caf866
7 files changed
+203 -70

No files matched your search

+3 -3
View File
@@ -46,13 +46,13 @@ The detail lives in `docs/claude/`. Read the files that touch the area you are e
| File | What it covers | | File | What it covers |
| --- | --- | | --- | --- |
| [architecture.md](docs/claude/architecture.md) | The two threads and what they share; the one `BufferStore` and the four display modes (`Live`, `Capturing`, `Held`, `Waiting`); the display clock and `stream_at_x0()`; the layers to keep straight (`SignalInfo` → `ChannelBuffer` → `SlotSeries` → `RowSet`); the source plugins. | | [architecture.md](docs/claude/architecture.md) | The two threads and what they share; the one `BufferStore` and the four display modes (`Live`, `Capturing`, `Held`, `Waiting`); the display clock and `stream_at_x0()`; the layers to keep straight (`SignalInfo` → `ChannelBuffer` → `SlotSeries` → `RowSet`); the source plugins; the window's chrome (slide-over, badge bar, dock) and when each is built. |
### Invariants worth knowing before editing ### Invariants worth knowing before editing
| File | What it covers | | File | What it covers |
| --- | --- | | --- | --- |
| [invariants-plot-and-layout.md](docs/claude/invariants-plot-and-layout.md) | What `plot::Figure`/`plot::Plot` can and cannot be asked to do, what a row's contents belong to, the layout editor's overlay children, and the rule that **layout must not depend on content**. | | [invariants-plot-and-layout.md](docs/claude/invariants-plot-and-layout.md) | What `plot::Figure`/`plot::Plot` can and cannot be asked to do, what a row's contents belong to, the layout editor's and the slide-over's overlay children (with the pick rule), the badge bar's fixed height, and the rule that **layout must not depend on content**. |
| [invariants-readouts-and-cursors.md](docs/claude/invariants-readouts-and-cursors.md) | A readout quotes a stored sample and never a value between two; how the cursors are read back from the plots; the one formatter both panes share. | | [invariants-readouts-and-cursors.md](docs/claude/invariants-readouts-and-cursors.md) | A readout quotes a stored sample and never a value between two; how the cursors are read back from the plots; the one formatter both panes share. |
| [invariants-data-path.md](docs/claude/invariants-data-path.md) | What rebuilds the buffer and drops history, why decimation at ingest is an **average** and never a stride, and the two rules the `f64` time ring and its cached first stamp impose. | | [invariants-data-path.md](docs/claude/invariants-data-path.md) | What rebuilds the buffer and drops history, why decimation at ingest is an **average** and never a stride, and the two rules the `f64` time ring and its cached first stamp impose. |
| [invariants-rendering.md](docs/claude/invariants-rendering.md) | How the renderer maps a visible range onto samples (never the `dx()` grid), what `gather()` emits and relative to which origin, why every series needs a real `MinMaxPyramid`, and why a dense trace is drawn one pixel column at a time. | | [invariants-rendering.md](docs/claude/invariants-rendering.md) | How the renderer maps a visible range onto samples (never the `dx()` grid), what `gather()` emits and relative to which origin, why every series needs a real `MinMaxPyramid`, and why a dense trace is drawn one pixel column at a time. |
@@ -61,4 +61,4 @@ The detail lives in `docs/claude/`. Read the files that touch the area you are e
| [invariants-measurements.md](docs/claude/invariants-measurements.md) | Why the pane is refreshed on a cadence, why neither pass may read its window raw, how a crossing is counted with hysteresis, and which mean over a split is which. | | [invariants-measurements.md](docs/claude/invariants-measurements.md) | Why the pane is refreshed on a cadence, why neither pass may read its window raw, how a crossing is counted with hysteresis, and which mean over a split is which. |
| [invariants-math-channels.md](docs/claude/invariants-math-channels.md) | A math channel *is* a channel; the engine runs on the producer thread, so a regression there costs packets and not frames; the node editor's own rules. | | [invariants-math-channels.md](docs/claude/invariants-math-channels.md) | A math channel *is* a channel; the engine runs on the producer thread, so a regression there costs packets and not frames; the node editor's own rules. |
| [invariants-session-and-export.md](docs/claude/invariants-session-and-export.md) | What a session file persists and in what order, when a save is refused, and when an export is collected. | | [invariants-session-and-export.md](docs/claude/invariants-session-and-export.md) | What a session file persists and in what order, when a save is refused, and when an export is collected. |
| [invariants-gtk-and-chrome.md](docs/claude/invariants-gtk-and-chrome.md) | The GTK signal-signature and dialog-lifetime traps this app has already fallen into, the status bar's two tiers, the bundled icons and their authoring rules, and how the Mallard manual is opened. | | [invariants-gtk-and-chrome.md](docs/claude/invariants-gtk-and-chrome.md) | The GTK signal-signature and dialog-lifetime traps this app has already fallen into, the status bar's two tiers, the bundled icons and their authoring rules, how the slide-over's click-away coexists with the plot's gestures, how the guided tour opens a page, and how the Mallard manual is opened. |
+76 -39
View File
@@ -73,7 +73,7 @@ switches trigger mode on, waits for an event, then leaves trigger mode the way t
"Go live" button does, closes the window, and prints on stdout the status line in "Go live" button does, closes the window, and prints on stdout the status line in
both states, the frame and per-channel sample counts, each plot's painted-frame both states, the frame and per-channel sample counts, each plot's painted-frame
count, which channel each trace slot holds, and the allocated size and reveal count, which channel each trace slot holds, and the allocated size and reveal
state of both side panels. state of the dock and the events pane.
Every size it prints is read from a value the frame tick captured while the window was alive, not Every size it prints is read from a value the frame tick captured while the window was alive, not
from the widget: the dump runs from the destroy handler, where the widget pointer is already freed from the widget: the dump runs from the destroy handler, where the widget pointer is already freed
@@ -96,9 +96,24 @@ src/plot_host.* the one plot::Figure, its rows' traces and cursors, the x lin
src/export.* the visible range as a table, and the .npy / .csv / .mf3 writers src/export.* the visible range as a table, and the .npy / .csv / .mf3 writers
src/layout_store.* the per-source session layout file (TOML), written and read back src/layout_store.* the per-source session layout file (TOML), written and read back
src/event_series.hpp an immutable captured event as a plot::data::Series src/event_series.hpp an immutable captured event as a plot::data::Series
src/ui.* the GTK4 window: toolbar, trigger panel, signal panel, plot box src/ui.* the GTK4 window: toolbar, trigger form, signal list, plot box, the dock
src/ui_slideover.cpp the slide-over panel over the plot's right edge, one page at a time
src/ui_badges.cpp the badge bar under the plot: the Trigger and Source badges
``` ```
**The window.** The plot is the window. Under it sits a fixed-height **badge bar** (36 px) whose
right-hand end carries two words-only badges: **Trigger** (`off`, `armed`, `capturing`, `triggered`
or `disarmed`) and **Source** (the kind's name and `connected`/`disconnected`; a read-out, not a button,
for a source with no controls of its own, and for a file). A badge opens the **slide-over**: a 360 px
panel laid over the plot's right edge, opaque, with its own title, a *Close* button and one scroller per
page. The Trigger badge opens the trigger form; the Source badge opens the source kind's own panels
(the E-1608's *Analog out* and *Digital I/O*, behind a segmented switch at the top of the panel). It
closes with a second click on its badge, with `Esc`, with *Close*, or with a click on the plot; it never
resizes the plot. The right-hand **dock** (the existing sidebar, 320 px) holds what is left of the old
tabs in one scroller: the signal list, the cursors and the measurements, shown from the start and
insensitive until the stream has signals. It is temporary -- see "Review findings not (yet)
implemented" for what the redesign still has to do with it.
Threading: one producer thread owns the data source (the UDPStreamer client is the built-in Threading: one producer thread owns the data source (the UDPStreamer client is the built-in
one) and calls `poll()`; the GTK main thread owns every widget and the GL contexts. one) and calls `poll()`; the GTK main thread owns every widget and the GL contexts.
They share `ScopeModel` under one mutex, which the producer takes once per DATA They share `ScopeModel` under one mutex, which the producer takes once per DATA
@@ -1006,7 +1021,9 @@ offset = 0.5
``` ```
It also stores `measure_stats` (the measurements statistics toggle, a boolean, absent means It also stores `measure_stats` (the measurements statistics toggle, a boolean, absent means
`false`). It is written (400 ms after the last change, and flushed on close) whenever the `false`) and `dock` (whether the dock is shown, a boolean, absent means `true`; it replaced the
sidebar's `side_tab`, and a file that still carries `side_tab` loads, the key being ignored). The
open slide-over page is not stored: a panel that opens by itself at start-up would be a surprise. It is written (400 ms after the last change, and flushed on close) whenever the
window, the layout, a drop or a calibration changes, and applied once, when the window, the layout, a drop or a calibration changes, and applied once, when the
first CONFIG names the signals -- signal *names* are the identity, because a first CONFIG names the signals -- signal *names* are the identity, because a
CONFIG renumbers and an enable/disable rebuilds the channel list. Signals the CONFIG renumbers and an enable/disable rebuilds the channel list. Signals the
@@ -1151,18 +1168,23 @@ up, the same delay the first-run auto-start uses, the pending request starts the
chooser's rows show **no accelerators** -- the window prints `F1`/`Ctrl+T` because the window binds chooser's rows show **no accelerators** -- the window prints `F1`/`Ctrl+T` because the window binds
them, and this dialog binds none. them, and this dialog binds none.
**The guided tour is five steps on a `GtkOverlay`** inserted above the window's content: the plot, **The guided tour is six steps on a `GtkOverlay`** inserted above the window's content: the plot,
the side panel, the trigger, the signals and the status line. There is no popover anywhere in it and the badge bar, the trigger form, the dock, the signals and the status line. There is no popover anywhere in it and
therefore no pointer grab to lose: what makes the tour modal is four dim rectangles -- above, below therefore no pointer grab to lose: what makes the tour modal is four dim rectangles -- above, below
and either side of the target -- that swallow the clicks that land on them. A stop declares the and either side of the target -- that swallow the clicks that land on them. A stop declares the
**page it stands on** (the trigger and the signals steps live on the sidebar's *Signals* page, named **slide-over page it stands on** (the trigger step names `trigger`, in `TourStop::page`): the tour opens
in `TourStop::page`), and the tour selects that page as the step is entered -- on the frame *after* that page as the step is entered, and a stop with no page *closes* the panel, so the card never points
the switch, since a stack allocates a new page's widgets a frame late -- so the spotlight lands on a at the status line over an open panel. It does not spotlight the form until the panel has finished
widget the user had left behind on the other tab instead of on nothing. `gtk_widget_get_mapped()` sliding in (about 120 ms, during which the surface is dimmed and the card waits in the corner), and the
decides which steps exist at all: a stop on the *wrong page* is kept, because selecting its page hole is the target's bounds clipped by every scroller above it -- on the documented 756x470 window the
reveals it, while one whose *pane is collapsed* is dropped, because the stack is unmapped with it trigger form is taller than the panel, and the spotlight covers the part that is on screen, not the
(a hidden page keeps its own *visible* flag true -- the widget's own flag, which is all badge bar under it. Skip, Escape and the last step close a panel the tour opened (an idle dismissal
`is_visible()` walks -- so the old test walked over surfaces that were not on screen). The chooser's leaves one the user opened alone). `gtk_widget_get_mapped()`
decides which steps exist at all: a stop on a slide-over page is always kept, because opening the page
reveals it, while one whose *pane is collapsed* (the dock, in a window too narrow to put it beside the
plot) is dropped, because it is unmapped with its pane (a hidden page keeps its own *visible* flag
true -- the widget's own flag, which is all `is_visible()` walks -- so the old test walked over
surfaces that were not on screen); a narrow window therefore walks four steps. The chooser's
tour works the same way over its own two pages: its tabs step shows the *Waveforms* page and tour works the same way over its own two pages: its tabs step shows the *Waveforms* page and
stepping off it -- in either direction -- brings the *Sources* page back. stepping off it -- in either direction -- brings the *Sources* page back.
@@ -2780,12 +2802,13 @@ reaches every widget). The full key table:
|---|---| |---|---|
| `a` | arm / disarm the trigger | | `a` | arm / disarm the trigger |
| `Space` | pause / resume | | `Space` | pause / resume |
| `t` | show / hide the trigger panel | | `t` | enable / disable trigger mode |
| `c` | show / hide cursors | | `c` | enable / disable the cursors |
| `m` | show / hide measurements | | `m` | go to Measure (scroll the dock to the cursors and the measurements) |
| `g` | show / hide math channels | | `g` | go to Signals (scroll the dock back to the signal list) |
| `l` | show / hide the layout editor | | `l` | show / hide the layout editor |
| `s` | show / hide the side panel | | `s` | show / hide the dock |
| `Esc` | close the slide-over panel (the guided tour takes it first) |
| `f` | fit to data (the plot's own key, not an application accelerator) | | `f` | fit to data (the plot's own key, not an application accelerator) |
| `Ctrl+E` | export visible range | | `Ctrl+E` | export visible range |
| `F1` | open the manual (`help:org.udpscope.app`) in the desktop help viewer | | `F1` | open the manual (`help:org.udpscope.app`) in the desktop help viewer |
@@ -2897,32 +2920,42 @@ now — they are listed so they are not mistaken for anything else:
progress. What the spec was protecting still holds — a *held* event is bound to its progress. What the spec was protecting still holds — a *held* event is bound to its
snapshot alone, and the axis is the event's own window from the edge onwards rather snapshot alone, and the axis is the event's own window from the edge onwards rather
than a sliding live view. than a sliding live view.
- **The sidebar is two tabs, and a math channel is a row of the signal list.** `SPECS.md` - **The sidebar is one dock, setup is a slide-over, and a math channel is a row of the signal list.**
names the panes as separate collapsible sections — trigger, cursors, measurements, math, `SPECS.md` names the panes as separate collapsible sections -- trigger, cursors, measurements, math,
signal list — in one scrolling column; with four of them open in a 320 px sidebar every signal list -- in one scrolling column; with four of them open in a 320 px sidebar every
section was short of room, and finding one meant scrolling past the others. The built section was short of room, and finding one meant scrolling past the others. The sidebar was then two
sidebar is a **Measure** tab (the cursors readout and the measurements) and a **Signals** text tabs (a **Measure** tab and a **Signals** tab, with a source kind's own tabs beside them), and four
tab (the trigger and the channels), **each a text tab** (the sidebar was icon-only for a while; glyphs for "cursors and ruler" and tabs did not fit 320 px. It is **one dock** now, with no tab switcher: the signal list, the cursors and the
"trigger and channel list" were not understandable enough, so the words won). A source kind's own tabs measurements in one scroller, shown from the start and insensitive until the stream has signals; `m` and
(the E-1608's **Analog out** and **Digital I/O**) sit beside them as text too. Both panes also have a **locked** mode, which is what a returning user gets: the `g` scroll it to the cursors and back to the signal list. The **trigger form** and a **source's own
controls** are pages of the slide-over, reached from the badge bar. Both panes also have a **locked** mode, which is what a returning user gets: the
trigger pane shows one line saying what it is set to, the signal list shows only the signals that are trigger pane shows one line saying what it is set to, the signal list shows only the signals that are
on, and neither offers a control that changes the configuration — each heading has an `[edit]` button on, and neither offers a control that changes the configuration -- each heading has an `[edit]` button
to switch to the full pane, and back. Dragging a signal onto a plot still works while locked — filling to switch to the full pane, and back. Dragging a signal onto a plot still works while locked -- filling
plots is what the list is for, not configuration — and a locked row can be dragged from anywhere on it, plots is what the list is for, not configuration -- and a locked row can be dragged from anywhere on it,
the colour chip included. In a window too narrow to show both, where the sidebar is *overlaid on* the the colour chip included. In a window too narrow to show both, where the dock is *overlaid on* the
plot, the sidebar steps aside for the length of the drag so there is something under the pointer to drop plot, the dock steps aside for the length of the drag so there is something under the pointer to drop
onto. A **first run** (no session file for that source) comes up onto. A **first run** (no session file for that source) comes up
unlocked, because configuring the host is what a first run is for. Each pane's heading also carries its **own switch**, the same control as the unlocked, because configuring the host is what a first run is for. Each pane's heading also carries its **own switch**, the same control as the
header bar's button — clicking either moves both — because a pane that is *disabled* while its header bar's button -- clicking either moves both -- because a pane that is *disabled* while its
feature is off is a pane whose controls you would otherwise have to leave to turn it back on. The feature is off is a pane whose controls you would otherwise have to leave to turn it back on. With the math
sidebar is one **dock** now, with no tab switcher: the signal list, the cursors and the measurements list folded into the signal list under a `MATH` divider whose `+` creates a channel -- a derived channel already *is* a channel in
in one scroller, shown from the start and insensitive until the stream has signals; `m` and `g`
scroll it to the cursors and back to the signal list. With the math
list folded into the signal list under a `MATH` divider whose `+` creates a channel — a derived channel already *is* a channel in
the model, so the two lists were the same list drawn twice. No the model, so the two lists were the same list drawn twice. No
toggle hides a pane any more: cursors-off **disables** its readout and leaves it in place, and toggle hides a pane any more: cursors-off **disables** its readout and leaves it in place, and
trigger-off leaves the trigger panel fully editable — a trigger is configured *before* it is trigger-off leaves the trigger form fully editable -- a trigger is configured *before* it is
switched on, and the locked view says `stream (trigger off)` instead of the settings — and the measurements' and math panes' toolbar toggles are gone with the panes. switched on, and the locked view says `stream (trigger off)` instead of the settings -- and the measurements' and math panes' toolbar toggles are gone with the panes.
- **The badge bar / slide-over / dock redesign is in progress; this is its first step, and it deviates
from `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md` in three known ways.**
(1) The dock keeps the sidebar's **320 px**, not the spec's ~280: the panels it holds were built for
320, and the width changes when they become compact readouts (step 3). (2) The session file gains the
dock's *shown* state only, not a width -- the dock's width is fixed, so there is nothing to persist
-- and does not store the open slide-over page. (3) The **Trigger badge** shows `off`, `armed` and
`triggered` as the spec says, and two more words, `capturing` (a post-trigger window is filling) and
`disarmed` (trigger on, not armed), so the label never claims a state the engine is not in. Still to
come, in the spec's order: the signal badges and drag (2), the compact readouts (3), the events
section in the dock, the deletion of the left pane and the Events toggle, and `USER-GUIDE.md` and the
manual's figures (4). Until then the events pane, the header's Events toggle and the manual still
describe the previous layout.
- **A plot holds at most eight signals** (`RowSet::kSlotsPerRow`), because a `Plot` - **A plot holds at most eight signals** (`RowSet::kSlotsPerRow`), because a `Plot`
cannot give up a trace and the slots are therefore created up front. cannot give up a trace and the slots are therefore created up front.
- **One toggle drives both cursor orientations.** The vertical markers and the - **One toggle drives both cursor orientations.** The vertical markers and the
@@ -3016,6 +3049,10 @@ silence timeout and then resumes, which is the EMI case end to end.
rather than rebuilds, and why the frame tick reads the markers back: a parked rather than rebuilds, and why the frame tick reads the markers back: a parked
cursor's snapped position is a real number, so nothing may be inferred from it. The cursor's snapped position is a real number, so nothing may be inferred from it. The
trigger marker is separate: its own cursor, independent of the cursor toggle. trigger marker is separate: its own cursor, independent of the cursor toggle.
- **The slide-over shows a form taller than the plot by scrolling it.** The panel is as tall as the
plot and never taller, and each page has a scroller of its own, so on the documented 756x470 window
the trigger form (about 474 px) shows 253 px of itself. The tour's spotlight is clipped to that, and at
that size the card of the trigger step overlaps the panel's edge by about 18 px (`clear=0`).
- **Verified headless only.** Acquisition, partitioning, the trigger state - **Verified headless only.** Acquisition, partitioning, the trigger state
machine and its integration with the buffers, and rendering under Xvfb machine and its integration with the buffers, and rendering under Xvfb
(llvmpipe) were exercised; the interactive paths (drag and drop, the dialogs, (llvmpipe) were exercised; the interactive paths (drag and drop, the dialogs,
+33
View File
@@ -91,6 +91,39 @@ What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*.
every handler tests `Form::alive`, which the groups' `destroy` clears, because the chooser may have every handler tests `Form::alive`, which the groups' `destroy` clears, because the chooser may have
destroyed the sibling widgets the handlers reach by raw pointer. destroyed the sibling widgets the handlers reach by raw pointer.
### The window's chrome: plot, slide-over, badge bar, dock
The main window is a plot with three things around it, each a unit of `src/ui_*.cpp` and none of them
touching the data path.
- **`SlideOver`** (`src/ui_slideover.cpp`) is one `GtkRevealer` holding a `GtkStack`, added as the second
child placed *over* the plot (after the layout editor's buttons) in `GtkOverlay#plot_overlay`. A page is
a `slideover_add_page(app, id, title, widget)`: the widget is wrapped in a `GtkScrolledWindow` of its
own and registered under its id (`trigger`, `source-<id>`; `signal:<name>`, `signals`, `measure`,
`cursors` and `events` are reserved for later steps). One page is open at a time and `App::slideover.page`
is its id, empty when closed. The open page is **not** a session value. Esc, *Close*, a second click on the
badge and a click on the plot close it; the click-away is a capture-phase observer (see
[invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). With two or more `source-*` pages, an
`AdwToggleGroup` under the header is the segmented switch between them.
- **`BadgeBar`** (`src/ui_badges.cpp`) is the fixed-height row between the plot and the status line
(`main-window.ui` `badge_bar`, two slots: signals, left, empty until step 2; system, right, holding the
**Trigger** and **Source** badges). A badge is a flat button whose `page` is the slide-over page it
toggles; the Source badge's page is the first `source-*` page, and a kind with none (UDPS, Flat UDP, a
file) leaves it a read-out with `can_target` off.
- **The dock** is the `AdwOverlaySplitView` sidebar (`side_box` > `dock_scroller` > `dock_page`): the
signal list, the cursors and the measurements in one scroller. It holds no tab switcher and no
revealer, and it does not hold a source kind's panels (those are slide-over pages).
**When each is built and when it is read.** `build_slideover()` runs in `on_activate` right after the tour
overlay is built (the tour's overlay wraps the whole window, so its dim rectangles cover the slide-over
too); the trigger form is registered as
a page by `build_trigger_panel`; `build_source_panels()` registers the source pages; and
`build_badge_bar()` runs *after* all of them, because the Source badge needs to know the first page.
Per-tick state (the Trigger word, the Source name and `connected`, the open marks) is written by
`sync_badge_bar()`, called from `tick()` and from `slideover_open`/`slideover_close`; it writes a label
only when its text changed. Nothing in the badge bar or the slide-over may be read from the producer
thread.
### Data path ### Data path
`ScopeModel` holds **one `BufferStore`** (`live()`): one shared time-base ring plus one `ScopeModel` holds **one `BufferStore`** (`live()`): one shared time-base ring plus one
+46 -23
View File
@@ -60,11 +60,6 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
user; `udpscope-measure-symbolic` **stayed**, because the Preferences window's Plot page carries it -- user; `udpscope-measure-symbolic` **stayed**, because the Preferences window's Plot page carries it --
an icon with a live user is not an orphan, whatever the toggle that introduced it did -- and it is an icon with a live user is not an orphan, whatever the toggle that introduced it did -- and it is
the Preferences window's icon. the Preferences window's icon.
The sidebar has **no tabs any more** (it is one dock): two tab glyphs (`udpscope-tab-measure-symbolic`,
`udpscope-tab-signals-symbolic`, each a 32x16 pair) were drawn for them and judged not understandable
enough, so the tabs were text, and both SVGs were deleted. The *trigger's* own icon was redrawn to the
Signals glyph's picture at that time and kept it.
- **A shortcut table row's `accel` is GTK accelerator syntax, and a pointer gesture has - **A shortcut table row's `accel` is GTK accelerator syntax, and a pointer gesture has
none.** `src/shortcuts.cpp`'s strings are handed to `AdwShortcutsItem`, which parses them none.** `src/shortcuts.cpp`'s strings are handed to `AdwShortcutsItem`, which parses them
with `gtk_accelerator_parse()` — so they are `"space"`, `"<Control>e"`, `"<Control>slash"`, with `gtk_accelerator_parse()` — so they are `"space"`, `"<Control>e"`, `"<Control>slash"`,
@@ -249,9 +244,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
`update_side_visibility()` and the tick's per-frame block set the column's visibility from the user's wish `update_side_visibility()` and the tick's per-frame block set the column's visibility from the user's wish
alone (`sidebar_wanted`) and the dock's *sensitivity* from `have_signals`; the toolbar's Dock button stays alone (`sidebar_wanted`) and the dock's *sensitivity* from `have_signals`; the toolbar's Dock button stays
sensitive throughout. The wish is persisted (`dock = true|false` in the session file) and a change of it sensitive throughout. The wish is persisted (`dock = true|false` in the session file) and a change of it
schedules a save. There is no tab icon to keep clear any more: the `keep_tabs_icon_free()` workaround for schedules a save. `dock:` in the dump reads the result.
`AdwViewSwitcherButton`'s placeholder `[x]` image went with the switcher, and `dock:` in the dump
replaces the `switcher:` line.
- **An *overlaid* sidebar covers the plot, so a drag out of it has nowhere to land.** `collapsed` in an - **An *overlaid* sidebar covers the plot, so a drag out of it has nowhere to land.** `collapsed` in an
`AdwOverlaySplitView` means the sidebar sits *on* the plot rather than beside it: the pointer is over the `AdwOverlaySplitView` means the sidebar sits *on* the plot rather than beside it: the pointer is over the
list, never over a drop target, and a signal can be dragged but not dropped. `row_drag_sidebar()` hides list, never over a drop target, and a signal can be dragged but not dropped. `row_drag_sidebar()` hides
@@ -594,6 +587,21 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps
grab to leak. The probe asserts it as a reading -- `controllers=` against the count taken before the grab to leak. The probe asserts it as a reading -- `controllers=` against the count taken before the
tour opened -- rather than as a claim, because "no lingering grab" is exactly the sort of thing a tour opened -- rather than as a claim, because "no lingering grab" is exactly the sort of thing a
later change can add back without noticing. later change can add back without noticing.
- **Click-away coexists with the plot's gestures because the observer never claims the sequence.** The
slide-over closes on a press anywhere outside it, and the plot underneath already owns pan, zoom, drop and
right-click. A `GtkGestureClick` added to `plot_overlay` in `GTK_PHASE_CAPTURE` sees the press before the
plot's own controllers and **does not call `gtk_gesture_set_state()`**, so the sequence stays unclaimed and
the plot's gestures run exactly as before; claiming it (or putting a full-area target over the plot) would
swallow the very pan the click was part of. The observer decides inside/outside by
`gtk_widget_compute_point()` into the revealer plus `gtk_widget_contains()`, never by pick. The press
that closes the panel still reaches the plot, which is accepted: it is one click and not a drag the user
started on purpose. `[slideover]` reads `click_inside_closes=0 click_outside_closes=1`.
- **`AdwToggleGroup` is the segmented switch** (libadwaita >= 1.7; the build asks for 1.8). It replaces the
`AdwViewSwitcher` for a source with several panels (the E-1608: Analog out | Digital I/O). Its toggles are
named by the slide-over page ids, and the group's `notify::active-name` is the *input*; writing it
ourselves (`slideover_open` on a source page) goes through `SlideOver::syncing`, or the program's own
write comes back as a click and re-opens the page it just opened. It is hidden unless a source page is
open and there are two or more, which `[badges] switch_visible=` reads.
- **The tour is one implementation over two windows.** `ui_impl::TourView` holds the overlay children - **The tour is one implementation over two windows.** `ui_impl::TourView` holds the overlay children
and the walk's place in it (`tour_build`/`tour_begin`/`tour_advance`/`tour_end`/`tour_update`), and and the walk's place in it (`tour_build`/`tour_begin`/`tour_advance`/`tour_end`/`tour_update`), and
what differs between the windows is only the *stops*: a `TourStop` names a widget the owner resolves, what differs between the windows is only the *stops*: a `TourStop` names a widget the owner resolves,
@@ -602,15 +610,26 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps
the view's `on_done`; the window's passes null, the chooser's sets `Config::tour_pending`, which is the view's `on_done`; the window's passes null, the chooser's sets `Config::tour_pending`, which is
how the two halves are one tour. `App` carries its own `TourView` (`app.tour`), so the machinery is how the two halves are one tour. `App` carries its own `TourView` (`app.tour`), so the machinery is
compiled once and the probe reads either window's state by the same fields. compiled once and the probe reads either window's state by the same fields.
- **A stop declares the page it stands on, and the tour selects that page as the stop is entered.** A - **A stop declares the page it stands on, and the tour selects that page as the stop is entered --
stop whose target lives on another page of a stack -- the window's `signals` (and `trigger`) stops by a hook in the main window and by a stack in the chooser.** The main window has no page stack any
on the sidebar's `signals` page, the chooser's "Two kinds of input" stop on its `waveforms` page -- more: its pages are the slide-over's. A `TourStop::page` there is a slide-over page id (`trigger`), and
names that page in `TourStop::page`, and `show_step()` selects it from `TourView::page_stack` `TourView::on_page(page_user, page)` opens it -- or, for a stop whose `page` is null, **closes** the
before anything is measured. The page switch is **not** followed by a one-shot measure: `show_step()` panel, so the card never points at the status line over an open panel. `tour_end()` calls
selects the page and `tour_update()`, which already runs every frame, fills the spotlight in on the `on_page(nullptr)` *only if a walk was running* (so Skip, Esc and the last Next close a panel the tour
frame *after*, when the new page has been laid out. A stack allocates the new page's widgets on the opened, while dismissing an idle tour does not close one the user opened). The chooser has no hook and
keeps `TourView::page_stack` (an `AdwViewStack`, selected by name). `select_view_page()` is the single
entry for both, and `show_step()` calls it before anything is measured.
Two rules follow from a panel that *slides*. The page switch is **not** followed by a one-shot measure:
`tour_update()`, which already runs every frame, fills the spotlight in on a later one, and for a stop
with a page it waits for `TourView::page_settled` (the slide-over's `gtk_revealer_get_child_revealed()`)
-- until then the target is moving, so the surface is dimmed with an empty hole. And **the hole is the
target's bounds clipped by every `GtkScrolledWindow` above it** (`tour_target_bounds()`): a form taller
than the panel's scroller overruns it, and an unclipped hole spotlights the badge bar and the status
line under the panel. A target scrolled out entirely is a *measured, empty* rectangle (the function
returns true), not a failure -- `tour_update()` zeroes the hole on it, because taking "false" for "not
measured yet" keeps the previous frame's hole standing. A stack allocates a new page's widgets on the
frame after the switch, so a measurement taken in the switch's own tick reads zero (the allocation frame after the switch, so a measurement taken in the switch's own tick reads zero (the allocation
bullet in [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)) -- and reading the bullet in [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)) -- reading the
page *name* back is fine immediately, because that is state and not geometry. page *name* back is fine immediately, because that is state and not geometry.
- **The stop filter runs on `get_mapped()`, not `is_visible()`, and that is what separates a wrong - **The stop filter runs on `get_mapped()`, not `is_visible()`, and that is what separates a wrong
page from a collapsed pane.** GTK 4.24's `gtk_widget_is_visible()` walks only the widgets' own page from a collapsed pane.** GTK 4.24's `gtk_widget_is_visible()` walks only the widgets' own
@@ -619,11 +638,14 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps
`gtk_widget_set_child_visible()`, so a widget on another page, *and* one in a collapsed pane, both `gtk_widget_set_child_visible()`, so a widget on another page, *and* one in a collapsed pane, both
read `is_visible() == TRUE` (verified against 4.24.1) -- the old filter kept a stop it meant to read `is_visible() == TRUE` (verified against 4.24.1) -- the old filter kept a stop it meant to
drop and left the spotlight on a target with no allocation. `stop_is_walkable()` keeps a stop when drop and left the spotlight on a target with no allocation. `stop_is_walkable()` keeps a stop when
its target is **mapped**, or when it names a page and the stack that page belongs to is mapped: a its target is **mapped**; a stop that names a page of a view with an `on_page` hook is **always**
wrong page leaves the stack mapped, so the stop is kept and the page switch reveals it; a collapsed kept (the hook opens the page whatever the dock is doing, since the slide-over does not depend on it);
pane leaves the stack unmapped too, so no page can reveal it and the stop is dropped. The filter and in a view with a `page_stack` a stop that names a page is kept when the stack is mapped -- a wrong
lives in `tour_begin()`, not in each window's `start_tour()`, both so the two windows cannot drift page leaves the stack mapped, so the page switch reveals it, while a collapsed pane leaves it unmapped
into two rules and because the rule needs the view's own `page_stack`. too and the stop is dropped. So the dock's stop is dropped in a window narrow enough to overlay it, and
the main window's tour is four steps there (`[toursurface] collapsed: ... steps=4`). The filter
lives in `tour_begin()`, not in each window's `start_tour()`, so the two windows cannot drift
into two rules.
- **The chooser's tour needs an overlay over its *whole* window, and `AdwWindow`'s content is - **The chooser's tour needs an overlay over its *whole* window, and `AdwWindow`'s content is
replaced, not wrapped.** Two of the chooser's stops name header-bar buttons, and an overlay inside replaced, not wrapped.** Two of the chooser's stops name header-bar buttons, and an overlay inside
the `AdwToolbarView`'s `content` would give them negative coordinates and clamp the hole to the top the `AdwToolbarView`'s `content` would give them negative coordinates and clamp the hole to the top
@@ -657,8 +679,9 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps
- **The `help figures:` line is captured on the tick, like every other size the dump prints.** It names - **The `help figures:` line is captured on the tick, like every other size the dump prints.** It names
ten surfaces' window-coordinate rectangles for the help's annotated figures, and the dump runs after ten surfaces' window-coordinate rectangles for the help's annotated figures, and the dump runs after
the window is destroyed -- a `gtk_widget_compute_bounds()` there is the "no `gtk_widget_*` call in the the window is destroyed -- a `gtk_widget_compute_bounds()` there is the "no `gtk_widget_*` call in the
dump" rule again. `is_visible()`, not `get_visible()`: a widget under a hidden parent -- the trigger dump" rule again. `is_visible()` *and* `get_mapped()`, not `get_visible()`: a widget under a hidden parent -- the trigger
summary while the pane is in configuration mode -- keeps its own flag true and reads 0x0, and a crop summary while the pane is in configuration mode -- keeps its own flag true and reads 0x0 (and a widget on a closed slide-over page, `trigger_*`
while the panel is shut, would read a bogus `630,46,0,0` without the mapped test), and a crop
box drawn from that is a box over the wrong thing. Its numbers depend on the *window's* size, which box drawn from that is a box over the wrong thing. Its numbers depend on the *window's* size, which
under the documented `xvfb-run` is the window's 756x894 minimum (a 640x480 screen), so a run on a under the documented `xvfb-run` is the window's 756x894 minimum (a 640x480 screen), so a run on a
larger screen gives different rectangles for the same panels. larger screen gives different rectangles for the same panels.
+26 -1
View File
@@ -1,6 +1,6 @@
# Invariants: the plot, the figure and the layout # Invariants: the plot, the figure and the layout
How `plot::Figure`/`plot::Plot` may and may not be driven, what a row's state belongs to, the layout editor's overlay, and the rule that layout must not depend on content. How `plot::Figure`/`plot::Plot` may and may not be driven, what a row's state belongs to, the layout editor's and the slide-over's overlay children, and the rule that layout must not depend on content.
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it. Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
@@ -97,6 +97,31 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
an equal division. `sync_layout_editor()` runs every frame on purpose: an equal division. `sync_layout_editor()` runs every frame on purpose:
`gtk_widget_set_margin_*()` returns early on an unchanged value, so a settled layout costs `gtk_widget_set_margin_*()` returns early on an unchanged value, so a settled layout costs
the comparisons and nothing else, and a resize needs no notification. the comparisons and nothing else, and a resize needs no notification.
- **The slide-over is the second thing placed over the plot, and it is one opaque widget, not a
transparent area.** The same pick rule as the editor's buttons applies, harder: the panel is a
`GtkRevealer` aligned to the end edge (`halign=END`, `valign=FILL`) at `kSlideOverWidth` = 360 px, so
its allocation *is* the part of the plot it covers and everything to its left is still the plot's --
pan, zoom, drop and hover are untouched. A full-area child with a panel drawn in it would swallow all
of them. Closed, the revealer is `can_target(FALSE)` (and `gtk_overlay_set_clip_overlay()` keeps its
slide-out inside the plot), so the frames of a slide-out do not eat input either. `[slideover]` reads
it back: `outside_hits_plot=1 inside_hits_panel=1`. Three consequences:
the panel **never resizes the plot** (it is an overlay child; `plot_unchanged=1`), its height is the
plot's and never the page's (each page has a `GtkScrolledWindow` with a `NEVER` horizontal policy that
does not propagate its child's height), and content that is taller than the plot -- the trigger form
on a 470 px window -- scrolls inside the panel instead of growing it. The widgets that go in a page
are reparented into it; their widths must fit 360 minus the border (the trigger form measures 267
minimum, the E-1608's panels 197).
- **The badge bar has a fixed height and sits outside the overlay.** `badge_bar` is 36 px
(`height-request`) between `plot_overlay` and the status line, so the plot above it never moves when a
badge's text changes (`bar_h` = `bar_h_after` in `[badges]`). This is "layout must not depend on
content" for the new row: a badge's state is a word from a short fixed set and the Source badge's name
is a kind's display name, so what changes with the stream is the row's *width* (the bar has room to
spare on the right) and never its height. The badge labels are not ellipsized: a name long enough to
need it would be a defect of the kind's `display_name()`. Rows that appear by trigger condition appear
inside the slide-over's scroller, never in the bar.
- **The dock's width is fixed too** (`side_column` `width-request` 300, the split view's 320 px floor
and cap), and it holds one scroller with the signal list, the cursors and the measurements. A
section's visibility is never what makes the dock taller; the scroller is what absorbs the extra.
- **Trace slots are identified by signal name, not channel index.** Enabling a signal - **Trace slots are identified by signal name, not channel index.** Enabling a signal
or a new CONFIG renumbers the channel list, so an index would silently re-point a or a new CONFIG renumbers the channel list, so an index would silently re-point a
trace at another signal. trace at another signal.
+8 -4
View File
@@ -30,10 +30,14 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
not freeze a colour that is meant to follow the signal list), **the math channels with their not freeze a colour that is meant to follow the signal list), **the math channels with their
graphs**, **the measurements pane**, and **`measure_stats`** — the measurements statistics graphs**, **the measurements pane**, and **`measure_stats`** — the measurements statistics
toggle; written as a bare top-level boolean (`measure_stats = true/false`), defaults to `false` toggle; written as a bare top-level boolean (`measure_stats = true/false`), defaults to `false`
when absent, and **`dock`** -- the Dock button's state (the user's last word on it, `sidebar_wanted`), when absent, and **`dock`** -- `LayoutState::dock_shown`, the Dock button's state (the user's last word on it,
a bare top-level boolean written where the sidebar's `side_tab` used to be, default `true`. The parser `App::sidebar_wanted`): a bare top-level boolean written where `side_tab` used to be, default `true`.
ignores unknown keys, so a file written before the tabs became a dock (`side_tab = "measure"`) still loads It is the **only** thing the dock persists -- its 320 px width is fixed, so there is nothing to store --
and simply opens with the dock shown; nothing needs to read the old key. Signal identity and **the open slide-over page is deliberately not persisted** (a panel that opens by itself at start-up
is a surprise, and a page id from another source kind may not exist). The parser ignores unknown keys
(its key chain has no erroring final `else`), so a file written before the tabs became a dock
(`side_tab = "measure"`, or `side_tab = "source-ao"`) still loads and opens with the dock shown;
nothing reads the old key, and `[dock-session]` asserts it. Signal identity
is the *name* throughout (a CONFIG renumbers channels), 'enabled' is read off the channels the is the *name* throughout (a CONFIG renumbers channels), 'enabled' is read off the channels the
model built -- an enabled signal is exactly one that produced a channel -- and `apply_layout()` model built -- an enabled signal is exactly one that produced a channel -- and `apply_layout()`
installs the graphs **before** assigning the rows, since a row may hold a math channel's name and installs the graphs **before** assigning the rows, since a row may hold a math channel's name and
+11
View File
@@ -15,6 +15,17 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
Two baselines every run is read against. The UDPS runs must be **unchanged by the source-plugin work**: Two baselines every run is read against. The UDPS runs must be **unchanged by the source-plugin work**:
a UDPS self-check, number-normalised (`sed 's/[0-9.]\+/N/g'`), diffs empty against one taken before it. a UDPS self-check, number-normalised (`sed 's/[0-9.]\+/N/g'`), diffs empty against one taken before it.
For the badge bar / slide-over / dock redesign the same diff is taken **against the commit before that
series (`c572ec2`)**, from a fresh `XDG_CONFIG_HOME` on each side (a stored mock session changes the
trigger lines), and it is "unchanged except" exactly these lines: `panels:` loses `switcher=` (the
trigger form is read through the slide-over, so its width and height change, and the `compact arm
probe` width with them); `switcher:` becomes `dock: shown= sensitive= w= scroller_pages=`;
`panel controls:` prints `dock=` for `switcher=`; `help figures:` loses `side_switcher=`, replaces
`side_stack=` with `badge_bar=` and `dock=`, moves the `trigger_*` rectangles into the slide-over and
shortens `plot=` by the 36 px bar; and the `tour started: steps=` count. A self-check taken **before**
`c572ec2` is no longer the baseline for those lines. The rest is the mock's own jitter (the sign of a
`square y=[...]` or a `trig engine:` figure, the position of one `trigger: edge` line), which the
normalisation does not remove -- anything else is a finding.
A clean build emits **one** warning and no more: a `-Woverlength-strings` on the icons' A clean build emits **one** warning and no more: a `-Woverlength-strings` on the icons'
generated GResource `.c`, whose blob is **9248** against C99's 4095-byte string-literal limit with generated GResource `.c`, whose blob is **9248** against C99's 4095-byte string-literal limit with
the five action icons and the two app icons that remain (a note about a generated file, which GCC the five action icons and the two app icons that remain (a note about a generated file, which GCC