A batch of view and instrument work, with the probes and docs for each. - **Full screen per figure row**: a toggle on each row's top-right corner, revealed by pointer proximity (`kMaximizeRevealPx` around its placed rect, per row, read from an overlay-level motion controller so a revealed button under the pointer cannot flicker); the plot menu offers the same as *Full screen this figure* / *Restore the layout*. The arrangement is saved and restored through `RowState`, so nothing plotted moves. A layout of one figure carries no toggle. - **Second window**: `open_scope_window()` copies a figure (cells, width shares, signals in their colours) or opens an empty one, with its own `RowSet` and `PlotArea`; the main window's tick binds, acquires and repaints it, mirroring the time window and pin. Entries: the plot menu's *Open this plot in a new window* and the overflow menu's *New scope window*. - **Layout editor from the plot**: the plot menu gains *Edit the layout...* / *Done editing the layout*, flipping the app's one toggle. - **Measurements gate**: `MeasureOptions::gate` restricts a measurement to the held/pause snapshot (live falls back to the live ring), persisted with the session and shown in the measure dialog. - **Crosshair axis badges**: a preference (`crosshair_axis_labels`, on by default) and the plotter's badges where the lines meet the axes, in the axes' own formats; the plot menu and the probe read them. - **Event cache per configuration**: `EventStore` is keyed `<source>/<configuration>/`, `retarget_event_store()` follows every configuration switch, and a pre-configuration cache (loose files) is adopted into the configuration's folder on first start. - **GTK4 lifecycle**: the main window's handlers move from the non-existent `"destroy"` signal to `close-request`; scope windows are closed with it and the application quits explicitly (the exit run's hang). The exit probe closes with `gtk_window_close()`. - Probes: `[maximize-probe]` (single-figure rule and the corner reveal), `[scope-probe]`, `[split-probe]` cell crosshair leg, `[prefs-probe]` store keying, `[configs]` store key, `[rowmenu]` layout-editor entry; docs, help pages, README and AGENT.md updated. Also fixes a probe-only use-after-free: `toggle_maximize_figure_row` replaces the figure object, so the area must be re-pointed (the button's path does; the probe's raw call did not).
18 KiB
Invariants: the session file, the export and the source chooser
What a session persists and in what order, when a save is refused, and when an export is collected.
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.
- The trigger is persisted, settings and state both. Its condition, its channel (by name), its
pre-trigger share, hold-off and mode, and whether trigger mode was on — written even while it is off,
because a trigger configured once and switched off is still the trigger you come back to. The two
enumerations are stored as the words the panel's dropdowns show, not as their indexes: an index ties
the file to a declaration order, which is the lesson
math_op_at()carries, and the words are shared with the panel so the two cannot drift. The channel resolves when the acquired channels exist (rebuild_trigger_signals()picks upApp::pending_trigger_channel, orapply_trigger_layout()resolves it itself when they are already there) — a name that resolved to nothing would silently move the trigger to channel 0. - A session that loaded is what locks the trigger pane. Locked is the returning user's mode: the
trigger pane shows one compact line and offers no control that changes the configuration (the signals
have no lock). A first run — no session file for that source — never
reaches
apply_layout()and stays in configuration mode, which is what a first run is for. The mode is a mode and not a setting: it is deliberately not persisted, so a scope reopened tomorrow starts where a returning user expects to start. Dragging a signal onto a plot is not configuration and survives the lock: it is the signal badge's drag (the list's rows are no longer drag sources, and the badges do not follow the lock).[sigbadges] drag_sourcesis the reading, anddrag_sourceson thesignals panel:line must be 0. - The session is the layout.
LayoutStatepersists what makes a reopened scope look like the one that was closed: the window, the rows and their slot signals, the calibration, the unit renames ([[unit]],name+unit, only the overrides;ScopeModel::set_signal_unitre-applies them to every CONFIG, andset_signals()takes them off the incoming units before the "same stream" comparison so a reconnect still keeps its history), the enabled signals, the chosen trace colours ([[color]],name+rgb = "#RRGGBB"— only the overrides, so a stream's new signals still take the positional palette and a saved session does not freeze a colour that is meant to follow the signal list), the math channels with their graphs, the measurements pane, andmeasure_stats— the measurements statistics toggle; written as a bare top-level boolean (measure_stats = true/false), defaults tofalsewhen absent, anddock--LayoutState::dock_shown, the Dock button's state (the user's last word on it,App::sidebar_wanted): a bare top-level boolean written whereside_tabused to be, defaulttrue. It is the only thing the dock persists -- its 280 px width is fixed, so there is nothing to store -- and the open slide-over page is deliberately not persisted (a panel that opens by itself at start-up is a surprise, and a page id from another source kind may not exist). The parser ignores unknown keys (its key chain has no erroring finalelse), so a file written before the tabs became a dock (side_tab = "measure", orside_tab = "source-ao") still loads and opens with the dock shown; nothing reads the old key, and[dock-session]asserts it. Signal identity is the name throughout (a CONFIG renumbers channels), 'enabled' is read off the channels the model built -- an enabled signal is exactly one that produced a channel -- andapply_layout()installs the graphs before assigning the rows, since a row may hold a math channel's name and that name is only a signal once its graph is in. Two format rules, both learned the hard way: write an op or a measure type as its index throughmath_op_at()/measure_type_at(), never as the raw enum value -- those accessors' orders are the dialog's, not the declaration order, so writing one and reading the other silently renames operations. And a top-level key written after a[[table]]belongs to that table in TOML:enabled = [...]emitted after[[calibration]]was parsed into the last calibration record and the enable state was lost without a word.mkdirs()used to create only components with a trailing slash, so~/.config/udpscopewas never made and the first save failed silently -- worth knowing when a layout change "does not stick". - A
[[plot]]table is one plot (one cell), and carriesrowandwidthfor the horizontal split.plots = Ncounts plots, as it always did.rowis the 0-based figure row the plot sits in andwidththe cell's share of that row's width (LayoutState::Row::row,::width); the tables run in the flattened order, so a row's cells are consecutive and left to right. The row's height shareweightstays per figure row but is written on every cell of it (so a table read alone is complete) and read from the row's first cell;collect_layout()takes it fromrow_weights()[figure_row_of(plot)], not fromrow_weights()[plot]-- the latter is a plot index into a figure-row vector, right until the first split. The file is writtenrow,width,weight,signals; a state built withrow < 0(nothing in the app does) omitsrowandwidth. Three loading rules, all inapply_layout()and none of them a refusal, because a session is the user's arrangement and not worth losing to a malformed key: (1) a file with norowloads as one cell per row -- every session written before the split -- with the per-plotweights as the row heights, exactly as it opened before; (2) arowsequence that cannot be a layout loads the same way: some plot without arow, a row number that goes backwards or skips one, a first row that is not 0, more than 4 cells in a row, or more than 8 plots; the signals stay on the plots they were on (invalid-rowsis the reading); (3) cell widths are applied after the arrangement, becauseset_cells_per_row()resets them, and only for rows of two or more cells -- a one-cell row has no widths and itswidthis ignored. Both new keys are parsed with the file's other forgiving rules:rowmust be an integer >= 0 andwidtha number > 0, anything else leaves the default. A save after loading a pre-split file writes therow/widthkeys, which is how an old session is upgraded without anyone asking. - Measurement options persist under named keys --
threshold,threshold2,noiseandhf-- so a file written before them loads at the defaults, which are exactly the behaviour it was written against:noise = 10is the hysteresis band the app has always counted with,hf = 0is no filter, and a file with nothreshold2-- every file written beforebetweenexisted -- leaves that measurement atthreshold2 = 75, which is the value the dialog offers a new one. The pair is stored as written rather than as ordered:measure_level_pcts()is what puts a reversed pair in order on read, so a file records what the user typed and a measurement file round-trips unchanged. - The math node string is thirteen pipe fields, and the reader accepts twelve or thirteen.
med_nis the thirteenth, aftery; a twelve-field node is a file written before the de-noise node existed, and itsmed_nstays at the struct's default -- which is what such a file meant. Field 0 is the op as amath_op_at()index, which is whyMathOpmay only be appended to. - The export is collected at click time, not in the file-dialog callback. The live view keeps scrolling while the chooser is up, so a late collection would write a window the user never saw. The writer is picked by the chosen path's extension.
- The MDF export is MDF 3.30 written by hand, and its layout is the reader's, not the
standard's prose. No dependency is added: MF4 is HDF5 and would need a library, while MF3 is a
handful of fixed-size blocks, so
write_mdf()spells them out. The field list comes fromasammdf8.8.27 (blocks/v2_v3_blocks.py,blocks/mdf_v3.py, which parses MF3 by explicit offsets) and was confirmed byte for byte against a known-good 3.30 file asammdf wrote (one group, atimemaster and twoVchannels). The shape, in file order: the ID block is 64 bytes with no id/length header ("MDF 3.30", byte order, version330) — a writer that emitsID+ size + links at offset 0 produces something no reader accepts, since the first real block starts at 64; the HD block is 208 (164 common + 44 of the 3.2-era time fields) and points at the data group; a TX comment follows it; then the samples; the DG is 28; a CC (conversion, 46 — aNONEconversion is the common fields alone) then a CN (channel, 228) precede each channel; and the CG (30) is last. Address fields are absolute file offsets written forward, so the whole address map is computed before a byte is written, and every block length is checked after it is built: a field at the wrong width does not fail the write, it leaves the reader in the middle of the next block. - The data group's data address holds the raw records, with no
DTheader — which is what asammdf's writer does and what its reader assumes (DataBlockis documented as "a pseudo block not defined by the MDF 3 standard"). This is the one place the file follows the reader rather than a narrow reading of the standard, and it is the assumption ScryLab compatibility rests on: a file with aDTblock there would have its header read as sample bytes. - In MDF the master is
abs_seconds, namedtime, and the relative axis is a channeltime_relative— the same two numbers the.npy/.csvtable holds, in the other roles. A master off the relative column is 0 at a different sample in each display mode, so it would put a live export and a held event's export on two timelines, which is the one thing the second column exists to avoid; both carry the units, and the signal columns follow in the.npy/.csvorder. The unit is the client's CONFIG unit (a math channel's own), and it is written into the CC block because that is the only place MDF 3 keeps one — and the place ScryLab reads a signal's unit from.ExportTable::Columngained the field for it; the two older writers ignore it, and their bytes are unchanged. The names are sanitised and de-duplicated against each other and against the two reserved names before being cut to 31 bytes (the CN name field is 32 with its NUL): ScryLab imports only the first occurrence of a channel name, so a cut made before de-duplication could silently drop a signal.write_export()is the one place the extension picks the writer (.csv,.mf3/.mdf, anything else.npy), and both the dialog and the self-check's export probe come through it so the two cannot offer different formats. - The chooser runs before
ScopeModelexists, and--serveris what decides.Configcarriesserver_specified, set only by the--serverbranch, andmain()asks only when it is false — so declining costs no buffer and no socket, andScopeModel,Acquisition,RowSetandrun_ui()need no notion of "no source yet". It is--serveralone and not "no arguments":--multicaststill needs a server to fetch CONFIG from. Every documented probe passes--server, which is what makes the feature invisible to the regression net — a probe added without it would reach the chooser and hang.gtk_init()plus a nestedGMainLoop, and thenrun_ui()'sGtkApplicationafterwards in the same process, is a verified combination (gtk_init()is idempotent; running your own loop is the documented GTK4 replacement for the removedgtk_dialog_run()). - A session file's name cannot say how its source was reached, so the file says it.
<host>_<port>.tomlcarries neither the multicast group nor the interface, so a chooser row could not reconnect to a multicast source at all.LayoutState::multicast/interface_addrare written in the top block, before the first[[table]]— a bare key after a table header is that table's key in TOML, which is exactly howenabledwas parsed into the last[[calibration]]record and lost without a word. A file from an older build has neither key and loads as unicast on the default route. The chooser's list filters to sessions that name at least one signal, because a probe run leaves a file per throwaway port and all of them holdsignals = ["", "", "", ""];known_sources()splits the filename at the last_, sincesanitize()turns anything unsafe in a host into one. save_layout_now()refuses to write whilesignals()is empty. A run against a source whose streamer was down receives no CONFIG, so every slot name stays empty and the save would overwrite a configured session withsignals = ["", ...]. The chooser then filters that file out — a source whose streamer was merely down during one run would silently vanish from the only list it can be picked from, for ever. Keeping the old file loses nothing: a run that configured nothing had nothing worth persisting.- It refuses again while
replayingis set: a file view is not the source's session. Same argument, different cause — this view configured nothing about the stream. A plain replay leaves the rows where the user put them, so its save would merely be pointless; a group rebinds every slot to its own renamed channels and the save wrotesignals = ["square (A)", …]into the file. Nothing noticed until the way back: Go live re-applies the session, those names match no live signal, so nothing was enabled and the live view came back with no channels at all — on the window the poisoned file carried, which is also how it swallowed the window restore below it. The reading is[events] live restored (after a group): the symptom was a stuckpending=1besidechannels=0, because the restore waits for a channel list that was never going to arrive. The explicit Save as is deliberately not guarded: that one is a user's own answer to "save this view", and its dialog reports the outcome.
A configuration is a named session, and a source may hold several
One source, several arrangements: the same streamer serves different experiments, and retyping a
layout to look at the second one is how a session gets lost. Storage is therefore a directory
per source — ~/.config/udpscope/<host>_<port>/ — holding one <name>.toml per configuration
plus an active file naming the one the source is on. configuration_path() is the only
expression of the scheme, and the name is sanitize()d exactly as the host is, so a name typed
with a slash cannot escape the directory.
A configuration carries the whole session and the settings that can only be decided before the
first socket: buffer_samples, relative_time, capture_packets. The startup chooser applies them
(apply_source()), which works because choose_source() runs before ScopeModel is constructed
— the one moment a buffer size can be applied at all. Switching configurations inside a running
app applies what can change live and says in the status line that the rest takes effect on the
next start; pretending otherwise would be a configuration that half-applies.
A configuration's startup settings reach the model from two directions, and both are needed:
apply_source() for a source picked in the chooser, and apply_stored_settings() for a source
named with --server, which never reaches the chooser at all. Without the second, a saved host's
buffer size, clock and capture ring were silently ignored — the layout still applied, because the
app loads that separately once a CONFIG names the signals — so an edit to any of the three read as
one that never took effect. A flag the user typed wins over the file (--buffer-samples,
--relative-time, --capture-packets): the file fills in what the command line did not say, and
Config::buffer_samples_specified is what tells "not asked" from "asked for the default".
The active file is written on every save and by the chooser's pick, so "which configuration was
I on" survives a restart. A session written before configurations existed is imported once as
the configuration named default, and the old file is renamed to <host>_<port>.toml.imported
rather than deleted: an import that cannot be undone by hand is a migration nobody can back out
of.
Nothing is lost by switching: the dialog saves the current session before loading the picked one, which is the only ordering in which a switch cannot lose what it switches from.
- The event cache is keyed on the source and its active configuration, and the key moves
with the switch. An event's trigger, its channel set and its units are the configuration's,
so the directory is
<base>/<host>-<port>/<configuration>/(EventStore's third constructor argument) andretarget_event_store()-- called next to everyApp::config_namewrite and resolving the name fromactive_configuration()when it is empty -- re-derives it, refreshing both views of the list only when the directory changed. A store built without a configuration keeps the legacy<base>/<source>/path, and the first construction under a configuration adopts the loose event files it finds there (*.npy/*.txt/*.bmp, moved into the configuration's folder): an upgrade keeps the events it had. The key is a sanitized path component, the same rule the configuration files use, so a name typed with a slash cannot leave the directory.