Asked for: dragging a signal onto a plot must still work in the compact (view) list. It does — the drag
source is on the *row* and was never conditional on the lock — but nothing said so, and "the lock rebuilds
this list and drops three of its controls" is exactly the change that could take the drag with it. Now it
is a reading: **`drag_sources` must be `rows - 1`**, one per signal row, the `MATH` divider not being
draggable.
signals panel: … rows=3 signals=2 math=0 divider=1 drag_sources=2 (locked)
signals panel: … rows=5 signals=4 math=0 divider=1 drag_sources=4 (configuration mode)
Measured the direct way first, with a throwaway walk of the list's rows and
`gtk_widget_observe_controllers()`: `locked=1 rows=3 rows_with_a_drag_source=2`, the third row being the
divider. That is where the number in the reading comes from, and the walk is what found the shape of the
answer rather than assuming it.
One consequence worth recording, because it is an improvement rather than a cost: a locked row can be
dragged from *anywhere* on it, the colour chip included. In configuration mode the chip carries a click
gesture that claims the event sequence precisely so a drag cannot start there (it opens the colour
chooser); the locked list does not build that gesture, so the chip is inert and the row's drag source
sees the press.
What the lock does *not* change is which signals are listed: only the ones that are on. A signal nobody
acquires draws nothing on a plot, so a row for it would be a drag that ends in an empty slot.
Verified: both lines above, from sessions differing only in whether one exists; `make test-ui` 14 files;
zero `Gtk-CRITICAL`. Docs: the `drag_sources` field with what a `0` would mean, the invariants' lock
bullet, and the README's note.
126 lines
10 KiB
Markdown
126 lines
10 KiB
Markdown
# 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](../../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 up `App::pending_trigger_channel`, or `apply_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 two panes.** Locked is the returning user's mode: the
|
|
trigger pane shows one compact line, the signal list only the signals that are on, and neither offers
|
|
a control that changes the configuration. 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 row onto a plot is not configuration and survives
|
|
the lock**: filling plots is what the list is *for*, so a locked row keeps its `GtkDragSource` — from
|
|
anywhere on it, the colour chip included, which in configuration mode has a click gesture that claims
|
|
the sequence precisely so a drag cannot start there. `drag_sources` on the `signals panel:` line is the
|
|
reading, and it must be `rows - 1`.
|
|
- **The session is the layout.** `LayoutState` persists what makes a reopened scope look like the
|
|
one that was closed: the window, the rows and their slot signals, the calibration, **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**, and **`measure_stats`** — the measurements statistics
|
|
toggle; written as a bare top-level boolean (`measure_stats = true/false`), defaults to `false`
|
|
when absent. 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 -- and `apply_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 through `math_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/udpscope` was never
|
|
made and the *first* save failed silently -- worth knowing when a layout change "does not stick".
|
|
- **Measurement options persist under *named* keys -- `noise` and `hf` -- so a file written before them
|
|
loads at the defaults**, which are exactly the behaviour it was written against: `noise = 10` is the
|
|
hysteresis band the app has always counted with, and `hf = 0` is no filter.
|
|
- **The math node string is thirteen pipe fields, and the reader accepts twelve *or* thirteen.** `med_n` is
|
|
the thirteenth, after `y`; a twelve-field node is a file written before the de-noise node existed, and its
|
|
`med_n` stays at the struct's default -- which is what such a file meant. Field 0 is the op as a
|
|
`math_op_at()` index, which is why `MathOp` may 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 chooser runs before `ScopeModel` exists, and `--server` is what decides.** `Config` carries
|
|
`server_specified`, set only by the `--server` branch, and `main()` asks only when it is false —
|
|
so declining costs no buffer and no socket, and `ScopeModel`, `Acquisition`, `RowSet` and
|
|
`run_ui()` need no notion of "no source yet". It is `--server` alone and not "no arguments":
|
|
`--multicast` still 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 nested `GMainLoop`, and then
|
|
`run_ui()`'s `GtkApplication` afterwards in the same process, is a verified combination
|
|
(`gtk_init()` is idempotent; running your own loop is the documented GTK4 replacement for the
|
|
removed `gtk_dialog_run()`).
|
|
- **A session file's name cannot say how its source was reached, so the file says it.**
|
|
`<host>_<port>.toml` carries neither the multicast group nor the interface, so a chooser row
|
|
could not reconnect to a multicast source at all. `LayoutState::multicast`/`interface_addr` are
|
|
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 how `enabled` was 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 hold
|
|
`signals = ["", "", "", ""]`; `known_sources()` splits the filename at the **last** `_`, since
|
|
`sanitize()` turns anything unsafe in a host into one.
|
|
- **`save_layout_now()` refuses to write while `signals()` 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 with `signals = ["", ...]`. 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 `replaying` is 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 wrote `signals = ["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 stuck `pending=1` beside
|
|
`channels=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_mb`, `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-mb`,
|
|
`--relative-time`, `--capture-packets`): the file fills in what the command line did not say, and
|
|
`Config::buffer_mb_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.
|