working on todo.md features

This commit is contained in:
Martino Ferrari committed 2026-10-10 22:52:12 +02:00
1 parent 210d124814
commit 8f54841c2c
110 files changed
+15932 -6576

No files matched your search

@@ -144,3 +144,134 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
`process()` uses that for `last_trigger_s_`, `pending_start_s_` **and** the pre-window gate —
all three, or the marker, the cut and the gate would disagree with each other by the width of
a pulse.
## A latch is also a file
The pane that lists the cache is **on demand**: the toolbar's event-history button and the pane are
collapsed until it is asked for, because a column of stale events beside a live plot is what a user
who has stopped looking at events does not want. What makes it *available* -- (recording is on) or
(an event is on screen) -- is decided in one place, `sync_events_availability()`, so the button and
the pane cannot disagree; a disabled cache with nothing being examined takes the button away *and*
collapses the pane, because a visible empty column is what "off" must not look like. Trigger mode is
deliberately **not** one of the conditions: a stored event being examined is a file, which has no
trigger, and tying the pane to the trigger would close the list under the click that opened an event.
The recording switch lives in the trigger panel, not in the pane it empties.
## A comparison is two plots of one model
*Compare* and *accumulate* are still one model -- the buffers cannot hold two independent loads -- but a
comparison is **two plots** over it, and which plot draws what is only a matter of which channel *names*
its rows bind. `App::rows` is the primary (`RowSet`, area, gestures); `App::rows_b` is the second, with
its own `RowSet` and its own hidden `PlotArea` in a paned, deliberately without the drop target, the
right-click menu or the frame counter: those act on the primary's rows, and a menu on the second plot
that rearranged the first plot's rows would be a menu lying about its subject.
Rules that cost a defect each:
- **The layout is the user's and is mirrored, never reinvented.** The group build must not call
`set_row_count()`; it reads the rows' slot names and re-names the same slots for each layer
(`… (A)`, `… (B)` for a comparison; a bare name for layer 0 and `… #2`, `… #3` for the extra layers of
an accumulation). An *accumulation* puts its extra layers into free slots -- the row that holds the
signal first, then any row with room -- so the user's traces never move. The default layout is four
signals in one row, which is why `kSlotsPerRow` is 8: with four, "accumulate these two events" had
nowhere to put the second copy and silently did nothing.
- **A layer is not a signal.** A comparison's model holds one channel per (signal, layer), and the
*panel* must not treat the copies as signals of their own: the signal list shows one row per signal
(`layer_base_name()`, the shared rule that takes `(A)`, `(B)` and `#N` back off), `signal_color_index()`
counts *signals* and not channels -- counting channels spread one signal's colour across the palette
-- and both plots of a comparison bind their signal's layers to the *same* colour index, because two
colours would say they were different signals. The reading is `list=4` in `[events] compare plots`
for a two-event comparison of four signals.
- **A slot name carries the last group's suffix, so it must be stripped before it is re-derived**:
after a comparison the rows are named `sine (A)`, and a following accumulation that mapped that name
again bound to no channel at all (`traces0=0`, a plot with nothing in it). `base_name()` takes
`(A)`, `(B)` and `#N` back off.
- **The axes are mirrored from the ranges, once per frame.** The plotter owns its window and a gesture
reaches it through the plot's own setter with nothing announcing it, so the frame compares each row's
signature with the previous frame's: whichever side moved is the side the user touched, and the other
is set to match. The signature is `{span, y.min, y.max}` when the x axis has a **live edge**
(`x_live_edge()`, not merely an armed follow: an armed axis with nothing streaming keeps a *stored*
window, whose edges a pan moves and whose span never changes) and `{x.min, x.max, y.min, y.max}`
otherwise. Comparing the edges under a live edge would read as "both sides moved" on every frame --
the window is recomputed as a distance behind the newest sample -- and a zoom in one plot would be
overwritten by the other's drift. When both moved the first plot is the reference, which is also what
makes the frame after a build converge.
- **`set_y_range()` clears that axis' auto-scale**, by design: a mirrored zoom is a user decision, and
*Fit* re-arms it.
## A group of events is one model, built once
*Compare* and *accumulate* are the same machinery: several event files in **one** model, because the
buffers cannot hold two independent loads -- `load_waveform_into_model` calls `set_signals()`, which
rebuilds them, so a second load takes the first with it. So the group is built by hand: one channel per
(signal, layer) pair, `set_target_span()` to cover the whole group *before* `set_signals()` (or the
oldest samples are written past the ring's end), then all of the events pushed in one pass. A row is
one **signal** and its slots are the events, which is what makes a comparison like for like and an
accumulation a density; the trace alpha (0.35 while accumulating, 1 otherwise) is what turns agreement
into brightness.
Two things every group must do, both of which cost a defect to learn:
- **Stop the acquisition first.** A live producer keeps pushing into the model it was started with --
and it pushes *four* stream signals into the group's *eight* channels, so the group's own samples are
the ones that go missing. The reading that catches it is the samples the busy channel holds
(`samples0` in the `[events] compare` line): it must be exactly the number of stamps pushed, not
that plus whatever the stream added.
- **Free the slots the group does not fill.** A row carries the previous view's binding, and a trace
left in slot 2 of a two-layer row is drawn as if it belonged to the group.
*Compare* shifts the first event so its span *ends* where the second's *begins*, and fills the half a
sample does not belong to with **NaN** -- the plotter excludes non-finite samples from every series and
from every pyramid bucket, so a NaN is a gap and not a repeat, and no line is drawn across the join.
**A replay changes the data and frames it once; it never rearranges the layout.** A row plots a signal
*name*, and an event's export carries the same names the stream does, so loading the event into the
model leaves every row, slot, colour and the row count pointing at the right signal by itself. So
`replay_event()` must **not** rebind the rows (`bind_channels_to_rows()`, which is what a freshly opened
*file* needs) -- that is the user's work, and a click on a list is not a request to rearrange it. The
window is restated **once, on open** (`apply_time_window(span)`, the only path allowed to change the
scale), so the whole event is framed the moment it is opened and the user's own zooms are left alone
afterwards; never per frame. The measurements, the cursors and the readouts then report the stored
event, because they read the model. What a replay *does* change is the model's contents and the trigger:
it leaves trigger mode, without which the display stays `Held` and draws the old snapshot.
**A file view has nothing to acquire.** The signal list's acquire tick is *hidden* -- not disabled --
whenever a file is on screen (`App::waveform_name` non-empty: a replay, a comparison, a `--open`run).
The samples are stored, "acquire this signal" is a stream's question, and a greyed tick invites the user
to look for a reason there is none for. Going live rebuilds the panel, so the ticks come back.
**"The same length" is the same count at the same rate, not the same span to the sample.** Two events
this app recorded itself differ in span by the fraction of a sample the acquisition's window happened to
fill to (0.9999 s of a 1 s pre-window is normal). Demanding one sample interval made a comparison of two
just-recorded events refuse to open; the check allows four intervals or a millisecond, whichever is
larger, and the merged stamp grid carries the difference as a gap.
**A replay must leave trigger mode.** `display_mode()` answers `Held` whenever the model holds a
snapshot, and a held display draws *the snapshot*, not the live channels -- so replaying an event with
trigger mode still armed loaded the file into the model and left the old capture on screen: a click
that looks like it did nothing, with the event sitting in the ring unread. The replay also frames the
view with `apply_time_window(report.span_s)`, so the click shows the whole event rather than the part
of it that fell inside the live window.
The switch that decides recording belongs to the **trigger panel**, not to the pane it empties: it
is a trigger option (it decides whether a latch is written at all), and a pane must not be the only
place to reach the control that stops it.
Every published snapshot is written to the event cache while recording is on: its table (the *export*
format, so `--open` reads it back), a sidecar of what the list shows, and a preview drawn from the
samples. Three rules keep that honest:
- **The record happens on the GTK thread, at the end of the tick that framed the snapshot**, not on
the producer. The snapshot is taken by `shared_ptr` (a latch landing mid-write cannot pull the data
away), and a `record_pending` flag defers the write to the end of the frame so the rows are already
bound to the event when the picture is drawn. Recording from the producer would put file I/O
between two `recv()` calls, which is the one thing the packet capture's design also refuses.
- **Nothing a latch does may be expensive.** The write is a `write_npy` of the event's own window and
a 480x160 raster; a 2 s, four-channel event is ~230 kB of picture and ~600 kB of samples. The pane
is refreshed, not rebuilt, and a failure to write is a line on stderr -- a cache that cannot be
written must not stop the scope.
- **The preview is drawn from the data, never captured from the window.** The plot is a `GtkGLArea`:
its ink is not in the widget's paint tree, so `gtk_snapshot_free_to_node()` on a widget paintable of
it returns NULL and the "screenshot" would be an empty file. Drawing it in the store also keeps that
class GTK-free, which is what makes the cache testable without a window.