Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
251 lines
22 KiB
Markdown
251 lines
22 KiB
Markdown
# 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 whenever the dock is not beside the plot.**
|
||
`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`
|
||
(`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 only 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; 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, and `visible_ok=1` that `visible` equals `expected`.
|
||
- **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.
|