The file was 1967 lines and is auto-loaded into every context, so every
session paid for the whole of it to reach one invariant. It now carries the
project's goal, the three facts that are always relevant, and a table of
contents; the body is fourteen files under docs/claude/ reached by link
rather than by import, so nothing is loaded until it is asked for.
The content is moved verbatim -- the only edits are each file's own header
and the cross-references that became cross-file ("see the invariant below"
is now a link to the file that owns it).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
8.4 KiB
8.4 KiB
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.
plot::Plotcannot remove or retarget a trace. Hence fixed trace slots created up front and rebinding viaSlotSeries; 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, soRowSet::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 whyrebuild_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 handedApp::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 byRowSet::bind()every frame (slots are identified by name, see above), and-1means the model built no channel for that name. Some names can never bind: the stream's time signal is skipped bybuild_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'smixmode 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 countschannel[s] >= 0, not!signal_name[s].empty()— and a per-frame bind means the cursors pane cannot key its rebuild ongeneration()alone, henceApp::cursors_pane_bound, a bit per row/slot (kMaxRows * kSlotsPerRowis 32, so onestd::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()writessignal_nameback 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()andremove_row()shift that state withcarry_row()and letrebuild_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 wholeRow: aRowalso holds the figure handles (plot, the axis and cursor ids) and theSlotSeriesobjects the traces point at, and those belong to the row's position. It clears the destination's slot bindings for the same reason — the nextbind()re-resolves them, and without the clear the frame in between draws the moved row's samples under the new row's legend. Both callrebuild_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
GtkOverlaychild is picked wherever its allocation is, andgtk_widget_set_can_target(FALSE)takes a widget's children out of picking with it, so a full-areaGtkFixedwould 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 fromFigureLayout::rows— the rects the frame was drawn from — divided byPlotArea::device_scale(), because aRectis 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::Sican 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 coverformat_si()'s%.3gfallback (-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 islayout().y_axis_width, which ismax(measured, floor)— partly our own answer coming back — so a constant added to it compounds. See the ratchet reading underlayout over N framesin probe-readings.md. The same rule is why the activity badge is one glyph in three colours rather than three glyphs, why theratefield collapses to one figure or a range instead of a per-channel list, and whyformat_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'sset_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
RowSetsimply 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 withaxesflush 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 whyapply_gutters()applies the pair andrebuild_all()calls it: a newFigurecarries neither. - A trace's colour is indexed by signal, not by trace slot.
Row::color_indexstores it andsignal_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::Plotfixes a colour atadd_trace(), so changing one needs a figure rebuild — whichassign()already forces. A signal on no plot getsRowSet::unplotted_color()(grey) rather than no chip, so the panel's rows stay the same height.