# 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. - **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. - **`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.