Files
udpscope/docs/claude/invariants-plot-and-layout.md
T
2026-11-03 11:15:19 +01:00

256 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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.
- **A sidebar's size must be its own, never a divider's position.** `GtkPaned` keeps a child's size in
`position`, which is a number of pixels **of the window**: the sidebar's width *was* `window - 940`, so on
any window narrower than 940 px — a scaled display, where the logical width is half what the screen looks
like, or a window a manager capped — the sidebar came up **0 px wide**, and the only way to see it was to
drag the divider back. Every patch that kept the paned failed for the same reason (the position derived
from the paned's own width, re-parenting the column, the property form of its children, size requests):
the *mechanism* was the fault. It is an **`AdwOverlaySplitView`** now — what a utility sidebar is in
GNOME, as against `AdwNavigationSplitView`, which is for navigation lists — with `min-sidebar-width` =
`max-sidebar-width` = 280 in **pixels**, so the width is its own at any window size, and `collapsed` (set
from the window's width in `tick()`) to *overlay* the plot when there is no room beside it rather than
being squeezed. `[sidebar-probe]` reads both states: `window=756 sidebar=280` under a 2x scale is exactly
the case that used to be zero.
- **The sidebar's button is the only input; `AdwOverlaySplitView:show-sidebar` is an output.** The split
view writes that property *itself* — at startup, and when it collapses — with no user involved, so reading
it back as the user's wish is wrong: doing that made connecting a source leave the sidebar shut, because
the widget's own `false` was recorded as the user asking for it. A `GBinding` from the property to the
toggle is the same mistake in one line, and it cannot be told from a real change by a "is this our own
write" flag either, since the toggle's signal may fire after the write returns. Instead `App::sidebar_wanted`
holds the wish (written only by `on_sidebar_toggled`, guarded by `App::sidebar_syncing` around our own
`set_active`), `update_side_visibility()` is the single place that turns that wish into the panel's visibility
and "are there signals" into the dock's *sensitivity* (the button stays sensitive), and `tick()` re-asserts both when the widget disagrees —
which is also why a hide *gesture* does not stick: the panel stays where the button says it is. The
no-signals case must leave the wish alone, or a source connecting later finds a sidebar the user never closed.
The wish is the session's `dock` key: a change of it (not a program's own write, and not a restore) schedules a save.
- **`AdwOverlaySplitView:sidebar-width-fraction` is a 0..1 fraction of the window, whatever
`sidebar-width-unit` says.** Setting a pixel count there (280) is a `GObject` critical, and it silently
falls back to 0.25 — which reads as "the sidebar is mysteriously 560 px wide" on a wide window with no
error anywhere the build would catch. Pin the width with `min-sidebar-width`/`max-sidebar-width`.
- **A `GtkPaned`'s position is stated after both its sides are filled**, and that is a fact about the
*function's order* rather than about the window's shape, which is why it is the one part of the
window skeleton that cannot live in `main-window.ui`. `gtk_paned_set_position()` clamps against what
its children ask for *now*: called before the sidebar's panels have been appended it is clamped
against an empty pane, and the divider lands wherever that left it. Moving the call into the `.ui`
moved the plot column by about 180 px, which the layout probe read as a different `done_box` and a
taller figure.
- **Events never take the plot's width.** The events list is a slide-over page (an overlay child over the plot) and
the dock's Events block lives in the fixed 280 px column, so there is no events divider, revealer or pane beside the
plot to squeeze it; the first latch must not move the plot area (`[events] ... plot_w_stable=1`, which compares the
allocated width before and after). A dock row is a fixed-width ellipsized label (title at 18 characters, `#seq`), the
list is a fixed 112 px scroller capped at `kDockEventsMax` rows plus one "N more" row, and it has no thumbnails: the
dock's width and height do not depend on how many events there are or what they say ("layout must not depend on
content"). The thumbnails are the *page's* (138 px wide).
- **`plot::Plot` cannot remove or retarget a trace.** Hence fixed trace slots created
up front and rebinding via `SlotSeries`; a *name* change (legend text) is the one
thing that forces a rebuild. Cursors are parked outside the axis range instead of
removed.
- **A `Figure`'s row count is fixed at construction**, so `RowSet::rebuild_all()` builds
a *new* figure and the area is handed it (`PlotArea::set_figure()`); it costs no GL
state. That makes one path for "a row's traces changed" and "the row count changed",
and it is why `rebuild_all()` restates a pinned event window (`pinned_pre_`/
`pinned_post_`) instead of re-framing on the live edge — a drop during trigger mode
would otherwise throw the frozen view onto the stream, where nothing moves it off
again until the next latch. `PlotArea::create()` is handed `App::placeholder_plot`,
never a row: the area keeps a reference to the plot it was created with, and a rebuild
destroys every row.
- **A trace is a slot with a name *and* a channel; a name alone is not one.** `Row::channel[s]`
is re-resolved by `RowSet::bind()` every frame (slots are identified by name, see above), and
`-1` means the model built no channel for that name. Some names can *never* bind: the stream's
**time signal** is skipped by `build_channels_locked()` however often it is enabled, so a slot
holding it has a name for ever and data never. The plot showed nothing for it, which is why it
went unnoticed, but the cursors pane's `mix` mode drew one line per *named* slot and so gave
each phantom a row that could only read `--` — "the pane shows a lot of empty signals that do
not exist", six lines over three real traces. Two halves to the rule. (1) Anything that counts
traces counts `channel[s] >= 0`, not `!signal_name[s].empty()` — and a per-frame bind means the
cursors pane cannot key its rebuild on `generation()` alone, hence `App::cursors_pane_bound`, a
bit per row/slot (`kMaxRows * kSlotsPerRow` is 32, so one `std::uint32_t`): a trace that
appears without a structural change still gets its line. (2) `apply_layout()` refuses a name
that cannot produce a channel (`ScopeModel::is_time_signal()`), because such a name was
**sticky**: `collect_layout()` writes `signal_name` back verbatim, so one that got into a
session file was re-saved every session for ever. The refusal also heals an existing file on
the next save. How the names first got in is unexplained — the drop path already refuses a
signal with no channel — most likely an older build or a CONFIG whose time signal was not yet
classified as one; the fix is the stickiness and the counting, not a guess at the origin.
- **A row's contents are state of the `RowSet`, not of the figure.** `insert_row()` and
`remove_row()` shift that state with `carry_row()` and let `rebuild_all()` re-deal the
figure handles, so inserting above row 1 moves what row 1 plotted down to row 2 rather
than renumbering the rows under the user. `carry_row()` deliberately copies fields
instead of assigning a whole `Row`: a `Row` also holds the figure handles (`plot`, the
axis and cursor ids) and the `SlotSeries` objects the traces point at, and those belong
to the row's *position*. It clears the destination's slot bindings for the same reason —
the next `bind()` re-resolves them, and without the clear the frame in between draws the
moved row's samples under the new row's legend.
Both call `rebuild_all()` themselves, so their callers must re-point the area
(`PlotArea::set_figure()`) directly; `relayout_areas()` is no help, since it only acts on
a *dirty* row.
- **The layout editor's buttons are *separate* overlay children, never one transparent
child covering the area.** A `GtkOverlay` child is picked wherever its allocation is, and
`gtk_widget_set_can_target(FALSE)` takes a widget's children out of picking with it, so a
full-area `GtkFixed` would swallow every pan, drop and hover the editor is not interested
in. Each button is therefore a small widget aligned to a corner or the centre and placed
by its **margins**, and the overlay is only measured from its main child, so moving them
cannot reach the plot's layout.
They are placed from `FigureLayout::rows` — the rects the frame was *drawn* from —
divided by `PlotArea::device_scale()`, because a `Rect` is framebuffer pixels and a GTK
margin is logical ones. Reading the figure's own rects rather than dividing the widget
height by the row count is what keeps a `×` on the row it names when the rows stop being
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
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). **One page wider than that widens the panel
for all of them** (the stack measures every page): the signals page's hint line once did, unwrapped, and
`[slideover] panel=` read 376 instead of 360; the label now wraps.
- **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: the Trigger badge's state is a word from a short fixed set and the Source badge
(shown only when the kind has source pages; hidden otherwise) is a kind's display name only (no connection state: that is the status bar's), 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 signal badges live in a scroller, so the bar's minimum width never depends on the signal count.**
The bar's signals slot holds a `GtkScrolledWindow` (horizontal `EXTERNAL`, vertical `NEVER`) around one
row of badges and the `+`, because a button row's minimum width is the sum of its children: nine badges
(the E-1608: eight inputs and the counter) would push the window's minimum width past a laptop screen.
Past `kMaxSignalBadges` (6) the row is replaced by one "N signals" badge, which also stands for the `+`.
The bar's height stays 36 px in every one of these states (`bar_h_fixed` in `[sigbadges]`), and
`bar_min_w` reads the same with one signal, nine signals and the overflow (244 on the E-1608, 124 on UDPS:
the Source badge is on screen for the E-1608 and hidden for UDPS, and its text differs by kind). A change that makes it grow with the signal count has
taken the scroller out of the measurement.
- **The dock's width is fixed too, and it is the readouts' width that holds it there** (`side_column`
`width-request` 260, the split view's 280 px floor and cap). The dock holds only the compact readouts and the Events block (a fixed-height list, below), so a
section's visibility is never what makes it taller; the scroller absorbs the rest. **The readouts' minimum
width is the sum of their cells' character widths, and it must stay under 280** (the Readouts box is 268:
280 less its 6 px margins): a cursor line is the `who` cell (5 characters) plus three value cells (9 each),
every one ellipsized at that width (`width-chars` = `max-width-chars`), so a long value is cut and its
tooltip carries the text and the layout never depends on what a value reads. The `who` cell was 8
characters, which made the readouts' minimum width 304 px; that pushed the sidebar to 304 px
(`dock_w=304`) and defeated the 280 pin. Widening any cell is the same
trap -- read `[readouts] ... dock_w=` (must be 280) and the `dock cursors:` / `dock measures:` `fits=`, and for the Events block `dock events: ... dock_w=` (268).
- **The readouts strip is a fixed 26 px, scrolls sideways, and is shown only when the dock is not beside the plot and there is something to read.**
`readouts_strip` (a `GtkScrolledWindow`, `height-request` 26, horizontal `external`, vertical `never`) sits
in `left_column` between `plot_overlay` and `badge_bar`. Its height is a request and not a measurement of
its chips (the buttons are trimmed by `button.udpscope-strip-button` in `ui_widgets.cpp`, or Adwaita's
34 px flat button would push it past 26), the chips' cells are fixed-width ellipsized labels like the
dock's, and an overflow scrolls with no bar taking height: "layout must not depend on content" for the
strip, the same as for the badge bar. It is *visible* exactly when (`show-sidebar` is false **or** the split view is `collapsed`)
**and** (the cursors are on **or** there is a measurement)
(`sync_readouts_strip()`, from the tick after the wish is re-asserted and before `set_collapsed()` -- so
`show-sidebar` is the wish and not the split view's collapse-time write, and `collapsed` is the previous
frame's value). Hidden with a wide window and the dock beside the plot, so the plot's 26 px go to one
pane of readouts and never both, and hidden with nothing to read (cursors off, no measurement). That last
is a deliberate, user-requested exception to "disable, do not hide": an empty strip is 26 px of nothing, and
the dock keeps both blocks on screen so the empty state is never invisible; the strip's two groups and the
separator follow the same rule (`strip_cursor_group` with the cursors, `strip_measure_group` with
measurements, `strip_sep` with both). In a collapsed window the dock is an overlay on the plot and the strip
stays, overlay open or not. The plot is 26 px shorter while it shows; that is the dock's and the window's
state moving the layout, not content. Reading `h=26` with `visible=1` (and `h=0` with `visible=0`) in
`strip:` is the assertion, `visible_ok=1` that `visible` equals `expected` and `groups_ok=1` that the groups agree.
- **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
trace at another signal.
- **Layout must not depend on content.** The y gutter is reserved at the widest label
`TickFormat::Si` can produce *for an axis with no unit* — a sign, three significant
figures and a one-glyph prefix, measured and not counted in characters — and only ever
grows; the status label is ellipsized so
its text cannot drive the paned divider. Both exist to stop per-frame relayout.
The reservation deliberately does **not** cover `format_si()`'s `%.3g` fallback
(`-1.23e-45`), which it used to: that form needs a value past 1e24, so it bought a third
of the gutter for a label no stream produces, and the floor being a monotone maximum over
the *measured* need means one that did appear widens the gutter for good anyway. Erring
wide is not free here — it is a third of the plot's width.
And **nothing may add slack to the gutter it is told**: `note_y_gutter()`'s argument is
`layout().y_axis_width`, which is `max(measured, floor)` — partly our own answer coming
back — so a constant added to it compounds. See the ratchet reading under `layout over N frames` in
[probe-readings.md](probe-readings.md).
The same rule is why the activity badge is one glyph in three colours rather than
three glyphs, why the `rate` field collapses to one figure or a range instead of a
per-channel list, and why `format_elapsed()` steps to a coarser unit instead of
letting the counter grow: each is a status field whose *width* would otherwise depend
on the stream, and the status row is directly above the plot.
The reservation goes on the **figure** (`Figure::set_min_y_gutter()`, added to the
plotter for this), not on the rows: `Figure::layout()` clears every row's
`set_min_y_gutter()` to measure what its own labels need, so a floor written onto the
rows survives exactly until the next layout.
- **The right-hand band is a want, and the left gutter is a measurement.** The two sides of
the plot are not symmetric in kind: the left holds the y labels, so it is measured and
floored; the right holds nothing, so there is nothing to measure and `RowSet` simply asks
for half the widest time label plus the label padding (`right_gutter_px_`, reserved in the
same one-shot block). Half a label is the figure that matters: an x tick label is
*centred* on its tick and a live axis is `[-window, 0]`, so there is always a tick exactly
on the axes' right edge, and with `axes` flush to the viewport half of that label was
drawn outside the plot. Both go on the **figure** and for the same reason — the rows have
to end on one column as well as start on one — which is why `apply_gutters()` applies the
pair and `rebuild_all()` calls it: a new `Figure` carries neither.
- **A trace's colour is indexed by *signal*, not by trace slot.** `Row::color_index`
stores it and `signal_color_index()` (ui.cpp) computes it from the signal list, so a
signal dropped on two rows is one colour and the signal panel's chip names a trace
unambiguously. `plot::Plot` fixes a colour at `add_trace()`, so changing one needs a
figure rebuild — which `assign()` already forces. A signal on no plot gets
`RowSet::unplotted_color()` (grey) rather than no chip, so the panel's rows stay the
same height.
- **The invalid-span band is data-anchored and reserves no layout space.** `Plot::set_invalid_spans()`
draws a tinted quad over the span it names; unlike `Plot::set_x_note()` (the `t0` caption), which
is chrome drawn at a fixed corner of the axes regardless of the data, a band pans and zooms with
the samples it accuses. The two are the complementary cases: the caption is chrome that data does
not move, the band is annotation that data does. Both are alike in the second count: neither
reserves layout space and neither can be picked, so a hole cannot move the layout the labels were
measured into.
The band has a **one-device-pixel floor on its width**. A hole of one sample in a window of hundreds
of thousands is a hundredth of a column, and a fill rounded to zero would drop exactly the case
the band is most needed for — a sub-pixel hole is invisible as a gap in the polyline and would be
invisible as a band too without the floor.
The band carries **three states** and they are a property of the hole, not of the plot: solid
(`Style::invalid_fill`) while the hole still lacks packets, faint (`Style::invalid_repaired_fill`)
once `Hole::repaired()` is true, absent once the hole's slots age out of the ring and
`forget_holes_before()` drops it from the table. A repaired hole is **not** retired early: the band
is a record of what the window was missing, and retiring it on repair would erase the report the
moment it became explicable — the faint tier is how the display says "this was missing but arrived
late". `RowSet` sends one `plot::InvalidSpan` per hole to every row — a gap is in the stream, not
in one signal — and only in `Live` and `Capturing`, because a `Held` or `Paused` snapshot carries
its own stamps that the live hole table does not address.
- **`RowSet::signal_color()` is the only route to a trace's colour.** The user can override the
palette entry per signal (clicking its chip; `set_signal_color()` / `clear_signal_color()`,
keyed by **name**, because a palette index is positional and a CONFIG renumbers), and that
override is exactly the state in which two routes to a colour disagree. So `install()` builds
the trace through this call and all three chip sites — the signal panel, the measures pane and
the cursors pane — read it too; none of them may index `palette_color()` itself. An override
marks every row holding the signal dirty (`mark_signal_dirty()`, not `assign()`: what the slot
*holds* did not change) and the rebuild happens on the frame tick via `relayout_pending`,
because `relayout_areas()` is the one place that builds a figure and re-points the area at it.
`UDPSCOPE_PROBE_COLOR_MS` reads the result back out of `plot::Plot::trace()` and out of the
chip's own markup, since asking the resolver twice would assert nothing.
- **Switching a signal off takes its traces off every plot.** The switch means "do not acquire this", and a
trace left behind draws nothing: it holds one of the four slots a plot has, reads `--`, and blocks the drop
that would fill it with something live. `on_signal_switch()` clears every slot by the **signal name** the
slot remembers -- never the channel index, which is exactly what the enable change renumbers -- and says what
it did in a notice, because a trace that vanishes silently is its own kind of confusing. The follow-ups (a
relayout, the panels' chips, the layout save) are done once by the caller rather than per slot. Re-enabling
does **not** put the traces back: the arrangement is the user's, and guessing where they were is worse than
leaving them off.