Files
udpscope/docs/claude/invariants-plot-and-layout.md
T

31 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 and the slide-over's overlay children, 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 = 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 shape (its cells per row) 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.

  • A figure row can be split into cells, and every plot index in the host is the flattened one. RowSet::row_count() is the number of plots and row(i), Row::plot, the cursors, the measurements, bind(), the readouts and the persistence layers all index plots row-major (row 0's cells left to right, then row 1's); figure_row_count() is the number of figure rows, and cells_in_row()/first_plot_of_row()/figure_row_of()/cell_of() translate. Code that means "the rows of the figure" -- the editor's row +, the row drag, the row weights, fl.rows -- must say figure_row_count(): with no split the two are equal and a slip passes every unsplit probe, which is how a row weight once drifted off its row (see the row-weights reading). The caps are kMaxCellsPerRow = 4 per row and kMaxRows = 8 plots in all (the constant bounds plots now, not figure rows), so every per-plot structure sized for 8 is unchanged. plot_label() is what the UI prints for a plot: the row's number (2) for a one-cell row, the number and a letter (2a..2d) for a split one -- never a flat index, which is 0-based and says nothing about where the plot is. What the cells share and what each keeps:

    • One time window. Every cell of every row is a member of the one x link group, so a pan or zoom in any cell moves them all, the crosshair is one x drawn in every cell (the hovered cell shows its y), and the vertical cursor pair is one shared time drawn in every cell (cursors_aligned). A split is an arrangement of more plots, not a second time scale: nothing about the time axis changes (see invariants-time-axis-and-window.md).
    • One y per figure row. The cells of a figure row share one y range (the plotter's Figure::set_share_row_y(true), which rebuild_all() sets before the cells are populated): one y axis (auto or fixed), one pair of horizontal rulers, one fit. A wheel or axis drag in any cell moves the row, an x gesture on any bottom-row cell moves the shared window. The host therefore acts on the figure row of a plot it is given (set_y_auto, set_y_range, fit_all, the Auto/Fixed dialog, row_unit, which joins the units of every cell and reads "" if they differ). The rulers are stored on the row's first plot index, copied to the other cells by sync_cursors() / insert_cell() / rebuild_all() and placed in every cell by place_cursors(). The cursors pane lists one ruler line per figure row: row N with N the 1-based figure row for a split row, and the 0-based plot index for an unsplit row (the pane read that before splitting existed). Readouts and measurements stay per trace and per plot index; only the axis is shared.
    • The x axis is the bottom row's for every run of rows with the same columns. A figure row gets XStrip::None only if the row directly below has the same columns (same cell count and same normalised cell weights); a row whose columns differ keeps its own strip under each of its cells ([1][2]/[3] has axes on 1, 2 and 3), because the axis below is at another width and scale. The rule is structural and transitive, never a function of content. Every cell that carries a strip is a place a middle-drag on the axis works. The t0 caption belongs on every cell that carries a strip, not only the last figure row's.
    • The right gutter is the last cell's. In a split row with a shared y the plotter gives right_gutter to the last cell alone and takes it off the row like the y gutter, so the visible gap between two plots is col_gap and equal weights give equal axes widths.
    • One left gutter, on the first cell only. The y gutter is one maximum for the whole figure with the same reserved floor as before (Figure::set_min_y_gutter()). It is carried by the first cell of each row alone: Figure::layout gives every other cell YStrip::None from its column index (structurally, never from what it holds), which forces its y label width, title width and reserved floor to 0. A None cell contributes 0 to the common maximum, so a split row's non-first cells cannot widen the gutter and a row's gutter does not depend on how many cells it has. A per-column gutter sized by the labels would make a cell's width depend on its content, which "layout must not depend on content" forbids. A row whose labels are narrower than the gutter simply has a wider margin. The weights apply to plot widths, not cell rects (below).
    • Widths are weights with a floor. The plotter keeps 64 px logical per plot and a column gap (kColGap, 6 px); the editor's drag is stricter (below). The floors and the weights act on plot widths: the row's y gutter (carried by the first cell) is taken off the row width first, then the cells share the rest, so equal weights give equal plot areas and the first cell's rect is its gutter plus its plot width. A weight is a share of what is left after the gutter and the floors, so cells of {3, 1} read 546:207 (rects) in a 756 px figure, not 3:1, and the editor's 96 px drag floor is a floor on the plot width, not on the share. Because a cell's width is 64 + extra * weight / total and not proportional to its weight, layout_col_apply() does not scale the pair's weights by the width ratio: it keeps the pair's weight sum S from the drag's start (layout_col_drag_start) and gives the left cell S * (want_left - 64) / (pair - 128), clamped to [0.001, 0.999] (the plotter refuses non-positive weights; 0.5 if the pair is not wider than 128). want_left and the pair are plot widths (layout_col_drag_begin subtracts the first cell's y_axis_width + y_title_width from its rect); want_left is the pointer's width clamped to [96, pair - 96] logical px. The divider therefore follows the pointer, the shrinking cell reads 96 px at the extreme and the pair keeps its total width (the split probe's col-drag line asserts all three). kPlotterMinCellPx (64) mirrors the plotter's kMinCellWidthLogical; both are logical px (the plotter scales it by ui_scale, which is PlotArea::device_scale(), the divisor used to turn layout rects into logical px), so the two must change together.
    • A cell is removed with its traces, and a new cell is empty. insert_cell(fr, at) pushes the cells at and after at right with their contents (the same rule as insert_row()), and remove_cell(plot) on a one-cell row removes the row. Both return false at a cap or when they would leave no plot, and never grow past 4 / 8. The editor's controls (rebuild_layout_editor()): a cell + (kLayoutCellKey, tooltip "split here") at a row's left edge, at its right edge and at every internal boundary -- n + 1 of them for a row of n cells, so a one-cell row can be split from either side -- inserting at that position; a drag arrow (kLayoutColKey, go-next-symbolic) at each internal boundary that moves the boundary between its two neighbours, each floored at kMinCellPx = 96 px logical (or half the pair when the pair is narrower) and measured from the two widths at drag start, so the drag does not compound; and the per-plot ×, which removes the cell in a split row ("remove this plot") and the row in a one-cell row ("remove this row"), insensitive when it would leave no plot. Cell + is insensitive at 4 cells in its row or 8 plots in all; the row + is insensitive at 8 plots. The cell + is keyed by kLayoutCellKey (figure row * 8 + position + 1) and never by set_row_key(): row_key() is a plot or boundary index, layout_button() finds a button by it, and a cell + that also carried one would be found as a row button; sync_layout_editor() tells the kinds apart by which key it finds. Like every editor control the cell buttons are placed from FigureLayout::cells, divided by device_scale(), and the editor does not draw while the figure's rects are not yet one per plot.
  • 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 only when the dock is not beside the plot and there is something to read. 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) and (the cursors are on or there is a measurement) (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 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, and hidden with nothing to read (cursors off, no measurement). That last is a deliberate, user-requested exception to "disable, do not hide": an empty strip is 26 px of nothing, and the dock keeps both blocks on screen so the empty state is never invisible; the strip's two groups and the separator follow the same rule (strip_cursor_group with the cursors, strip_measure_group with measurements, strip_sep with 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, visible_ok=1 that visible equals expected and groups_ok=1 that the groups agree.

  • 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. A split row keeps this rule: cell widths come from the weights and the 64 px floor and never from what a cell's labels need, and the gutter stays one figure-wide maximum. 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.