Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
141 lines
11 KiB
Markdown
141 lines
11 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 overlay, 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.
|
||
|
||
- **`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.
|
||
- **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.
|