scope: full-screen figures, second windows, gated measurements, per-configuration events

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).
This commit is contained in:
Martino Ferrari committed 2026-11-05 17:58:59 +01:00
1 parent bfe13630f9
commit bdf0ff7ddc
39 files changed
+2267 -136

No files matched your search

+74 -12
View File
@@ -123,6 +123,23 @@ src/ui_badges.cpp the badge bar under the plot: the signal badges, the + badge,
src/ui_signal_page.cpp the per-signal slide-over pages (`signal:<name>`)
```
**A row full screen, and a second window.** Each figure row carries a full-screen toggle in its
top-right corner: it comes out of hiding while the pointer is near that corner (the button's own
rect plus a `kMaximizeRevealPx` of slack, per row) and is invisible otherwise, so the plot is not
decorated by controls nobody is reaching for; one click makes that row fill
the plot area and the same button restores the whole arrangement; the plot's right-click menu
offers the same as **Full screen this figure** / **Restore the layout**, and a layout of a single
figure carries no button (there is nothing to enlarge *to*). It is a *view* — the row set
saves every row's state on the way in and re-applies it on the way out (`RowState` in
`plot_host.*`), so nothing plotted moves and nothing is saved. A plot's right-click menu has
**Open this plot in a new window**, which copies that figure — its cells, their width shares and
every slot's signal in its colour — into a second top-level window with its own `RowSet`, figure
and `PlotArea`; the overflow menu's **New scope window** opens one with an empty figure. A scope
window is another *view* of the same acquisition: the main window's tick binds and acquires its
rowset under the same model mutex, mirrors the main window's time window (or its pin) onto it
every frame, and pumps its render — so the two windows never disagree about the data, the display
mode or the span.
**The window.** The plot is the window. Under it sits a fixed-height **badge bar** (36 px). Its left
part is the **signals**: one badge per *enabled* signal (a dot in the signal's colour, filled while the
signal is plotted, and its name), then a `+` badge that opens the **Add a signal** page (`g` does the same). That page proposes only what can be
@@ -163,8 +180,10 @@ page's label and to the dock's, so the two cannot disagree. **When the dock is n
with the dock button, or the window narrow enough (< 900 px, "compact mode") for the dock to be a collapsed
overlay on the plot, open or not -- the same readouts appear on **one line under the plot**, above the badge bar (the
*readouts strip*): a **Cursors** button (opens the `cursors` page) and one chip per cursor line (its name and
its *delta* value), then a **Measures** button (opens the `measure` page) and one chip per measurement (the
signal's colour chip, the type, the value; a click opens its dialog). Each of the two groups is there only
its *delta* value), then one chip per measurement (the
signal's colour chip, the type, the value; a click opens its dialog) and a **`[+]`** after the last chip that
adds a new measurement -- the strip is where the readings are watched, so it is where adding one belongs.
Each of the two groups is there only
with something to read -- the Cursors group with the cursors switched on, the Measures group with at least one
measurement, the separator with both -- and **with neither, the strip is not shown at all**.
It is a **fixed 26 px** high and its chips are as wide as the dock's cells (name 6 characters, type 10, value
@@ -396,7 +415,9 @@ edge the ticks were generated for; the caption always names the anchor the label
with, so the addition stays exact.
**Measurements** (overflow menu) reveals a pane with a `[+]` and one row per measurement:
`[badge] <type> <value> [edit]`. `[+]` opens a dialog with the signal, the type, the options
`[chips] <N>. <signal> · <type> <value> [edit]` — the number is the signal's among the
active channels, the same one the signal's badge in the bar and the dialog's signal dropdown
carry. `[+]` opens a dialog with the signal, the type, the options
that type actually uses, and "show on plot"; `edit` opens the same dialog on that
measurement, with a `Delete` beside `Save`.
@@ -507,6 +528,21 @@ Both cost: a filtered window disables the two bucket fast paths, because a bucke
samples and cannot be mixed into a filtered walk. A filtered window is still walked once and shared by every
measurement that asks for it, so a pane of five measurements at two corners costs two walks, not five.
**The gate measures a slice of the window, and it is a per-measurement choice.** A
measurement's dialog carries `Gate enabled`; every gated measurement then describes
only the samples inside one shared violet band, carried by its own pair of draggable edges on
the plots — *not* the cursors, which keep meaning what the cursors pane reads. The band is
shown exactly while some measurement is gated: gate one and it appears, gate or delete the
last one and it goes. Ungated measurements on the same signal are untouched — they share the
window walks they always shared, and a gated one costs one more walk, not one per pane. The
band lives in the same relative time the axis labels do, so it stays exactly where it was put
in both modes: on a live view "300 ms ago to 100 ms ago" stays put on screen while the data
flows under it — the gated read of the newest data a bench scope gives — and on a held event
it names a fixed slice of the frozen frame. Drag an edge to resize, grab the middle to move; a
band with no samples in it — dragged outside the view, or over a gap — reads `--` like any
window with nothing in it. The band's position and each measurement's gate flag are session
state, so a reopened scope measures what the closed one measured.
### Why a crossing is counted with hysteresis
A crossing is not read off a single level. It is read off a **band**: two thresholds a tenth of
@@ -592,7 +628,15 @@ results must still narrow with the axis (`follows=1`).
**Show on plot** marks the result on the row that plots the signal: a ruler at the level
(`top`/`bottom`), both rulers at the extremes (`min`/`max`), the pulse's or the cycle's two
edge times as vertical markers. The marks are **readouts, not instruments**: a fixed pool of
edge times as vertical markers. Each mark carries its **badge**: the ruler's own value written
in a small opaque panel straddling the ruler — in the y axis' own tick format, so it reads
exactly like the tick beside it — and the time mark's time in a box centred on the marker (in
the pane's own formatter, so a live view's badge is an age). The boxes exist for the trace
underneath: a number drawn bare over a noisy signal is a number nobody can read, and an
opaque one is a number nothing prints through. And when marks land close
together — two levels a few pixels apart, a pulse's two edges — their badges **merge into one
panel with one line per mark**, because two texts over one column is a smear and one boxed
readout is a reading. The marks are **readouts, not instruments**: a fixed pool of
cursors per row, created **unselectable** (`Plot::set_cursor_selectable()`, added for this), so
the controller's hit test never offers the pointer one — it can neither hover nor drag a
measurement's ruler. They are also re-placed every frame, because a mark is only correct for
@@ -850,15 +894,21 @@ Three things it deliberately is not, and the reasons are in
## Captured events
Trigger mode keeps the last **32 triggered acquisitions** in a cache, so an event you did not have
time to look at is still there:
time to look at is still there. The folder is the source **and its active configuration** -- an
event's trigger, channel set and units are the configuration's, so two configurations of one source
do not list each other's events:
```
~/.cache/udpscope/events/192.168.1.40-8000/
~/.cache/udpscope/events/192.168.1.40-8000/default/
20261009-123250-0001.npy # the event, in the export format
20261009-123250-0001.txt # when, how long, how many signals, the rate, the edge
20261009-123250-0001.bmp # a picture of it, drawn from the samples
```
Switching configuration re-points the cache with it, and a cache written before configurations
existed (loose files directly in `<source>/`) is adopted into the configuration's folder on the
first start.
Events are read in two places. The **dock's Events block** lists them compactly -- one row per event, newest
first, at most **24** with a dim "... N more, see Manage" row after them: a marker (a filled circle on the
event on screen, a hollow one on each member of the comparison or accumulation on screen), the time and
@@ -1067,9 +1117,13 @@ named `default`: copied into the directory, remembered as active, and the old fi
`~/.config/udpscope/settings.toml`, written by **Preferences…** -- in the overflow menu or the source
chooser -- and read at start-up. It holds the event cache's directory, how many events to keep (`0`
keeps every one), whether relative time is on, and the interface's theme. Whether anything is cached at
keeps every one), whether relative time is on, the interface's theme, and the crosshair's **axis
labels** — the two small boxes where the crosshair's lines meet the axes: the y value among the y
ticks, the time among the x ticks, each in its axis' own format. On by default; the switch applies at
once. Whether anything is cached at
all is the trigger pane's own switch, and there is deliberately no second one here; the directory is
the one place every source gets a folder inside, `<cache>/udpscope/events` by default. The cache
the one place every source gets a folder inside (and every configuration a folder within that),
`<cache>/udpscope/events` by default. The cache
directory and relative time are *start-up* settings -- the buffers are planned and the cache directory
is opened before any source is known -- and the dialog says so; the theme applies as soon as it is
chosen. A change
@@ -2292,10 +2346,17 @@ the line marks the edge, so the pre/post split can be read straight off the plot
not one of the user's cursors: it has its own colour, ignores the cursor show/hide
toggle, and is parked (invisible) when there is no event.
**The status line.** It starts with an activity badge and then the mode, the
**The status line.** It starts with the **trigger circle**, then an activity badge and then
the mode, the
connection, the measured rate, the packet/frame/gap counters, what plot 1 holds and
the buffer's size and span. Three of those are worth calling out:
the buffer's size and span. Four of those are worth calling out:
- **The trigger circle is the trigger's own light**, in the same glyph-and-colour alphabet as
the badge: solid green while the trigger has fired (an event is held, or a capture is
filling), **flashing** green while armed and waiting for an event, red while the trigger
mode is on but disarmed — and *hidden* while the trigger mode is off, because a light for a
mode the user is not in is noise, and the mode text beside it is not showing trigger words
then either. Its tooltip names the state in words.
- **The badge blinks green at 1 Hz while the pipeline is actually running** — the
stream is connected, a CONFIG has arrived, acquisition is not paused, and in trigger
mode the trigger is armed. Anything else leaves it steady, with the reason on its
@@ -2800,13 +2861,14 @@ edge.
**Rows are edited in place, not chosen from a count.** The header bar used to carry a
`plots` dropdown of 1 to 4, and a count is the wrong handle: it says how many rows you
want and leaves which row your traces land on to the renumbering. **Layout editor**
(overflow menu) instead puts the controls on the rows — a `×` at the top-left of each row
(overflow menu, a plot's right-click menu, or `l`) instead puts the controls on the rows — a `×` at the top-left of each row
removes it, a `+` on each boundary inserts one there — so the row an action names is the
row you are pointing at. `--plots` is now only the count to start with, and `RowSet::kMaxRows`
(8) is a ceiling rather than a menu: past about that a row is chrome and no plot.
The editor also carries its own way out: a **`Done`** button in the plot area's top-right
corner while it is up, because the toggle that opened it lives in the overflow menu — a
corner while it is up, because the toggle that opened it lives in the overflow menu (and in a
plot's right-click menu, as *Edit the layout...* / *Done editing the layout*) — a
long way from the plot being edited. It turns that toggle off, so there is still exactly
one piece of state, and the buttons (its own included) go with it.