src/ui/main-window.ui holds the window, its two panes, the plot column with the status line under it, and the sidebar's scroller. What stays in code is what depends on the stream: the events pane, the two plot areas, the sidebar's panels and the status popover, each put into a slot by id. The header bar is deliberately not in the file. It is the window's own titlebar, every button on it is wired to an action, and an AdwApplicationWindow refuses a titlebar outright -- a fatal Adwaita-ERROR rather than a warning that leaves the window merely ugly -- so the window is a GtkApplicationWindow and the header bar stays where it was. gtk_paned_set_position() is the other thing that had to stay in code, and the reason is order rather than shape: it clamps against its children *now*, so stating it before the sidebar's panels exist moved the plot column by ~180 px. The layout, events and box-zoom probes read what they read before this change, and the header reads window_button_w=130 bar_w=756 as it always has -- checked by building the previous commit and running the same probe against it, because the layout numbers in the notes turned out to be stale from an earlier session. Five UI files now.
149 lines
12 KiB
Markdown
149 lines
12 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.
|
||
|
||
- **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.
|