Files
udpscope/docs/claude/invariants-plot-and-layout.md
T
Martino Ferrari bb01278d88 fix: the sidebar keeps its own width, so a small window can no longer hide it
The sidebar's width was a GtkPaned divider position: `window - 940` in pixels. On any window
narrower than 940 px the divider landed past the right edge and the sidebar came up 0 px wide,
reachable only by dragging the divider back.

Every attempt that kept the paned failed for that reason -- deriving the position from the paned's
own width, re-parenting the column, property-form children, code-set size requests. The mechanism was
the fault, so it is gone: the row is an AdwOverlaySplitView, which is what a utility sidebar is in
GNOME, with min-sidebar-width = max-sidebar-width = 320 in pixels. Its width is therefore its own at
any window size, and when there is no room beside the plot it *overlays* the plot (collapsed, set from
the window's width in tick()) instead of being squeezed to nothing.

Two things found on the way, both recorded in the invariants:

- sidebar-width-fraction is a 0..1 fraction of the window whatever sidebar-width-unit says. Setting a
  pixel count there is a GObject critical and it falls back to 0.25, which is why the sidebar first
  read 560. The width is pinned with min/max instead.
- the window opened at its content minimum (756 px, the plot column plus the sidebar) because the
  explicit gtk_window_set_default_size() was dropped when the window moved into the .ui. Restored.

[sidebar-probe] reads both states and is the guard: window=756 sidebar=320 under a 2x scale, which is
the case that used to read 0.
2026-10-12 12:17:56 +02:00

13 KiB
Raw Blame History

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. 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 = 320 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=320 under a 2x scale is exactly the case that used to be zero.

  • AdwOverlaySplitView:sidebar-width-fraction is a 0..1 fraction of the window, whatever sidebar-width-unit says. Setting a pixel count there (320) 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.

  • 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. 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.