diff --git a/AGENT.md b/AGENT.md
new file mode 100644
index 0000000..4e85781
--- /dev/null
+++ b/AGENT.md
@@ -0,0 +1,62 @@
+# AGENT.md
+
+Working notes for an AI agent (or a new contributor) in this repository. **Read
+[CLAUDE.md](CLAUDE.md) first, then the `docs/claude/` file that covers the area you are touching** —
+this page is the fast path into it, not a replacement. `SPECS.md` is what was asked for; `README.md`
+is what was built, deviations and limitations included; when the two disagree, README explains why.
+
+## Build
+
+```sh
+git submodule update --init --recursive # deps/plotter is a submodule
+cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
+cmake --build build # the app
+cmake --build build --target test-ui # round-trips every .ui through blueprint-compiler
+cmake --build build --target test-help # yelp-check over help/C/org.udpscope.app/
+./build/udpscope --server 127.0.0.1 --port 8000
+```
+
+A clean build emits **exactly one warning** (`-Woverlength-strings` on the generated GResource
+`.c`). Two is a regression.
+
+## Verify
+
+There is **no unit test suite**. The check is the app's own headless self-check against a mock
+source; add a probe rather than a harness:
+
+```sh
+python3 tools/mock_udpstreamer.py --port 8000 --duration 120 &
+xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=16000 UDPSCOPE_ENABLE_ALL_MS=1000 \
+ UDPSCOPE_PROBE__MS=3000 ./build/udpscope --server 127.0.0.1 --port 8000
+```
+
+Two baselines every run is read against: **`gtk warnings: 0`** in the exit dump (a `Gtk-CRITICAL` is
+a defect, not noise) and the single build warning above. The probes, their env vars and the value
+each line must read are in [docs/claude/probe-readings.md](docs/claude/probe-readings.md); how a run
+is compared with the previous one is in [docs/claude/verification.md](docs/claude/verification.md).
+For UI work, drive the real surface: the app under `xvfb-run` plus `xdotool`/`import` for pointer and
+pixel checks (see `tmp/`-free examples in the probe sources), not only internal state reads.
+
+## Hard rules
+
+- **SPDX MIT header on every source file**, in the format the existing files use.
+- **Two threads only**: a producer owning the data source and the GTK main thread owning every
+ widget and the GL context. They share `ScopeModel` under **one mutex**; samples cross through
+ lock-free rings, never through the lock.
+- **A view never acquires.** Scope windows, full-screen and the layout editor are arrangements of
+ views over the one acquisition; they must not plan buffers, epochs or sources of their own.
+- **Layout must not depend on content** (docs/claude/invariants-plot-and-layout.md).
+- **Documentation moves with the code.** A behaviour change updates the matching
+ `docs/claude/invariants-*.md`, `README.md`, the Mallard page under `help/C/org.udpscope.app/`, and
+ the probe reading in `docs/claude/probe-readings.md` — the probe baselines are read as tests.
+- **`deps/plotter` is a submodule and has its own history**: commit there first, then commit the pin
+ in this repository. Never edit its files without committing them both sides.
+- No new dependencies, no generated-file edits, no silent changes to `SPECS.md` semantics: a
+ deliberate deviation is written into `README.md` under "Deviations from the spec".
+
+## Commits
+
+Subject: `: `, lowercase, imperative, no trailing period (`layout editor: …`,
+`docs: …`, `plot: …`). The body explains *why* (the defect it fixes, the trade-off it takes), not a
+restatement of the diff. Keep each commit one coherent change; probes, docs and the code they
+describe travel together.
diff --git a/README.md b/README.md
index e06ee7c..e831ccc 100644
--- a/README.md
+++ b/README.md
@@ -123,6 +123,53 @@ 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:`)
```
+**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*). Beside the toggle, and with the
+same show-on-approach, a **detach** button moves the figure into a window of its own (removed
+from the main window's rows, which save the change); the detached window carries an **attach**
+button in the same corner — its figure returns to the main window's rows and the window closes.
+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. **A signal badge dragged onto a scope window's plot lands there** — the same
+drop the main plot takes, into that window's own row set, on the cell the pointer is over — and
+the signal page's *Plot on* buttons list **every window**: one group per window (named, `In
+"plot 2"`), one button per plot of it. **The session remembers every extra window**, but a launch never opens one: a window is a view
+you closed on purpose, and a start that tiled them onto the desktop would take that decision for
+you. The overflow menu's **Reopen closed windows (N)** brings them back, arrangement and all; a
+window that was *attached* is not remembered (its figure is on the main window again), and a
+detached figure is preserved by its window's entry, so quitting with one open no longer loses it.
+
+A scope window draws the **same instruments as the main window** — the two cursors (draggable
+there, and shared: moving one in any window moves it everywhere), the gate band, the trigger
+marker and the measurements' marks on the traces each window actually plots. They have one owner
+(the main window's plot set) and are written into every window each frame, so no window can drift.
+
+A scope window also **carries its own layout editor**: `L` (or the grid button in its corner,
+revealed like the others) shows the same `+`/`×`/Done controls the main window's editor has, and
+they change *that* window's rows — so a second window can hold several figures as easily as the
+main one. A right-click on a scope window's plot opens **its own menu** — y axis (auto or a fixed
+range), *Fit X axis*, *Fit all*, remove a trace — acting on that window's rows (the x fit resets the **shared** time span, so every view follows); a detached
+window's menu carries **Attach back to the main window** beside them. A window that cannot paint
+says so on stderr and in the status bar after two seconds, naming its **GSK renderer**: a blank
+plot is indistinguishable from a context that failed, and the GTK **Vulkan** renderer is known to
+interact badly with an embedded `GtkGLArea` in a second toplevel — the sentence then says to
+restart with `GSK_RENDERER=gl`. Each scope window also carries a **tick callback of its own**,
+which keeps its toplevel's frame clock advancing: the main window's tick binds and queues the
+render, but a second window whose clock never ticks presents nothing.
+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
@@ -132,7 +179,10 @@ divider's `+` for a new math channel; when every signal is acquired a dim "every
stands in for the list. While the bar has overflowed into the "N signals" badge the page lists the acquired
signals too, since their rows are then the only way to each signal's page. There is no signal lock: a
signal's page and the list always allow acquire, colour, calibration, the math editor and delete.
-A signal badge opens that signal's own page in the slide-over -- the unit, gain and offset and the CONFIG's details directly under the name (aligned rows; a pencil beside the unit, gain and offset edits just that one -- a unit rename is stored per signal name, saved as `[[unit]]` and re-applied to every CONFIG), the colour and the *Plot on N*
+A signal badge opens that signal's own page in the slide-over -- its title is the **numbered name**
+(`2. sine`, the same number the badge and the measurements pane use) and the body carries the unit,
+gain and offset and the CONFIG's details directly under it, with no second copy of the name and no
+"back to the list" button (the header's Close is the way out) (aligned rows; a pencil beside the unit, gain and offset edits just that one -- a unit rename is stored per signal name, saved as `[[unit]]` and re-applied to every CONFIG), the colour and the *Plot on N*
buttons -- and is also the drag handle: drag it onto a plot to draw the signal there, or onto another
badge to reorder them. Past **six** signal badges the bar shows a single **"N signals"** badge in their
place (it opens the list, and `+` is hidden): the E-1608's 8 analog inputs and counter would not fit
@@ -163,8 +213,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 +448,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] [edit]`. `[+]` opens a dialog with the signal, the type, the options
+`[chips] . · [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 +561,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 +661,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 +927,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 `/`) 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 +1150,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, `/udpscope/events` by default. The cache
+the one place every source gets a folder inside (and every configuration a folder within that),
+`/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 +2379,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 +2894,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.
diff --git a/deps/plotter b/deps/plotter
index 2cb31cb..1d5b286 160000
--- a/deps/plotter
+++ b/deps/plotter
@@ -1 +1 @@
-Subproject commit 2cb31cbb91eefcac0e9479b8b3fb1da487d8288b
+Subproject commit 1d5b2866119b52b9452770568b3a8a367fc28faf
diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md
index 2214ac6..dd1fa98 100644
--- a/docs/claude/invariants-gtk-and-chrome.md
+++ b/docs/claude/invariants-gtk-and-chrome.md
@@ -9,6 +9,16 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
tick as the channel dump; the status line only formats what is already there. The
badge's markup is likewise rewritten on a change of phase (twice a second), never on
all sixty frames.
+- **The trigger circle is the badge's sibling, in its alphabet and under its rules.** It sits
+ before the badge on the status row (`main-window.ui` `trigger_status`) and says what the
+ badge cannot: the *trigger's* state -- solid green while an event is held or a capture is
+ filling, flashing green (the lit and dim halves) while armed and waiting, 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 shows no trigger
+ words then either. Same width-locked glyph in every state, same half-second square for the
+ flash, markup rewritten on a change of the state key only (`trigger_circle_state`), and the
+ exit dump reads the tick's cached answer (`dbg_trigger_circle`) because it cannot ask the
+ widget.
- **A `notify::` handler takes three parameters, a `value-changed` handler two.**
`GObject::notify` passes `(GObject*, GParamSpec*, gpointer)` while
`GtkSpinButton::value-changed` passes `(GtkSpinButton*, gpointer)`, so one callback cannot
@@ -722,3 +732,28 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps
box drawn from that is a box over the wrong thing. Its numbers depend on the *window's* size, which
under the documented `xvfb-run` is the window's 756x894 minimum (a 640x480 screen), so a run on a
larger screen gives different rectangles for the same panels.
+
+- **GTK4 has no `GtkWidget::destroy` signal; the lifecycle hook is `close-request`, and
+ `gtk_window_destroy()` never emits it.** A `g_signal_connect(window, "destroy", ...)` on a GTK4
+ widget is a connect to a signal that does not exist: it never fires (and its "signal not found"
+ warning is in the GObject domain, not GTK's, so the dump's `gtk warnings:` counter cannot see
+ it). That is why the dialogs use `gtk_window_close()` when their *lifetime* matters (a close
+ clears `App::measure_dialog`, which a destroy would not), and why the main window's own handler
+ -- the scope windows' teardown, the debounced layout-save flush and the application quit -- is
+ connected to **`close-request`** and returns `FALSE` so the default destroy still runs.
+ Everything that ends the main window programmatically (the exit probe's `exit_probe_step3b`)
+ closes it with `gtk_window_close()` for the same reason. The **scope windows go with it**:
+ each is a `GtkWindow` added to the application, and one left behind keeps the GApplication's
+ main loop alive around a window that is gone -- the hang where the exit run never reached its
+ dump.
+
+- **A page's name lives in the slide-over header, once.** The header prints the open page's title
+ (from the `GtkStackPage`) beside its **Close** button; a page body that repeats the name is the
+ same word twice, and a body-level "back to the list" button duplicates a navigation the header
+ already owns. Signal pages therefore carry **no** name label and **no** back button
+ (`[signalpage] no_dup=1`), and their title is the **numbered** name -- `. `,
+ the same `measure_channel_number()` the badges and the measurements pane use -- so the panel says
+ *which acquired channel* this is rather than only what it is called. `slideover_set_title()`
+ retitles a page in place (the open one included) because that number changes when the stream
+ renumbers its channels; `fill()` sets it on every fill, and a page added by
+ `slideover_add_page()` gets its final title from the `fill()` that follows.
diff --git a/docs/claude/invariants-measurements.md b/docs/claude/invariants-measurements.md
index 03c0c9b..592743c 100644
--- a/docs/claude/invariants-measurements.md
+++ b/docs/claude/invariants-measurements.md
@@ -70,6 +70,47 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
compares with a relative tolerance, because clamping to exactly `window_s_` can leave the
group's span an ULP above it and a clamp that fires every frame is a re-frame loop, not a
clamp.
+- **A row names its channel: number and signal, from one resolver.** The pane's row reads
+ `N. signal · type`, the dock's and the strip's lines prefix the same number to the type,
+ the signal's badge carries it, and the measurement dialog's signal dropdown numbers its
+ entries with it -- all through `measure_channel_number()`, which is the position of the
+ signal's channel in the model's channel list (the acquired channels in stream order), or 0
+ when the signal is not acquired. The readouts rule cuts both ways here: every view shows the
+ same number because every view asks the one function, and a view that derived a number by
+ its own walk of the channel list would be the second spelling that drifts. 0 is a state, not
+ a failure -- the unacquired signal's row is already grey -- so the readers *omit* the number
+ rather than printing a 0. Renumbering on every enable, disable and CONFIG is inherent (the
+ number answers "which active channel is this", which is what a rebuild renumbers), so a
+ generation change rebuilds the pane as well as the signal list.
+- **The gate is a per-measurement option, and one band shared by everyone who uses it.**
+ `MeasureOptions::gate` is set per measurement in its dialog; every gated measurement reads
+ only inside the one shaded band -- a plotter `Region`, violet, its two bounds the gate's own
+ pair of instruments, not the cursors, which stay what the cursors pane reads -- and the band
+ is *shown exactly while some measurement is gated*, derived at `rebuild_measures_pane()`,
+ because every change of the measure set (an add, an edit, a delete, a session restore)
+ passes through there. Ungated measurements are keyed away from gated ones
+ (`MeasureWindow`'s key is `(channel, fc, gated)`), so a gated measurement costs one more
+ walk and changes no ungated number.
+ The bounds live on the `RowSet` in **display x** (`gate_x0()`/`gate_x1()`), the cursors' own
+ space, and that is the point: a live axis is newest-relative, so a band at `[-0.3, -0.1]`
+ keeps meaning "300 ms to 100 ms ago" while the data flows under it, and a held event's
+ stamps are edge-relative, so there the band names a fixed slice of the frozen frame. Either
+ way it stays where it was put, which is what makes it a gate and not a second window.
+ `update_measures()` clips the pane's window (viewport live, pin pinned) into the band once,
+ and each gated window walks the clip: every statistic, mark and "it moved" booking below
+ describes the gated slice through that one expression. A band entirely off the view clips to
+ a zero span and the pane reads the same `--` any empty window gives. Because a band that
+ moves can change what the *gated* measurements describe without the viewport or pin moving,
+ the "it moved" test asks the band as well (`measure_gate_window`, booked every pass). While
+ a region drag runs, the tick adopts the bounds out of the dragged row's plot
+ (`sync_gate(drag_row)` -- the `sync_cursors()` contract: read only while a gesture holds the
+ instrument, because outside one the read is our own echo) and mirrors them to every other
+ row; the drag's *end* saves the session, because the bounds are session state
+ (`gate_x0`/`gate_x1`, and each measure's `gate` flag in its `[[measure]]` section) and a
+ drag that is not saved is a session that lies about where the gate is. `[gate-probe]` is the
+ reading: the gated average differs from the ungated one on the same signal, the booked gate
+ window is the band moved onto the stream's clock, the band is still where it was put a
+ second later while `s0=` has moved under it, and a restored session brings both back.
- **A measurement must not read its window raw.** Both passes go through
`plot::data::MinMaxPyramid`, which the producer already maintains at amortised O(1) -- so the
pane inherits the summary of the new samples rather than computing one, which is what the
diff --git a/docs/claude/invariants-plot-and-layout.md b/docs/claude/invariants-plot-and-layout.md
index 903e658..4b9f53f 100644
--- a/docs/claude/invariants-plot-and-layout.md
+++ b/docs/claude/invariants-plot-and-layout.md
@@ -119,8 +119,11 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
a split one -- never a flat index, which is 0-based and says nothing about where the plot is.
What the cells share and what each keeps:
- **One time window.** Every cell of every row is a member of the one x link group, so a pan or zoom
- in any cell moves them all, the crosshair is one x drawn in every cell (the hovered cell shows its
- y), and the **vertical cursor pair is one shared time drawn in every cell** (`cursors_aligned`).
+ in any cell moves them all, the crosshair is one x drawn in every cell, and the **vertical cursor
+ pair is one shared time drawn in every cell** (`cursors_aligned`). The horizontal crosshair line is
+ the hovered cell's y **drawn in every cell of its figure row** -- the cells share that y, so the
+ line is one value spanning the row rather than stopping at the divider; no other row gets one
+ (`Figure::set_crosshair` spreads it, `[split-probe] cell-xhair` reads it).
A split is an arrangement of more plots, not a second time scale: nothing about the time axis changes
(see [invariants-time-axis-and-window.md](invariants-time-axis-and-window.md)).
- **One y per figure row.** The cells of a figure row share one y range (the plotter's
@@ -340,3 +343,49 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
relayout, the panels' chips, the layout save) are done once by the caller rather than per slot. Re-enabling
does **not** put the traces back: the arrangement is the user's, and guessing where they were is worse than
leaving them off.
+
+- **The full-screen toggle and a scope window are *views*: neither edits the arrangement nor takes a second
+ acquisition.** The per-row toggle (`RowSet::toggle_maximize_figure_row()`) saves every row's persistent
+ state (`RowState` -- the fields `carry_row()` copies, because a `Row` is not copy-assignable: its
+ `SlotSeries` owns a `unique_ptr`) plus the cells, the weights and the plot count, rebuilds the figure with
+ the asked-for row's plots moved to the front, and puts every field back on the second click; it saves
+ nothing, so a reopened session has the arrangement, not the view. Its overlay button is on screen
+ *always* (`App::maximize_buttons`, a set of its own -- `layout_buttons` is the editor's and is cleared with
+ it), placed from the same `FigureLayout` rects as the editor's buttons. A scope window
+ (`App::ScopeWindow`, `open_scope_window()`) owns a `RowSet`, a `PlotArea` and the area's placeholder
+ `Plot`, and is driven by the **main window's tick**: the same model mutex binds and acquires its rows
+ (same display mode, same epoch), and its span follows the main window's window or pin every frame. It is
+ therefore a second *view* -- the settle rule is that a scope must never acquire, plan or epoch anything of
+ its own. Closing the main window destroys its scopes first: a leftover toplevel keeps the GApplication's
+ loop alive around a window that is gone (the exit run's hang). What a user *arranges* in a scope is the
+ scope's own arrangement, and it takes the same two doors the main window's rows take: a badge **dropped on
+ a scope window's plot** lands in that window's row set on the cell under the pointer (its own drop target,
+ its own `assign_signal_to_scope()`), and the signal page's *Plot on* section carries **one named group per
+ window** -- rebuilt out of `App::scopes` on every fill, and again when a window closes (the destroy handler
+ refreshes the pages before the deferred delete can dangle a button's scope pointer). The change itself is
+ never applied synchronously: a drop arrives from inside the very widget it rearranges, so the rebuild and
+ the area's re-point are the window's next frame in the tick.
+
+- **A figure row's full-screen is one click from two places, and never from one figure.** The
+ toggle sits on the row's top-right corner, **out of hiding only while the pointer is near that
+ corner**: its own placed rect inflated by `kMaximizeRevealPx`, per row, so approaching one
+ row's corner reveals that row's toggle and no other. The pointer is read from a
+ `GtkEventControllerMotion` on the **overlay**, not on the GL area -- a revealed toggle sits
+ under the pointer, and a leave on the GL widget would fire the moment the button appeared and
+ hide it again (the flicker the ancestor avoids); a leave on the overlay means the pointer left
+ the plot, which is when the toggles go away. The plot menu offers the same action as *Full
+ screen this figure* / *Restore the layout*;
+ both end in the same `toggle_fullscreen()` -- the `RowState` save/restore, the area's figure,
+ the toggles and the signal panels -- so the two cannot drift apart. A layout of a single
+ figure row carries **no** toggle (and no menu entry): there is nothing to enlarge *to*, and a
+ visible no-op is worse than none. The button reappears while a row *is* maximized, because
+ there it is the way back.
+
+- **A view's arrangement is the view's, editor included.** A scope window holds its own figure
+ rows and edits them with its own `+`/`×`/Done set (`set_scope_layout_editing()`,
+ `rebuild_scope_layout_editor()`, `sync_scope_layout_editor()`): the buttons are tagged with the
+ window and the handlers act on *that* window's row set, so the two editors cannot move each
+ other's figures. `L` acts on the window the key arrived in (a `GtkEventControllerKey` per
+ window; every other accelerator reaches the application through `run_app_action()`), and a
+ pinned layout change is **not** written to the main window's session — the windows themselves
+ are not persisted.
diff --git a/docs/claude/invariants-readouts-and-cursors.md b/docs/claude/invariants-readouts-and-cursors.md
index 31133d8..742082e 100644
--- a/docs/claude/invariants-readouts-and-cursors.md
+++ b/docs/claude/invariants-readouts-and-cursors.md
@@ -118,3 +118,26 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
marker says where the scope put the edge and a measurement mark says what a measurement measured;
neither is something a user may move. The dump prints `readout instruments locked: N`, which must
be 1 -- a drag is not something a probe can perform, so the state is what is asserted.
+
+- **The crosshair's axis badges are painted *over* the tick labels, and that costs a flush.**
+ A badge is a fill in the crosshair's colour framing the value where the line meets the axis
+ it names — and it sits *in the label strip*, on top of the tick label at that row. The tick
+ labels are staged into `glyphs_over_`, the over-text batch the frame flushes **after every
+ line batch**, so a panel fill drawn at the ordinary moment lands *under* them: the label
+ prints through the badge and the backplate reads as transparent (the defect this rule was
+ learned from). `draw_crosshair_axis_badges()` therefore flushes the accumulated over-text
+ *first* — those labels are already placed, so it is one batch — then draws both fills, then
+ stages the badge texts, which leave with the rest of the over batch over everything. An
+ assertion on badge pixels inside a label's rows is the regression test: with the fill under
+ the text, the label's glyph pixels are still lit inside the badge's rect.
+
+- **The instruments have one owner: the main window's row set.** The two cursors, the gate band,
+ the trigger marker and the measurement marks are *shared*, not copied per window. The main tick's
+ `sync_scope_instruments()` writes the owner's state into every scope window each frame (the
+ setters early-out, so a steady frame is a handful of comparisons), and a gesture inside a scope
+ window is read back from **that** window's controllers first -- `sync_cursors()`/`sync_gate()`
+ only adopt while their own plot holds the grab, which is exactly "the drag happened here" -- and
+ adopted by the owner before anything is written out. The measurement marks travel by **signal
+ name** (`RowSet::MeasureMark::signal`), so a window finds the row that plots it; a window that
+ does not plot the signal draws none. The search marks are times on the window and are placed on
+ every window by the same per-frame path.
diff --git a/docs/claude/invariants-session-and-export.md b/docs/claude/invariants-session-and-export.md
index db2f0d4..e8fd986 100644
--- a/docs/claude/invariants-session-and-export.md
+++ b/docs/claude/invariants-session-and-export.md
@@ -192,3 +192,26 @@ 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 `/-//` (`EventStore`'s third constructor
+ argument) and `retarget_event_store()` -- called next to every `App::config_name` write and
+ resolving the name from `active_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 `//` 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.
+
+- **The session remembers extra windows; a launch never opens them.** Each scope or detached
+ window is a `[[window]]` block (`name`, `detached`, and its own `[[window.plot]]` rows -- the
+ same keys the main `[[plot]]` rows carry), written from the open windows' present arrangement
+ plus the ones already closed (`collect_layout()`). Loading puts **all** of them in
+ `App::closed_windows`, never into `app.scopes`: the overflow menu's *Reopen closed windows (N)*
+ is the only path that opens them (`open_scope_window_from_state()`, the one constructor every
+ window uses, then its rows). Closing a window records it (by name, replaced, so close/reopen
+ cycles do not multiply entries); **attaching** records nothing -- its figure is on the main
+ window again -- and a detached figure survives a quit through its window's entry instead of
+ being lost from the main layout.
diff --git a/docs/claude/invariants-trigger-and-pinning.md b/docs/claude/invariants-trigger-and-pinning.md
index 53dd376..cbca069 100644
--- a/docs/claude/invariants-trigger-and-pinning.md
+++ b/docs/claude/invariants-trigger-and-pinning.md
@@ -629,3 +629,11 @@ samples. Three rules keep that honest:
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.
+
+- **A pin is mirrored as a *range*, never as a pair of numbers that might be spans.** `Range`
+ (`pinned_range()`: `{-pre, +post}`) and the pin's *spans* (`pin_event_window(pre, post)`) are two
+ different things that both look like a pair, and mixing them inverts the window: a second view
+ that handed `pinned_range().min` to `pin_event_window()` pinned to `[+pre, +post]`, and the plot
+ drew nothing — no trace, no axis labels — because the x range was degenerate. Views that mirror a
+ pin use `pin_display_range(x0, x1)`, the range's own form. `[scope-probe] pin_mirror=1` reads the
+ two windows' pins and the scope's x range, and an inverted pin reads `0`.
diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md
index 254abcf..9d1c697 100644
--- a/docs/claude/probe-readings.md
+++ b/docs/claude/probe-readings.md
@@ -305,8 +305,8 @@ Two lines of the self-check output are assertions in disguise:
on screen. Both ranges are on the **stream's** clock, which is the space `measure_window` and
the time marks are stored in -- a live axis is a fixed `[-window, 0]`, so comparing display x
would read no drift whatever the data did. `win` is the window as of the last **tick**
- (`App::measure_view_window`) and is deliberately not read back here: the dump runs from the
- destroy handler, by which point the exit probe has switched the trigger on, and
+ (`App::measure_view_window`) and is deliberately not read back here: the dump runs after
+ `g_application_run` returns, by which point the exit probe has switched the trigger on, and
`stream_at_x0()` then answers for a mode no frame was ever drawn in -- which read as a 45000 %
drift on a run whose every frame was exact. `pin` is the same tick's pin (`App::measure_pin_window`,
`off` when nothing was pinned) and it is the **target** while the view is pinned: a measurement
@@ -1348,6 +1348,191 @@ mock's five signals and dies with `OSError: [Errno 90] Message too long`, so the
two steps to it, which is visible in its `ges/x1`, `ges/x2` lines. A `gave up waiting after N
polls` line means no latch (or no pin) was ever reached, and is a reading about the stream.
+## `[gate-probe]`
+
+`UDPSCOPE_PROBE_GATE_MS`. The reading for **what the per-measurement gate does**: the gated
+measurement reads the shared band, the ungated one the whole window, and the band is
+*relative* — fixed on screen while the data flows under it, live or held. Measured output
+(port 8140, `--rate 100000 --batch 100 --freq 1`):
+
+```
+[gate-probe] on: gate=[-0.300000..-0.100000] view=[-2.000000..0.000000] s0=2.022990 for=[0.022990..2.022990] band=1 placed=1 plain=1 split=1 gwin=1 ungated=-1.36e-17 V gated=355.31 mV
+[gate-probe] still: gate=[-0.300000..-0.100000] s0=[2.022990->2.852990] still=1 s0_moved=1
+```
+
+- `gate=` is the band, in **display** x; `view=` the axis; `s0=` the stream time of the
+ display's x = 0 (`stream_at_x0()`); `for=` the pane's own window (the ungated one), in
+ stream seconds. The probe measures only once the ring is older than the window (`s0 >= 2`
+ here): the `plain=` assertion is exact zero because a whole-seconds window of the 1 Hz sine
+ holds whole periods, and a young ring would clip it to a fraction of a cycle.
+- `band=1` — one gated measurement was enough to show the band, because the band is shared by
+ every gated measurement and shown exactly while one exists. `placed=1` — the row's plot
+ region holds exactly the bounds the `RowSet` does: the drawing and the state are one pair of
+ numbers, not two.
+- `plain=1` — the ungated average is zero (`-1.36e-17 V`): it read the whole window.
+ `split=1` — the gated average differs (`355.31 mV`, wherever in its period the stream
+ happened to be): it read the band. The pair is the per-measurement split on one signal.
+- `gwin=1` — the pass booked `App::measure_gate_window` as the band moved onto the stream's
+ clock, within 5 % of the band (the probe's `s0` read and the pass's can straddle one
+ production interval). That is the phase-free statement of *which* slice the gated walk
+ read; the slice's mean value itself depends on where in its period the stream is, which is
+ why the assertion is on the window and not on the value.
+- The second line is the **relative** assertion, one second later: `still=1` — the band
+ exactly where it was put — and `s0_moved=1` — the stream's clock moved under it.
+ `still=0` is a gesture echo adopted outside a drag; `s0_moved=0` on a live stream means the
+ display epoch stopped following, which is the unbound-trace state the probe's assignment
+ exists to prevent.
+- The exit dump carries the session leg: `gate: on=1 bounds=[-0.300000..-0.100000]`, and a
+ second run on the same config with the probe unset restores both measurements with their
+ gate flags (`measurements: 2 1.sine average=-0.000005 1.sine average=-0.338823` on one
+ restore run -- numbered with the signal's active-channel number, the same one the pane's
+ rows carry) and the same band.
+
+## `[crosshair-probe]`
+
+`UDPSCOPE_PROBE_CROSSHAIR_MS`. The reading for **the crosshair's axis badges**: a real pointer
+hover — synthesised through the GTK adapter's own controller, a plain move with no button —
+must place the crosshair, and with it the two badges the plot writes in the label strips: the
+y value in the y strip, the x time in the x strip, each inside its strip and outside the axes.
+Measured output (viewport pixels; the absolute numbers are the headless window's own):
+
+```
+[crosshair-probe] active=1 y=[74x47@31,588] x=[59x47@749,1529] y_ok=1 x_ok=1 y_centre=1
+[crosshair-probe] off: plot_flag=0
+```
+
+- `active=1` is the hover having reached the plot (`Crosshair::active`) — the adapter's
+ viewport conversion is what this probe exercises; the pixels themselves are the plotter
+ suite's (`crosshair_ink_stays_inside_the_axes`, which asserts the badges' rects against the
+ frame and pins the off leg too).
+- `y_ok=1 x_ok=1` are the two placements: the y badge right-aligned inside the y label strip
+ (its right edge on the axis line, where the y ticks end), the x badge inside the x label
+ strip below the axes. Zero-sized badges, or one outside its strip, is the defect.
+- `y_centre=1` is the y badge **centred on its line**: its middle row is the pointer's screen
+ y (the probe injected one). `0` is the top-aligned badge — the line running along the
+ badge's top edge, reading as if the number belonged to the tick below it — which is half a
+ badge off, well beyond the one pixel of slack.
+- The second line flips the flag the preference drives (`set_crosshair_axis_labels(false)`)
+ and reads the plot's own flag back: `plot_flag=0` — a preference that reaches the file but
+ not the plots is the failure this leg names.
+- The pixel-level assertions live in the plotter's suite; this probe is the adapter's half.
+ The preference itself is `[prefs-probe]`'s `xhair=` field, which reads the settings file
+ back from disk after the dialog toggles it.
+
+## `[maximize-probe]`
+
+`UDPSCOPE_PROBE_MAXIMIZE_MS`. The one-click **full-screen row** toggle, exercised through the
+`RowSet` call the button's handler makes. **Do not run it with `UDPSCOPE_ENABLE_ALL_MS`**: the
+enable-all probe's second leg clears the front plot two seconds in, and this probe's `before`
+read then names an empty row (`row0=`) -- a probe race, not a defect. The full-screen recipe:
+`RowSet` call the button's handler makes (a probe cannot click an overlay button the frame
+places), arranged as two single-cell rows with one signal each. The two reads after it are the
+**corner reveal**: no pointer over the area, then a pointer position in the first row's corner
+(set directly -- the probe owns the state the motion controller fills). Measured output:
+
+```
+[maximize-probe] single_buttons=0 detach_single=0 before: rows=2 frows=2 row0=sine row1=square buttons=2 detach=2; max: rows=1 frows=1 flag=1 row0=sine; back: rows=2 row0=sine row1=square flag=0
+[maximize-probe] corner: hidden_when_off=1 shown_when_near=1 other_row=0 detach_near=1 detach_other=0
+```
+
+- `single_buttons=` is the toggle count *before* the second row is added, while the layout is
+ the default one figure: **0** — one figure has nothing to enlarge *to*, so it carries no
+ button (and `buttons=` is what the same count reads once the second row exists).
+ `detach_single=` is the same for the **detach** buttons: **0** — detaching the only figure
+ would leave the main window with nothing to draw — and `detach=2` once the second row exists
+ (and **0** again on the `max:` line, where a maximized layout has hidden its rows).
+- the corner leg's `detach_near=1 detach_other=0` read the detach buttons with the same pointer
+ the maximize toggle reads: both come out together at the corner, the other row's stay hidden.
+ The pointer sits on the detach button's own centre — its zone is the strictest of the pair.
+- `before:` is the arrangement as set: two plots, two figure rows, the signals on their rows,
+ and `buttons=` one overlay toggle per figure row (the toggles are on screen whether or not
+ the layout editor is up).
+- `max:` is the toggle: one plot, one figure row, `flag=1`, and the *maximized row's own*
+ signal (`row0=sine`) — the row that was asked for, not whatever sat first.
+- `back:` is the toggle again: the arrangement, the plot count and both rows' signals exactly
+ as before, `flag=0`. A signal that changed here is the state save/restore (`RowState` in
+ `plot_host.*`) losing a field; the restore is a view change, never an edit.
+- `corner:` is the proximity reveal: `hidden_when_off=1` (no pointer in the area, so nothing
+ shows), `shown_when_near=1` (a pointer in the first row's top-right corner brings *that*
+ row's toggle out), and `other_row=0` -- the second row's toggle stays hidden, because the
+ zone is per row. A `shown_when_near=0` names a dead reveal path (the motion hook or the zone);
+ `other_row=1` a zone that leaked across rows.
+
+## `[scope-probe]`
+
+`UDPSCOPE_PROBE_SCOPE_MS`. The **second window**: `open_scope_window()` twice — once copying
+the main window's figure 0, once empty — and read back a few frames later (the main tick is the
+scope's bind/acquire/repaint pump). It can run beside `UDPSCOPE_ENABLE_ALL_MS`, but that probe's
+second leg clears the front plot through the signal page, so the main's slot 0 is empty by then
+and `copied` names the **first non-empty** copied slot instead (`'square'` in that order, `'sine'`
+alone). `copied=''` with every slot empty is a copy that moved nothing. Measured output:
+
+```
+[scope-probe] loaded closed=2 scopes=0
+[scope-probe] count=2 copied='sine' bound=1 empty_ok=1 windows=1
+[scope-probe] drop=1 slot1='square' page_groups=2 empty_drop=1 empty_slot0='square'
+[scope-probe] detach=1 scopes=3 frows=1 attach=1 scopes_back=2 frows_back=2 slot='ramp'
+[scope-probe] gl copy=ok(67f) empty=ok(39f) empty_bound=1
+[scope-probe] gl detached ok=1 frames=42 rows=1 slot0='sine'
+[scope-probe] rclick=1
+[scope-probe] pin_mirror=1 main=[-1.000..1.000] scope=[-1.000..1.000] x=[-1.000..1.000]
+```
+
+- `count=2` is both windows having been created and retained; `windows=1` each being a visible
+ top-level window.
+- `copied='sine'` is the copy's first non-empty slot holding the main window's figure-0 signal
+ (`'square'` when the enable-all probe ran first and cleared slot 0 first), and
+ `bound=1` the tick having resolved it to a live channel — the scope is bound to the same
+ model, not a snapshot taken at creation.
+- `empty_ok=1` is the `-1` call producing an empty row, which is the other entry point the
+ overflow menu uses.
+- `loaded closed=N scopes=0` is the **session remembering windows without opening any**: the
+ entries a previous run saved are in `App::closed_windows`, and no scope window exists at start.
+ `scopes` above zero is a launch that opened windows on its own.
+- `reopen scopes=… remembered=… left=0` is **close → remember → the menu entry**: destroying a
+ window puts its arrangement into `closed_windows` (by name -- `name=1` checks it), clicking the
+ *Reopen closed windows* item ("clicked", the item's own handler) opens every remembered one, and
+ the list is empty after (`left=0`). A `remembered` that did not grow is a destroy handler that
+ forgot to record; `left` non-zero is an entry the menu did not take.
+- `instruments windows=N cursors=N gate=N trigger=N marks=N` is the **one owner, every window**:
+ given a cursor pair, a gate band and the trigger marker on the main window, every scope window
+ reports the same (each count is how many windows matched). `marks` is the per-window measurement
+ mark count agreeing with what that window plots -- a window that plots the measured signal has
+ its ruler, one that does not has none. All four counts equal to `windows` is the reading;
+ `trigger` is compared against the **main** (the marker is up only while the trigger is), and the
+ parenthesis reports the main's state so a zero is distinguishable from an unequal one. The leg
+ adds a real gated `Top` measurement on `sine`, because the gate is restated visible every frame
+ by `update_measures()` and `marks` only come from `show_on_plot` measurements.
+- The second line is the **window as a drop target and a *Plot on* group**: `drop=1` is
+ `on_scope_drop_channel()` — the handler the window's own target wires — putting `square` in
+ the copied window's second slot (`slot1=`), the rebuild and the area's re-point then left to
+ the window's next frame; `page_groups=2` is the signal page of an unplotted signal carrying
+ **one *Plot on* group per window** (`udpscope-scope-plot-on`), so a signal can be plotted
+ into a scope window without dragging it there. `empty_drop=1 empty_slot0='square'` is the
+ same drop onto the **empty** window — the user's own path, "open a scope window and drop a
+ signal into it" — landing in its row set. `drop=0` names a refused drop (a full plot or an
+ unacquirable signal); `page_groups` less than the window count is a page rebuilt before the
+ windows were read or a group that lost its window.
+- `pin_mirror=1` is a scope window **mirroring a pin as a range**: the probe pins the main window
+ and reads the scope's pin and its plot's x range a frame later. Both must agree and the x range
+ must be ordered (`x=[min..max]`, `min < max`). A `0` with an inverted scope pin (`[+pre, +post]`)
+ is the defect this exists for: `pin_event_window()` takes **spans**, and passing it a range's
+ signed `min` turned the window inside out, so a scope in Held or Paused mode drew no trace and no
+ x labels at all.
+- `layout buttons=3 rows=1->2` is the window's **own layout editor**: the toggle builds
+ `2 × rows + 1` buttons (a `×` per row, a `+` per boundary, Done is not counted) for one figure,
+ and emitting the first boundary `+` adds a row. A wrong count is a button set built for the main
+ window's rows; `rows` unchanged is a handler writing the wrong row set.
+- `rclick=1` is the scope window's **own right-click menu** being wired (one click gesture with
+ button 3 on its GL area, beside the drop target): the menu's actions act on that window's row
+ set.
+- The fourth line is the windows' **GL account** (`gl copy=`/`gl empty=`): `ok(Nf)` is a context
+ that staged N frames, `err:` carries the adapter's failure message. A scope whose context
+ failed draws *nothing* — no axes, no traces, exactly the "blank plot widget" report — so
+ `err:` there is a driver/context finding, not a layout one, and `empty_bound=1` (the dropped
+ signal's channel, read a frame after the drop) with `ok(...)` together mean the window is
+ drawing; a blank window under those two readings is a compositor problem, not the app's.
+
## `[search-probe]`
`UDPSCOPE_PROBE_SEARCH_MS`. The handler calls `condition_probe()` directly — the condition is
@@ -1432,10 +1617,10 @@ popover row reads `failed: ` — one stderr line, no retry storm again
```
[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01 0 0 1
[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25
-[configs] before=default active=default
-[configs] save-as=1 names=probe,default active=probe
+[configs] before=default active=default store=default
+[configs] save-as=1 names=probe,default active=probe store=probe
[configs] round trip read=1 comment='written by the configs probe' buffer_samples=10000000 relative=0 capture=0 plots=1 signals=4 measures=0 math=0 noise=-1 hf=-1
-[configs] after forget names=default
+[configs] after forget names=default active=default store=default
```
`noise=` and `hf=` are the first measurement's band and corner as they came back from the file. The probe
sets them to **7.5 and 250** before saving, so a key that never reached the file reads back as the defaults
@@ -1450,16 +1635,17 @@ sets them to **7.5 and 250** before saving, so a key that never reached the file
| `[configs] names` | gains `probe`, and loses it after `forget` | a save that writes somewhere the listing does not read, or a delete that leaves the file |
| `[configs] active` | `probe` after the save-as | the `active` file not written, so the next start opens the wrong configuration |
| `[configs] round trip` | `read=1`, the note verbatim, `buffer_samples` non-zero | the note or the startup settings not persisted — a configuration that carries a layout but not the buffer size it was tuned with |
+| `[configs] store` | the **event cache's configuration key**, following the active one: `default` → `probe` → `default` | a cache still keyed on the source alone (the key never moves), or a switch that renames the session but leaves the events list showing the configuration it left: `store=default` on the `save-as` line, or `store=probe` after `forget`, names it |
## `[rowmenu]`
```
-[rowmenu] main 4 'Remove signal' h=18 label_x=26 accel_right=-1/174 ok=1
-[rowmenu] main rows=5 bad=0 labels=[Y axis: auto scale,Y axis: fixed range...,Fit X axis,Fit all,Remove signal]
-[rowmenu] remove 0 'Back' h=18 label_x=52 accel_right=-1/178 ok=1
-[rowmenu] remove rows=6 bad=0 labels=[Back,sine,square,ramp,noise,All traces on this plot]
-[rowmenu] summary main_rows=5 main_bad=0 remove_rows=6 remove_bad=0 after_back_rows=5 after_back_bad=0 second_rows=6 second_bad=0 remove_row_in_main=1 remove_row_gone=1 can_open_again=1
-[rowmenu] remove-clicked 'sine' trace_freed=1
+[rowmenu] main 6 'Remove signal' h=18 label_x=26 accel_right=-1/248 ok=1
+[rowmenu] main rows=7 bad=0 labels=[Y axis: auto scale,Y axis: fixed range...,Fit X axis,Fit all,Open this plot in a new window,Edit the layout...,Remove signal]
+[rowmenu] remove 0 'Back' h=18 label_x=52 accel_right=-1/118 ok=1
+[rowmenu] remove rows=1 bad=0 labels=[Back]
+[rowmenu] summary main_rows=7 main_bad=0 remove_rows=1 remove_bad=0 after_back_rows=7 after_back_bad=0 second_rows=1 second_bad=0 remove_row_in_main=1 remove_row_gone=1 can_open_again=1
+[rowmenu] remove-clicked '' trace_freed=1
```
| field | value it must read | the defect a wrong reading names |
@@ -1467,6 +1653,7 @@ sets them to **7.5 and 250** before saving, so a key that never reached the file
| `h` | 18 for every row of both views | the plot menu reverting to Adwaita's 34 px buttons — the row style not applied (`popover.udpscope-menu`) |
| `label_x` | 26 for every row, 52 for **Back** | labels not sharing one column; the Back row's 52 is its leading icon, which every menu indents for |
| `accel_right`/`W` | equal when the row has a shortcut (`Fit all` → `F`), `-1` when it has none | the shortcut not flush right, or shown where the action has none |
+| `main_rows` | 6 for a one-figure layout, **7** with `Full screen this figure` (a layout of two or more figures), plus 1 with `Restore the layout` while one is maximized | the full-screen entry missing where it would act (it is deliberately absent for a single figure: there is nothing to enlarge to), or the layout-editor entry missing: the plot menu is the third route into the editor beside `l` and the overflow menu |
| `remove_rows` | 1 per occupied trace slot, plus `All traces on this plot` when there is more than one, plus the **Back** row | a remove view built from the wrong row, or missing its way out |
| `remove_row_gone`, `remove_row_in_main` | 1 | a submenu that floats beside the menu instead of replacing it |
| `can_open_again` | 1 | the second click not reopening: the `GtkMenuButton`-owned submenu that collapsed and left its row dead |
@@ -1620,7 +1807,7 @@ run they report:
[events] after events page opened: available=1 dock_sensitive=1 page_sensitive=1 page=events
[events] open alone: page=events page_box=347
[events] preview redrawn: signals=3 stored=480x132 row_drawn=1
-[events] dir=/home/u/.cache/udpscope/events/127.0.0.1-21023 n=3 newest: stem=20261013-184140-0003 dur=2.000000 pre=1.600000 post=0.400000 edge=5.660100 signals=3 samples=20001 rate=10000 seq=3 thumb_bytes=190134 recorded=1 page=1 plot_w_stable=1 plot_w=756/756
+[events] dir=/home/u/.cache/udpscope/events/127.0.0.1-21023/default n=3 newest: stem=20261013-184140-0003 dur=2.000000 pre=1.600000 post=0.400000 edge=5.660100 signals=3 samples=20001 rate=10000 seq=3 thumb_bytes=190134 recorded=1 page=1 plot_w_stable=1 plot_w=756/756
[events] activated row0: replaying=1 page=events page_box=347 tick=0 window=2.000 axis=[-1.600,0.400] marker=0.000 thumbnail=1 thumb_size=138x46 thumb_visible=1 rows_kept=1 selected=2/2
[events] dock events: rows=3 pane_rows=3 same_titles=1 sensitive=1 dock_w=268 on_screen=1 marked=0 members=0 max_ok=1 expected_marked=1 at=after pane replay
[events] dock replay: clicked=1 replaying=1 waveform_changed=1
@@ -1857,9 +2044,9 @@ These ride on the `[cursors-probe]` readings (every state line gets a `dock curs
## `strip:` (the readouts strip)
```
-[cursors-probe] strip: visible=1 expected=1 visible_ok=1 dock_shown=1 cursors=4 measures=0 same_text=1 h=26 sens_same=1 collapsed=1 window=756 groups=100 groups_ok=1
-[measures-probe] strip: visible=1 expected=1 visible_ok=1 dock_shown=1 cursors=0 measures=12 same_text=1 h=26 sens_same=1 collapsed=1 window=756 groups=010 groups_ok=1
-[measures-probe] start strip: visible=0 expected=0 visible_ok=1 dock_shown=1 cursors=0 measures=0 same_text=1 h=0 sens_same=1 collapsed=1 window=756 groups=000 groups_ok=1
+[cursors-probe] strip: visible=1 expected=1 visible_ok=1 dock_shown=1 cursors=4 measures=0 same_text=1 h=26 sens_same=1 collapsed=1 window=756 groups=100 groups_ok=1 add=1
+[measures-probe] strip: visible=1 expected=1 visible_ok=1 dock_shown=1 cursors=0 measures=12 same_text=1 h=26 sens_same=1 collapsed=1 window=756 groups=010 groups_ok=1 add=1
+[measures-probe] start strip: visible=0 expected=0 visible_ok=1 dock_shown=1 cursors=0 measures=0 same_text=1 h=0 sens_same=1 collapsed=1 window=756 groups=000 groups_ok=1 add=1
[cursors-probe] dock hidden strip: visible=1 expected=1 visible_ok=1 dock_shown=0 cursors=4 measures=0 same_text=1 h=26 sens_same=1 ...
[cursors-probe] dock restored strip: visible=1 expected=1 visible_ok=1 dock_shown=1 ... collapsed=1 window=756
```
@@ -1868,7 +2055,7 @@ One `strip:` line follows every `dock cursors:` / `dock measures:` line, and eac
- **`visible` equals `expected`, and `expected = (!dock_shown || collapsed) && (cursors on || measures non-empty)`** (`dock_shown` is `show-sidebar`, `collapsed` the split view's, both read in the same frame as `visible`; "cursors on" is the cursors toggle, not the `cursors=` chip count, which is a rebuild's and is 1 even with them off). `visible_ok=1` is that equality; `visible_ok=0` is `sync_readouts_strip()` not run, or run before the reconciliation. A single such line right after the probe's own `update_side_visibility()` or on the very first line (the tick has not set `collapsed` yet) is the sub-frame before the next tick and not a defect.
- **What each leg reads.** `[measures-probe] start` (before the probe adds anything, from a **fresh** config: a stored session brings its measurements back) is the "neither" state: `visible=0 expected=0 groups=000 h=0`. The measures probe's later lines have measurements and the cursors off: `groups=010`. The cursors probe alone has the cursors on and no measurements: `groups=100`; run after the measures probe in the same session it has both: `groups=111`. A line with the strip visible and `groups=000`, or hidden with a group on, is `sync_readouts_strip()` not called from that path.
-- **`groups=`** is which of `strip_cursor_group`, `strip_measure_group` and `strip_sep` are visible; **`groups_ok=1`** is that they equal (cursors on, a measurement, both). `0` is a group left over from the previous state.
+- **`groups=`** is which of `strip_cursor_group`, `strip_measure_group` and `strip_sep` are visible; **`groups_ok=1`** is that they equal (cursors on, a measurement, both). `0` is a group left over from the previous state. **`add=1`** is the strip's `[+]` sitting after the last measurement chip (a button, not the chip box) -- the strip adds a measurement itself rather than only opening the pane; it is structural because a probe that clicked it would open a dialog on every report.
- **`h=0` with `visible=1` on the `cursors-probe ... on` and `measures-probe ... added` lines** is the strip shown in the same frame the probe reads it, before its first allocation; the next line reads 26. It is not a `visible_ok` miss: the toggle handler and `rebuild_measures_pane()` call `sync_readouts_strip()` themselves, so `visible` is already right.
- **`visible=1 expected=1` on every line that has something to read, under `xvfb-run`**, the `dock restored` line and the lines with `dock_shown=1` included: the window is 756 px, below the 900 px `kSidebarRoom`, so the split view is collapsed, the dock is an overlay on the plot, and the strip stays. `visible=0` there is the old rule (hidden whenever the dock is shown) or `collapsed` not being read. The wide case (`collapsed=0`, `dock_shown=1`, `visible=0 expected=0`) is **not reachable from a probe**, because a window with no manager cannot be resized by `gtk_window_set_default_size()`.
- **`h` is 26 with `visible=1`, 0 with `visible=0`.** Any other visible height is the chips setting the strip's height (the button trim in `ui_widgets.cpp` or the `height-request` lost).
@@ -2118,15 +2305,22 @@ same reason.
## `[prefs-probe]`
```
-[prefs-probe] opened=1 rows=1 dir=/tmp/k5/cache/udpscope/events store=/tmp/k5/cache/udpscope/events/127.0.0.1-8000
-[prefs-probe] dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark live=1
+[prefs-probe] opened=1 rows=1 dir=/tmp/k5/cache/udpscope/events store=/tmp/k5/cache/udpscope/events/127.0.0.1-8000/default cfg=1 cfg_switch=1
+[prefs-probe] dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark xhair=0 live=1
[prefs-probe] default restored: file=''
[prefs-probe] browse: cancelled=1 icon_ok=1 field='/tmp/k5/cache/udpscope/events'
```
`UDPSCOPE_PROBE_PREFS_MS` opens the preferences, changes every row, and reads the settings file *back
from disk* after each change -- not from `App::settings`, because a dialog that edits its own copy and
-never writes is exactly the failure this has to catch. **A change applies as it is made**: the rows are
+never writes is exactly the failure this has to catch.
+
+`store=` is the directory the cache is **really** using, and the `cfg=`/`cfg_switch=` pair is that
+directory's key: `cfg=1` reads `store=` ends `///` (the cache is per
+source *and* configuration, so two configurations of one source never list each other's events), and
+`cfg_switch=1` reads `retarget_event_store()` -- the one helper every configuration switch calls --
+moving the key to another configuration and back. A `cfg=0` names a store still keyed on the source
+alone; a `cfg_switch=0` a switch that leaves the cache pointing at the configuration it left. **A change applies as it is made**: the rows are
libadwaita's (`AdwEntryRow`, `AdwSpinRow`, `AdwSwitchRow`, `AdwComboRow`), and the `preferences.ui`
dialog has no Apply button for the probe to press.
@@ -2138,8 +2332,10 @@ dialog has no Apply button for the probe to press.
plus the source's folder name**, which is the reading that says the row shows the path in force rather
than a plausible string.
- The second line must echo the probe's own input: directory `/tmp/udpscope-prefs-events`, keep `7`,
- relative time `1`, theme `dark`. Any field that differs means the row, the write or the parse dropped
- it. `live=1` is the theme having reached the running style manager rather than only the file.
+ relative time `1`, theme `dark`, and `xhair=0` — the crosshair's axis-badge switch, which the
+ probe drives off exactly as it drives the rest, and which reaches the running plots at once
+ (`RowSet::set_crosshair_axis_labels()`). Any field that differs means the row, the write or the
+ parse dropped it. `live=1` is the theme having reached the running style manager rather than only the file.
- `default restored: file=''` closes the loop from the other side: the probe puts the default path back
into the row, and the file must end up with **no** `event_cache_dir` at all. A path in the file is a
user's choice, and leaving the default is not one -- pinning it there would stop the setting following
@@ -2152,7 +2348,7 @@ dialog has no Apply button for the probe to press.
```
-[prefs-probe] opened=1 apply=1 dir=/tmp/k5/cache/udpscope/events store=/tmp/k5/cache/udpscope/events/127.0.0.1-8000
+[prefs-probe] opened=1 apply=1 dir=/tmp/k5/cache/udpscope/events store=/tmp/k5/cache/udpscope/events/127.0.0.1-8000/default cfg=1 cfg_switch=1
[prefs-probe] dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark live=1
[prefs-probe] default restored: file=''
[prefs-probe] browse: toplevels=3 extra=1 icon_ok=1 field='/tmp/k5/cache/udpscope/events'
@@ -2759,7 +2955,7 @@ left the caller's `false` alone would read `dock_shown=0 default=1 ok=0`. The pr
## `[signalpage]`
```
-[signalpage] name=sine open=1 pages=1 enable_visible=1 plot_buttons=1 plot_on_row0=1 plot_on_row0_disabled=1 removed=1 btn_row0=1 back_to_list=1 row_opens=1 pruned=1 other_page_kept=1 rows=1
+[signalpage] name=sine open=1 pages=1 enable_visible=1 plot_buttons=1 plot_on_row0=1 plot_on_row0_disabled=1 removed=1 btn_row0=1 title='1. sine' title_ok=1 no_dup=1 row_opens=1 pruned=1 other_page_kept=1 rows=1
```
`UDPSCOPE_PROBE_SIGNAL_PAGES_MS` builds and drives the page of the first streamed signal. `open=1` (the slide-over shows `signal:`), `pages=1`
@@ -2767,8 +2963,11 @@ left the caller's `false` alone would read `dock_shown=0 default=1 ok=0`. The pr
`rows=` (one "Plot on" button per figure row), `plot_on_row0=1` (`assign_signal_to_row()` returned true and the signal is plotted, from a state where the probe first took it off every plot), `plot_on_row0_disabled=1`
(after the page refilled with the signal on plot 1, that plot's "Plot on" button is insensitive, "already on this plot"; `0` is a button that would put the signal in a second slot of
the same plot), `removed=1`
-(the page's "Remove from plots" took it off), `btn_row0=1` (the "Plot on 1" button, the user's path, put it back), `back_to_list=1` ("Back to signals"
-target), `row_opens=1` (the probe switches the signal off so it is addable, activates its list row and the page opens; the signal is then switched back on), and `pruned=1`: a math channel's page was opened, its **Delete** button clicked, and
+(the page's "Remove from plots" took it off), `btn_row0=1` (the "Plot on 1" button, the user's path, put it back),
+`title=` the slide-over **header's** title while the page is open, `title_ok=1` when it is `. ` -- the
+same numbering the badge carries (`measure_channel_number`), so a signal page says which acquired channel it is -- and
+`no_dup=1` when the page **body** carries neither a "Back to signals" button (the header's Close is the way out) nor a
+second copy of the name (a `title-3` label); `0` is either duplicate back, `row_opens=1` (the probe switches the signal off so it is addable, activates its list row and the page opens; the signal is then switched back on), and `pruned=1`: a math channel's page was opened, its **Delete** button clicked, and
the slide-over closed and the page was dropped by `signal_pages_sync()`. `pruned=-1` means no math channel could be added; `0` is a stale page. `other_page_kept=1`: with the `trigger` page open, a signal page that is not on show was pruned (a math channel added, its page built, the
channel removed) and the slide-over was still on `trigger`, both by `slideover_page()` and by the stack's visible child; `0` is `signal_pages_sync()` parking the stack on the
signals list while the title and the badge marker still name `trigger`; `-1` when there is no `trigger` page or no math channel could be added.
@@ -2862,9 +3061,10 @@ inside the panel does not close it), `click_outside_closes=1` (a click on the pl
[split-probe] ruler-labels split=[row 1,row 2] mixed=[row 0,row 2]
[split-probe] shared-y y_shared=1 y_gutter=1 y_fit=1 ruler_mirror=1 ranges=[A=-3.000000..7.000000 B=-3.000000..7.000000 | fit A=-1.04..1.04 B=-1.04..1.04 data=-1..1] axes_w=[671.599976,671.599976] auto=1
[split-probe] x-axes row0_labelled=[1,0,1] bottom_labelled=[1,1,1] gap_ok=[1,1,1] gap_px=[3.0/3.0,3.0/3.0,3.0/3.0] col_gap_px=3.0 caption_ok=[1,1,1] last_plot_zoom=1
+[split-probe] cell-xhair left=1 right=1 centre_dy=0.0 sibling=0 (the horizontal line spans a split row's shared y, and no other row)
```
-`UDPSCOPE_PROBE_SPLIT_MS` (stderr; see [verification.md](verification.md)). 28 steps (0..27), 250 ms apart;
+`UDPSCOPE_PROBE_SPLIT_MS` (stderr; see [verification.md](verification.md)). 31 steps (0..30), 250 ms apart;
each restructures and the *next* step reports the frame that was laid out with it, so a line's label
names the arrangement the line **reads**, not the call that made it. The state lines share one format:
`plots` (`row_count()`), `frows` (`figure_row_count()`), `cells=[..]` (per figure row), `widths=[..]`
@@ -2887,6 +3087,7 @@ The other lines, in order:
| `row+cell` | `cells=[2,2]`, `weights=[3,1\|-]` | row 0's cell weights lost or moved when a row and a cell were inserted below it (they belong to the figure row) |
| `caps-calls` | `insert_cell=[1,1,0] refused_cell=1 insert_row=[1,1,0] refused_row=1 plots=8` | a cap that is not reached or not held: row 0 starts with two cells and takes two more, refusing the third call at 4; two rows then fit before the plot total reaches 8 and the third is refused; `plots` must stop at **8** |
| `caps` | `plots=8 frows=4 cells=[4,2,1,1]`, `tile=1` | a ninth plot, or a fifth cell in a row |
+| `cell-xhair` | `left=1 right=1 centre_dy=0.0 sibling=0` | the crosshair's horizontal line, placed through `Figure::set_crosshair()` on the **second** cell of a two-cell row: it must also be drawn in the first cell (`left=1`) at the same screen row (`centre_dy`) — the cells share one y, so the line is one value — and must not appear in the figure's other (unsplit) row (`sibling=0`). A `left=0` names a row whose cells each keep their own line; a `sibling=1` a y spread across rows that do not share one |
| `remove` | `plots=7 cells=[3,2,1,1]` | `remove_cell()` on a multi-cell row that removed the row, or that dropped a neighbour's traces |
| `roundtrip-io` | `wrote=1 read=1 plots_in_file=3` | a save that was refused, or a file that lost a `[[plot]]` table |
| `roundtrip` (state line) | `cells=[1,2] widths=[756\|524,229] weights=[-\|3,1]` and the three traces on `1`, `2a`, `2b` | the arrangement did not survive `collect_layout()` -> file -> `apply_layout()` |
diff --git a/docs/claude/verification.md b/docs/claude/verification.md
index 6314ba5..14ac1ac 100644
--- a/docs/claude/verification.md
+++ b/docs/claude/verification.md
@@ -60,7 +60,8 @@ see [probe-readings.md](probe-readings.md).
That second baseline used to be "exactly two, `gtk_widget_get_height` and `gtk_widget_get_width`,
from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump
-runs from the destroy handler, so the widget pointer is **freed memory**, and a critical is only
+runs **after `g_application_run` returns**, so the window and every widget in it are gone -- the pointer
+is **freed memory**, and a critical is only
the lucky reading of it. A run that had built and destroyed other windows first —
`UDPSCOPE_PROBE_HELP_MS`, which opens the About and shortcut dialogs and then closes them — has had
that memory reused, and the same two calls
@@ -74,7 +75,26 @@ precedent, not an exception.
then dump the whole pipeline state to stdout (frame/sample counts, per-plot painted
frames, trace bindings, panel sizes, status line in both states).
- `UDPSCOPE_ENABLE_ALL_MS`, `UDPSCOPE_ARM_TRIGGER_MS`, `UDPSCOPE_PROBE_DISARM_MS`,
- `UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_PROBE_DISARM_MS` only
+ `UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_ARM_TRIGGER_MS` clicks the
+ trigger toggle and then samples the **trigger status circle** once a second for four
+ seconds: an armed, waiting trigger reads `circle=flash-lit` / `circle=flash-dim` (the two
+ halves of the 1 Hz flash, in the phase the sample lands in), a latched event `circle=green`,
+ a disarm `circle=red`; the exit dump carries the last tick's state as `trigger circle:`
+ (`off` under `UDPSCOPE_EXIT_AFTER_MS`, whose own probe goes live before it dumps).
+ `UDPSCOPE_PROBE_CROSSHAIR_MS` hovers the pointer over row 0 through the adapter's own
+ controller and asserts the crosshair's two **axis badges** landed in their label strips
+ (`[crosshair-probe] active=1 ... y_ok=1 x_ok=1`), then flips the flag off and reads the
+ plot's back; the pixel-level contract is the plotter suite's. The preference behind the
+ flag is `[prefs-probe]`'s `xhair=` field. `UDPSCOPE_PROBE_MAXIMIZE_MS` arranges two
+ single-cell rows (sine, square) and exercises the one-click **full-screen** toggle through
+ the same `RowSet` call the button's handler makes — `[maximize-probe] before: rows=2 frows=2
+ row0=sine row1=square buttons=2; max: rows=1 frows=1 flag=1 row0=sine; back: rows=2
+ row0=sine row1=square flag=0` — where `buttons=` is the overlay's one toggle per figure row
+ and the `back` leg is the proof that a maximize moves no signal. `UDPSCOPE_PROBE_SCOPE_MS`
+ opens the **second window** twice, once copying figure 0 and once empty, and reads it back
+ a few frames later: `[scope-probe] count=2 copied='sine' bound=1 empty_ok=1 windows=1` —
+ `bound=1` is the main tick having bound the copy's rows to the same model, and `empty_ok=1`
+ the empty scope staying empty. `UDPSCOPE_PROBE_DISARM_MS` only
*starts* its sequence: it switches the trigger on and then polls for a capture to open,
so the Disarm lands ~300 ms into one rather than wherever a fixed delay happened to fall.
- `UDPSCOPE_PROBE_FORCE_MS` — the trigger panel's **Force** button: one acquisition at the present
@@ -234,13 +254,16 @@ precedent, not an exception.
window prices a 15 s one at nothing (four measurements over four channels read `35.69 ms` a
recompute and a 4.58 ms tick mean at a 15 s window against `6.58 ms`/1.34 ms for one window at
the same window).
-- `[measure-probe] N measurements on 'X'` (stderr, `UDPSCOPE_PROBE_MEASURE_MS`) — adds one
+- `[measure-probe] N measurements on 'X' (channel C)` (stderr, `UDPSCOPE_PROBE_MEASURE_MS`) — adds one
measurement of each statistical type (average, top, peak to peak, amplitude) on the first
*channel* and forces a recompute. It must name a **channel**, not `signals().front()`: the
first signal of a stream is its timestamp, which is not acquired, and naming it adds four
measurements that all read `--` *and* makes a performance run look free (each bails before
collecting anything -- that is how the first baseline of the stall below read 0.4 ms).
- The values are in the exit dump as `measurements: =`; on the fast mock
+ The values are in the exit dump as `measurements: N.signal =`, each numbered
+ with the signal's active-channel number (the same one the pane's rows carry, and unnumbered
+ when the signal is not acquired -- `enable_all` switches one off before the dump, so its
+ measurements read unnumbered there); on the fast mock
(`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`) a unit sine must read
`peak to peak=2` **exactly**, `average=0`, and the two flat-level types as their definition
says: **`top=0.957988`** and `amplitude=1.915983`. `top` is no longer 2/pi -- it is the *flat*
@@ -346,6 +369,42 @@ precedent, not an exception.
[probe-readings.md](probe-readings.md); the window rule itself is in
[invariants-measurements.md](invariants-measurements.md).
+- `[gate-probe]` (stderr, `UDPSCOPE_PROBE_GATE_MS`) — the **per-measurement measurement gate**:
+ a gated measurement must read the shared band and an ungated one the whole window, and the
+ band must stay put while the data moves under it. It enables the sine, puts it on a plot
+ (the way `enable_all_probe` does — an unbound trace leaves the display epoch at 0, and a
+ still window is the state the `still=` leg asserts moves), adds **two** averages — index 0
+ ungated, index 1 gated, which is what makes the pane rebuild *derive* the band from the
+ measure set — and re-states the band's bounds to a fixed relative interval, 300 ms ago to
+ 100 ms ago, where samples exist whatever the run's age. It first waits for the ring to be
+ older than the window, because the `plain=` assertion below is exact zero only while a
+ whole-seconds window of the mock's 1 Hz sine holds whole periods; a young ring clips the
+ window to a fraction of a cycle and the reading is merely small. Two lines:
+
+ ```sh
+ python3 tools/mock_udpstreamer.py --port 8140 --rate 100000 --batch 100 --freq 1 &
+ sleep 1.5
+ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=14000 UDPSCOPE_PROBE_GATE_MS=3000 \
+ ./build/udpscope --server 127.0.0.1 --port 8140 2>&1 | grep -E 'gate-probe'
+ ```
+
+ The first line must read **`band=1 placed=1 plain=1 split=1 gwin=1`**: one gated
+ measurement showed the band, the plot's region holds exactly the bounds the `RowSet` does,
+ the ungated average is zero (it read the whole window — `-1.36e-17 V` on the reference run),
+ the gated average differs (it read the band — `355.31 mV`, wherever in its period the
+ stream happened to be), and the pass booked `measure_gate_window` = the band moved onto the
+ stream's clock (`s0=`), which is the phase-free statement of *which* slice the gated walk
+ read. The second line, a second later, must read **`still=1 s0_moved=1`**: the band exactly
+ where it was put while the stream's clock moved under it — the *relative* behaviour the
+ gate exists for. The probe saves the bounds the way a drag end does; the exit dump's
+ **`gate:` line** is the round trip — a second run on the same config, with the probe unset,
+ must restore both measurements (`measurements: 2 average≈0 average≠0`) and read
+ `gate: on=1 bounds=[-0.300000..-0.100000]`. **Not in one run with the other measurement
+ probes** (`UDPSCOPE_PROBE_MEASURE_MS`, `_MEASURES_MS`, `_BANDAVG_MS`, `_LEVELS_MS`,
+ `_MEASUREPIN_MS`): they add measurements to one pane and these lines quote indexes 0 and 1.
+ The lines are in [probe-readings.md](probe-readings.md); the rule is in
+ [invariants-measurements.md](invariants-measurements.md).
+
- `[pan-probe] asked=[..] was=[..]`, `[pan-probe] after=[..] held=D asked=[..]`, then
`[pan-probe] zoom asked=[..] after=[..] span=S window=W limited=L`
(stderr, `UDPSCOPE_PROBE_PAN_MS`) — **`held` must be 1**: a gesture must not move a window
diff --git a/help/C/org.udpscope.app/cursors.page b/help/C/org.udpscope.app/cursors.page
index 44e2b41..ad2c8bd 100644
--- a/help/C/org.udpscope.app/cursors.page
+++ b/help/C/org.udpscope.app/cursors.page
@@ -63,6 +63,16 @@
A marker past the newest sample reads --. With only one marker
over data the row reads partial: a difference between a number and nothing
is not a number.
+
The hover crosshair also writes its two coordinates where the eye already
+ looks: the value where its horizontal line meets the y axis, and the time where its
+ vertical line meets the x axis — each in a small box among that axis' own tick labels,
+ in the axis' own format. Each box is centred on the line it names, and its background
+ covers what is behind it, so a tick label underneath never prints through the number.
+ The preference's Crosshair axis labels turns the pair off.
+
In a split row the horizontal line runs across every plot of the row,
+ not just the one under the pointer: the plots share one y range, so it is one value
+ along the whole row. It stops at the row's edges — another row has a scale of its
+ own.
In mix each marker also places a crossing annotation per trace,
which is what lets you see on the plot which sample a number came from. It is on
that sample, so when the marker falls between two it sits a little off its own
diff --git a/help/C/org.udpscope.app/layout.page b/help/C/org.udpscope.app/layout.page
index 6ad40ae..0caba3e 100644
--- a/help/C/org.udpscope.app/layout.page
+++ b/help/C/org.udpscope.app/layout.page
@@ -29,7 +29,8 @@
add a trace.
-
Press l, or pick Layout editor from the overflow menu, to add
+
Press l, pick Layout editor from the overflow menu, or pick
+ Edit the layout… from a plot's right-click menu, to add
and remove rows: a × appears at the top left of each plot and a +
on each boundary between rows, including above the first and below the last.
@@ -58,12 +59,39 @@
All the plots of a row, and of the whole figure, share one time window: zooming or
panning along time in any of them moves all of them, and the vertical cursors are drawn in
every plot. The plots of a split row also share one y range: the y labels are drawn at the
- left of the first plot only, the horizontal cursors sit at the same values in every plot, and a
+ left of the first plot only, the horizontal cursors sit at the same values in every plot, the
+ hover crosshair's horizontal line spans all of them, and a
zoom, pan, fit or fixed range in any of them applies to the whole row. The time labels
sit under the bottom row, and under any row above it whose columns differ from the row below. A row holds at most 4 plots and the figure at most 8
in total; the + buttons go grey at the limit.
+
+ A row full screen, and a second window
+
+
Every row carries a full-screen button in its top-right corner: it comes
+ out of hiding when the pointer moves near that corner and hides again when it leaves, so the
+ plot is not decorated by a control you are not reaching for. One click makes that row fill the plot area — the
+ other rows step aside with everything they were plotting — and the same button (now the
+ restore icon) brings the whole layout back exactly as it was. It is a view, not an edit:
+ nothing is saved, nothing is dropped, and the rows' traces, colours and cursors come back
+ where they were. The plot's right-click menu offers the same as Full screen this
+ figure (and Restore the layout while one is enlarged). A layout of a single
+ figure has nothing to enlarge *to*, so it carries no button.
Beside the toggle, shown the same way, a detach button moves the figure into a
+ window of its own — it leaves the main window's rows; the detached window carries an
+ attach button in the same corner, which returns the figure to the main window and
+ closes the window.
+
A plot's right-click menu has Open this plot in a new window: a second
+ window showing the same stream through a copy of that figure (its cells, their widths and
+ every trace), with its own axes following the main window's time window. The overflow menu's
+ New scope window opens the same kind of window with an empty figure. A signal
+ badge dragged onto a window's plot lands in that window (on the plot under the pointer), and
+ the signal page's Plot on section lists every window — one named group per
+ window, one button per plot of it. Closing one closes that view only; the main window keeps
+ acquiring.
+
+
+
Worth knowing
diff --git a/help/C/org.udpscope.app/measurements.page b/help/C/org.udpscope.app/measurements.page
index c6f19e0..541c3fc 100644
--- a/help/C/org.udpscope.app/measurements.page
+++ b/help/C/org.udpscope.app/measurements.page
@@ -80,8 +80,19 @@
shorter than a level's own resolution, or one read through hf — where
top/bottom fall back to the mean of each side of the split and
amplitude to the window's peak to peak.
+
Each row names the channel it measures: its number among the active
+ channels (1., 2., … the same number the signal's badge in the bar carries), its name, and
+ the type — 1. sine · average. The measurement dialog's signal dropdown
+ numbers its entries the same way. A channel list that changes (a signal switched off, a
+ new one enabled) renumbers, because the number is the answer to "which active channel is
+ this".
The on-plot marks come in two kinds and the mix is the reading: a level
- mark is a ruler at a value, a time mark is a marker at a position. An average
+ mark is a ruler at a value — its value written in a small box on the ruler, the way the
+ axis writes its own ticks — and a time mark is a marker at a position, with the
+ position's own badge beside it. A level ruler's badge carries its number; a time
+ marker's carries its time. Marks whose badges meet share one panel with one line each,
+ so a column of close readings is one boxed readout rather than a smear of texts; and a
+ box over a noisy trace is what keeps the number readable. An average
draws one level mark; a max draws a level mark and a time mark at the first
sample that reached it; peak to peak draws a ruler at each extreme and
amplitude a ruler at each of its two flat levels — the pair is the span, so
@@ -136,6 +147,25 @@
+
+ The gate
+
A measurement's dialog carries Gate enabled. Every measurement
+ with it set reads only the samples inside one shared band — violet, labelled gate,
+ carried by its own two draggable edges on the plots, which are not the cursors (those keep
+ meaning what the cursors pane reads). Measurements without it are untouched: they read the
+ whole window and share the walks they always shared.
+
The band is shown exactly while some measurement is gated — gate one and it appears,
+ gate or delete the last one and it goes — because it is one area that all of the gated
+ measurements share, not a property of any one of them. Drag an edge to resize it, grab the
+ middle to move it.
+
The band lives in the same relative time the axis labels use, so it stays where it was
+ put in both modes: live, a band at 300 ms to 100 ms ago stays put on screen while the data
+ streams beneath 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. A band with no samples in it — dragged
+ outside the view, or over a gap — reads -- like any empty window. The band's
+ position and each measurement's gate flag are saved with the session.
+
+
Example
On a 1 Hz ±1 sine: average=0, top=0.957988,
diff --git a/help/C/org.udpscope.app/signals.page b/help/C/org.udpscope.app/signals.page
index a642169..f688483 100644
--- a/help/C/org.udpscope.app/signals.page
+++ b/help/C/org.udpscope.app/signals.page
@@ -30,7 +30,8 @@
Once a signal is acquired its badge appears. Click it to open that signal's page: its
- details directly under its name — Unit, Gain and Offset,
+ title carries the signal's name and its channel number (2. sine), and its
+ details follow — Unit, Gain and Offset,
each with a pencil button that edits that one value, then the type, size, range, sample
rate and timing the stream declared — its colour and Plot on buttons for the
rows.
diff --git a/help/C/org.udpscope.app/status.page b/help/C/org.udpscope.app/status.page
index 3360273..9c641de 100644
--- a/help/C/org.udpscope.app/status.page
+++ b/help/C/org.udpscope.app/status.page
@@ -29,6 +29,11 @@
not a working badge. Of the two steady colours, grey is no stream at all or a
pipeline you stopped (the tooltip says which), and yellow is a source that
answered but has sent no configuration.
+
Left of the badge is the trigger circle, and it is the trigger's own
+ light: 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 entirely while the trigger mode is off — a
+ light for a mode you are not in is noise. Its tooltip names the state in words.
The state field is one word from a fixed set: connecting,
live, paused, armed, capturing,
event held, disarmed. Two states add one field each: in trigger
diff --git a/help/C/org.udpscope.app/trigger.page b/help/C/org.udpscope.app/trigger.page
index 60bf4e6..9e54055 100644
--- a/help/C/org.udpscope.app/trigger.page
+++ b/help/C/org.udpscope.app/trigger.page
@@ -169,7 +169,9 @@
The event cache, replay and compare
Every triggered acquisition is kept. Latches are written to the event cache
- (~/.cache/udpscope/events/<source>/) as a .npy of the
+ (~/.cache/udpscope/events/<source>/<configuration>/ -- one folder per
+ source and configuration, so two configurations of one source keep their events apart) as a
+ .npy of the
event, a .txt of what it was, and a .bmp picture drawn from the
samples. The Events block at the foot of the dock lists them, newest first, one
short row each: a marker for the event on screen, its title and its number. It is dimmed
diff --git a/src/event_store.cpp b/src/event_store.cpp
index bef25c6..20a5099 100644
--- a/src/event_store.cpp
+++ b/src/event_store.cpp
@@ -239,15 +239,75 @@ bool event_meta_parse(const std::string& text, EventRecord* out) {
std::string default_events_dir() { return std::string(cache_dir()) + kEventsSubdir; }
-EventStore::EventStore(std::string source, std::string base_dir) {
- if (source.empty()) return;
+namespace {
+
+/// A path component from a user-visible name: alphanumerics, `.`, `_` and `-` kept, everything else
+/// replaced. The same rule the layout store applies to a configuration name, and for the same
+/// reason -- a name typed with a slash in it must not be able to leave the directory.
+std::string sanitize_component(const std::string& in) {
std::string safe;
- for (char c : source) {
+ safe.reserve(in.size());
+ for (char c : in) {
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
c == '.' || c == '_' || c == '-';
safe.push_back(ok ? c : '_');
}
- dir_ = (base_dir.empty() ? default_events_dir() : base_dir) + "/" + safe;
+ return safe;
+}
+
+/// The directory a source's configuration writes into: `///`, or the
+/// source's own directory when it has no configuration name (a store built without one).
+std::string event_dir(const std::string& root, const std::string& source,
+ const std::string& configuration) {
+ std::string dir = root + "/" + sanitize_component(source);
+ if (!configuration.empty()) dir += "/" + sanitize_component(configuration);
+ return dir;
+}
+
+} // namespace
+
+EventStore::EventStore(std::string source, std::string base_dir, std::string configuration)
+ : root_(base_dir.empty() ? default_events_dir() : std::move(base_dir)),
+ source_(std::move(source)),
+ configuration_(std::move(configuration)) {
+ if (source_.empty()) {
+ root_.clear();
+ configuration_.clear();
+ return;
+ }
+ dir_ = event_dir(root_, source_, configuration_);
+ // **An upgrade keeps its events.** Before configurations came in, a source's events sat directly
+ // in `//`; the first store opened under a configuration adopts every loose event
+ // file it finds there, so the cache a user already has is the cache the new folder lists.
+ if (!configuration_.empty()) {
+ const std::string from = root_ + "/" + sanitize_component(source_);
+ DIR* d = ::opendir(from.c_str());
+ if (d != nullptr) {
+ bool moved = false;
+ while (dirent* e = ::readdir(d)) {
+ const std::string name = e->d_name;
+ if (name == "." || name == "..") continue;
+ const bool event_file = name.size() > 4 &&
+ (name.compare(name.size() - 4, 4, ".npy") == 0 ||
+ name.compare(name.size() - 4, 4, ".txt") == 0 ||
+ name.compare(name.size() - 4, 4, ".bmp") == 0);
+ if (!event_file) continue;
+ if (!moved) {
+ mkdirs(dir_);
+ moved = true;
+ }
+ std::rename((from + "/" + name).c_str(), (dir_ + "/" + name).c_str());
+ }
+ ::closedir(d);
+ }
+ }
+}
+
+bool EventStore::set_configuration(const std::string& configuration) {
+ if (source_.empty() || configuration == configuration_) return false;
+ configuration_ = configuration;
+ dir_ = event_dir(root_, source_, configuration_);
+ return true;
}
std::string EventStore::save(const ExportTable& table, const EventRecord& meta, std::size_t keep) {
diff --git a/src/event_store.hpp b/src/event_store.hpp
index f70b9e5..df353e8 100644
--- a/src/event_store.hpp
+++ b/src/event_store.hpp
@@ -45,11 +45,28 @@ std::string default_events_dir();
class EventStore {
public:
- /// `source` is the `-` pair, the same key the packet capture uses.
+ /// `source` is the `-` pair, the same key the packet capture uses. `configuration`
+ /// is the source's **named configuration** -- the same name the session files and the packet
+ /// capture use, `default` for a source that never saved one: an event's trigger, its channel set
+ /// and its units belong to the configuration it was latched under, so two configurations of one
+ /// source must not list each other's events. The directory is
+ /// `///`; an empty configuration is the legacy layout
+ /// (`//`), and the first construction under a configuration **adopts** the loose
+ /// event files it finds there, so an upgrade keeps the events it had.
+ ///
/// `base_dir` is the directory every source's own folder is created under; empty means
/// `/udpscope/events`. It comes from the preferences (item 9), because a user with a small
/// home partition and a second disk should not have to choose between events and free space.
- explicit EventStore(std::string source = {}, std::string base_dir = {});
+ explicit EventStore(std::string source = {}, std::string base_dir = {},
+ std::string configuration = {});
+
+ /// Points the store at another configuration of the same source, for the configuration switch
+ /// the session has: only the directory changes, and `true` means it did -- the caller then
+ /// refreshes whatever lists the cache. A store with no source (`empty_dir()`) never changes.
+ bool set_configuration(const std::string& configuration);
+
+ /// The configuration the store currently writes to (the constructor's, or `set_configuration`'s).
+ const std::string& configuration() const noexcept { return configuration_; }
/// How many events to keep, newest first. The oldest beyond it are deleted on every save; item 9's
/// preferences are where this becomes the user's number.
@@ -89,6 +106,12 @@ class EventStore {
void prune(std::size_t keep) const;
private:
+ /// The base directory every source folder lives under (an absolute path, or empty for a store
+ /// with no source), and the source's own sanitized key: kept so a configuration switch can
+ /// re-derive `dir_` without asking the caller for either again.
+ std::string root_;
+ std::string source_;
+ std::string configuration_;
std::string dir_;
};
diff --git a/src/layout_store.cpp b/src/layout_store.cpp
index 41e04e4..4e014e5 100644
--- a/src/layout_store.cpp
+++ b/src/layout_store.cpp
@@ -448,7 +448,8 @@ bool load_layout(const std::string& path, LayoutState& out) {
std::string line;
// What the last `[[...]]` header opened, so keys land in the right record.
- enum class Section { Top, Plot, Calibration, Unit, Color, Math, Measure } section = Section::Top;
+ enum class Section { Top, Plot, Window, WindowPlot, Calibration, Unit, Color, Math, Measure } section =
+ Section::Top;
int plot_count = 0;
bool plot_section_seen = false;
while (std::getline(in, line)) {
@@ -459,6 +460,14 @@ bool load_layout(const std::string& path, LayoutState& out) {
section = Section::Plot;
plot_section_seen = true;
st.rows.emplace_back();
+ } else if (t == "[[window]]") {
+ section = Section::Window;
+ st.windows.emplace_back();
+ } else if (t == "[[window.plot]]") {
+ // A window's own plot rows: the same shape as the main layout's `[[plot]]`, one level in.
+ if (st.windows.empty()) st.windows.emplace_back();
+ section = Section::WindowPlot;
+ st.windows.back().rows.emplace_back();
} else if (t == "[[calibration]]") {
section = Section::Calibration;
st.calibration.emplace_back();
@@ -573,6 +582,11 @@ bool load_layout(const std::string& path, LayoutState& out) {
}
} else if (key == "measure_stats") {
st.measure_stats = value == "true";
+ } else if (key == "gate_x0" || key == "gate_x1") {
+ try {
+ (key == "gate_x0" ? st.gate_x0 : st.gate_x1) = std::stod(value);
+ } catch (...) {
+ }
} else if (key == "dock") {
st.dock_shown = value == "true";
} else if (key.rfind("trigger_", 0) == 0) {
@@ -603,6 +617,35 @@ bool load_layout(const std::string& path, LayoutState& out) {
} catch (...) {
}
}
+ } else if (section == Section::Window && !st.windows.empty()) {
+ if (key == "name") {
+ st.windows.back().name = strip_quote(value);
+ } else if (key == "detached") {
+ st.windows.back().detached = value == "true";
+ }
+ } else if ((section == Section::WindowPlot && !st.windows.empty() &&
+ !st.windows.back().rows.empty())) {
+ // The window's rows take exactly the main layout's keys; `rows` is reached through the
+ // window record so the parser stays one shape.
+ LayoutState::Row& row = st.windows.back().rows.back();
+ if (key == "signals") {
+ std::vector names;
+ if (value.size() >= 2 && value.front() == '[' && value.back() == ']') {
+ parse_string_array(value.substr(1, value.size() - 2), names);
+ }
+ row.signals = names;
+ } else if (key == "weight") {
+ row.weight = std::strtod(value.c_str(), nullptr);
+ } else if (key == "row") {
+ try {
+ const int r = std::stoi(value);
+ if (r >= 0) row.row = r;
+ } catch (...) {
+ }
+ } else if (key == "width") {
+ const double w = std::strtod(value.c_str(), nullptr);
+ if (std::isfinite(w) && w > 0.0) row.width = w;
+ }
} else if (section == Section::Plot && !st.rows.empty()) {
if (key == "signals") {
std::vector names;
@@ -685,6 +728,8 @@ bool load_layout(const std::string& path, LayoutState& out) {
m.options.above = value == "true";
} else if (key == "show") {
m.options.show_on_plot = value == "true";
+ } else if (key == "gate") {
+ m.options.gate = value == "true";
}
} else if (section == Section::Calibration && !st.calibration.empty()) {
if (key == "name") {
@@ -762,6 +807,8 @@ bool save_layout(const std::string& path, const LayoutState& state) {
o << "]\n";
}
o << "measure_stats = " << (state.measure_stats ? "true" : "false") << "\n";
+ o << "gate_x0 = " << state.gate_x0 << "\n";
+ o << "gate_x1 = " << state.gate_x1 << "\n";
o << "dock = " << (state.dock_shown ? "true" : "false") << "\n";
// The trigger, settings and state both. Written even when it is off: that is the point of it.
o << "trigger_enabled = " << (state.trigger.enabled ? "true" : "false") << "\n";
@@ -793,8 +840,7 @@ bool save_layout(const std::string& path, const LayoutState& state) {
if (state.buffer_samples > 0) o << "buffer_samples = " << state.buffer_samples << "\n";
o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n";
if (state.capture_packets > 0) o << "capture_packets = " << state.capture_packets << "\n";
- for (const auto& row : state.rows) {
- o << "\n[[plot]]\n";
+ const auto write_row = [&](const LayoutState::Row& row) {
// A row of -1 is a state built without the split (nothing in the app does that now); leaving the
// key out keeps it readable as the one-cell-per-row file it is.
if (row.row >= 0) o << "row = " << row.row << "\nwidth = " << row.width << "\n";
@@ -804,6 +850,19 @@ bool save_layout(const std::string& path, const LayoutState& state) {
o << toml_escape(row.signals[i]);
}
o << "]\n";
+ };
+ for (const auto& row : state.rows) {
+ o << "\n[[plot]]\n";
+ write_row(row);
+ }
+ // The extra windows: written so a closed one can be reopened, never opened by a launch.
+ for (const auto& window : state.windows) {
+ o << "\n[[window]]\nname = " << toml_escape(window.name)
+ << "\ndetached = " << (window.detached ? "true" : "false") << "\n";
+ for (const auto& row : window.rows) {
+ o << "\n[[window.plot]]\n";
+ write_row(row);
+ }
}
for (const auto& cal : state.calibration) {
o << "\n[[calibration]]\nname = " << toml_escape(cal.name) << "\ngain = " << cal.gain
@@ -841,7 +900,8 @@ bool save_layout(const std::string& path, const LayoutState& state) {
<< "\nthreshold2 = " << m.options.threshold_pct2
<< "\nabove = " << (m.options.above ? "true" : "false") << "\nshow = "
<< (m.options.show_on_plot ? "true" : "false") << "\nnoise = " << m.options.noise_pct
- << "\nhf = " << m.options.hf_fc << "\n";
+ << "\nhf = " << m.options.hf_fc
+ << "\ngate = " << (m.options.gate ? "true" : "false") << "\n";
}
// A sibling temp file + rename: a crash mid-write leaves the old layout, not a
diff --git a/src/layout_store.hpp b/src/layout_store.hpp
index 0521429..9bbde2a 100644
--- a/src/layout_store.hpp
+++ b/src/layout_store.hpp
@@ -36,6 +36,19 @@ struct LayoutState {
double window_s = 0.0; ///< 0 keeps the default window
std::vector rows;
+ /// An **extra window**: a scope or detached window, remembered with the session but never opened
+ /// by a launch. A window is a view a user closed, and bringing one back is a decision -- the
+ /// *Reopen closed windows* entry asks for it, so a start never conjures windows onto a desktop
+ /// that does not want them (a tiling WM would tile them straight away). `name` is the window's own
+ /// short name (`plot 2`, `scope 3`), `detached` whether its figure came from a detach, and `rows`
+ /// the same plot rows the main layout carries: signals, row weights and cell widths.
+ struct Window {
+ std::string name;
+ bool detached = false;
+ std::vector rows;
+ };
+ std::vector windows;
+
struct Calibration {
std::string name;
double gain = 1.0;
@@ -87,6 +100,14 @@ struct LayoutState {
/// stream and mean nothing restored against another one.
bool measure_stats = false;
+ /// The measurement gate's **bounds** -- where the one shared band sits, in the display x the
+ /// axes read, the cursors' coordinate space, so they restore as the same relative offsets
+ /// ("half a second ago to a tenth ago") whatever the stream is doing. Whether the gate is on
+ /// is *not* here: it is a per-measurement option (`MeasureOptions::gate`, in `measures`
+ /// below), and the band is shown exactly while some measurement is gated.
+ double gate_x0 = 0.0;
+ double gate_x1 = 0.0;
+
/// The dock's shown state (the user's last word on the toolbar's Dock button). The one thing the dock
/// persists -- its 320 px width is the `.ui`'s. An older file's `side_tab` key is not read: the parser
/// ignores keys it does not know.
diff --git a/src/measure.hpp b/src/measure.hpp
index d31642a..b9f7eb8 100644
--- a/src/measure.hpp
+++ b/src/measure.hpp
@@ -93,6 +93,13 @@ struct MeasureOptions {
/// -- a filtered window cannot use the LOD pyramid, because a bucket aggregates unfiltered samples --
/// so a non-zero corner costs a full raw walk.
double hf_fc = 0.0;
+ /// Every type: measure only inside the **gate** -- the one shaded band shared by every gated
+ /// measurement, clipped to the window this measurement would otherwise describe. The band is
+ /// carried by its own pair of draggable bounds on the plots (see `RowSet::gate_x0()`), and it
+ /// is shown while *any* measurement is gated: it is one area, and its bounds are one pair of
+ /// hands. Off by default; a measurement with it off reads the whole window and shares the
+ /// walks it always shared.
+ bool gate = false;
};
/// One entry in the measurements pane, and everything needed to recompute it: the signal by
diff --git a/src/plot_host.cpp b/src/plot_host.cpp
index 3ddb812..f2e430d 100644
--- a/src/plot_host.cpp
+++ b/src/plot_host.cpp
@@ -40,6 +40,12 @@ constexpr plot::u32 kSearchColor = 0x7FB2FFu;
/// around. Sixteen identical lines with no way to tell which of them the readout is quoting is a
/// picture that cannot be read, so the current hit is brighter than its neighbours.
constexpr plot::u32 kSearchCurrentColor = 0xE8D37Fu;
+/// The measurement gate's colour: violet, a hue no instrument on screen uses -- the cursors'
+/// yellow and cyan, the measurements' green, the trigger's red and a hit's blue all name
+/// something else, and the gate's shaded band is the one area that must not read as any of
+/// them. The fill is the same hue at the low alpha a region reads as "area of interest" through.
+constexpr plot::u32 kGateColor = 0xB080FFu;
+constexpr float kGateFillAlpha = 0.15f;
/// The smallest difference a `TickFormat::Time` label can *write* at the magnitude `t` -- what
/// the last digit of a label at that magnitude is worth. `format_time` writes three significant
@@ -210,24 +216,64 @@ void RowSet::set_row_count(int rows) {
rebuild_all();
}
-void RowSet::carry_row(Row& dst, const Row& src) {
- dst.signal_name = src.signal_name;
- dst.name = src.name;
- dst.channel = src.channel;
- dst.color_index = src.color_index;
- // A layer travels with the slot that draws it: it *is* part of what that slot holds. The tick's
- // `sync_layers()` re-plans them all next frame in any case, which is what heals a row that has
- // moved under a plan made for the old one.
- dst.layer = src.layer;
- dst.slot_alpha = src.slot_alpha;
+void RowSet::toggle_maximize_figure_row(int fr) {
+ if (maximized_) {
+ // Back exactly as it was: every row's state first, then the arrangement it was indexed
+ // by, then the rebuild that deals the saved rows back into fresh plots.
+ for (int i = 0; i < saved_rows_ && i < kMaxRows; ++i) {
+ apply_row_state(storage_[static_cast(i)],
+ saved_row_states_[static_cast(i)]);
+ }
+ cells_per_row_ = saved_cells_;
+ cell_weights_ = saved_cell_weights_;
+ row_weights_ = saved_row_weights_;
+ rows_ = saved_rows_;
+ maximized_ = false;
+ saved_cells_.clear();
+ saved_cell_weights_.clear();
+ saved_row_weights_.clear();
+ saved_rows_ = 0;
+ rebuild_all();
+ return;
+ }
+ if (fr < 0 || fr >= figure_row_count()) return;
+ const int n = cells_in_row(fr);
+ const int first = first_plot_of_row(fr);
+ if (!(n > 0)) return;
+ // The arrangement and every row's persistent state, so the restore is the inverse and
+ // nothing the user plotted is lost to a view change.
+ for (int i = 0; i < kMaxRows; ++i) {
+ saved_row_states_[static_cast(i)] =
+ row_state_of(storage_[static_cast(i)]);
+ }
+ saved_cells_ = cells_per_row_;
+ saved_cell_weights_ = cell_weights_;
+ saved_row_weights_ = row_weights_;
+ saved_rows_ = rows_;
+ // The row's plots move to the front, in order: `rebuild_all()` re-deals `storage_[i]` to
+ // figure plot `i`, so a maximized figure row *is* those rows and nothing else. The states
+ // are copied from the saved snapshot, so a source row further down is not overwritten
+ // before it is read.
+ for (int k = 0; k < n; ++k) {
+ apply_row_state(storage_[static_cast(k)],
+ saved_row_states_[static_cast(first + k)]);
+ }
+ cells_per_row_.assign(1, n);
+ cell_weights_.assign(1, fr < static_cast(saved_cell_weights_.size())
+ ? saved_cell_weights_[static_cast(fr)]
+ : std::vector{});
+ // One figure row: equal weights of one. `apply_row_weights()` sizes the vector to the rows
+ // that exist, so this is the stated "one row takes the whole figure".
+ row_weights_.assign(1, 1.0f);
+ rows_ = std::min(n, static_cast(kMaxRows));
+ maximized_ = true;
+ rebuild_all();
+}
- dst.ruler_y = src.ruler_y;
- dst.ruler_b_y = src.ruler_b_y;
- // The slot's series stays where it is, but what it is bound to belonged to the row
- // this state came *from*. `bind()` re-resolves every slot next frame; clearing it here
- // is what stops the frame in between from drawing the moved row's data under the new
- // row's legend.
- for (SlotSeries& s : dst.slots) s.bind(nullptr);
+void RowSet::carry_row(Row& dst, const Row& src) {
+ // One spelling of "a row's persistent state": the save/restore the maximize toggle uses
+ // is the same copy, and a field added there is a field carried here.
+ apply_row_state(dst, row_state_of(src));
}
void RowSet::clear_row(Row& r) {
@@ -435,6 +481,21 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
// does not stop a drag -- the controller holds the marker for the gesture's life and follows the
// pointer between frames -- so excluding it from the hit test is the only place that can say no.
r.plot->set_cursor_selectable(r.trigger_marker, false);
+ // The measurement gate: one region per plot, parked until the gate is switched on (see
+ // `place_gate()`). Its bounds are the gate's own pair of instruments -- grabbable, the
+ // way a cursor is -- and its shaded fill is the area the user sees: the measurements
+ // describe what is inside it. The label names it, because a shaded band with no caption
+ // next to a plot full of readout marks is a band the user has to guess at.
+ plot::Region gate;
+ gate.x0 = kParked;
+ gate.x1 = kParked + 1.0;
+ gate.fill = plot::Color::hex(kGateColor, kGateFillAlpha);
+ gate.edge = plot::Color::hex(kGateColor);
+ gate.label = "gate";
+ r.gate_region = r.plot->add_region(gate);
+ // The crosshair's axis badges carry the set's flag onto the new plot: a rebuild keeps the
+ // setting, the way it keeps the cursor mode.
+ p.set_crosshair_axis_labels(crosshair_axis_labels_);
// No `set_x_follow(Live)`: the link group owns the window *and* the follow state, and
// `Figure::link_x()` refuses a member whose x axis still follows on its own.
}
@@ -499,6 +560,10 @@ void RowSet::rebuild_all() {
r.ruler_b_y = first.ruler_b_y;
}
place_cursors(r);
+ // The gate too: a rebuild builds new plots, whose regions are parked until this
+ // restates the shared bounds on them -- a row added while the gate was on would
+ // otherwise be the one row without the band.
+ place_gate(r);
if (trigger_marker_on_) {
r.plot->set_cursor_x(r.trigger_marker, trigger_marker_x_, plot::CursorPlacement::Free);
}
@@ -880,6 +945,16 @@ void RowSet::place_cursors(Row& r, plot::CursorPlacement placement) {
}
+void RowSet::set_cursors(double a, double b) {
+ if (a == cursor_a_x_ && b == cursor_b_x_) return;
+ cursor_a_x_ = a;
+ cursor_b_x_ = b;
+ // `Free`: the pair is the owner's already-committed position (a gesture in another window, or
+ // this window's own last sync); snapping it here would fight the hand still moving it.
+ for (int i = 0; i < rows_; ++i) place_cursors(row(i), plot::CursorPlacement::Free);
+ place_crossings();
+}
+
void RowSet::set_cursor_mode(CursorMode mode) {
if (mode == cursor_mode_) return;
cursor_mode_ = mode;
@@ -992,6 +1067,7 @@ void RowSet::set_measure_marks(const std::vector& marks) {
const std::size_t mi = static_cast(v++);
r.plot->set_cursor_x(r.measure_v[mi], m.value, plot::CursorPlacement::Free);
r.plot->set_cursor_color(r.measure_v[mi], plot::Color::hex(m.color));
+ r.plot->set_cursor_label(r.measure_v[mi], m.label);
}
}
// What the measurements left over is parked rather than kept: a stale readout at the old
@@ -1001,6 +1077,7 @@ void RowSet::set_measure_marks(const std::vector& marks) {
}
for (; v < kMeasureMarks; ++v) {
r.plot->set_cursor_x(r.measure_v[static_cast(v)], kParked);
+ r.plot->set_cursor_label(r.measure_v[static_cast(v)], "");
}
}
}
@@ -1315,6 +1392,63 @@ void RowSet::sync_cursors(int drag_row) {
place_crossings();
}
+void RowSet::place_gate(Row& r) {
+ if (!r.plot) return;
+ // Off is parked, the cursors' own convention for taking an instrument off screen: bounds
+ // the window cannot reach contribute no clipped rect, so nothing draws and -- because the
+ // controller's hit test reads the same clipped geometry -- nothing is grabbable either.
+ const double a = gate_on_ ? gate_x0_ : kParked;
+ const double b = gate_on_ ? gate_x1_ : kParked + 1.0;
+ r.plot->set_region(r.gate_region, a, b);
+}
+
+void RowSet::set_gate_visible(bool on) {
+ if (on && !gate_seeded_) reset_gate();
+ gate_on_ = on;
+ for (int i = 0; i < kMaxRows; ++i) place_gate(row(i));
+}
+
+void RowSet::set_gate(double x0, double x1) {
+ if (!std::isfinite(x0) || !std::isfinite(x1)) return;
+ gate_x0_ = std::min(x0, x1);
+ gate_x1_ = std::max(x0, x1);
+ gate_seeded_ = true;
+ for (int i = 0; i < kMaxRows; ++i) place_gate(row(i));
+}
+
+void RowSet::reset_gate() {
+ if (figure_ == nullptr) return;
+ const plot::Range x = figure_->x_link_range(x_group_);
+ if (!(x.span() > 0.0) || !std::isfinite(x.min) || !std::isfinite(x.max)) return;
+ // The middle half of whatever is on screen -- the same quarter/three-quarter answer
+ // `reset_cursors()` gives the vertical pair, so both instruments start in the same place
+ // a user looking for them knows to look.
+ gate_x0_ = x.min + 0.25 * x.span();
+ gate_x1_ = x.min + 0.75 * x.span();
+ gate_seeded_ = true;
+ for (int i = 0; i < kMaxRows; ++i) place_gate(row(i));
+}
+
+void RowSet::sync_gate(int drag_row) {
+ if (!gate_on_ || drag_row < 0 || drag_row >= rows_) return;
+ // With the gate off the plots hold the parked pair, whose bounds read back finite --
+ // adopting them would be the same echo `sync_cursors()` guards against.
+ Row& r = row(drag_row);
+ if (!r.plot) return;
+ const plot::Region& reg = r.plot->region(r.gate_region);
+ if (!std::isfinite(reg.x0) || !std::isfinite(reg.x1)) return;
+ if (reg.x0 == gate_x0_ && reg.x1 == gate_x1_) return;
+ gate_x0_ = reg.x0;
+ gate_x1_ = reg.x1;
+ gate_seeded_ = true;
+ // Every row's region is written from the shared bounds; the dragged row already holds
+ // them, and `set_region()` orders them, so a bound dragged across the other lands here
+ // as the swap the controller already made in its own plot.
+ for (int i = 0; i < rows_; ++i) {
+ if (i != drag_row) place_gate(row(i));
+ }
+}
+
void RowSet::clamp_to_data() {
if (!figure_) return;
// The rows share one window, so they share one bound: the union of what they hold.
@@ -1385,6 +1519,19 @@ void RowSet::pin_event_window(double pre_s, double post_s) {
figure_->set_x_link_range(x_group_, {-pre_s, post_s});
}
+void RowSet::pin_display_range(double x0, double x1) {
+ if (!(x1 > x0)) return;
+ window_s_ = std::max(1.0e-3, x1 - x0);
+ pinned_ = true;
+ pinned_pre_ = -x0;
+ pinned_post_ = x1;
+ if (!figure_) return;
+ // The same two statements `pin_event_window()` makes, from the range instead of the spans: the
+ // group stops following the live edge and is placed on the event's own window.
+ figure_->set_x_link_follow(x_group_, plot::XFollow::Off);
+ figure_->set_x_link_range(x_group_, {x0, x1});
+}
+
void RowSet::fit_all() {
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
@@ -1461,6 +1608,14 @@ void RowSet::set_cursors_visible(bool on) {
for (int i = 0; i < kMaxRows; ++i) place_cursors(row(i));
}
+void RowSet::set_crosshair_axis_labels(bool on) {
+ crosshair_axis_labels_ = on;
+ for (int i = 0; i < kMaxRows; ++i) {
+ Row& r = row(i);
+ if (r.plot != nullptr) r.plot->set_crosshair_axis_labels(on);
+ }
+}
+
void RowSet::reset_cursors() {
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
diff --git a/src/plot_host.hpp b/src/plot_host.hpp
index 45d62f5..203f1a3 100644
--- a/src/plot_host.hpp
+++ b/src/plot_host.hpp
@@ -133,6 +133,13 @@ class RowSet {
/// it keeps its position whatever the cursor toggle says.
plot::u32 trigger_marker = 0;
+ /// The measurement gate: one shaded interval per plot, whose bounds are the gate's own
+ /// pair of instruments -- not the cursors above, which stay what the cursors pane reads.
+ /// Every row carries its own `Region` because each plot shades its own axes rect, but
+ /// they all draw the one shared interval (`gate_x0_`/`gate_x1_` below), the way the
+ /// vertical cursors are one time on every row.
+ plot::u32 gate_region = 0;
+
/// The measurements' readout marks: a pool created with the plot (a `Plot` cannot add or
/// drop a cursor after the fact) and parked unless a measurement is using the slot.
std::array measure_h{};
@@ -211,6 +218,18 @@ class RowSet {
/// Shows `rows` figure rows of one cell each (so `rows` plots, any split undone). A changed
/// arrangement rebuilds the figure, since a `Figure`'s shape is fixed at construction.
void set_row_count(int rows);
+
+ /// One figure row filling the plot area, and back -- the "full screen this row" toggle.
+ ///
+ /// The arrangement, the weights and **every row's state** are saved on the way in and
+ /// restored on the way out, so a maximize is a *view*: nothing a user plotted moves, and
+ /// nothing survives a restore that was not there before it. Volatile like a zoom -- the
+ /// session keeps the arrangement, not the view. Calling it while maximized restores, so a
+ /// click on the eye-catching button is its own way back; a figure row outside the current
+ /// arrangement (the maximized one, by definition a single row) is ignored when not
+ /// maximized.
+ void toggle_maximize_figure_row(int fr);
+ bool figures_maximized() const noexcept { return maximized_; }
/// Inserts an empty single-cell figure row at `at` (0 = above the first row,
/// `figure_row_count()` = below the last), pushing the rows at and below it down with their
/// cells. Returns false at `kMaxRows` plots.
@@ -322,6 +341,11 @@ class RowSet {
void set_y_range(int row, double lo, double hi);
void set_cursors_visible(bool on);
+ /// The crosshair's axis badges (the y and x values the crosshair's lines meet the axes at,
+ /// drawn in the label strips): one flag for the whole set, restated to every new plot by
+ /// `install()` -- a rebuilt row keeps the setting, the way it keeps the cursor mode.
+ void set_crosshair_axis_labels(bool on);
+ bool crosshair_axis_labels() const noexcept { return crosshair_axis_labels_; }
/// What the cursors are (see `CursorMode`). Switching parks the instruments the mode
/// does not use rather than removing them: a `Plot` cannot drop a cursor.
void set_cursor_mode(CursorMode mode);
@@ -331,6 +355,16 @@ class RowSet {
void place_crossings();
CursorMode cursor_mode() const noexcept { return cursor_mode_; }
+ /// Writes both vertical cursors at once: the two-time pair is one instrument with one owner
+ /// (the main window's row set), and a scope window is written from it each frame -- reading it
+ /// back from that window's own plots is what lets a drag *there* move the same pair.
+ void set_cursors(double a, double b);
+
+ /// The trigger marker's position and whether it is up: what a scope window is written from, the
+ /// same way it is written from the cursor pair.
+ double trigger_marker_x() const noexcept { return trigger_marker_x_; }
+ bool trigger_marker_visible() const noexcept { return trigger_marker_on_; }
+
/// The two vertical cursors' positions, in the **display** x the axis reads -- not stream
/// seconds, which they coincided with only while `display_epoch()` was always 0. A live view
/// is newest-relative now, so a cursor left at -1.5 s keeps meaning "1.5 s ago" and the data
@@ -369,12 +403,20 @@ class RowSet {
/// One measurement's readout mark on a row: a *level* (drawn as a horizontal ruler) or a
/// *time* (a vertical marker). Which one a measurement needs is the measurement's own
- /// business; the row is the one that plots its signal.
+ /// business; the row is the one that plots its signal. A time mark's `label` is the badge
+ /// text the plot draws beside it (its own time, the pane's formatter), so a column of
+ /// marks reads as numbers and not as bare lines; a level ruler writes its own value, so
+ /// its label stays empty.
struct MeasureMark {
int row = 0;
+ /// The signal the mark belongs to, so a *scope window* can find the row that plots it (its
+ /// row numbering is its own): a mark that never carried the name could only be placed in a
+ /// copy by index, which a window's own layout editor invalidates.
+ std::string signal;
bool level = false;
double value = 0.0;
plot::u32 color = 0;
+ std::string label;
};
/// Places the measurements' marks and parks every pool slot they do not use. The marks are
@@ -494,6 +536,33 @@ class RowSet {
/// Called once a frame from the tick, with `drag_row` < 0 when no cursor is held.
void sync_cursors(int drag_row);
+ // --- the measurement gate -------------------------------------------------
+
+ /// Shows (or hides) the gate on every row. Bounds that were never seeded are seeded
+ /// first (`reset_gate()`), so the first enable puts the band under the data instead of
+ /// parking it wherever zero is.
+ void set_gate_visible(bool on);
+ /// Whether the gate is on. One flag for the whole set, like the cursors' own: a per-row
+ /// copy is something a rebuild can clear behind the user's back.
+ bool gate_visible() const noexcept { return gate_on_; }
+ /// The gate's bounds, in the **display** x the axes read -- the cursors' own coordinate
+ /// space. A live view is newest-relative, so a gate left at `[-0.5, -0.1]` keeps meaning
+ /// "half a second ago to a tenth of a second ago" and the data flows under it; a held
+ /// event's stamps are edge-relative, so there the band names a fixed slice of the frozen
+ /// frame. Either way it stays where it was put, which is what a gate has to do.
+ double gate_x0() const noexcept { return gate_x0_; }
+ double gate_x1() const noexcept { return gate_x1_; }
+ /// Restates the gate's bounds (ordered, both finite) and redraws every row.
+ void set_gate(double x0, double x1);
+ /// Puts the gate over the middle half of the current x window -- where
+ /// `reset_cursors()` puts the vertical pair -- and remembers it as seeded.
+ void reset_gate();
+ /// Adopts the gate bounds a gesture is moving, on `drag_row` (the row whose controller
+ /// holds a region), and mirrors them to every other row. Same contract as
+ /// `sync_cursors()`: reading outside a drag reads our own echo. Called once a frame from
+ /// the tick, with `drag_row` < 0 when no region drag is open.
+ void sync_gate(int drag_row);
+
/// Puts both vertical cursors at a quarter and three quarters of the x window, and
/// the two rulers at a third and two thirds of each row's y range. Also switches the
/// cursors on, so the button cannot look dead.
@@ -515,6 +584,13 @@ class RowSet {
/// 0, so nothing outside the pre+post window is visible.
void pin_event_window(double pre_s, double post_s);
+ /// The same pin, stated as the **range** a second view mirrors: the caller has the display
+ /// range (`x0 < x1`) and not the spans -- a scope window takes `pinned_range()` from the main
+ /// window and hands it on. `pin_event_window()` takes *spans*, and passing it a range's signed
+ /// `min` turned the pin inside out (a scope in Held mode pinned to `[+pre, +post]`: no trace
+ /// and no x labels). A degenerate or inverted range pins nothing.
+ void pin_display_range(double x0, double x1);
+
/// Fits every row's y axis to the data it holds, and puts x back on the whole time window --
/// `fit_x()`'s x half exactly, so the two cannot drift apart. Like it, **this does not change
/// the time window**: the y axes are fitted to their data, x is merely un-zoomed.
@@ -585,6 +661,46 @@ class RowSet {
/// objects the traces point at, and those belong to the row's *position*, not to its
/// contents. `rebuild_all()` re-deals them straight after.
static void carry_row(Row& dst, const Row& src);
+ /// One row's persistent state, without the plot-bound `slots`: a `SlotSeries` owns a
+ /// `unique_ptr`, so a `Row` is not copy-assignable, and the state a save needs is exactly
+ /// what `carry_row()` copies. The maximize toggle saves it for every row on the way in and
+ /// re-applies it on the way out, so a view change moves no signal.
+ struct RowState {
+ std::array signal_name;
+ std::array name;
+ std::array channel;
+ std::array color_index;
+ std::array layer;
+ std::array slot_alpha;
+ double ruler_y = 0.0;
+ double ruler_b_y = 0.0;
+ };
+ static RowState row_state_of(const Row& r) {
+ RowState s;
+ s.signal_name = r.signal_name;
+ s.name = r.name;
+ s.channel = r.channel;
+ s.color_index = r.color_index;
+ s.layer = r.layer;
+ s.slot_alpha = r.slot_alpha;
+ s.ruler_y = r.ruler_y;
+ s.ruler_b_y = r.ruler_b_y;
+ return s;
+ }
+ static void apply_row_state(Row& r, const RowState& s) {
+ r.signal_name = s.signal_name;
+ r.name = s.name;
+ r.channel = s.channel;
+ r.color_index = s.color_index;
+ r.layer = s.layer;
+ r.slot_alpha = s.slot_alpha;
+ r.ruler_y = s.ruler_y;
+ r.ruler_b_y = s.ruler_b_y;
+ // What it was bound to belonged to the row this state came *from*; the tick's `bind()`
+ // re-resolves every slot next frame, and clearing stops the frame in between from
+ // drawing the moved row's data under the new row's legend (the `carry_row()` rule).
+ for (SlotSeries& slot : r.slots) slot.bind(nullptr);
+ }
/// Back to an empty row: nothing plotted, the default palette entries, no cursors.
static void clear_row(Row& r);
@@ -596,6 +712,9 @@ class RowSet {
/// at the end of the gesture (`Controller::end_drag()`), and a value read out of the plot
/// mid-drag has already been through that act.
void place_cursors(Row& r, plot::CursorPlacement placement = plot::CursorPlacement::Snapped);
+ /// Writes a row's gate region from the shared bounds -- parked outside the axis when the
+ /// gate is off, the cursors' own convention for taking an instrument off screen.
+ void place_gate(Row& r);
std::unique_ptr figure_;
/// The one x link group every visible row belongs to. Groups are numbered from 1.
@@ -635,6 +754,17 @@ class RowSet {
std::vector spans_;
CursorMode cursor_mode_ = CursorMode::Vertical;
bool cursors_on_ = false;
+ bool crosshair_axis_labels_ = true;
+ /// The maximize toggle's saved arrangement (see `toggle_maximize_figure_row()`): every
+ /// row's persistent state, the cells, the weights and the plot count, kept only while
+ /// `maximized_`. States, not `Row`s: a `Row` is not copyable (its slots own a
+ /// `unique_ptr`) and the figure handles belong to the position anyway.
+ bool maximized_ = false;
+ std::array(kMaxRows)> saved_row_states_{};
+ std::vector saved_cells_;
+ std::vector> saved_cell_weights_;
+ std::vector saved_row_weights_;
+ int saved_rows_ = 0;
bool relative_x_ = false;
double x_origin_ = 0.0;
/// What was last *written* to each row's own vertical cursors. A row is only evidence of a
@@ -648,6 +778,13 @@ class RowSet {
/// The one stream time each vertical cursor is at, whatever row's copy was last moved.
double cursor_a_x_ = 0.0;
double cursor_b_x_ = 0.0;
+ /// The gate's shared interval and its on/off flag (`gate_visible()`). `gate_seeded_`
+ /// distinguishes "never placed" from "placed at the window's own origin", which the
+ /// first enable needs: seeding must put the band over the data, not over x = 0.
+ bool gate_on_ = false;
+ bool gate_seeded_ = false;
+ double gate_x0_ = 0.0;
+ double gate_x1_ = 0.0;
std::array(kMaxRows)> row_dirty_{};
};
diff --git a/src/settings.cpp b/src/settings.cpp
index c9bbdf8..fde4df5 100644
--- a/src/settings.cpp
+++ b/src/settings.cpp
@@ -70,6 +70,8 @@ Settings load_settings() {
if (mb >= 16) s.memory_limit_mb = static_cast(std::min(mb, 1000000000L));
} else if (key == "theme") {
if (value == "auto" || value == "light" || value == "dark") s.theme = value;
+ } else if (key == "crosshair_axis_labels") {
+ s.crosshair_axis_labels = parse_bool(value, s.crosshair_axis_labels);
} else if (key == "contextual_help") {
s.contextual_help = parse_bool(value, s.contextual_help);
} else if (key == "tour_seen") {
@@ -94,6 +96,7 @@ bool save_settings(const Settings& settings) {
// second. `read_rows()` in the preferences starts from `load_settings()`, so a dialog save carries
// them through untouched.
out << "contextual_help = " << (settings.contextual_help ? "true" : "false") << "\n";
+ out << "crosshair_axis_labels = " << (settings.crosshair_axis_labels ? "true" : "false") << "\n";
out << "tour_seen = " << (settings.tour_seen ? "true" : "false") << "\n";
out.flush();
return out.good();
diff --git a/src/settings.hpp b/src/settings.hpp
index a302949..93fd165 100644
--- a/src/settings.hpp
+++ b/src/settings.hpp
@@ -39,6 +39,11 @@ struct Settings {
/// "auto", "light" or "dark": what the interface should look like. Kept as the three names libadwaita
/// uses, so the file says what the setting is rather than what an enum happens to be numbered.
std::string theme = "auto";
+ /// The two small badges the crosshair writes in the label strips -- the y value where its
+ /// horizontal line meets the y axis, the x time where its vertical line meets the x axis.
+ /// **On by default**: it is the axis reading an oscilloscope puts where the eye already is,
+ /// and off is the decision that needs a reason. Applies as it changes, like the theme.
+ bool crosshair_axis_labels = true;
/// The contextual-help layer: a small "?" beside each surface it can explain. **Off by default** --
/// a first run shows the interface as a user expects it and the layer is asked for, from the Help
/// menu or from the chooser's own help menu, by whoever wants it; on is then a decision that
diff --git a/src/ui.cpp b/src/ui.cpp
index c36a031..d6fdfe2 100644
--- a/src/ui.cpp
+++ b/src/ui.cpp
@@ -547,6 +547,25 @@ GtkWidget* make_area(App& app) {
gtk_overlay_set_child(GTK_OVERLAY(app.area_overlay), widget);
gtk_widget_set_hexpand(app.area_overlay, true);
gtk_widget_set_vexpand(app.area_overlay, true);
+ // The corner toggles are revealed by proximity, so the pointer's position is kept here,
+ // on the **overlay** and not on the GL area: a revealed toggle sits *under* the pointer,
+ // and the event still travels the ancestor -- a leave on the GL widget would fire the
+ // moment the button appeared and hide it again (the flicker the overlay avoids). `leave`
+ // here means the pointer left the plot altogether, which is when the toggles go away.
+ GtkEventController* motion = gtk_event_controller_motion_new();
+ g_signal_connect(motion, "motion", G_CALLBACK(+[](GtkEventControllerMotion*, double x,
+ double y, gpointer user) {
+ App& a = *static_cast(user);
+ a.area_pointer_x = x;
+ a.area_pointer_y = y;
+ a.area_pointer_valid = true;
+ }),
+ &app);
+ g_signal_connect(motion, "leave", G_CALLBACK(+[](GtkEventControllerMotion*, gpointer user) {
+ static_cast(user)->area_pointer_valid = false;
+ }),
+ &app);
+ gtk_widget_add_controller(app.area_overlay, GTK_EVENT_CONTROLLER(motion));
return app.area_overlay;
}
@@ -559,6 +578,191 @@ void relayout_areas(App& app) {
if (app.area != nullptr) app.area->set_figure(&app.rows->figure());
}
+/// A second top-level window: the same stream, another figure, its own `RowSet`. The main
+/// window's tick is its bind/acquire/repaint pump (see `tick()`), so the two never disagree
+/// about the data or the display mode. `figure_row` names the main window's figure to copy --
+/// its cells, their width shares and every slot's signal in its colour -- because "open this
+/// one big" is the reason a user reaches for a second window; -1 opens an empty scope.
+void open_scope_window(App& app, int figure_row, bool detached) {
+ if (figure_row >= app.rows->figure_row_count()) figure_row = -1;
+ auto scope = std::make_unique();
+ scope->rows = std::make_unique();
+ if (figure_row >= 0) {
+ const int cells = app.rows->cells_in_row(figure_row);
+ scope->rows->set_cells_per_row(std::vector(1, cells));
+ if (cells > 1) scope->rows->set_cell_weights(0, app.rows->cell_weights(figure_row));
+ const int first = app.rows->first_plot_of_row(figure_row);
+ for (int k = 0; k < cells; ++k) {
+ const RowSet::Row& src = app.rows->row(first + k);
+ for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
+ const std::size_t si = static_cast(s);
+ if (src.signal_name[si].empty()) continue;
+ scope->rows->assign(k, s, src.signal_name[si], src.name[si], src.color_index[si]);
+ }
+ }
+ }
+ scope->rows->set_time_window(app.rows->time_window());
+ // The preference travels: the scope draws the same chrome the main window does, unless
+ // the setting is off.
+ scope->rows->set_crosshair_axis_labels(app.settings.crosshair_axis_labels);
+ scope->area = plot::gtk4::PlotArea::create(scope->placeholder);
+ scope->area->set_figure(&scope->rows->figure());
+ scope->widget = scope->area->widget();
+ gtk_widget_set_hexpand(scope->widget, true);
+ gtk_widget_set_vexpand(scope->widget, true);
+ gtk_widget_set_size_request(scope->widget, 320, 160);
+ // The window's short name, what the signal page's *Plot on* groups carry: "plot N" for a copy
+ // (of the main window's figure N), "scope N" -- numbered app-wide -- for an empty one.
+ scope->name = figure_row >= 0 ? "plot " + std::to_string(figure_row + 1)
+ : "scope " + std::to_string(++app.scope_seq);
+ // A drop target like the main area's: a signal badge dragged onto this window's plot lands in
+ // that window's own row set, on the cell the pointer is over.
+ scope->drop = gtk_drop_target_new(G_TYPE_STRING, GDK_ACTION_COPY);
+ g_object_set_data(G_OBJECT(scope->drop), "udpscope-scope", scope.get());
+ g_signal_connect(scope->drop, "drop", G_CALLBACK(on_scope_drop_channel), &app);
+ gtk_widget_add_controller(scope->widget, GTK_EVENT_CONTROLLER(scope->drop));
+ // The window's own frame pump: a tick callback on its area asks GTK for one frame per tick,
+ // which is what keeps *this* toplevel's frame clock running. The main window's tick binds and
+ // queues the render, but a second toplevel whose clock is never advanced keeps its queue full
+ // and presents nothing -- a blank plot with axes nowhere to be seen.
+ gtk_widget_add_tick_callback(scope->widget,
+ +[](GtkWidget* widget, GdkFrameClock*, gpointer) -> gboolean {
+ gtk_gl_area_queue_render(GTK_GL_AREA(widget));
+ return G_SOURCE_CONTINUE;
+ },
+ nullptr, nullptr);
+ // The window's own right-click menu: its y scales, its traces, its removals -- the same
+ // actions the main window's plot menu carries, aimed at **this window's** row set.
+ GtkGesture* right = gtk_gesture_click_new();
+ gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(right), 3);
+ g_signal_connect(right, "pressed", G_CALLBACK(on_scope_right_click), &app);
+ gtk_widget_add_controller(scope->widget, GTK_EVENT_CONTROLLER(right));
+ scope->detached = detached;
+ scope->window = GTK_WINDOW(gtk_window_new());
+ std::string title = figure_row >= 0
+ ? "udpscope \u2014 plot " + std::to_string(figure_row + 1)
+ : std::string("udpscope \u2014 scope");
+ if (detached) title += " (detached)";
+ gtk_window_set_title(scope->window, title.c_str());
+ gtk_window_set_default_size(scope->window, 900, 500);
+ // The overlay holds every corner control this window has: the attach button (detached windows
+ // only), the layout toggle, and the layout editor's own buttons. It is **always** created, not
+ // only for detached windows, because a scope is a view with its own arrangement and every one of
+ // them can be rearranged. The motion controller lives on the overlay, so a revealed button sits
+ // *under* the pointer and the event still travels the ancestor -- a leave on the GL widget would
+ // fire the moment the button appeared and hide it again.
+ scope->overlay = gtk_overlay_new();
+ gtk_overlay_set_child(GTK_OVERLAY(scope->overlay), scope->widget);
+ gtk_window_set_child(scope->window, scope->overlay);
+ GtkEventController* motion = gtk_event_controller_motion_new();
+ g_signal_connect(motion, "motion", G_CALLBACK(+[](GtkEventControllerMotion*, double x,
+ double y, gpointer user) {
+ App::ScopeWindow* scope = static_cast(user);
+ scope->pointer_x = x;
+ scope->pointer_y = y;
+ scope->pointer_valid = true;
+ }),
+ scope.get());
+ g_signal_connect(motion, "leave", G_CALLBACK(+[](GtkEventControllerMotion*, gpointer user) {
+ static_cast(user)->pointer_valid = false;
+ }),
+ scope.get());
+ gtk_widget_add_controller(scope->overlay, GTK_EVENT_CONTROLLER(motion));
+ if (detached) {
+ // Only a detached window has something to put back.
+ GtkWidget* attach = gtk_button_new_from_icon_name("view-restore-symbolic");
+ gtk_widget_set_tooltip_text(attach, "attach this figure back to the main window");
+ gtk_widget_add_css_class(attach, "circular");
+ gtk_widget_add_css_class(attach, "osd");
+ gtk_widget_set_size_request(attach, kLayoutButtonPx, kLayoutButtonPx);
+ gtk_widget_set_visible(attach, FALSE);
+ g_object_set_data(G_OBJECT(attach), "udpscope-scope", scope.get());
+ g_signal_connect(attach, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
+ App& a = *static_cast(user);
+ auto* scope =
+ static_cast(g_object_get_data(G_OBJECT(b),
+ "udpscope-scope"));
+ if (scope == nullptr) return;
+ outlive_the_rebuild(GTK_WIDGET(b));
+ attach_scope_window(a, *scope);
+ }),
+ &app);
+ gtk_overlay_add_overlay(GTK_OVERLAY(scope->overlay), attach);
+ scope->attach_buttons.push_back(attach);
+ }
+ // The layout toggle: the editor's door, the same one the main window's overflow menu carries,
+ // revealed by the same proximity rule.
+ scope->layout_toggle = gtk_button_new_from_icon_name("view-grid-symbolic");
+ gtk_widget_set_tooltip_text(scope->layout_toggle,
+ "layout editor: add and remove figure rows in this window");
+ gtk_widget_add_css_class(scope->layout_toggle, "circular");
+ gtk_widget_add_css_class(scope->layout_toggle, "osd");
+ gtk_widget_set_size_request(scope->layout_toggle, kLayoutButtonPx, kLayoutButtonPx);
+ gtk_widget_set_visible(scope->layout_toggle, FALSE);
+ g_object_set_data(G_OBJECT(scope->layout_toggle), "udpscope-scope", scope.get());
+ g_signal_connect(scope->layout_toggle, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
+ App& a = *static_cast(user);
+ auto* scope =
+ static_cast(g_object_get_data(G_OBJECT(b),
+ "udpscope-scope"));
+ if (scope == nullptr) return;
+ set_scope_layout_editing(a, *scope, !scope->layout_editing);
+ }),
+ &app);
+ gtk_overlay_add_overlay(GTK_OVERLAY(scope->overlay), scope->layout_toggle);
+ // The window's keyboard: the same accelerators the main window's keys carry. `L` acts on *this*
+ // window's layout; every other action reaches the application (see `on_scope_key()`).
+ GtkEventController* keys = gtk_event_controller_key_new();
+ g_signal_connect(keys, "key-pressed", G_CALLBACK(on_scope_key), &app);
+ gtk_widget_add_controller(GTK_WIDGET(scope->window), GTK_EVENT_CONTROLLER(keys));
+ if (GtkApplication* application = gtk_window_get_application(GTK_WINDOW(app.window))) {
+ gtk_window_set_application(scope->window, application);
+ }
+ // The vector owns the struct, so the window has to remove itself. The erase is deferred to
+ // an idle: the destroy emission runs before the GL child is finalized, and the area
+ // wrapper (`PlotArea::create()`'s, owned by the widget) holds references into this struct's
+ // placeholder plot and row set -- freeing them under the finalization would be a
+ // use-after-free. One main-loop turn later the widget is gone and the erase is safe.
+ g_signal_connect(scope->window, "destroy", G_CALLBACK(+[](GtkWindow* window, gpointer user) {
+ App* a = static_cast(user);
+ for (auto it = a->scopes.begin(); it != a->scopes.end(); ++it) {
+ if ((*it)->window != window) continue;
+ App::ScopeWindow* victim = it->release();
+ // A closed window is remembered -- its arrangement is the session's, and
+ // *Reopen closed windows* brings it back. Attaching is the exception: the
+ // figure is on the main window again, so a reopen entry would be a
+ // duplicate of what is already on screen.
+ if (victim->remember_on_close) remember_closed_window(*a, *victim);
+ a->scopes.erase(it);
+ g_idle_add_full(G_PRIORITY_DEFAULT_IDLE,
+ +[](gpointer p) -> gboolean {
+ delete static_cast(p);
+ return G_SOURCE_REMOVE;
+ },
+ victim, nullptr);
+ break;
+ }
+ // The signal page's *Plot on* groups carry a button per window: with one gone,
+ // the pages are rebuilt before anything can read a dangling scope pointer
+ // (the struct itself is deleted on the idle above).
+ signal_pages_sync(*a);
+ }),
+ &app);
+ gtk_window_present(scope->window);
+ app.scopes.push_back(std::move(scope));
+}
+
+/// The plot menu's "open this plot in a new window": the figure row the clicked plot belongs
+/// to, copied into a scope window; the popover dismisses like every other row's action.
+void on_open_scope_row(GtkButton* button, gpointer user) {
+ App* app = static_cast(user);
+ const int row = row_key(GTK_WIDGET(button));
+ const int fr = app->rows->figure_row_of(row);
+ open_scope_window(*app, fr);
+ GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(button), "udpscope-popover"));
+ if (popover != nullptr) gtk_popover_popdown(popover);
+}
+
// --- panel rebuilds ---------------------------------------------------------
/// Defined below, forward-declared because placing or freeing a trace slot has to
@@ -891,17 +1095,665 @@ void on_layout_cell_insert(GtkButton* button, gpointer user) {
///
/// What each button does with the look is the caller's: they differ in alignment, sensitivity, the data they
/// carry and the gesture they take.
-GtkWidget* layout_overlay_button(App& app, const char* icon, const char* tooltip) {
+/// The look every overlay button shares -- icon, the two CSS classes that make a small round
+/// on-screen-display control, and the size -- in one place, because the editor's buttons and
+/// the full-screen toggles must not drift apart.
+GtkWidget* overlay_button(App& app, const char* icon, const char* tooltip,
+ std::vector& list) {
GtkWidget* b = gtk_button_new_from_icon_name(icon);
gtk_widget_set_tooltip_text(b, tooltip);
gtk_widget_add_css_class(b, "circular");
gtk_widget_add_css_class(b, "osd");
gtk_widget_set_size_request(b, kLayoutButtonPx, kLayoutButtonPx);
gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), b);
- app.layout_buttons.push_back(b);
+ list.push_back(b);
return b;
}
+/// The editor's own set: on the overlay only while the editor is up, cleared with it.
+GtkWidget* layout_overlay_button(App& app, const char* icon, const char* tooltip) {
+ return overlay_button(app, icon, tooltip, app.layout_buttons);
+}
+
+/// Forward declarations: the toggles' handlers are defined after the builder that wires them.
+void on_maximize_clicked(GtkButton* b, gpointer user);
+void on_detach_clicked(GtkButton* b, gpointer user);
+void on_fullscreen_row(GtkButton* b, gpointer user);
+void rebuild_maximize_buttons(App& app);
+
+/// The one action behind both the row's toggle and the plot menu's entry: the figure row goes
+/// full screen, or -- while one is -- the saved arrangement comes back. The figure is handed
+/// over at once (the same path a cell insert takes), the toggles are rebuilt because their
+/// number and icon follow the state, and the signal panels are rebuilt because a maximize
+/// renumbers which row shows what.
+void toggle_fullscreen(App& app, int fr) {
+ app.rows->toggle_maximize_figure_row(fr);
+ if (app.area != nullptr) app.area->set_figure(&app.rows->figure());
+ rebuild_maximize_buttons(app);
+ rebuild_signal_panels(app);
+ if (app.layout_editing) rebuild_layout_editor(app);
+}
+
+/// One full-screen toggle per figure row: the row to enlarge is the row you are looking at,
+/// so the control sits on the row itself rather than behind the layout editor. While a row
+/// is maximized the single remaining button is the way back. A layout of one figure has
+/// nothing to maximize *to*, so it carries no toggle -- except while that one figure is
+/// maximized, where the button is the way out.
+void rebuild_maximize_buttons(App& app) {
+ if (app.area_overlay == nullptr) return;
+ for (GtkWidget* b : app.maximize_buttons) {
+ gtk_overlay_remove_overlay(GTK_OVERLAY(app.area_overlay), b);
+ }
+ app.maximize_buttons.clear();
+ for (GtkWidget* b : app.detach_buttons) {
+ gtk_overlay_remove_overlay(GTK_OVERLAY(app.area_overlay), b);
+ }
+ app.detach_buttons.clear();
+ app.maximize_buttons_rows = app.rows->figure_row_count();
+ const bool maximized = app.rows->figures_maximized();
+ if (!maximized && app.maximize_buttons_rows <= 1) return;
+ for (int fr = 0; fr < app.maximize_buttons_rows; ++fr) {
+ GtkWidget* b = overlay_button(app,
+ maximized ? "view-restore-symbolic" : "view-fullscreen-symbolic",
+ maximized ? "back to the whole layout"
+ : "show this figure full screen",
+ app.maximize_buttons);
+ g_object_set_data(G_OBJECT(b), kMaximizeKey, GINT_TO_POINTER(fr + 1));
+ g_signal_connect(b, "clicked", G_CALLBACK(on_maximize_clicked), &app);
+ // The detach button sits beside it, one rule behind it: a layout of one figure detaches
+ // nothing (the main window would be left with nothing to draw), and a maximized one has
+ // hidden its own rows anyway.
+ if (!maximized && app.maximize_buttons_rows >= 2) {
+ GtkWidget* d = overlay_button(app, "send-to-symbolic",
+ "detach this figure to a window of its own",
+ app.detach_buttons);
+ gtk_widget_set_name(d, "udpscope-detach-button");
+ g_object_set_data(G_OBJECT(d), kMaximizeKey, GINT_TO_POINTER(fr + 1));
+ g_signal_connect(d, "clicked", G_CALLBACK(on_detach_clicked), &app);
+ }
+ }
+}
+
+/// Restates where the toggles sit, and rebuilds them when the figure row count changed (a
+/// resize, a flush, a maximize). Placed from the same `FigureLayout` rects the editor's
+/// buttons use -- the axes' right edge, so the button sits over the right gutter and never
+/// over a trace -- and from the rows' top edges, so each one names the row it would enlarge.
+void sync_maximize_buttons(App& app) {
+ if (app.area_overlay == nullptr || app.area_widget == nullptr || app.area == nullptr) return;
+ const int frows = app.rows->figure_row_count();
+ if (frows != app.maximize_buttons_rows) rebuild_maximize_buttons(app);
+ if (app.maximize_buttons.empty()) return;
+ const plot::FigureLayout& fl = app.rows->figure().layout();
+ const double scale = app.area->device_scale() > 0.0f
+ ? static_cast(app.area->device_scale())
+ : 1.0;
+ const double width = static_cast(gtk_widget_get_width(app.area_widget));
+ const double height = static_cast(gtk_widget_get_height(app.area_widget));
+ for (GtkWidget* b : app.maximize_buttons) {
+ const int fr = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kMaximizeKey)) - 1;
+ if (fr < 0 || fr >= frows || fr >= static_cast(fl.rows.size())) {
+ gtk_widget_set_visible(b, FALSE);
+ continue;
+ }
+ const int first = app.rows->first_plot_of_row(fr);
+ const int n = app.rows->cells_in_row(fr);
+ const plot::Plot* last = n > 0 ? app.rows->row(first + n - 1).plot : nullptr;
+ const double right =
+ last != nullptr
+ ? static_cast(last->layout().axes.right()) / scale
+ : static_cast(fl.rows[static_cast(fr)].right()) / scale;
+ const double top =
+ static_cast(fl.rows[static_cast(fr)].y) / scale + kLayoutButtonPad;
+ gtk_widget_set_halign(b, GTK_ALIGN_START);
+ gtk_widget_set_valign(b, GTK_ALIGN_START);
+ const double by = static_cast(std::clamp(top, 0.0, std::max(0.0, height - kLayoutButtonPx)) + 0.5);
+ gtk_widget_set_margin_top(b, static_cast(by));
+ const double bx = static_cast(std::clamp(right - kLayoutButtonPx - kLayoutButtonPad, 0.0,
+ std::max(0.0, width - kLayoutButtonPx)) + 0.5);
+ gtk_widget_set_margin_start(b, static_cast(bx));
+ // Out of hiding only while the pointer is near this row's corner: the button's own rect
+ // inflated by `kMaximizeRevealPx`. The rect is the placed one (`bx`/`by`), so the zone and
+ // the button cannot disagree -- a zone measured from the axes' edge while the button was
+ // clamped to the widget would leave a corner that never reveals.
+ const bool revealed =
+ app.area_pointer_valid && app.area_pointer_x >= bx - kMaximizeRevealPx &&
+ app.area_pointer_x <= bx + kLayoutButtonPx + kMaximizeRevealPx &&
+ app.area_pointer_y >= by - kMaximizeRevealPx &&
+ app.area_pointer_y <= by + kLayoutButtonPx + kMaximizeRevealPx;
+ gtk_widget_set_visible(b, revealed);
+ }
+ // The detach buttons, one per figure row to the **left** of its full-screen toggle: the same
+ // corner, the same reveal, the row's own rect -- a pointer arriving for one of the pair shows
+ // both, and leaving hides both.
+ for (GtkWidget* b : app.detach_buttons) {
+ const int fr = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kMaximizeKey)) - 1;
+ if (fr < 0 || fr >= frows || fr >= static_cast(fl.rows.size())) {
+ gtk_widget_set_visible(b, FALSE);
+ continue;
+ }
+ const int first = app.rows->first_plot_of_row(fr);
+ const int n = app.rows->cells_in_row(fr);
+ const plot::Plot* last = n > 0 ? app.rows->row(first + n - 1).plot : nullptr;
+ const double right =
+ last != nullptr
+ ? static_cast(last->layout().axes.right()) / scale
+ : static_cast(fl.rows[static_cast(fr)].right()) / scale;
+ const double top =
+ static_cast(fl.rows[static_cast(fr)].y) / scale + kLayoutButtonPad;
+ gtk_widget_set_halign(b, GTK_ALIGN_START);
+ gtk_widget_set_valign(b, GTK_ALIGN_START);
+ const double dx = std::clamp(right - 2 * kLayoutButtonPx - 2 * kLayoutButtonPad, 0.0,
+ std::max(0.0, width - kLayoutButtonPx));
+ gtk_widget_set_margin_start(b, static_cast(dx));
+ const double dy = std::clamp(top, 0.0, std::max(0.0, height - kLayoutButtonPx));
+ gtk_widget_set_margin_top(b, static_cast(dy));
+ const bool revealed =
+ app.area_pointer_valid && app.area_pointer_x >= dx - kMaximizeRevealPx &&
+ app.area_pointer_x <= dx + kLayoutButtonPx + kMaximizeRevealPx &&
+ app.area_pointer_y >= dy - kMaximizeRevealPx &&
+ app.area_pointer_y <= dy + kLayoutButtonPx + kMaximizeRevealPx;
+ gtk_widget_set_visible(b, revealed);
+ }
+}
+
+/// The row's toggle: the `kMaximizeKey` data names the figure row it sits on.
+void on_maximize_clicked(GtkButton* b, gpointer user) {
+ App& app = *static_cast(user);
+ toggle_fullscreen(app, GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kMaximizeKey)) - 1);
+}
+
+/// The plot menu's entry: the figure row the clicked plot belongs to, through the same action,
+/// with the popover dismissed like every other row's.
+void on_fullscreen_row(GtkButton* b, gpointer user) {
+ App& app = *static_cast(user);
+ toggle_fullscreen(app, app.rows->figure_row_of(row_key(GTK_WIDGET(b))));
+ GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(b), "udpscope-popover"));
+ if (popover != nullptr) gtk_popover_popdown(popover);
+}
+
+/// The detach button, the full-screen toggle's neighbour: the figure row it names moves to a
+/// window of its own.
+void on_detach_clicked(GtkButton* b, gpointer user) {
+ App& app = *static_cast(user);
+ detach_figure(app, GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kMaximizeKey)) - 1);
+}
+
+/// The attach control's reveal and placement, one call per frame from the tick: the button sits at
+/// the window's top-right corner and comes out of hiding while the pointer is near it -- the
+/// placed rect inflated by `kMaximizeRevealPx`, the zone and the button measured from the same
+/// numbers so they cannot disagree.
+void sync_scope_attach(App& app, App::ScopeWindow& scope) {
+ (void)app;
+ if (scope.overlay == nullptr || scope.widget == nullptr) return;
+ const double scale = scope.area != nullptr && scope.area->device_scale() > 0.0f
+ ? static_cast(scope.area->device_scale())
+ : 1.0;
+ (void)scale;
+ const double width = static_cast(gtk_widget_get_width(scope.widget));
+ const double height = static_cast(gtk_widget_get_height(scope.widget));
+ for (GtkWidget* b : scope.attach_buttons) {
+ const double bx = std::clamp(static_cast(width - kLayoutButtonPx - kLayoutButtonPad),
+ 0.0, std::max(0.0, width - kLayoutButtonPx));
+ const double by = std::clamp(static_cast(kLayoutButtonPad), 0.0,
+ std::max(0.0, height - kLayoutButtonPx));
+ gtk_widget_set_halign(b, GTK_ALIGN_START);
+ gtk_widget_set_valign(b, GTK_ALIGN_START);
+ gtk_widget_set_margin_start(b, static_cast(bx));
+ gtk_widget_set_margin_top(b, static_cast(by));
+ const bool revealed =
+ scope.pointer_valid && scope.pointer_x >= bx - kMaximizeRevealPx &&
+ scope.pointer_x <= bx + kLayoutButtonPx + kMaximizeRevealPx &&
+ scope.pointer_y >= by - kMaximizeRevealPx &&
+ scope.pointer_y <= by + kLayoutButtonPx + kMaximizeRevealPx;
+ gtk_widget_set_visible(b, revealed);
+ }
+ if (scope.layout_toggle != nullptr) {
+ // The layout toggle sits at the corner, to the left of the attach button when there is one.
+ const double bx = std::clamp(static_cast(width - kLayoutButtonPx - kLayoutButtonPad -
+ (scope.attach_buttons.empty()
+ ? 0
+ : kLayoutButtonPx + kLayoutButtonPad)),
+ 0.0, std::max(0.0, width - kLayoutButtonPx));
+ const double by = std::clamp(static_cast(kLayoutButtonPad), 0.0,
+ std::max(0.0, height - kLayoutButtonPx));
+ gtk_widget_set_halign(scope.layout_toggle, GTK_ALIGN_START);
+ gtk_widget_set_valign(scope.layout_toggle, GTK_ALIGN_START);
+ gtk_widget_set_margin_start(scope.layout_toggle, static_cast(bx));
+ gtk_widget_set_margin_top(scope.layout_toggle, static_cast(by));
+ const bool revealed =
+ !scope.layout_editing && scope.pointer_valid &&
+ scope.pointer_x >= bx - kMaximizeRevealPx &&
+ scope.pointer_x <= bx + kLayoutButtonPx + kMaximizeRevealPx &&
+ scope.pointer_y >= by - kMaximizeRevealPx &&
+ scope.pointer_y <= by + kLayoutButtonPx + kMaximizeRevealPx;
+ gtk_widget_set_visible(scope.layout_toggle, revealed);
+ }
+ // The attach button steps aside while the editor is up: Done is the way out of that mode.
+ for (GtkWidget* b : scope.attach_buttons) {
+ if (scope.layout_editing) gtk_widget_set_visible(b, FALSE);
+ }
+}
+
+// --- the scope windows' own layout editor -----------------------------------------------------
+//
+// A scope window is a view with its own arrangement, and its arrangement is the user's: it holds
+// as many figure rows as it is given and edits them with the same controls the main window's
+// editor carries (`+` on a boundary, `×` on a row, Done to leave). The buttons are the window's
+// own -- the main editor's target is the main window -- so the two editors cannot move each
+// other's rows.
+
+namespace {
+/// Data keys of a scope editor's buttons: the figure row a `×` removes, the boundary a `+` splits.
+constexpr const char* kScopeLayoutRowKey = "udpscope-scope-layout-row";
+constexpr const char* kScopeLayoutBoundaryKey = "udpscope-scope-layout-boundary";
+
+App::ScopeWindow* scope_layout_scope(GtkWidget* w) {
+ return static_cast(g_object_get_data(G_OBJECT(w), "udpscope-scope"));
+}
+} // namespace
+
+void rebuild_scope_layout_editor(App& app, App::ScopeWindow& scope);
+void set_scope_layout_editing(App& app, App::ScopeWindow& scope, bool on);
+
+void clear_scope_layout_editor(App::ScopeWindow& scope) {
+ if (scope.overlay != nullptr) {
+ for (GtkWidget* b : scope.layout_buttons) {
+ gtk_overlay_remove_overlay(GTK_OVERLAY(scope.overlay), b);
+ }
+ if (scope.layout_done != nullptr) {
+ gtk_overlay_remove_overlay(GTK_OVERLAY(scope.overlay), scope.layout_done);
+ }
+ }
+ scope.layout_buttons.clear();
+ scope.layout_done = nullptr;
+ scope.layout_buttons_rows = -1;
+}
+
+/// One of the scope editor's buttons: the main editor's look (`circular`, `osd`, the button size)
+/// on this window's overlay.
+GtkWidget* scope_layout_button(App::ScopeWindow& scope, const char* icon, const char* tip) {
+ GtkWidget* b = gtk_button_new_from_icon_name(icon);
+ gtk_widget_set_tooltip_text(b, tip);
+ gtk_widget_add_css_class(b, "circular");
+ gtk_widget_add_css_class(b, "osd");
+ gtk_widget_set_size_request(b, kLayoutButtonPx, kLayoutButtonPx);
+ g_object_set_data(G_OBJECT(b), "udpscope-scope", &scope);
+ gtk_overlay_add_overlay(GTK_OVERLAY(scope.overlay), b);
+ scope.layout_buttons.push_back(b);
+ return b;
+}
+
+void on_scope_layout_row_remove(GtkButton* b, gpointer user) {
+ App& app = *static_cast(user);
+ App::ScopeWindow* scope = scope_layout_scope(GTK_WIDGET(b));
+ if (scope == nullptr || scope->rows == nullptr) return;
+ const int fr = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kScopeLayoutRowKey)) - 1;
+ outlive_the_rebuild(GTK_WIDGET(b));
+ if (!scope->rows->remove_row(fr)) {
+ set_notice(app, "a window keeps at least one figure", 4);
+ return;
+ }
+ if (scope->area != nullptr) scope->area->set_figure(&scope->rows->figure());
+ rebuild_scope_layout_editor(app, *scope);
+ if (scope->widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(scope->widget));
+}
+
+void on_scope_layout_row_insert(GtkButton* b, gpointer user) {
+ App& app = *static_cast(user);
+ App::ScopeWindow* scope = scope_layout_scope(GTK_WIDGET(b));
+ if (scope == nullptr || scope->rows == nullptr) return;
+ const int at = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kScopeLayoutBoundaryKey));
+ outlive_the_rebuild(GTK_WIDGET(b));
+ if (!scope->rows->insert_row(at)) {
+ set_notice(app, "this window holds " + std::to_string(RowSet::kMaxRows) + " figures already", 4);
+ return;
+ }
+ if (scope->area != nullptr) scope->area->set_figure(&scope->rows->figure());
+ rebuild_scope_layout_editor(app, *scope);
+ if (scope->widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(scope->widget));
+}
+
+void on_scope_layout_done(GtkButton* b, gpointer user) {
+ App& app = *static_cast(user);
+ App::ScopeWindow* scope = scope_layout_scope(GTK_WIDGET(b));
+ if (scope == nullptr) return;
+ set_scope_layout_editing(app, *scope, false);
+}
+
+void rebuild_scope_layout_editor(App& app, App::ScopeWindow& scope) {
+ clear_scope_layout_editor(scope);
+ if (!scope.layout_editing || scope.rows == nullptr || scope.overlay == nullptr) return;
+ const int frows = scope.rows->figure_row_count();
+ scope.layout_buttons_rows = frows;
+ for (int fr = 0; fr < frows; ++fr) {
+ GtkWidget* rm = scope_layout_button(scope, "window-close-symbolic", "remove this figure");
+ g_object_set_data(G_OBJECT(rm), kScopeLayoutRowKey, GINT_TO_POINTER(fr + 1));
+ g_signal_connect(rm, "clicked", G_CALLBACK(on_scope_layout_row_remove), &app);
+ }
+ for (int at = 0; at <= frows; ++at) {
+ GtkWidget* add = scope_layout_button(scope, "list-add-symbolic", "add a figure here");
+ g_object_set_data(G_OBJECT(add), kScopeLayoutBoundaryKey, GINT_TO_POINTER(at));
+ g_signal_connect(add, "clicked", G_CALLBACK(on_scope_layout_row_insert), &app);
+ }
+ scope.layout_done = gtk_button_new_with_label("Done");
+ gtk_widget_set_tooltip_text(scope.layout_done, "leave the layout editor");
+ gtk_widget_add_css_class(scope.layout_done, "osd");
+ gtk_widget_set_size_request(scope.layout_done, -1, kLayoutButtonPx);
+ g_object_set_data(G_OBJECT(scope.layout_done), "udpscope-scope", &scope);
+ g_signal_connect(scope.layout_done, "clicked", G_CALLBACK(on_scope_layout_done), &app);
+ gtk_overlay_add_overlay(GTK_OVERLAY(scope.overlay), scope.layout_done);
+}
+
+/// Restates where the editor's buttons sit, from the rects the last frame was laid out with: the
+/// `×` inside its row's left edge, the `+` on its boundary, Done in the corner.
+void sync_scope_layout_editor(App& app, App::ScopeWindow& scope) {
+ if (!scope.layout_editing || scope.rows == nullptr || scope.area == nullptr ||
+ scope.widget == nullptr) {
+ return;
+ }
+ const int frows = scope.rows->figure_row_count();
+ if (frows != scope.layout_buttons_rows) rebuild_scope_layout_editor(app, scope);
+ const plot::FigureLayout& fl = scope.rows->figure().layout();
+ if (static_cast(fl.rows.size()) != frows) return;
+ const double scale =
+ scope.area->device_scale() > 0.0f ? static_cast(scope.area->device_scale()) : 1.0;
+ const double height = static_cast(gtk_widget_get_height(scope.widget));
+ const double width = static_cast(gtk_widget_get_width(scope.widget));
+ if (!(height > 0.0) || !(width > 0.0)) return;
+ const double half = kLayoutButtonPx * 0.5;
+ for (GtkWidget* b : scope.layout_buttons) {
+ const int fr = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kScopeLayoutRowKey)) - 1;
+ const int at = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kScopeLayoutBoundaryKey));
+ gtk_widget_set_halign(b, GTK_ALIGN_START);
+ gtk_widget_set_valign(b, GTK_ALIGN_START);
+ if (fr >= 0) {
+ if (fr >= frows) continue;
+ const plot::Rect& row = fl.rows[static_cast(fr)];
+ gtk_widget_set_margin_top(
+ b, static_cast(std::clamp(static_cast(row.y) / scale + kLayoutButtonPad, 0.0,
+ height - kLayoutButtonPx) + 0.5));
+ // Inside the axes, not over the y labels: the row's first cell's plot knows where they end.
+ const plot::Plot* p = scope.rows->row(scope.rows->first_plot_of_row(fr)).plot;
+ const double left =
+ (p != nullptr ? static_cast(p->layout().axes.x) / scale
+ : static_cast(row.x) / scale) + kLayoutButtonPad;
+ gtk_widget_set_margin_start(b, static_cast(std::clamp(left, 0.0, width - kLayoutButtonPx) + 0.5));
+ } else if (at >= 0) {
+ const double edge = at < frows
+ ? static_cast(fl.rows[static_cast(at)].y) / scale
+ : (static_cast(fl.rows.back().y) +
+ static_cast(fl.rows.back().h)) / scale;
+ gtk_widget_set_margin_top(
+ b, static_cast(std::clamp(edge - half, 0.0, height - kLayoutButtonPx) + 0.5));
+ gtk_widget_set_margin_start(b, static_cast(kLayoutButtonPad));
+ }
+ }
+ if (scope.layout_done != nullptr) {
+ gtk_widget_set_halign(scope.layout_done, GTK_ALIGN_END);
+ gtk_widget_set_valign(scope.layout_done, GTK_ALIGN_START);
+ gtk_widget_set_margin_end(scope.layout_done, kLayoutButtonPad);
+ gtk_widget_set_margin_top(scope.layout_done, kLayoutButtonPad);
+ }
+}
+
+void set_scope_layout_editing(App& app, App::ScopeWindow& scope, bool on) {
+ if (scope.layout_editing == on) return;
+ scope.layout_editing = on;
+ if (on) {
+ rebuild_scope_layout_editor(app, scope);
+ } else {
+ clear_scope_layout_editor(scope);
+ }
+ sync_scope_attach(app, scope);
+ if (scope.widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(scope.widget));
+}
+
+void sync_scope_instruments(App& app) {
+ if (app.scopes.empty()) return;
+ // A gesture in a **scope** window moves the same pair of instruments: read it back from that
+ // window's own controllers first, so the main window adopts it before anything is written out.
+ // (`sync_cursors()`/`sync_gate()` only adopt while their own plot is the one being dragged,
+ // which is exactly the question asked here.)
+ for (const std::unique_ptr& scope : app.scopes) {
+ if (scope->rows == nullptr || scope->area == nullptr) continue;
+ int cursor_row = -1;
+ int gate_row = -1;
+ for (int i = 0; i < scope->rows->row_count(); ++i) {
+ plot::input::Controller* c = scope->area->row_controller(static_cast(i));
+ if (c == nullptr) continue;
+ if (cursor_row < 0 && c->grabbed_cursor() != plot::kInvalid) cursor_row = i;
+ if (gate_row < 0 && c->grabbed_region() != plot::kInvalid) gate_row = i;
+ }
+ if (cursor_row < 0 && gate_row < 0) continue;
+ scope->rows->sync_cursors(cursor_row);
+ scope->rows->sync_gate(gate_row);
+ if (cursor_row >= 0) {
+ app.rows->set_cursors(scope->rows->cursor_a_x(), scope->rows->cursor_b_x());
+ }
+ if (gate_row >= 0) app.rows->set_gate(scope->rows->gate_x0(), scope->rows->gate_x1());
+ }
+ // The main window's row set is the owner: every scope is written from it each frame. The setters
+ // early-out when nothing changed, so this is a handful of comparisons on a steady frame.
+ const bool cursors = app.rows->cursors_visible();
+ const bool gate = app.rows->gate_visible();
+ const bool trigger = app.rows->trigger_marker_visible();
+ for (const std::unique_ptr& scope : app.scopes) {
+ if (scope->rows == nullptr) continue;
+ scope->rows->set_cursors_visible(cursors);
+ scope->rows->set_cursor_mode(app.rows->cursor_mode());
+ // The pair is shared even while hidden: a window whose cursors are shown later must show them
+ // where the others already stand (`set_cursors()` places them only where the mode and the
+ // visibility let them be drawn).
+ scope->rows->set_cursors(app.rows->cursor_a_x(), app.rows->cursor_b_x());
+ scope->rows->set_gate_visible(gate);
+ scope->rows->set_gate(app.rows->gate_x0(), app.rows->gate_x1());
+ scope->rows->set_trigger_marker(app.rows->trigger_marker_x(), trigger);
+ }
+}
+
+void mirror_measure_marks(App& app) {
+ if (app.scopes.empty()) return;
+ for (const std::unique_ptr& scope : app.scopes) {
+ if (scope->rows == nullptr) continue;
+ std::vector marks;
+ for (const RowSet::MeasureMark& mk : app.measure_marks_display) {
+ // The window's rows are its own: find the row that plots the mark's signal (a window that
+ // does not show it draws nothing for it, which is what "the mark is on the trace it
+ // measures" means).
+ int row = -1;
+ for (int r = 0; r < scope->rows->row_count() && row < 0; ++r) {
+ for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) {
+ if (scope->rows->row(r).signal_name[static_cast(sl)] == mk.signal) {
+ row = r;
+ break;
+ }
+ }
+ }
+ if (row < 0) continue;
+ RowSet::MeasureMark t = mk;
+ t.row = row;
+ marks.push_back(std::move(t));
+ }
+ scope->rows->set_measure_marks(marks);
+ }
+}
+
+void sync_reopen_scopes_item(App& app) {
+ if (app.reopen_scopes_item == nullptr) return;
+ const std::size_t n = app.closed_windows.size();
+ const std::string label =
+ n == 0 ? std::string("Reopen closed windows")
+ : "Reopen closed windows (" + std::to_string(n) + ")";
+ gtk_button_set_label(GTK_BUTTON(app.reopen_scopes_item), label.c_str());
+ gtk_widget_set_sensitive(app.reopen_scopes_item, n > 0 ? TRUE : FALSE);
+}
+
+App::ScopeWindow* open_scope_window_from_state(App& app, const LayoutState::Window& state) {
+ // The window itself comes from the one constructor every window uses -- an empty scope window,
+ // the same one the overflow menu makes -- and then its arrangement is dealt into its row set.
+ open_scope_window(app, -1, state.detached);
+ if (app.scopes.empty()) return nullptr;
+ App::ScopeWindow* scope = app.scopes.back().get();
+ if (scope->rows == nullptr || state.rows.empty()) {
+ scope->name = state.name;
+ if (scope->window != nullptr) gtk_window_set_title(scope->window, state.name.c_str());
+ return scope;
+ }
+ // The cells per figure row, from the `row` keys the same way `apply_layout()` reads the main
+ // layout: a file that names the figure row of every plot carries its splits, an older one is
+ // one cell per row.
+ std::vector cells;
+ {
+ int prev = -1;
+ for (const LayoutState::Row& r : state.rows) {
+ const int fr = r.row < 0 ? prev + 1 : r.row;
+ if (fr != prev) cells.push_back(1);
+ else if (++cells.back() > RowSet::kMaxCellsPerRow) cells.back() = RowSet::kMaxCellsPerRow;
+ prev = fr;
+ }
+ if (cells.size() > static_cast(RowSet::kMaxRows)) cells.resize(RowSet::kMaxRows);
+ }
+ scope->rows->set_cells_per_row(cells);
+ std::vector row_weights;
+ row_weights.reserve(cells.size());
+ std::size_t at = 0;
+ for (const int n : cells) {
+ row_weights.push_back(static_cast(state.rows[at].weight));
+ at += static_cast(n);
+ }
+ scope->rows->set_row_weights(std::move(row_weights));
+ at = 0;
+ for (std::size_t fr = 0; fr < cells.size(); ++fr) {
+ const std::size_t n = static_cast(cells[fr]);
+ if (n >= 2) {
+ std::vector widths;
+ for (std::size_t c = 0; c < n; ++c) widths.push_back(static_cast(state.rows[at + c].width));
+ scope->rows->set_cell_weights(static_cast(fr), std::move(widths));
+ }
+ at += n;
+ }
+ // The slots, by name (the channel index is re-resolved every frame by `bind()`). The copy is
+ // taken under the model's lock: the producer replaces the list on every CONFIG.
+ std::vector signals;
+ {
+ std::lock_guard lock(app.model->mutex());
+ signals = app.model->signals();
+ }
+ const auto color_of = [&](const std::string& name) {
+ for (std::size_t si = 0; si < signals.size(); ++si) {
+ if (signals[si].name == name) return signal_color_index(signals, si);
+ }
+ return app.rows->signal_colors().count(name) != 0
+ ? static_cast(app.rows->signal_colors().at(name))
+ : 0;
+ };
+ for (int i = 0; i < scope->rows->row_count() &&
+ static_cast(i) < state.rows.size();
+ ++i) {
+ const LayoutState::Row& row = state.rows[static_cast(i)];
+ for (int sl = 0; sl < RowSet::kSlotsPerRow && static_cast(sl) < row.signals.size(); ++sl) {
+ const std::string& name = row.signals[static_cast(sl)];
+ if (name.empty()) continue;
+ scope->rows->assign(i, sl, name, name, color_of(name));
+ }
+ }
+ if (scope->area != nullptr) scope->area->set_figure(&scope->rows->figure());
+ scope->name = state.name;
+ if (scope->window != nullptr) {
+ gtk_window_set_title(scope->window, (state.name + (state.detached ? " (detached)" : "")).c_str());
+ }
+ return scope;
+}
+
+gboolean on_scope_key(GtkEventControllerKey* controller, guint keyval, guint, GdkModifierType state,
+ gpointer user) {
+ App& app = *static_cast(user);
+ const Accel* accel = find_accel(keyval, static_cast(state));
+ if (accel == nullptr) return FALSE;
+ if (accel->action == AppAction::LayoutEditor) {
+ // The window the key arrived in, not the main one: `L` is the layout editor of the view you
+ // are looking at.
+ GtkWidget* widget = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(controller));
+ GtkWidget* window = widget != nullptr ? gtk_widget_get_ancestor(widget, GTK_TYPE_WINDOW) : nullptr;
+ for (const std::unique_ptr& scope : app.scopes) {
+ if (GTK_WIDGET(scope->window) != window) continue;
+ set_scope_layout_editing(app, *scope, !scope->layout_editing);
+ return TRUE;
+ }
+ return FALSE;
+ }
+ // Everything else is the application's, exactly as the main window's keys take it.
+ run_app_action(app, accel->action);
+ return TRUE;
+}
+
+/// **Detach**: the figure row moves to a window of its own. Copied first (the new window opens
+/// carrying exactly what was on screen), then removed from the main window's rows -- the copy and
+/// the removal are one action in one call, so nothing renders in between. A detached window is the
+/// one kind with an attach control; the arrangement the user had follows it there.
+void detach_figure(App& app, int fr) {
+ if (app.rows->figures_maximized() || app.rows->figure_row_count() <= 1) return;
+ if (fr < 0 || fr >= app.rows->figure_row_count()) return;
+ const std::string label = app.rows->plot_label(app.rows->first_plot_of_row(fr));
+ open_scope_window(app, fr, /*detached=*/true);
+ if (!app.rows->remove_row(fr)) return;
+ if (app.area != nullptr) app.area->set_figure(&app.rows->figure());
+ rebuild_maximize_buttons(app);
+ rebuild_signal_panels(app);
+ if (app.layout_editing) rebuild_layout_editor(app);
+ schedule_layout_save(app);
+ set_notice(app, "plot " + label + " detached to its own window", 4);
+}
+
+/// **Attach**: a detached window's figure row is appended to the main window's -- the window's
+/// cells, their weights and every slot's signal in its colour -- and the window closes. Closing
+/// runs its own teardown (the pages' *Plot on* groups are refreshed before the window's struct is
+/// freed), so nothing here may touch `scope` once the destroy has been asked for.
+void attach_scope_window(App& app, App::ScopeWindow& scope) {
+ if (!scope.detached || scope.rows == nullptr || scope.rows->row_count() <= 0) return;
+ const int at = app.rows->figure_row_count();
+ if (!app.rows->insert_row(at)) {
+ set_notice(app, "no room for another figure row", 4);
+ return;
+ }
+ const int cells = scope.rows->cells_in_row(0);
+ for (int k = 1; k < cells; ++k) {
+ if (!app.rows->insert_cell(at, k)) break;
+ }
+ if (cells > 1) app.rows->set_cell_weights(at, scope.rows->cell_weights(0));
+ const int first = app.rows->first_plot_of_row(at);
+ for (int k = 0; k < cells; ++k) {
+ const RowSet::Row& src = scope.rows->row(k);
+ for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
+ const std::size_t si = static_cast(s);
+ if (src.signal_name[si].empty()) continue;
+ app.rows->assign(first + k, s, src.signal_name[si], src.name[si], src.color_index[si]);
+ }
+ }
+ GtkWindow* window = scope.window;
+ scope.remember_on_close = false; // its figure is going back to the main window
+ if (window != nullptr) gtk_window_destroy(window); // erases the scope, refreshes the pages
+ app.relayout_pending = true;
+ if (app.area != nullptr) app.area->set_figure(&app.rows->figure());
+ rebuild_maximize_buttons(app);
+ rebuild_signal_panels(app);
+ if (app.layout_editing) rebuild_layout_editor(app);
+ schedule_layout_save(app);
+ set_notice(app, "the detached figure is back on the main window", 4);
+}
+
+/// The plot menu's way into the layout editor: the same toggle the overflow menu's `Layout editor`
+/// entry flips, so the menu cannot drift from the button's own state -- and the place a user who is
+/// looking at the plot will find splitting.
+void on_row_menu_layout_editor(GtkButton* b, gpointer user) {
+ App& app = *static_cast(user);
+ if (app.layout_toggle != nullptr) {
+ gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.layout_toggle), !app.layout_editing);
+ }
+ GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(b), "udpscope-popover"));
+ if (popover != nullptr) gtk_popover_popdown(popover);
+}
+
void rebuild_layout_editor(App& app) {
clear_layout_editor(app);
if (!app.layout_editing || app.area_overlay == nullptr) return;
@@ -1684,7 +2536,23 @@ GtkWidget* measures_row(App& app, std::size_t index, const Measure& m) {
gtk_label_set_markup(GTK_LABEL(sig_chip), measure_chip_markup(app, m.signal).c_str());
gtk_widget_set_tooltip_text(sig_chip, m.signal.c_str());
gtk_box_append(GTK_BOX(box), sig_chip);
- GtkWidget* kind = gtk_label_new(measure_type_name(m.type));
+ // The row's who: the channel's number and name, then the type. The number is the active
+ // channel's (see `measure_channel_number`) -- the same one the signal's badge carries -- so
+ // a row, a badge and the signal page all answer "which channel is this" with one word.
+ // Markup because the name is the stream's text; escaped for the same reason. A signal that
+ // is not acquired keeps the old type-only row (there is no number to show and the row is
+ // already grey).
+ const int number = measure_channel_number(app, m.signal);
+ std::string kind_text;
+ {
+ char* esc = g_markup_escape_text(m.signal.c_str(), -1);
+ kind_text = number > 0 ? "" + std::to_string(number) + ". " + esc + " · " +
+ measure_type_name(m.type)
+ : std::string(measure_type_name(m.type));
+ g_free(esc);
+ }
+ GtkWidget* kind = gtk_label_new(nullptr);
+ gtk_label_set_markup(GTK_LABEL(kind), kind_text.c_str());
gtk_widget_set_halign(kind, GTK_ALIGN_START);
gtk_widget_set_hexpand(kind, true);
gtk_label_set_ellipsize(GTK_LABEL(kind), PANGO_ELLIPSIZE_END);
@@ -1982,9 +2850,22 @@ void open_measure_dialog(App& app, int edit_index) {
}
// The `const char*` array is built from the *finished* strings: taking `c_str()` as the
// vector grew left every pointer pointing into memory the next push reallocated.
+ //
+ // Each entry is shown with its **channel number** (`measure_channel_number`), the same one
+ // the pane's rows and the signal badges carry -- the dropdown and the rows name a channel
+ // alike, which is what makes "1. sine" in one findable in the other. `d->signals` keeps the
+ // raw names: the number is decoration, and Save resolves through the raw name. An entry
+ // that is not an active channel (a persistence layer, `sine (A)`, whose base signal's
+ // channel is someone else's) shows unnumbered rather than borrowing one.
+ std::vector sig_display;
+ sig_display.reserve(sig_names.size());
std::vector sig_cstrs;
sig_cstrs.reserve(sig_names.size() + 1);
- for (const std::string& n : sig_names) sig_cstrs.push_back(n.c_str());
+ for (const std::string& n : sig_names) {
+ const int number = measure_channel_number(app, n);
+ sig_display.push_back(number > 0 ? std::to_string(number) + ". " + n : n);
+ }
+ for (const std::string& n : sig_display) sig_cstrs.push_back(n.c_str());
sig_cstrs.push_back(nullptr);
if (editing != nullptr) {
const auto it = std::find(sig_names.begin(), sig_names.end(), editing->signal);
@@ -2051,10 +2932,23 @@ void open_measure_dialog(App& app, int edit_index) {
"at the time it happened, the pulse's edges as markers");
gtk_box_append(GTK_BOX(box), show);
+ // The per-measurement gate: one check per measurement, one band shared by all of them. The
+ // band is shown while any measurement is gated (`rebuild_measures_pane()` derives it), and
+ // it is the thing this check switches on the plots -- the measurement itself only names
+ // which side of the statistics question reads inside it. Shown for every type: the gate is
+ // about *which samples* answer, and every type reads samples.
+ GtkWidget* gate = gtk_check_button_new_with_label("Gate enabled");
+ gtk_widget_set_tooltip_text(gate,
+ "restrict this measurement to the shared gate band on the plots "
+ "(violet). The band is shown while any measurement is gated, and "
+ "stays where it is put: live it names an age (\"300 ms ago to "
+ "100 ms ago\"), on a held event a fixed slice of the frame");
+ gtk_box_append(GTK_BOX(box), gate);
+
auto* d = new MeasureDialog{&app, edit_index, signal_drop, type_drop,
threshold, band, above, show,
- window, threshold_row, threshold_label, band_row,
- band_label, side_row, side_label,
+ gate, window, threshold_row, threshold_label,
+ band_row, band_label, side_row, side_label,
noise, noise_row, hf, hf_row,
sig_names};
app.measure_dialog = window;
@@ -2083,6 +2977,7 @@ void open_measure_dialog(App& app, int edit_index) {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(hf), editing->options.hf_fc);
gtk_switch_set_active(GTK_SWITCH(above), editing->options.above);
gtk_check_button_set_active(GTK_CHECK_BUTTON(show), editing->options.show_on_plot);
+ gtk_check_button_set_active(GTK_CHECK_BUTTON(gate), editing->options.gate);
} else {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(threshold), 50.0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(band), MeasureOptions{}.threshold_pct2);
@@ -2174,6 +3069,11 @@ void place_search_marks(App& app) {
gtk_label_set_text(GTK_LABEL(app.search_marks_label), text);
}
app.rows->set_search_marks(xs, current);
+ // A hit is a time on the window, not a property of a row: every scope window draws the same
+ // marks (and the same current one) on its own plots.
+ for (const std::unique_ptr& scope : app.scopes) {
+ if (scope->rows != nullptr) scope->rows->set_search_marks(xs, current);
+ }
}
@@ -2663,6 +3563,7 @@ GtkWidget* build_measures_panel(App& app) {
update_measures(*a, /*force=*/true, /*accumulate=*/true);
}),
&app);
+
// The heading row -- the .ui's anonymous first child, carrying the title and the `+`. It is where
// the contextual help's "?" joins (see `ui_context_help.cpp`).
app.measures_head = gtk_widget_get_first_child(box);
@@ -2864,8 +3765,9 @@ void on_y_auto_clicked(GtkButton* button, gpointer user) {
void on_fixed_range_apply(GtkButton*, gpointer user) {
FixedRangeContext* ctx = static_cast(user);
- ctx->app->rows->set_y_range(ctx->row, gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->lo)),
- gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->hi)));
+ RowSet& rows = ctx->rows != nullptr ? *ctx->rows : *ctx->app->rows;
+ rows.set_y_range(ctx->row, gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->lo)),
+ gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->hi)));
gtk_window_destroy(GTK_WINDOW(ctx->window));
}
@@ -2880,7 +3782,7 @@ void on_y_fixed_clicked(GtkButton* button, gpointer user) {
gtk_box_append(GTK_BOX(box), labeled_box("min", lo));
gtk_box_append(GTK_BOX(box), labeled_box("max", hi));
- auto* ctx = new FixedRangeContext{app, row, lo, hi, window};
+ auto* ctx = new FixedRangeContext{app, nullptr, row, lo, hi, window};
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window);
GtkWidget* apply = gtk_button_new_with_label("Apply");
@@ -2900,9 +3802,9 @@ void on_y_fixed_clicked(GtkButton* button, gpointer user) {
/// Frees one trace slot of a row. All it takes is the free: the row is rebuilt with a series that is
/// nothing, so it draws nothing and leaves the legend.
-void remove_trace(App& app, int row, int slot) {
- if (row < 0 || row >= app.rows->row_count()) return;
- app.rows->assign(row, slot, std::string(), std::string(), 0);
+void remove_trace(RowSet& rows, int row, int slot) {
+ if (row < 0 || row >= rows.row_count()) return;
+ rows.assign(row, slot, std::string(), std::string(), 0);
}
/// Disables every math channel that (transitively) reads `name`, and answers with their names.
@@ -2955,7 +3857,7 @@ int remove_signal_from_plots(App& app, const std::string& name) {
// The slot remembers the *signal* (and its element), never the channel index -- the channel list is
// renumbered on every enable change, which is what this is reacting to.
if (app.rows->row(row).signal_name[static_cast(slot)] != name) continue;
- remove_trace(app, row, slot);
+ remove_trace(*app.rows, row, slot);
++removed;
}
}
@@ -2974,14 +3876,14 @@ void on_remove_clicked(GtkButton* button, gpointer user) {
int removed = 0;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
if (r.channel[static_cast(s)] < 0) continue;
- remove_trace(app, target->row, s);
+ remove_trace(*target->app->rows, target->row, s);
++removed;
}
what = "removed " + std::to_string(removed) + (removed == 1 ? " trace" : " traces") +
" from plot " + app.rows->plot_label(target->row);
} else {
const std::string name = r.name[static_cast(target->slot)];
- remove_trace(app, target->row, target->slot);
+ remove_trace(*target->app->rows, target->row, target->slot);
what = "removed '" + name + "' from plot " + app.rows->plot_label(target->row);
}
@@ -3069,6 +3971,21 @@ void row_menu_fill(RowMenuState& st) {
// is what "fit" means here. `F` is the plot's own key for the second of them.
add_row("Fit X axis", nullptr, G_CALLBACK(on_fit_x_clicked), &app);
add_row("Fit all", "F", G_CALLBACK(on_fit_clicked), &app);
+ // One click from a row to that row alone filling the plot: the button on the figure's
+ // corner does the same, and the menu is where a user who cannot see it looks. A layout of
+ // one figure has nothing to maximize *to*, so the entry appears only when it would act.
+ if (app.rows->figure_row_count() > 1 || app.rows->figures_maximized()) {
+ add_row(app.rows->figures_maximized() ? "Restore the layout" : "Full screen this figure",
+ nullptr, G_CALLBACK(on_fullscreen_row), &app);
+ }
+ // One click from looking at a figure to having it in a window of its own -- the second
+ // window copies this plot's figure, so the picture on screen is the picture it opens with.
+ add_row("Open this plot in a new window", nullptr, G_CALLBACK(on_open_scope_row), &app);
+ // Arranging is a plot gesture too: the editor's own buttons live on the plot, but the way in
+ // belongs where the arrangement is being judged. It flips the app's one toggle, so its label
+ // and the overflow menu's state cannot disagree.
+ add_row(app.layout_editing ? "Done editing the layout" : "Edit the layout...", "L",
+ G_CALLBACK(on_row_menu_layout_editor), &app);
gtk_box_append(GTK_BOX(st.box), menu_separator());
// A row, not a `GtkMenuButton`: the removals are the *next view of this menu*, and the chevron
@@ -3167,6 +4084,188 @@ void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer
open_row_menu(*app, row, x, y, widget);
}
+/// Scope-menu helpers: the rows carry their window, their plot and their slot, the popover to
+/// dismiss, and the handlers are free functions -- a menu row's handler is a plain callback, not a
+/// lambda, whose commas would split the `G_CALLBACK` macro into arguments.
+App::ScopeWindow* scope_menu_scope(GtkButton* b) {
+ return static_cast(g_object_get_data(G_OBJECT(b), "udpscope-scope"));
+}
+int scope_menu_row(GtkButton* b) {
+ return GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), "udpscope-row"));
+}
+void scope_menu_dismiss(GtkButton* b) {
+ GtkPopover* p = GTK_POPOVER(g_object_get_data(G_OBJECT(b), "udpscope-popover"));
+ if (p != nullptr) gtk_popover_popdown(p);
+}
+
+void on_scope_y_auto_clicked(GtkButton* b, gpointer) {
+ App::ScopeWindow* scope = scope_menu_scope(b);
+ if (scope == nullptr || scope->rows == nullptr) return;
+ scope->rows->set_y_auto(scope_menu_row(b), true);
+ scope_menu_dismiss(b);
+}
+
+/// The main menu's fixed-range dialog, aimed at **this window's** rows: the dialog's Apply
+/// writes the row set the context names, and the context carries the scope's.
+void on_scope_y_fixed_clicked(GtkButton* b, gpointer user) {
+ App* a = static_cast(user);
+ App::ScopeWindow* scope = scope_menu_scope(b);
+ const int row = scope_menu_row(b);
+ if (scope == nullptr || scope->rows == nullptr || scope->rows->row(row).plot == nullptr) return;
+ GtkWidget* window = dialog_window(a->window, "Fixed Y range");
+ GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window));
+ const plot::Range cur = scope->rows->row(row).plot->y_range(0);
+ GtkWidget* lo = spin(-1.0e12, 1.0e12, 0.1, 6, cur.min);
+ GtkWidget* hi = spin(-1.0e12, 1.0e12, 0.1, 6, cur.max);
+ gtk_box_append(GTK_BOX(box), labeled_box("min", lo));
+ gtk_box_append(GTK_BOX(box), labeled_box("max", hi));
+ auto* ctx = new FixedRangeContext{a, scope->rows.get(), row, lo, hi, window};
+ GtkWidget* cancel = gtk_button_new_with_label("Cancel");
+ g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window);
+ GtkWidget* apply = gtk_button_new_with_label("Apply");
+ g_object_set_data_full(G_OBJECT(apply), "udpscope-ctx", ctx,
+ [](gpointer p) { delete static_cast(p); });
+ g_signal_connect(apply, "clicked", G_CALLBACK(on_fixed_range_apply), ctx);
+ dialog_add_button(window, cancel);
+ dialog_add_button(window, apply);
+ gtk_window_present(GTK_WINDOW(window));
+ scope_menu_dismiss(b);
+}
+
+/// The y fit of a scope window: the y axes alone.
+void on_scope_fit_y_clicked(GtkButton* b, gpointer) {
+ App::ScopeWindow* scope = scope_menu_scope(b);
+ if (scope == nullptr || scope->rows == nullptr) return;
+ scope->rows->set_y_auto(scope_menu_row(b), true);
+ scope_menu_dismiss(b);
+}
+
+/// The x fit of a scope window is the main window's: there is **one** time span for every view,
+/// and the scope mirrors it -- so the fit resets the shared x range and the next frame mirrors
+/// the result back, in this window and in every other.
+void on_scope_fit_x_clicked(GtkButton* b, gpointer user) {
+ App& app = *static_cast(user);
+ app.rows->fit_x();
+ app.notice = "x back to the " + format_window(app.rows->time_window()) + " window";
+ app.notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
+ scope_menu_dismiss(b);
+}
+
+/// Both fits: this window's y, the shared x.
+void on_scope_fit_all_clicked(GtkButton* b, gpointer user) {
+ App& app = *static_cast(user);
+ App::ScopeWindow* scope = scope_menu_scope(b);
+ if (scope == nullptr || scope->rows == nullptr) return;
+ scope->rows->set_y_auto(scope_menu_row(b), true);
+ app.rows->fit_x();
+ app.notice = "y fitted to the data, x back to the " +
+ format_window(app.rows->time_window()) + " window";
+ app.notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
+ scope_menu_dismiss(b);
+}
+
+void on_scope_remove_clicked(GtkButton* b, gpointer) {
+ App::ScopeWindow* scope = scope_menu_scope(b);
+ if (scope == nullptr || scope->rows == nullptr) return;
+ remove_trace(*scope->rows, scope_menu_row(b),
+ GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), "udpscope-slot")));
+ scope_menu_dismiss(b);
+}
+
+void on_scope_attach_menu_clicked(GtkButton* b, gpointer user) {
+ App& a = *static_cast(user);
+ App::ScopeWindow* scope = scope_menu_scope(b);
+ if (scope == nullptr) return;
+ outlive_the_rebuild(GTK_WIDGET(b));
+ attach_scope_window(a, *scope);
+}
+
+/// A scope window's own right-click menu. A scope window is a *view with its own arrangement*:
+/// its y scales, its traces and its removals are its own, so the menu acts on **that window's**
+/// row set -- the same rows the drop target and the signal page's *Plot on* groups write. What it
+/// deliberately does not carry is an x fit: the window follows the main window's time span, so an
+/// x fit here would be undone by the next frame.
+void on_scope_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer user) {
+ App* app = static_cast(user);
+ GtkWidget* widget = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(gesture));
+ App::ScopeWindow* scope = nullptr;
+ for (const std::unique_ptr& s : app->scopes) {
+ if (s->widget == widget) scope = s.get();
+ }
+ if (scope == nullptr || scope->rows == nullptr || scope->rows->row_count() <= 0) return;
+ const int row = std::clamp(static_cast(scope->area->row_at(x, y)), 0,
+ scope->rows->row_count() - 1);
+ gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_CLAIMED);
+ if (plot::input::Controller* c = scope->area->row_controller(static_cast(row))) {
+ c->on_key(plot::input::KeyEvent{plot::input::kKeyEscape, 0, true});
+ }
+
+ GtkWidget* popover = gtk_popover_new();
+ gtk_widget_add_css_class(popover, "udpscope-menu");
+ GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
+ gtk_widget_set_margin_top(box, 6);
+ gtk_widget_set_margin_bottom(box, 6);
+ gtk_widget_set_margin_start(box, 6);
+ gtk_widget_set_margin_end(box, 6);
+ const auto row_btn = [&](const char* label) {
+ GtkWidget* b = gtk_button_new_with_label(label);
+ gtk_widget_add_css_class(b, "flat");
+ gtk_widget_set_halign(b, GTK_ALIGN_FILL);
+ g_object_set_data(G_OBJECT(b), "udpscope-popover", popover);
+ g_object_set_data(G_OBJECT(b), "udpscope-scope", scope);
+ g_object_set_data(G_OBJECT(b), "udpscope-row", GINT_TO_POINTER(row));
+ gtk_box_append(GTK_BOX(box), b);
+ return b;
+ };
+
+ char markup[64];
+ std::snprintf(markup, sizeof(markup), "Plot %s", scope->rows->plot_label(row).c_str());
+ GtkWidget* heading = gtk_label_new(nullptr);
+ gtk_label_set_markup(GTK_LABEL(heading), markup);
+ gtk_widget_set_halign(heading, GTK_ALIGN_START);
+ gtk_box_append(GTK_BOX(box), heading);
+
+ GtkWidget* y_auto = row_btn("Y axis: auto scale");
+ g_signal_connect(y_auto, "clicked", G_CALLBACK(on_scope_y_auto_clicked), user);
+ GtkWidget* y_fixed = row_btn("Y axis: fixed range...");
+ g_signal_connect(y_fixed, "clicked", G_CALLBACK(on_scope_y_fixed_clicked), user);
+ GtkWidget* fit_x = row_btn("Fit X axis");
+ g_signal_connect(fit_x, "clicked", G_CALLBACK(on_scope_fit_x_clicked), user);
+ GtkWidget* fit = row_btn("Fit all");
+ g_signal_connect(fit, "clicked", G_CALLBACK(on_scope_fit_all_clicked), user);
+
+ // One removal per trace on the plot, flat rather than as a second view: a scope window holds
+ // at most its own figure's traces, and the menu is small.
+ bool traces = false;
+ for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
+ const std::size_t si = static_cast(s);
+ const std::string name = scope->rows->row(row).signal_name[si];
+ if (name.empty()) continue;
+ traces = true;
+ GtkWidget* rm = row_btn(("Remove '" + name + "'").c_str());
+ g_object_set_data(G_OBJECT(rm), "udpscope-slot", GINT_TO_POINTER(s));
+ g_signal_connect(rm, "clicked", G_CALLBACK(on_scope_remove_clicked), user);
+ }
+ if (!traces) {
+ GtkWidget* none = gtk_label_new("no traces on this plot");
+ gtk_widget_add_css_class(none, "dim-label");
+ gtk_widget_set_halign(none, GTK_ALIGN_START);
+ gtk_box_append(GTK_BOX(box), none);
+ }
+
+ if (scope->detached) {
+ GtkWidget* back = row_btn("Attach back to the main window");
+ g_signal_connect(back, "clicked", G_CALLBACK(on_scope_attach_menu_clicked), user);
+ }
+
+ gtk_popover_set_child(GTK_POPOVER(popover), box);
+ GdkRectangle rect{static_cast(x), static_cast(y), 1, 1};
+ gtk_popover_set_pointing_to(GTK_POPOVER(popover), &rect);
+ gtk_widget_set_parent(popover, widget);
+ gtk_popover_popup(GTK_POPOVER(popover));
+ g_signal_connect(popover, "closed", G_CALLBACK(on_popover_closed), nullptr);
+}
+
/// The availability of everything that does nothing until the stream has signals: the dock and the two
/// slide-over pages that were once in it (Cursors, Measurements). "Disable, do not hide": a page may be
/// opened by its key before the first CONFIG, and shows its controls insensitive until then. The signal
@@ -3768,6 +4867,11 @@ gboolean tick(gpointer user) {
reset_measure_stats(*app);
app->search.clear();
rebuild_signal_panels(*app);
+ // The measures pane too, for the row's channel number: the number is the channel's
+ // position in the new list, and a rebuild is what re-derives it. The stats were reset
+ // above for exactly this reason (the old readings do not belong to the new list), so the
+ // rebuild's own reset is the same act, not a second one.
+ rebuild_measures_pane(*app);
}
// A gesture moves the *plot's* cursor; adopting it here is what makes the two
// vertical markers one time across every row, and what the pane then reads.
@@ -3776,16 +4880,29 @@ gboolean tick(gpointer user) {
// (`cursor(id).x` is the free pointer position while a drag runs and the snapped value
// otherwise, so reading outside a gesture reads our own echo).
int cursor_drag_row = -1;
+ // The same question for the measurement gate's region: a drag on it is the only time
+ // `region(id)`'s bounds are the hand's and not our own last write.
+ int gate_drag_row = -1;
if (app->area != nullptr) {
for (int i = 0; i < app->rows->row_count(); ++i) {
plot::input::Controller* c = app->area->row_controller(static_cast(i));
- if (c != nullptr && c->grabbed_cursor() != plot::kInvalid) {
- cursor_drag_row = i;
- break;
- }
+ if (c == nullptr) continue;
+ if (cursor_drag_row < 0 && c->grabbed_cursor() != plot::kInvalid) cursor_drag_row = i;
+ if (gate_drag_row < 0 && c->grabbed_region() != plot::kInvalid) gate_drag_row = i;
}
}
app->rows->sync_cursors(cursor_drag_row);
+ app->rows->sync_gate(gate_drag_row);
+ // The scope windows' instruments, from the main window's one owner: the cursors, the gate band
+ // and the trigger marker are the same instruments on every window, and a drag on one of them
+ // *there* comes back through here to the one owner.
+ sync_scope_instruments(*app);
+ // The drag's end is a new pair of gate bounds, and the bounds are session state: save there,
+ // the way a dragged row sizing is saved when its own gesture ends. During the drag every
+ // frame would only re-arm the same 400 ms debounce, but the *end* is the one moment the
+ // state is neither mid-gesture nor already saved.
+ if (app->gate_drag_last >= 0 && gate_drag_row < 0) schedule_layout_save(*app);
+ app->gate_drag_last = gate_drag_row;
update_cursors_pane(*app);
update_measures(*app, /*force=*/false, /*accumulate=*/true);
// Every frame, whether or not a scan is running: the marks are in stream seconds and the axis
@@ -3875,6 +4992,13 @@ gboolean tick(gpointer user) {
app->rows_b->bind(model, display);
app->rows_b->acquire(model);
}
+ // A scope window is a second view of the same data: its own figure binds and acquires
+ // under the same lock, and the window/pin mirroring after it keeps the two windows on
+ // one span.
+ for (const std::unique_ptr& scope : app->scopes) {
+ scope->rows->bind(model, display);
+ scope->rows->acquire(model);
+ }
// What x = 0 means, written *after* the bind and the acquire on purpose: the live answer
// is minus the newest sample the traces can now report, and asking before they were bound
// would put the newest data a frame's production to the right of the axis -- a third of
@@ -3914,6 +5038,75 @@ gboolean tick(gpointer user) {
// acquisition thread mutates, so it belongs under this lock and not after it.
app->rows->set_invalid_spans(model, display);
}
+ // The scope windows, outside the lock: mirroring a window, restating a pin and asking for a
+ // render are RowSet and GTK work, not model reads. The span follows the main window every
+ // frame -- a scope is "the same picture, another window" -- and its own media (a held event
+ // binds the same snapshot under the same epoch) comes from the bind above.
+ if (!app->scopes.empty()) {
+ const plot::Range pin = app->rows->pinned_range();
+ const bool pinned = app->rows->window_pinned();
+ const double window_s = app->rows->time_window();
+ for (const std::unique_ptr& scope : app->scopes) {
+ if (pinned) {
+ // The pin as a *range*: `pin_event_window()` takes spans (pre, post), and handing it
+ // `pin.min` -- a signed time -- inverted the window, so a scope in Held or Paused mode
+ // drew no trace and no x labels at all.
+ scope->rows->pin_display_range(pin.min, pin.max);
+ } else if (std::fabs(scope->rows->time_window() - window_s) > 1.0e-12) {
+ scope->rows->set_time_window(window_s);
+ }
+ // An arrangement change (a drop, a *Plot on* button) rebuilt the row set: point the area at
+ // the new figure here, in the tick -- the change arrives from inside this very widget (a drop
+ // signal), and a synchronous rebuild would reset the drag controller under it. The traces
+ // themselves are `SlotSeries` rebinds, so a slot that gains or loses a source draws either
+ // way; what *does* need this is the figure's own state: a row's legend, its cell structure,
+ // and the trace objects `install()` creates only for the slots that hold a signal at rebuild
+ // time -- a window opened empty and filled by a drop has none until this runs.
+ if (scope->rows->rebuild_if_dirty() && scope->area != nullptr) {
+ scope->area->set_figure(&scope->rows->figure());
+ }
+ if (scope->detached || scope->layout_toggle != nullptr) sync_scope_attach(*app, *scope);
+ // The window's own editor: buttons restated from the rects this frame was laid out with.
+ // Its own arrangement is its own -- nothing here saves the main window's session.
+ if (scope->layout_editing) sync_scope_layout_editor(*app, *scope);
+ // **A window that cannot paint says so.** A blank plot is indistinguishable from a
+ // context that failed or an area that never got a frame, and the report ("nothing is
+ // drawn, no axes") is exactly what this turns into a sentence: the window's own title
+ // carries the reason, and stderr carries it with the window's name.
+ if (scope->area != nullptr && scope->paint_checks >= 0 && scope->paint_checks < 120) {
+ ++scope->paint_checks;
+ if (scope->paint_checks == 120) {
+ const plot::Status st = scope->area->init_status();
+ const plot::u64 painted = scope->area->frames_painted();
+ if (!st || painted == 0) {
+ std::string why =
+ st ? std::string("the plot painted no frame in two seconds")
+ : std::string("GL context failed: ") + st.message;
+ // Name the GSK renderer: the Vulkan one is known to interact badly with an embedded
+ // `GtkGLArea` in a second toplevel, and the workaround is one environment variable.
+ if (GskRenderer* r = gtk_native_get_renderer(GTK_NATIVE(scope->window))) {
+ const char* type = G_OBJECT_TYPE_NAME(r);
+ why += std::string(" [GSK renderer: ") + type + "]";
+ if (g_strstr_len(type, -1, "Vulkan") != nullptr) {
+ why += "; restart with GSK_RENDERER=gl to check";
+ }
+ }
+ std::fprintf(stderr, "[scope] window '%s' is not drawing: %s\n",
+ scope->name.c_str(), why.c_str());
+ std::fflush(stderr);
+ set_notice(*app, "window '" + scope->name + "' is not drawing: " + why, 10);
+ if (scope->window != nullptr) {
+ const char* current = gtk_window_get_title(scope->window);
+ gtk_window_set_title(scope->window,
+ ((current != nullptr ? std::string(current) + " — " : "") + why)
+ .c_str());
+ }
+ }
+ }
+ }
+ if (scope->widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(scope->widget));
+ }
+ }
// A depth the room or the retention budget could not show in full says so, once per *plan* rather
// than once per frame: a user who asked for eight layers and sees two has no other way to tell
// "the display is deliberately shallower" from "the feature is broken". Both bounds are named,
@@ -4299,6 +5492,9 @@ gboolean tick(gpointer user) {
// The editor's buttons are placed from the rects this frame was laid out with, so they
// follow a resize and the gutter settling without being told.
if (app->layout_editing) sync_layout_editor(*app);
+ // The full-screen toggles are on the overlay always, so they follow the figure every frame
+ // (and rebuild themselves when the figure row count changed).
+ sync_maximize_buttons(*app);
// The one thing that has to be decided *per frame* about the dock: whether there is room for it
// beside the plot. Collapsed, the split view puts it *over* the plot rather than giving it a share of
@@ -4648,6 +5844,31 @@ gboolean tick(gpointer user) {
gtk_widget_set_tooltip_text(app->status_badge, kWhy[reason]);
}
+ // The trigger's own circle, one row up in the same alphabet as the badge: solid green while
+ // the trigger has fired (an event held, or a capture filling), flashing green while armed
+ // and waiting for one, red while armed-able but disarmed, and *hidden* while the trigger is
+ // off -- a light for a mode the user is not in is noise, and the mode line next to it is
+ // not showing trigger words either. The flash is the badge's own half-second square, and
+ // the markup is rewritten on a change of the state key only, for the same relayout reason
+ // the badge's is.
+ const int circle = !trigger_on ? -1
+ : (app->event_held || app->capturing)
+ ? 0
+ : armed ? (lit ? 1 : 2) : 3;
+ app->dbg_trigger_circle = circle;
+ if (app->trigger_status != nullptr && circle != app->trigger_circle_state) {
+ app->trigger_circle_state = circle;
+ gtk_widget_set_visible(app->trigger_status, circle >= 0);
+ if (circle >= 0) {
+ gtk_label_set_markup(GTK_LABEL(app->trigger_status), trigger_circle_markup(circle));
+ gtk_widget_set_tooltip_text(
+ app->trigger_status,
+ circle == 0 ? "trigger: triggered (an event is on screen)"
+ : circle == 3 ? "trigger: disarmed (Arm to wait for an event)"
+ : "trigger: armed, waiting for an event");
+ }
+ }
+
int bound = 0;
std::size_t samples = 0;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
@@ -5169,6 +6390,32 @@ GtkWidget* build_toolbar(App& app) {
app.menu_help_row = help_row;
gtk_box_append(GTK_BOX(menu_box), help_row);
+ GtkWidget* scope_item = menu_button("New scope window", nullptr);
+ gtk_widget_set_tooltip_text(scope_item,
+ "a second window onto the same stream, with an empty figure; "
+ "a plot's own menu opens that figure in one");
+ g_signal_connect(scope_item, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
+ open_scope_window(*static_cast(user), -1);
+ }),
+ &app);
+ gtk_box_append(GTK_BOX(menu_box), scope_item);
+ // The windows a session remembers but a launch does not open: the entry carries their count and
+ // opens them on request.
+ app.reopen_scopes_item = menu_button("Reopen closed windows", nullptr);
+ gtk_widget_set_tooltip_text(app.reopen_scopes_item,
+ "open the extra windows the session remembers (they are saved, "
+ "never opened at start)");
+ g_signal_connect(app.reopen_scopes_item, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
+ App& a = *static_cast(user);
+ std::vector windows = std::move(a.closed_windows);
+ a.closed_windows.clear();
+ for (const LayoutState::Window& w : windows) open_scope_window_from_state(a, w);
+ sync_reopen_scopes_item(a);
+ schedule_layout_save(a);
+ }),
+ &app);
+ gtk_box_append(GTK_BOX(menu_box), app.reopen_scopes_item);
+ sync_reopen_scopes_item(app);
GtkWidget* prefs_item = menu_button("Preferences\u2026", nullptr);
g_signal_connect(prefs_item, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
open_preferences(*static_cast(user));
@@ -5283,6 +6530,7 @@ void on_activate(GtkApplication* application, gpointer user) {
app->plot_split = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_split"));
app->plot_overlay = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_overlay"));
app->status_badge = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_badge"));
+ app->trigger_status = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "trigger_status"));
app->status_label = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_label"));
app->status_info_button = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_info"));
// The status line's row, which is where the status line's own "?" joins and one of the surfaces the
@@ -5309,18 +6557,43 @@ void on_activate(GtkApplication* application, gpointer user) {
}
// The window's actions live on the application, so it has to know which one it belongs to.
gtk_window_set_application(GTK_WINDOW(app->window), application);
- // A window closed by hand (a WM close button) must stop the frame tick: it holds
- // pointers to widgets that are finalized with the window.
- g_signal_connect(app->window, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer user) {
+ // The destroy handler below needs the application, and a captureless callback cannot close
+ // over it: kept on the window, which outlives the handler.
+ g_object_set_data(G_OBJECT(app->window), "udpscope-application", application);
+ // The main window's lifecycle hook, and it is `close-request`: **GTK4 has no
+ // `GtkWidget::destroy` signal** (the GTK3 connect this used to be never fired), and the only
+ // way a window is asked to end is the close request -- the WM's button, `gtk_window_close()`
+ // from a menu action, and the exit probe's own close. `gtk_window_destroy()` does not emit
+ // it, which is why everything that ends this window programmatically goes through
+ // `gtk_window_close()` instead.
+ //
+ // What it does, in order: the **scope windows** go with it (they are views of this window's
+ // acquisition; left behind they keep the GApplication's loop alive around a window that is
+ // gone -- the exit run's hang), the debounced layout save is flushed (a pending save would
+ // never fire now), the tick is stopped (`a->window = nullptr`; the widgets are about to be
+ // finalized), and the application is asked to quit -- the main window is its anchor, so the
+ // run must end whatever else was open. `FALSE` lets the default handler destroy the window.
+ g_signal_connect(app->window, "close-request", G_CALLBACK(+[](GtkWindow* window, gpointer user) {
App* a = static_cast(user);
- // A pending debounced save will never fire now: flush it, so
- // quitting right after a change keeps the change.
+ // Collected first: each scope's own destroy handler erases it from
+ // `scopes` as it fires.
+ std::vector scope_windows;
+ scope_windows.reserve(a->scopes.size());
+ for (const std::unique_ptr& scope : a->scopes) {
+ if (scope->window != nullptr) scope_windows.push_back(scope->window);
+ }
+ for (GtkWindow* w : scope_windows) gtk_window_close(w);
if (a->layout_save_source != 0) {
g_source_remove(a->layout_save_source);
a->layout_save_source = 0;
save_layout_now(*a);
}
a->window = nullptr;
+ if (auto* application = GTK_APPLICATION(
+ g_object_get_data(G_OBJECT(window), "udpscope-application"))) {
+ g_application_quit(G_APPLICATION(application));
+ }
+ return FALSE;
}),
app);
// The header bar is the window's own titlebar, and it is *not* in the `.ui`: every button on it is
@@ -5362,6 +6635,12 @@ void on_activate(GtkApplication* application, gpointer user) {
// tooltip says which phase the badge is *not* reporting.
gtk_label_set_markup(GTK_LABEL(app->status_badge), badge_markup(BadgePhase::Idle));
gtk_widget_set_tooltip_text(app->status_badge, "waiting for the stream");
+ // The trigger circle starts hidden: the trigger starts off, and a light for a mode the user
+ // is not in is noise (see the tick's write).
+ if (app->trigger_status != nullptr) {
+ gtk_label_set_markup(GTK_LABEL(app->trigger_status), trigger_circle_markup(3));
+ gtk_widget_set_visible(app->trigger_status, FALSE);
+ }
// The line is rewritten every frame and its text changes width as the counters
// grow. A label's *minimum* width is its text, so an unellipsized one pushes the
@@ -6122,6 +7401,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
app.model = &model;
app.acq = &acq;
app.rows = &rows;
+ // The crosshair's axis badges, before the window exists: the setting is per plot, and the
+ // flag on the set is what every rebuild restates (see `RowSet::install()`).
+ rows.set_crosshair_axis_labels(startup_settings.crosshair_axis_labels);
// Which data source this session talks to: the persisted-layout file's name.
// A waveform has **no source**: the address the command line defaults to is not one, and treating
// it as one made a file-mode run load -- and then *save* -- the session of `127.0.0.1:8000`, which
@@ -6141,11 +7423,20 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
app.source_title = app.source_host + ":" + std::to_string(app.source_port);
}
if (SourceKind* k = find_kind(cfg.source_kind)) app.source_has_datagrams = k->has_datagrams();
- // The event cache is keyed on the source, like the packet capture. A waveform has no source, so a
- // file-mode run has no cache either -- there are no latches in a file.
+ // The event cache is keyed on the source **and its active configuration**, like the packet
+ // capture and the session files: an event's trigger, channel set and units belong to the
+ // configuration it was latched under, so two configurations of one source must not list each
+ // other's events. The active configuration is readable here -- the configuration files are not
+ // stream data -- so the store starts on the right one instead of being re-keyed after the first
+ // CONFIG arrives. A waveform has no source, so a file-mode run has no cache either -- there are
+ // no latches in a file.
if (!app.source_host.empty() && app.source_port != 0) {
- app.event_store = std::make_unique(app.source_host + "-" +
- std::to_string(app.source_port), app.settings.event_cache_dir);
+ if (app.config_name.empty()) {
+ app.config_name = active_configuration(app.source_host, app.source_port);
+ }
+ app.event_store = std::make_unique(
+ app.source_host + "-" + std::to_string(app.source_port), app.settings.event_cache_dir,
+ app.config_name);
}
refresh_events_pane(app);
@@ -6244,6 +7535,18 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURE_MS")) {
g_timeout_add(static_cast(std::atoi(env)), measure_probe, &app);
}
+ if (const char* env = g_getenv("UDPSCOPE_PROBE_GATE_MS")) {
+ g_timeout_add(static_cast(std::atoi(env)), gate_probe_step, &app);
+ }
+ if (const char* env = g_getenv("UDPSCOPE_PROBE_CROSSHAIR_MS")) {
+ g_timeout_add(static_cast(std::atoi(env)), crosshair_probe, &app);
+ }
+ if (const char* env = g_getenv("UDPSCOPE_PROBE_MAXIMIZE_MS")) {
+ g_timeout_add(static_cast(std::atoi(env)), maximize_probe_step, &app);
+ }
+ if (const char* env = g_getenv("UDPSCOPE_PROBE_SCOPE_MS")) {
+ g_timeout_add(static_cast(std::atoi(env)), scope_probe_step, &app);
+ }
if (const char* env = g_getenv("UDPSCOPE_PROBE_LEVELS_MS")) {
g_timeout_add(static_cast(std::atoi(env)), levels_probe, &app);
}
@@ -6732,7 +8035,16 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
std::printf("[udpscope] measurements: %zu", app.measures.size());
for (std::size_t i = 0; i < app.measures.size() && i < app.measure_results.size(); ++i) {
const MeasureResult& r = app.measure_results[i];
- std::printf(" %s=%s", measure_type_name(app.measures[i].type),
+ // Numbered like the pane's row: which active channel this measurement is about, and
+ // unnumbered when its signal is not acquired -- a probe that switched a signal off
+ // before the dump (enable_all does exactly that) must not read as a numbering fault.
+ // The number is read from the model here (the dump cannot ask the row's label), so a
+ // mismatch with what the pane showed while the signal was up is a finding about
+ // `measure_channel_number`.
+ const int number = measure_channel_number(app, app.measures[i].signal);
+ const std::string prefix = number > 0 ? std::to_string(number) + "." : "";
+ std::printf(" %s%s %s=%s", prefix.c_str(),
+ app.measures[i].signal.c_str(), measure_type_name(app.measures[i].type),
r.valid ? std::to_string(r.primary).c_str() : "--");
if (r.has_secondary) std::printf("(%s)", std::to_string(r.secondary).c_str());
}
@@ -6762,6 +8074,20 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
: 0.0);
}
}
+ // The measurement gate's own line: whether it is on and where its bounds sit. The state is
+ // the RowSet's (no GTK here -- the dump runs after the window is gone), and it is what a
+ // session round trip is read against: a restore that left the gate off, or put the band
+ // somewhere else than the saved session had it, reads here.
+ std::printf("[udpscope] gate: on=%d bounds=[%.6f..%.6f]\n",
+ app.rows->gate_visible() ? 1 : 0, app.rows->gate_x0(), app.rows->gate_x1());
+ // The trigger circle's state as of the last tick: off / green / flash / dim / red. The
+ // dump runs after the window is gone, so this is the tick's cached answer, not a read.
+ {
+ static const char* const kCircle[] = {"green", "flash", "dim", "red"};
+ const int c = app.dbg_trigger_circle;
+ std::printf("[udpscope] trigger circle: %s\n",
+ c < 0 ? "off" : (c < 4 ? kCircle[c] : "?"));
+ }
std::printf("[udpscope] badge frames: lit=%d dim=%d idle=%d connecting=%d\n",
app.dbg_badge_frames[0], app.dbg_badge_frames[1], app.dbg_badge_frames[2],
app.dbg_badge_frames[3]);
diff --git a/src/ui/main-window.ui b/src/ui/main-window.ui
index 8903bfe..973d9ce 100644
--- a/src/ui/main-window.ui
+++ b/src/ui/main-window.ui
@@ -153,6 +153,13 @@
226
+
+
+
+
diff --git a/src/ui/measures-panel.ui b/src/ui/measures-panel.ui
index bd4086d..56b585d 100644
--- a/src/ui/measures-panel.ui
+++ b/src/ui/measures-panel.ui
@@ -9,9 +9,10 @@
readout row — so the rows are built and rebuilt in code, and this file holds the frame they land in
(`measures_list`) plus the three widgets whose state outlives them.
- One statistics switch for the panel rather than one per reading: statistics are either what a user is
- looking at or they are not, and a per-row switch is a column of chrome paid for on every row to answer a
- question asked once.
+ One statistics switch for the panel rather than one per reading: statistics are either what a user
+ is looking at or they are not, and a per-row switch is a column of chrome paid for on every row to
+ answer a question asked once. The gate is the opposite case — a per-measurement choice — so it
+ lives in the measurement dialog, not here.
-->
+
+
+