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

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.

+

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. 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/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..0fc7974 100644 --- a/src/layout_store.cpp +++ b/src/layout_store.cpp @@ -573,6 +573,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) { @@ -685,6 +690,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 +769,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"; @@ -841,7 +850,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..76422f2 100644 --- a/src/layout_store.hpp +++ b/src/layout_store.hpp @@ -87,6 +87,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..c5f7756 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); } @@ -992,6 +1057,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 +1067,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 +1382,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. @@ -1461,6 +1585,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..3fb5d08 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); @@ -369,12 +393,16 @@ 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; 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 +522,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. @@ -585,6 +640,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 +691,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 +733,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 +757,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..9997e1f 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,85 @@ 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) { + 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); + scope->window = GTK_WINDOW(gtk_window_new()); + const std::string title = figure_row >= 0 + ? "udpscope \u2014 plot " + std::to_string(figure_row + 1) + : std::string("udpscope \u2014 scope"); + gtk_window_set_title(scope->window, title.c_str()); + gtk_window_set_default_size(scope->window, 900, 500); + gtk_window_set_child(scope->window, scope->widget); + 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->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; + } + }), + &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 +989,146 @@ 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_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(); + 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); + } +} + +/// 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 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 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 +1911,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 +2225,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 +2307,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 +2352,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); @@ -2663,6 +2933,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); @@ -3069,6 +3340,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 @@ -3768,6 +4054,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 +4067,25 @@ 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 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 +4175,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 +4221,23 @@ 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) { + scope->rows->pin_event_window(pin.min, pin.max); + } else if (std::fabs(scope->rows->time_window() - window_s) > 1.0e-12) { + scope->rows->set_time_window(window_s); + } + 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 +4623,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 +4975,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 +5521,15 @@ 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); 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 +5644,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 +5671,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 +5749,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 +6515,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 +6537,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 +6649,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 +7149,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 +7188,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 @@ 2 2 6 + + + + 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. --> diff --git a/src/ui/preferences.ui b/src/ui/preferences.ui index 837b77a..e757e9e 100644 --- a/src/ui/preferences.ui +++ b/src/ui/preferences.ui @@ -68,6 +68,13 @@ Take the plot's time from the sample rate and the packet counter rather than from the stream's own clock, so arrival and clock jitter cannot move a sample; absolute time is still kept for export. Read at start-up, because the buffers are planned before any source is known. + + + Crosshair _axis labels + True + Show the value and the time where the crosshair's lines meet the axes, each in its axis' own format, in the strips the tick labels are written in. Applies at once. + + _Theme diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 7ce88d2..b3a26a1 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -190,6 +190,14 @@ struct App { /// figure, so there is one widget, one context and one `::render` whatever the row /// count is. GtkWidget* area_widget = nullptr; + /// The pointer's last position over the plot area, in the area's coordinates (the overlay's, + /// for the toggles' corner), and whether one is over it at all: the full-screen toggles are + /// revealed by proximity, so the frame tick reads this. The overlay's own motion controller + /// keeps it current, which is why a pointer *on* a revealed toggle still counts as over the + /// area -- the toggle is an overlay child, and the event still travels the ancestor. + double area_pointer_x = 0.0; + double area_pointer_y = 0.0; + bool area_pointer_valid = false; plot::gtk4::PlotArea* area = nullptr; /// The GL area wrapped in a `GtkOverlay`, which is what the plot box actually holds. /// The layout editor's buttons are overlay children: each is a small widget placed by @@ -197,6 +205,24 @@ struct App { /// is -- a single transparent child covering the area would swallow every pan, drop and /// hover the editor is not interested in. GtkWidget* area_overlay = nullptr; + /// One extra top-level scope window: its own figure and `RowSet`, bound to the same + /// `ScopeModel` by the main window's tick -- which is also its repaint pump -- so a scope + /// is a second *view* of one acquisition, never a second acquisition. The main window's + /// x window (or its pin) is mirrored onto it every frame, so the two look at the same + /// span; its own controllers can still pan and zoom, and the mirror reclaims the span on + /// the next frame. + struct ScopeWindow { + GtkWindow* window = nullptr; + plot::gtk4::PlotArea* area = nullptr; + GtkWidget* widget = nullptr; + /// The area's initial plot, owned here: `PlotArea::create()` wants a `Plot&` that + /// outlives the widget, and sharing the main window's placeholder would hand two areas + /// one plot the moment the figure mode is ever left. + plot::Plot placeholder; + std::unique_ptr rows; + }; + std::vector> scopes; + /// On while the layout editor is up. Nothing else about the plot changes: the editor is /// a set of overlay buttons, not a mode the figure knows about. bool layout_editing = false; @@ -204,6 +230,11 @@ struct App { /// The editor's buttons, in the order they were added, so they can be taken off the /// overlay again. Rebuilt when the row count changes, repositioned when the rows move. std::vector layout_buttons; + /// The full-screen toggles: one per figure row, on the overlay **always** (not only while + /// the editor is up), placed in each row's top-right corner. Rebuilt when the figure row + /// count changes (`maximize_buttons_rows` is the count they were built for). + std::vector maximize_buttons; + int maximize_buttons_rows = -1; /// The boundary a resize drag is on, and the weights it started from: a drag is *relative* to where it /// began, so the beginning has to be kept -- reading the weights while dragging would compound. int layout_drag_row = -1; @@ -261,6 +292,15 @@ struct App { /// What the badge was last set to (`BadgePhase`), so the markup is rewritten on a /// change of phase rather than on every one of the 60 frames a second. int badge_phase = -1; + /// The trigger status circle beside it (`main-window.ui` `trigger_status`): green while an + /// event is held or a capture is filling, flashing green while armed and waiting, red while + /// disarmed, hidden while the trigger is off. Same width-locked logic as the badge: one + /// "●" glyph in every state, markup rewritten on a change of the state key only, and the + /// flash runs off the same half-second square the badge blinks on. + GtkWidget* trigger_status = nullptr; + int trigger_circle_state = -1; + /// The circle's state as of the last tick, for the exit dump (which cannot read widgets). + int dbg_trigger_circle = -1; /// The display-window control: a header-bar button whose label is the current window /// and whose popover holds the number, its unit and the two decade steppers. The label /// is width-locked (see `kWindowLabelChars`) because the header bar sits above the plot @@ -681,6 +721,14 @@ struct App { plot::Range measure_window{}; bool measure_window_valid = false; bool measure_was_held = false; + /// The **gate band** the last pass saw, in the same stream seconds, clipped to the window the + /// pane was about. The gate is per measurement, but the band is one: when any measurement is + /// gated, a band that moves -- a drag, a seed, a mode change -- changes what *those* + /// measurements describe without the viewport or the pin moving, so the "it moved" question + /// is asked of this as well. Booked even when nothing is gated (then it is the pane's own + /// window, and the comparison below repeats one the viewport has already answered). + plot::Range measure_gate_window{}; + bool measure_gate_window_valid = false; /// The window **on screen**, in the same stream seconds, as of the last *tick* -- which is the /// only thing `measure_window` is comparable with. Captured here rather than read back in the /// dump for the reason every other dump field is (see verification.md): the dump runs from the @@ -704,12 +752,15 @@ struct App { /// `MeasureChunk`). /// One channel's window, at one HF corner, with every level accumulator the pane asked for. /// - /// Keyed by `(channel, fc)` and not by channel alone: the corner is a per-measurement setting, so two - /// measurements on one signal at different corners are reading two different signals and cannot share a - /// walk. + /// Keyed by `(channel, fc, gated)` and not by channel alone: the corner and the gate are + /// per-measurement settings, so two measurements on one signal at different corners -- or one + /// gated and one not -- are reading two different windows and cannot share a walk. A gated + /// window is walked over the gate band clipped to the pane's window; an ungated one over the + /// pane's window whole. struct MeasureWindow { int channel = -1; double fc = 0.0; ///< the HF corner this window was walked at; 0 is unfiltered + bool gated = false; ///< this window is the gate band, not the pane's window whole MeasureStats stats; std::vector pct; ///< the level shares asked for, deduplicated std::vector noise; ///< each entry's band, as a percentage; parallel to `pct` @@ -798,6 +849,29 @@ struct App { /// probes add measurements, so they must not be scheduled in one run (see verification.md), and a /// shared counter would make that a silent mis-step rather than a documented prohibition. int dbg_bandavg_step = 0; + /// The gate probe's (`UDPSCOPE_PROBE_GATE_MS`) step counter and bookkeeping: the bounds it + /// set and the `s0` it read when it set them, so the second leg can say whether the band + /// stayed still while the stream's clock moved under it. + int dbg_gate_step = 0; + double dbg_gate_x0 = 0.0; + double dbg_gate_x1 = 0.0; + double dbg_gate_s0 = 0.0; + /// The gate probe's settle polls (`dbg_gate_step == 1` re-enters while the ring is younger + /// than the window, which is what its whole-periods arithmetic needs), bounded so a stalled + /// stream still measures and prints. + int dbg_gate_wait = 0; + /// The row a gate-region drag was on last frame, or -1. The fall to -1 is the drag's end, + /// and the tick saves the session there: the bounds are session state, and a drag that is + /// not saved is a session that lies about where the gate is. + int gate_drag_last = -1; + /// Which step of the crosshair probe runs next (`UDPSCOPE_PROBE_CROSSHAIR_MS`). + int dbg_crosshair_step = 0; + /// Which step of the maximize probe runs next (`UDPSCOPE_PROBE_MAXIMIZE_MS`). + int dbg_maximize_step = 0; + std::size_t dbg_maximize_single = 0; + int dbg_maximize_hidden = -1; + /// Which step of the scope-window probe runs next (`UDPSCOPE_PROBE_SCOPE_MS`). + int dbg_scope_step = 0; int cursors_pane_mode = -1; int cursors_pane_rows = -1; unsigned cursors_pane_generation = ~0u; @@ -1440,6 +1514,10 @@ struct MeasureDialog { GtkWidget* band = nullptr; GtkWidget* above = nullptr; GtkWidget* show = nullptr; + /// The per-measurement gate: measure only inside the one shared band. Type-independent -- + /// the gate is about *which samples* answer, and every type reads samples -- so unlike the + /// rows above it is shown for every type and never retitled. + GtkWidget* gate = nullptr; GtkWidget* window = nullptr; /// The option rows' boxes and labels, so the dialog can *retitle* them per type: the same /// switch means "the pulse is above the level" for a pulse and nothing at all for a period, @@ -1618,6 +1696,12 @@ constexpr int kWindowLabelChars = 9; constexpr int kRowKeyBias = 1; constexpr int kLayoutButtonPx = 24; constexpr int kLayoutButtonPad = 4; +/// How far from its corner a full-screen toggle comes out of hiding. The toggles are on the +/// screen only while the pointer is near them; this is the slack around the button's own rect +/// (a square corner zone a few button-widths wide), large enough that a pointer arrives without +/// having to find the exact pixel and small enough that crossing a plot's top edge leaves it +/// hidden. +constexpr int kMaximizeRevealPx = 48; constexpr const char* kLayoutKindKey = "udpscope-layout-insert"; /// The ↕ handles' own key: what they are is *not* what the `+` is, and `sync_layout_editor()` @@ -1627,6 +1711,9 @@ constexpr const char* kLayoutResizeKey = "udpscope-layout-resize"; constexpr const char* kLayoutCellKey = "udpscope-layout-cell"; /// Column resize handles carry the plot index of the right-hand cell. constexpr const char* kLayoutColKey = "udpscope-layout-col"; +/// The full-screen toggle's key: the figure row it acts on, +1 (0 is not a valid row). Its +/// own key because the button is on the overlay *always*, not only while the editor is up. +constexpr const char* kMaximizeKey = "udpscope-maximize-row"; /// The minimum width a cell may be dragged to, in logical pixels. constexpr double kMinCellPx = 96.0; /// The plotter's own per-cell floor, `kMinCellWidthLogical` in `deps/plotter/src/plot/figure.cpp` @@ -1705,6 +1792,9 @@ int window_unit_for(double s); std::string format_window_value(double s, int unit); std::string format_window(double s); const char* badge_markup(BadgePhase phase); +/// The trigger status circle's markup, by state (see `trigger_circle_markup` in +/// `ui_status.cpp`): 0 solid green, 1/2 the flash's halves, 3 red. +const char* trigger_circle_markup(int state); std::string format_measure(double v, const std::string& unit); void set_notice(App& app, std::string what, int seconds = 4); void set_row_key(GtkWidget* widget, int row); @@ -1862,6 +1952,9 @@ bool event_on_screen(const App& app, const EventRecord& r); bool event_in_group(const App& app, const EventRecord& r); /// The signal chip of a measurement's row, in the colour every other view names that signal with. std::string measure_chip_markup(App& app, const std::string& signal); +/// The 1-based number of an active channel (its position among the acquired channels), or 0 +/// when the signal is not acquired. The number the badge bar and the measurements pane show. +int measure_channel_number(App& app, const std::string& signal); std::string row_unit(App& app, int row); std::string slot_unit(App& app, int row, int slot); std::uint32_t bound_slot_mask(App& app); @@ -2002,6 +2095,11 @@ GdkTexture* scale_texture(GdkTexture* source, int width, int height); EventThumbCache* event_thumb_cache(App& app); GdkTexture* event_preview_texture(App& app, const std::string& path); void refresh_events_pane(App& app); +/// Points the event cache at the configuration the session is on -- the active one when +/// `App::config_name` is empty -- and refreshes the two views of it when the directory changed. +/// Every configuration switch calls it next to setting `App::config_name`, so the list on screen +/// is always the configuration's own. +void retarget_event_store(App& app); /// Opens the `events` page. **Every route that opens that page must use this** and never `slideover_open()` /// directly: the page's list is rebuilt only while the page is open (see `record_event()`), and the /// slide-over has no on-open hook, so the list is refreshed here, once, before the page shows. The refresh @@ -2041,6 +2139,14 @@ gboolean mathwrap_probe_wait(gpointer user); gboolean mathwrap_probe_report(gpointer user); gboolean measure_probe(gpointer user); gboolean levels_probe(gpointer user); +gboolean gate_probe_step(gpointer user); +gboolean crosshair_probe(gpointer user); +gboolean maximize_probe_step(gpointer user); +gboolean scope_probe_step(gpointer user); +/// A second top-level window showing the same stream through its own figure: `figure_row` >= 0 +/// copies that figure of the main window (cells, widths, every slot's signal), -1 opens an +/// empty one-row scope. The main window's tick binds, acquires and repaints it. +void open_scope_window(App& app, int figure_row); gboolean measurepin_probe(gpointer user); gboolean measurepin_held_wait(gpointer user); gboolean measurepin_pause_wait(gpointer user); diff --git a/src/ui_badges.cpp b/src/ui_badges.cpp index 19ad510..5078b20 100644 --- a/src/ui_badges.cpp +++ b/src/ui_badges.cpp @@ -122,7 +122,9 @@ gboolean on_badge_drop(GtkDropTarget*, const GValue* value, double, double, gpoi return TRUE; } -/// A signal's badge: its colour dot (grey when it has no trace) and its name. Words and a dot, no icon. +/// A signal's badge: its colour dot (grey when it has no trace) and its name, prefixed with +/// the channel's number -- the same number the measurements pane shows, so a badge and a +/// measurement row answer "which channel" with one word. Words and a dot, no icon. void make_signal_badge(App& app, Badge& b, const std::vector& signals, std::size_t index) { b.signal = index; b.signal_name = signals[index].name; @@ -133,7 +135,13 @@ void make_signal_badge(App& app, Badge& b, const std::vector& signal GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget* dot = gtk_label_new(nullptr); gtk_label_set_markup(GTK_LABEL(dot), signal_chip_markup(app, signals, index).c_str()); - b.name = gtk_label_new(b.signal_name.c_str()); + // The channel's number, the same one the measurements pane carries: an active channel is + // "the k-th acquired channel", and the badge and the rows name it alike. A signal the + // stream does not carry yet (a badge can outlive its channel for a tick) shows unnumbered. + const int number = measure_channel_number(app, b.signal_name); + const std::string who = number > 0 ? std::to_string(number) + ". " + b.signal_name + : b.signal_name; + b.name = gtk_label_new(who.c_str()); gtk_label_set_ellipsize(GTK_LABEL(b.name), PANGO_ELLIPSIZE_END); gtk_label_set_max_width_chars(GTK_LABEL(b.name), 14); gtk_box_append(GTK_BOX(row), dot); diff --git a/src/ui_dock.cpp b/src/ui_dock.cpp index d372830..6374b66 100644 --- a/src/ui_dock.cpp +++ b/src/ui_dock.cpp @@ -10,8 +10,9 @@ // layout does not depend on what a value reads; the full text is the label's tooltip. // // The same strings are shown a third time in the readouts strip under the plot (`readouts_strip`, one -// fixed-height line: a "Cursors" button, one chip per cursor line with its delta, a "Measures" button, one -// chip per measurement with its value), which is on screen whenever the dock is not beside the plot (hidden +// fixed-height line: a "Cursors" button, one chip per cursor line with its delta, one chip per +// measurement with its value and a `[+]` to add one), which is on screen whenever the dock is not +// beside the plot (hidden // with the Dock button, or the window collapsed and the dock an overlay). The setters below // write the strip's labels in the same call as the dock's, from the same argument, so there is still one // string and no second formatter. @@ -148,7 +149,12 @@ void dock_cursors_header(GtkGrid* grid) { } } -/// Fills the strip `main-window.ui` declares: the two buttons and the two boxes the rebuilds fill. +/// The strip's `[+]`: a new measurement, through the same dialog the pane's own `+` opens. +void on_add_measure(GtkButton*, gpointer user) { + open_measure_dialog(*static_cast(user), -1); +} + +/// Fills the strip `main-window.ui` declares: the two groups and the two boxes the rebuilds fill. void build_readouts_strip(App& app) { if (app.strip_box == nullptr) return; clear_box(app.strip_box); @@ -163,10 +169,13 @@ void build_readouts_strip(App& app) { gtk_widget_set_margin_top(app.strip_sep, 5); gtk_widget_set_margin_bottom(app.strip_sep, 5); app.strip_measure_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); - gtk_box_append(GTK_BOX(app.strip_measure_group), - strip_button("Measures", G_CALLBACK(on_edit_measures), &app, - "the measurements' full panel")); + // The chips, then the `[+]` after the last one: the compact strip is where the readings are + // watched, so that is where adding one belongs -- and it replaces the old "Measures" button, + // which only opened the panel any chip already opens its own measurement from (the dock and + // the page keep their Edit). gtk_box_append(GTK_BOX(app.strip_measure_group), app.strip_measure_box); + gtk_box_append(GTK_BOX(app.strip_measure_group), + strip_button("+", G_CALLBACK(on_add_measure), &app, "add a measurement")); gtk_box_append(GTK_BOX(app.strip_box), app.strip_cursor_group); gtk_box_append(GTK_BOX(app.strip_box), app.strip_sep); gtk_box_append(GTK_BOX(app.strip_box), app.strip_measure_group); @@ -274,6 +283,12 @@ void dock_rebuild_measures(App& app) { } for (std::size_t i = 0; i < app.measures.size(); ++i) { const Measure& m = app.measures[i]; + // The row's channel number, the same one the pane's row carries: the dock line says which + // channel it is about with one word, and the chip beside it says which colour. + const int number = measure_channel_number(app, m.signal); + const std::string who = + number > 0 ? std::to_string(number) + ". " + measure_type_name(m.type) + : std::string(measure_type_name(m.type)); GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); g_object_set_data(G_OBJECT(line), "udpscope-measure-index", GINT_TO_POINTER(static_cast(i) + 1)); @@ -281,7 +296,7 @@ void dock_rebuild_measures(App& app) { gtk_label_set_markup(GTK_LABEL(chip), measure_chip_markup(app, m.signal).c_str()); gtk_widget_set_tooltip_text(chip, m.signal.c_str()); gtk_box_append(GTK_BOX(line), chip); - GtkWidget* kind = fixed_label(measure_type_name(m.type), 12, GTK_ALIGN_START); + GtkWidget* kind = fixed_label(who.c_str(), 12, GTK_ALIGN_START); gtk_widget_set_hexpand(kind, true); gtk_widget_set_tooltip_text(kind, (m.signal + "\n" + measure_type_name(m.type)).c_str()); gtk_box_append(GTK_BOX(line), kind); @@ -306,7 +321,10 @@ void dock_rebuild_measures(App& app) { gtk_label_set_markup(GTK_LABEL(schip), measure_chip_markup(app, m.signal).c_str()); gtk_widget_set_tooltip_text(schip, m.signal.c_str()); gtk_box_append(GTK_BOX(sline), schip); - GtkWidget* skind = fixed_label(measure_type_name(m.type), kStripTypeChars, GTK_ALIGN_START); + const std::string swho = + number > 0 ? std::to_string(number) + ". " + measure_type_name(m.type) + : std::string(measure_type_name(m.type)); + GtkWidget* skind = fixed_label(swho.c_str(), kStripTypeChars, GTK_ALIGN_START); gtk_widget_set_tooltip_text(skind, (m.signal + "\n" + measure_type_name(m.type)).c_str()); gtk_box_append(GTK_BOX(sline), skind); GtkWidget* svalue = fixed_label("--", kValueChars, GTK_ALIGN_END); diff --git a/src/ui_events.cpp b/src/ui_events.cpp index 6703e41..5d2d37d 100644 --- a/src/ui_events.cpp +++ b/src/ui_events.cpp @@ -929,6 +929,14 @@ static void rebuild_events_pane_list(App& app, std::vector& records /// Both views of the cache, rebuilt from the same list: the events page's rows (with their pictures) and the /// dock block's compact ones. The dock follows even when the page's list does not exist. To *open* the page /// use `open_events_page()`, which calls this only when the page is not already open. +void retarget_event_store(App& app) { + if (app.event_store == nullptr) return; + const std::string name = app.config_name.empty() + ? active_configuration(app.source_host, app.source_port) + : app.config_name; + if (app.event_store->set_configuration(name)) refresh_events_pane(app); +} + void refresh_events_pane(App& app) { // One read of the cache directory for both views (none without a store, which the page's rebuild // and the dock's both treat as nothing to list). diff --git a/src/ui_layout.cpp b/src/ui_layout.cpp index c5f77a1..071e295 100644 --- a/src/ui_layout.cpp +++ b/src/ui_layout.cpp @@ -73,6 +73,8 @@ LayoutState collect_layout(App& app) { st.math = app.math; st.measures = app.measures; st.measure_stats = app.measure_stats_on; + st.gate_x0 = app.rows->gate_x0(); + st.gate_x1 = app.rows->gate_x1(); st.dock_shown = app.sidebar_wanted; // The trigger, its settings *and* whether it was on. The channel goes by name, like every identity // in this file: every rebuild renumbers the channels. @@ -138,6 +140,7 @@ void on_config_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) { app.config_name = name; app.config_comment = st.comment; set_active_configuration(app.source_host, app.source_port, name); + retarget_event_store(app); apply_layout(app, st); // A buffer size, a clock and a capture ring are decided before the first socket: saying so beats @@ -173,6 +176,8 @@ void on_config_forget_clicked(GtkButton* button, gpointer user) { app.config_comment.clear(); } forget_configuration(app.source_host, app.source_port, name); + // The cleared name means "whatever is active now", which `retarget_event_store()` resolves. + retarget_event_store(app); config_dialog_refresh(*d); } @@ -312,6 +317,7 @@ bool save_configuration_as(App& app, const std::string& name, const std::string& return false; } set_active_configuration(app.source_host, app.source_port, name); + retarget_event_store(app); return true; } @@ -442,6 +448,16 @@ void apply_layout(App& app, const LayoutState& st) { gtk_check_button_set_active(GTK_CHECK_BUTTON(app.measures_stats_check), app.measure_stats_on); } + // The gate's bounds, before the pane rebuild below: whether the gate is on rides on the + // restored measurements (`MeasureOptions::gate`), and `rebuild_measures_pane()` derives the + // band's visibility from them -- a session with a gated measurement gets the band back + // exactly where this file says it sat. A file that never carried bounds leaves the RowSet + // unseeded on purpose: `set_gate()` with the file's degenerate defaults would mark the + // bounds as placed, and the first gated measurement would then show a zero-width band at + // x = 0 instead of seeding the middle half of the window. + if (std::isfinite(st.gate_x0) && std::isfinite(st.gate_x1) && st.gate_x1 > st.gate_x0) { + app.rows->set_gate(st.gate_x0, st.gate_x1); + } // The toggled handler is guarded by updating_panels, so the Reset button's sensitivity // and the stats-label visibility are set directly from the loaded state here; the labels' // visibility is also set per-row inside rebuild_measures_pane via measures_row(). diff --git a/src/ui_measure.cpp b/src/ui_measure.cpp index 432e3f0..89cd7ac 100644 --- a/src/ui_measure.cpp +++ b/src/ui_measure.cpp @@ -51,6 +51,21 @@ std::string measure_row_summary(const Measure& m) { } } +/// The 1-based number of an **active** channel, as the badge bar and the measurements show it: +/// its position in the model's channel list, which is the acquired channels in stream order +/// (streamed signals first, a math channel after them). 0 when the signal is not acquired -- +/// a row whose signal stopped is already grey, and inventing a number for it would be a second +/// lie. Renumbering on every enable and CONFIG is inherent: the number identifies "the k-th +/// active channel", which is exactly what a CONFIG renumbers. +int measure_channel_number(App& app, const std::string& signal) { + std::lock_guard lock(app.model->mutex()); + const std::vector& channels = app.model->channels(); + for (std::size_t c = 0; c < channels.size(); ++c) { + if (channels[c].name == signal) return static_cast(c) + 1; + } + return 0; +} + /// The channel a measurement's signal resolved to, or -1 while the stream does not carry it. int measure_channel(App& app, const std::string& signal) { std::lock_guard lock(app.model->mutex()); @@ -111,6 +126,15 @@ void rebuild_measures_pane(App& app) { gtk_box_append(GTK_BOX(app.measures_list), measures_row(app, i, app.measures[i])); app.measure_text.emplace_back(); } + // The gate band is one area shared by every gated measurement, and it is on exactly while + // one exists: shown as soon as a measurement is gated, gone when the last one stops. Every + // change of the measure set -- an add, an edit, a delete, a session restore -- passes + // through here, which is what makes this the one derivation. `set_gate_visible()` seeds the + // bounds over the middle half of the window on the first enable, so a gate asked for with + // no band ever placed still lands over the data. + bool any_gated = false; + for (const Measure& m : app.measures) any_gated = any_gated || m.options.gate; + app.rows->set_gate_visible(any_gated); dock_rebuild_measures(app); sync_readouts_strip(app); // the strip's Measures group (and the strip itself) follows at once, not a tick later update_measures(app, /*force=*/true, /*accumulate=*/true); @@ -232,6 +256,7 @@ void on_measure_dialog_apply(GtkButton*, gpointer user) { m.options.threshold_pct2 = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->band)); m.options.above = gtk_switch_get_active(GTK_SWITCH(d->above)); m.options.show_on_plot = gtk_check_button_get_active(GTK_CHECK_BUTTON(d->show)); + m.options.gate = gtk_check_button_get_active(GTK_CHECK_BUTTON(d->gate)); m.options.noise_pct = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->noise)); m.options.hf_fc = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->hf)); if (d->edit >= 0 && static_cast(d->edit) < app.measures.size()) { @@ -261,7 +286,14 @@ void place_measure_marks(App& app) { const double s0 = app.model->stream_at_x0(); app.measure_marks_display = app.measure_marks; for (RowSet::MeasureMark& mk : app.measure_marks_display) { - if (!mk.level) mk.value -= s0; + if (!mk.level) { + mk.value -= s0; + // The badge text the plot draws beside the mark: its own time, in the pane's + // formatter -- the reading a cursor's ruler line carries, on the axis the mark is an + // answer about. Display x (an age, on a live view), because that is the coordinate the + // axis labels and the cursors pane both read. + mk.label = format_measure(mk.value, "s"); + } } app.rows->set_measure_marks(app.measure_marks_display); } @@ -305,8 +337,21 @@ void update_measures(App& app, bool force, bool accumulate) { const plot::Range pin = app.rows->pinned_range(); const bool pinned_view = app.rows->window_pinned() && pin.span() > 0.0 && std::isfinite(pin.min) && std::isfinite(pin.max); + // The gate band, clipped to that window, for the measurements that are gated. The gate is + // per measurement (`MeasureOptions::gate`), but the band is one and shared: when no + // measurement is gated the clip below is a no-op (`gated == window`) and nothing is walked + // twice. A band entirely outside the window clips to a zero span, and a zero-span window + // reads no sample, so a gated measurement over nothing falls to the same "--" an empty + // window gives. const plot::Range window = pinned_view ? pin : view; + plot::Range gated = window; + if (app.rows->gate_visible()) { + const double lo = std::max(app.rows->gate_x0(), window.min); + const double hi = std::min(app.rows->gate_x1(), window.max); + gated = hi > lo ? plot::Range{lo, hi} : plot::Range{lo, lo}; + } const plot::Range swin{window.min + s0, window.max + s0}; + const plot::Range gswin{gated.min + s0, gated.max + s0}; // Both windows of the tick, on the stream's clock: what is **on screen**, and (while pinned) // what the pin *is*. The dump reads them instead of calling `stream_at_x0()` itself, which by // the time it runs answers for a mode no frame was drawn in. @@ -328,10 +373,19 @@ void update_measures(App& app, bool force, bool accumulate) { // change below all move `swin` -- and a settled one satisfies `swin == measure_window` exactly // and so reads `moved == false` on its own, which is what keeps it from recomputing every // frame (457 passes in one run is the shape that mistake took). - const bool moved = app.measure_window_valid && - std::fabs(swin.min - app.measure_window.min) + - std::fabs(swin.max - app.measure_window.max) > - 0.02 * std::max(1.0e-12, swin.span()); + const bool moved = (app.measure_window_valid && + std::fabs(swin.min - app.measure_window.min) + + std::fabs(swin.max - app.measure_window.max) > + 0.02 * std::max(1.0e-12, swin.span())) || + // The gate is the second thing that can move a measurement's window + // without anything on screen moving: a band dragged on a still frame + // changes what the gated measurements describe, and a pane whose every + // measurement is ungated answers this with its own echo (the band clip + // is the window, which the test above already compares). + (app.measure_gate_window_valid && + std::fabs(gswin.min - app.measure_gate_window.min) + + std::fabs(gswin.max - app.measure_gate_window.max) > + 0.02 * std::max(1.0e-12, gswin.span())); // A measurement is a statistic of the visible window, and *a live view moves that window every // frame* -- so "recompute when the window moves" is "recompute every frame", at O(visible // samples) per measurement. That is what stalled the frame tick (0.4 ms -> 13.7 ms, a third of @@ -365,6 +419,8 @@ void update_measures(App& app, bool force, bool accumulate) { } app.measure_window = swin; app.measure_window_valid = true; + app.measure_gate_window = gswin; + app.measure_gate_window_valid = true; app.measure_was_held = held; app.measure_stamp_us = now_us; app.measure_stamp_valid = true; @@ -414,13 +470,15 @@ void update_measures(App& app, bool force, bool accumulate) { const double fc = m.options.hf_fc > 0.0 ? m.options.hf_fc : 0.0; std::size_t wi = 0; while (wi < app.measure_samples.size() && - (app.measure_samples[wi].channel != channel || app.measure_samples[wi].fc != fc)) { + (app.measure_samples[wi].channel != channel || app.measure_samples[wi].fc != fc || + app.measure_samples[wi].gated != m.options.gate)) { ++wi; } if (wi == app.measure_samples.size()) { App::MeasureWindow fresh; fresh.channel = channel; fresh.fc = fc; + fresh.gated = m.options.gate; measure_filter_set(fresh.scan_filter, fc, slot_dt); measure_filter_set(fresh.acc_filter, fc, slot_dt); app.measure_samples.push_back(fresh); @@ -477,7 +535,11 @@ void update_measures(App& app, bool force, bool accumulate) { std::vector& buckets = app.measure_bucket_scratch; for (App::MeasureWindow& w : app.measure_samples) { const gint64 fetch_start_us = g_get_monotonic_time(); - MeasureWindowReader reader(app, static_cast(w.channel), window); + // The window this walk is about: the gate band for a gated walk, the pane's window whole + // otherwise. Everything below -- reader, buckets, chunk edges -- reads it through `wwindow` + // and never the pane's own. + const plot::Range wwindow = w.gated ? gated : window; + MeasureWindowReader reader(app, static_cast(w.channel), wwindow); const std::size_t n = reader.size(); const std::function time_of = [&reader](std::size_t i) { return reader.time_of(i); @@ -667,8 +729,15 @@ void update_measures(App& app, bool force, bool accumulate) { // A time mark is stored on the **stream's** clock -- the walks report display x, so `s0` // puts it back -- and `place_measure_marks()` converts it again on every frame it draws. // That is what keeps the mark on its own sample as a live view rolls underneath it; a level - // mark is a y and is stored as it comes. - marks.push_back({row, spec.level, spec.level ? spec.value : spec.value + s0, color}); + // mark is a y and is stored as it comes. Field by field: a partial brace-initialiser + // re-adds the missing-initialiser warning the baseline forbids (label has no room in a + // `MeasureMarkSpec` -- the badge text is built where the display conversion happens). + RowSet::MeasureMark mark; + mark.row = row; + mark.level = spec.level; + mark.value = spec.level ? spec.value : spec.value + s0; + mark.color = color; + marks.push_back(mark); } } app.measure_marks = std::move(marks); diff --git a/src/ui_prefs.cpp b/src/ui_prefs.cpp index 5a7852e..8998c28 100644 --- a/src/ui_prefs.cpp +++ b/src/ui_prefs.cpp @@ -39,6 +39,7 @@ Settings read_rows(const PrefsForm& f) { s.relative_time = adw_switch_row_get_active(f.relative_row) != FALSE; s.memory_limit_mb = static_cast(adw_spin_row_get_value(f.memory_row)); s.theme = theme_name(adw_combo_row_get_selected(f.theme_row)); + s.crosshair_axis_labels = adw_switch_row_get_active(f.crosshair_row) != FALSE; // The row shows the *effective* directory, so the default is written out in full rather than // described. Leaving it at that default is not a choice to pin it: the file keeps "unset", and the // setting goes on following the cache directory it is named after. @@ -67,6 +68,7 @@ void fill_rows(PrefsForm& f, const Settings& s) { adw_switch_row_set_active(f.relative_row, s.relative_time ? TRUE : FALSE); adw_spin_row_set_value(f.memory_row, s.memory_limit_mb); adw_combo_row_set_selected(f.theme_row, s.theme == "light" ? 1 : (s.theme == "dark" ? 2 : 0)); + adw_switch_row_set_active(f.crosshair_row, s.crosshair_axis_labels ? TRUE : FALSE); f.filling = false; } @@ -146,6 +148,7 @@ void on_closed(AdwDialog*, gpointer user) { f.relative_row = nullptr; f.memory_row = nullptr; f.theme_row = nullptr; + f.crosshair_row = nullptr; f.browse = nullptr; f.parent = nullptr; } @@ -160,13 +163,15 @@ void resolve_rows(PrefsForm& f) { f.relative_row = ADW_SWITCH_ROW(gtk_builder_get_object(f.builder, "relative_row")); f.theme_row = ADW_COMBO_ROW(gtk_builder_get_object(f.builder, "theme_row")); f.memory_row = ADW_SPIN_ROW(gtk_builder_get_object(f.builder, "memory_row")); + f.crosshair_row = ADW_SWITCH_ROW(gtk_builder_get_object(f.builder, "crosshair_axis_labels_row")); if (f.dir_row == nullptr || f.keep_row == nullptr || f.relative_row == nullptr || - f.theme_row == nullptr || f.memory_row == nullptr) { + f.theme_row == nullptr || f.memory_row == nullptr || f.crosshair_row == nullptr) { f.dir_row = nullptr; f.keep_row = nullptr; f.relative_row = nullptr; f.theme_row = nullptr; f.memory_row = nullptr; + f.crosshair_row = nullptr; g_clear_object(&f.builder); } } @@ -222,6 +227,7 @@ void prefs_open(PrefsForm& form, GtkWidget* parent, const Settings& current, g_signal_connect(form.relative_row, "notify::active", G_CALLBACK(on_row_notify), &form); g_signal_connect(form.memory_row, "notify::value", G_CALLBACK(on_row_notify), &form); g_signal_connect(form.theme_row, "notify::selected", G_CALLBACK(on_row_notify), &form); + g_signal_connect(form.crosshair_row, "notify::active", G_CALLBACK(on_row_notify), &form); if (GObject* reset = gtk_builder_get_object(form.builder, "reset_row"); reset != nullptr) { g_signal_connect(reset, "activated", G_CALLBACK(on_reset), &form); } @@ -248,6 +254,7 @@ void prefs_probe_change(PrefsForm& form) { adw_switch_row_set_active(form.relative_row, TRUE); adw_spin_row_set_value(form.memory_row, 4096.0); adw_combo_row_set_selected(form.theme_row, 2); + adw_switch_row_set_active(form.crosshair_row, FALSE); } /// A changed memory limit reaches the running model. Only when it differs from the model's own (every @@ -277,6 +284,9 @@ void open_preferences(App& app) { prefs_open(app.prefs, app.window, app.settings, [&app](const Settings& s, bool written) { app.settings = s; apply_memory_limit(app, s.memory_limit_mb); + // The crosshair's axis badges, at once like the theme: it is a drawing flag on the + // plots, and a change is the next frame's drawing. + app.rows->set_crosshair_axis_labels(s.crosshair_axis_labels); // Only a failure is announced. A change arrives once per keystroke in the directory row, and a // status line that flashed on each one would be the app shouting about its own bookkeeping. if (!written) set_notice(app, "could not write the preferences file", 4); diff --git a/src/ui_prefs.hpp b/src/ui_prefs.hpp index 927366a..9f28101 100644 --- a/src/ui_prefs.hpp +++ b/src/ui_prefs.hpp @@ -64,6 +64,8 @@ struct PrefsForm { AdwSwitchRow* relative_row = nullptr; AdwSpinRow* memory_row = nullptr; AdwComboRow* theme_row = nullptr; + /// The crosshair's axis-badge switch (`Settings::crosshair_axis_labels`). + AdwSwitchRow* crosshair_row = nullptr; /// The folder chooser beside the directory row. A text field alone asks a user to know a path and /// type it correctly; the chooser is how they find one, and what it returns goes into that row -- so /// the text stays the single value and there is nothing to keep in step. diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 3153597..3d83ba7 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -219,7 +219,10 @@ gboolean exit_probe_step3b(gpointer user) { // `dbg_window_text` is kept by `sync_window_display()`, the only place that knows the span the // window control is showing: there is no label to read it back from any more. slideover_close(*a); // opened by step 3 for the signal list's allocations read above - if (window != nullptr) gtk_window_destroy(GTK_WINDOW(window)); + // `gtk_window_close()`, not `gtk_window_destroy()`: only a close emits `close-request`, + // which is where the window's lifecycle (the scope windows, the save flush, the application + // quit) lives -- GTK4 has no `GtkWidget::destroy` to hook. + if (window != nullptr) gtk_window_close(GTK_WINDOW(window)); return G_SOURCE_REMOVE; } @@ -1350,13 +1353,448 @@ gboolean measure_probe(gpointer user) { } rebuild_measures_pane(*a); update_measures(*a, /*force=*/true, /*accumulate=*/true); - std::fprintf(stderr, "[measure-probe] %zu measurements on '%s'\n", a->measures.size(), - name.c_str()); + std::fprintf(stderr, "[measure-probe] %zu measurements on '%s' (channel %d)\n", + a->measures.size(), name.c_str(), measure_channel_number(*a, name)); std::fflush(stderr); g_timeout_add(250, measure_trace, a); return G_SOURCE_REMOVE; } +/// `UDPSCOPE_PROBE_GATE_MS`: the **per-measurement gate**. It adds two averages on one +/// channel -- index 0 ungated, index 1 with `MeasureOptions::gate` -- and puts the channel on +/// a plot (the way `enable_all_probe` does it: a channel with no trace leaves the display +/// epoch still, which is the state the `still=` leg needs to see move). The pane rebuild then +/// derives the band's visibility from the measure set, and the probe re-states its bounds to a +/// known relative interval, 300 ms ago to 100 ms ago, on the mock's 1 Hz sine: a whole-seconds +/// window is whole *periods* there, so the ungated average is exactly 0 however the phase +/// sits, while the gated slice's mean is wherever in its period the stream happens to be. +/// Three lines. `on=` carries the assertions: `band=1` (one gated measurement showed the +/// band), `placed=1` (the plot's region holds exactly the bounds the `RowSet` does), +/// `plain=1` (the ungated reading is zero -- it read the whole window), `split=1` (the gated +/// reading differs -- it read the band) and `gwin=1` (the pass booked `measure_gate_window` = +/// the band moved onto the stream's clock, the phase-free statement of *which* slice the +/// gated walk read). `still=`, a second later, is the relative behaviour: the band exactly +/// where it was put while `s0` moved under it. The exit dump's `gate:` line is the session +/// leg: the probe saves the bounds the way a drag end does, and a second run on the same +/// config restores the band -- and the two measurements' gate flags -- from it. **Not in one +/// run with the other measurement probes**: they add measurements of their own, and these +/// lines quote indexes 0 and 1. +gboolean gate_probe_step(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + switch (a->dbg_gate_step) { + case 0: { + // The signal is enabled *here*, and the measurement goes on a *channel*: the first + // signal of a stream is its timestamp, which is not acquired -- the same two rules + // `measure_probe` states, for the same reasons. + { + const std::vector& signals = a->model->signals(); + for (std::size_t i = 0; i < signals.size(); ++i) { + if (signals[i].name == "sine") a->model->set_signal_enabled(i, true); + } + } + std::string name; + plot::u32 color_index = 0; + { + std::lock_guard lock(a->model->mutex()); + if (a->model->channels().empty()) return G_SOURCE_REMOVE; + const ChannelInfo& ch = a->model->channels().front(); + name = ch.name; + color_index = static_cast( + signal_color_index(a->model->signals(), ch.signal)); + } + a->rows->assign(0, 0, name, name, static_cast(color_index)); + a->relayout_pending = true; + MeasureOptions gated_options; + gated_options.gate = true; + a->measures.push_back({name, MeasureType::Average, MeasureOptions{}}); + a->measures.push_back({name, MeasureType::Average, gated_options}); + // The rebuild derives the band from the measure set -- this, and not a widget's + // emission, is the one path a gate flag reaches the plots through -- and forces a + // recompute. The wait after it is not idle: the epoch the reading below needs is + // written by the first frame that binds the trace, and this callback runs between + // ticks. + rebuild_measures_pane(*a); + a->dbg_gate_step = 1; + g_timeout_add(800, gate_probe_step, a); + break; + } + case 1: { + const RowSet::Row& r0 = a->rows->row(0); + if (r0.plot == nullptr) return G_SOURCE_REMOVE; + // The ring must be older than the window before anything is measured: the ungated + // reading below is exact zero only because a whole-seconds window of the 1 Hz sine is + // whole *periods*, and a window clipped to what a young ring holds is a fraction of a + // cycle. `stream_at_x0()` is the newest sample's stream time, so waiting for 2 s of it + // is waiting for the window to be full. Bounded, so a stalled stream still prints. + if (a->model->stream_at_x0() < 2.0 && a->dbg_gate_wait < 40) { + ++a->dbg_gate_wait; + return G_SOURCE_CONTINUE; + } + // A fixed relative interval -- 300 ms ago to 100 ms ago -- rather than a share of the + // view: the reading below must land on samples that *exist*, and at this point in a + // short run the view's left half can predate the ring's first sample. The newest half + // second cannot. A 200 ms slice of a sine has two zero-mean phases per second, and the + // band lands on one with a few percent probability per run -- a reading about the + // phase, not about the gate -- so a first attempt that reads a zero mean with a valid + // window moves the band a quarter period older and measures again, once. + bool band = false; + bool placed_ok = false; + bool plain_zero = false; + bool split = false; + bool gwin = false; + double lo = 0.0; + double hi = 0.0; + double s0 = 0.0; + plot::Range xr{}; + for (int attempt = 0; attempt < 2 && !split; ++attempt) { + const double shift = 0.25 * attempt; // a quarter period, still inside the newest data + lo = -0.30 - shift; + hi = -0.10 - shift; + a->rows->set_gate(lo, hi); + // Saved the way a drag end saves (`ui.cpp`'s tick): the bounds are session state, and + // the exit dump's `gate:` line is read against the file a second run restores from. + schedule_layout_save(*a); + s0 = a->model->stream_at_x0(); + xr = r0.plot->x_range(r0.axis_x); + update_measures(*a, /*force=*/true, /*accumulate=*/true); + band = a->rows->gate_visible(); + const plot::Region& placed = r0.plot->region(r0.gate_region); + placed_ok = placed.x0 == a->rows->gate_x0() && placed.x1 == a->rows->gate_x1(); + const bool have = a->measure_results.size() >= 2 && a->measure_results[0].valid && + a->measure_results[1].valid; + const double plain = have ? a->measure_results[0].primary : 0.0; + const double gated = have ? a->measure_results[1].primary : 0.0; + // The ungated window is a whole number of seconds of a 1 Hz sine: whole periods, mean + // zero (to the double, and the tolerance is far coarser than the arithmetic). + plain_zero = have && std::fabs(plain) < 0.05; + split = have && std::fabs(gated - plain) > 0.01; + // The gated walk's window, booked by the pass itself (`App::measure_gate_window`): + // the band moved onto the stream's clock, which is the phase-free statement of "the + // gated measurement read *this* slice" -- the slice mean depends on where in its + // period the stream happens to be, the window does not. The tolerance is the same + // 5 % of the band as the production-interval slack: the probe's `s0` and the pass's + // can straddle one. + const double gtol = 0.05 * (hi - lo); + gwin = a->measure_gate_window_valid && + std::fabs(a->measure_gate_window.min - (lo + s0)) < gtol && + std::fabs(a->measure_gate_window.max - (hi + s0)) < gtol; + } + a->dbg_gate_x0 = lo; + a->dbg_gate_x1 = hi; + a->dbg_gate_s0 = s0; + std::fprintf(stderr, + "[gate-probe] on: gate=[%.6f..%.6f] view=[%.6f..%.6f] s0=%.6f " + "for=[%.6f..%.6f] band=%d placed=%d plain=%d split=%d gwin=%d " + "ungated=%s gated=%s\n", + lo, hi, xr.min, xr.max, s0, a->measure_window.min, a->measure_window.max, + band ? 1 : 0, placed_ok ? 1 : 0, plain_zero ? 1 : 0, split ? 1 : 0, + gwin ? 1 : 0, + a->measure_text.size() > 0 ? a->measure_text[0].c_str() : "--", + a->measure_text.size() > 1 ? a->measure_text[1].c_str() : "--"); + std::fflush(stderr); + a->dbg_gate_step = 2; + g_timeout_add(1000, gate_probe_step, a); + break; + } + case 2: { + const double s0 = a->model->stream_at_x0(); + const bool still = + a->rows->gate_x0() == a->dbg_gate_x0 && a->rows->gate_x1() == a->dbg_gate_x1; + const bool s0_moved = std::fabs(s0 - a->dbg_gate_s0) > 1.0e-9; + std::fprintf(stderr, + "[gate-probe] still: gate=[%.6f..%.6f] s0=[%.6f->%.6f] still=%d s0_moved=%d\n", + a->rows->gate_x0(), a->rows->gate_x1(), a->dbg_gate_s0, s0, still ? 1 : 0, + s0_moved ? 1 : 0); + std::fflush(stderr); + return G_SOURCE_REMOVE; + } + default: + return G_SOURCE_REMOVE; + } + return G_SOURCE_REMOVE; +} + +/// `UDPSCOPE_PROBE_CROSSHAIR_MS`: drives a real pointer **hover** across row 0 through the +/// GTK adapter's own controller -- the only path a user's crosshair arrives by -- and asserts +/// that the plot then placed the crosshair and, with it, the two axis badges: the y value in +/// the y label strip, the x time in the x label strip, each inside its strip and outside the +/// axes. The pixels themselves are the plotter suite's (`crosshair_ink_stays_inside_the_axes`); +/// what this probe owns is the adapter -- the viewport conversion from its pointer pixels to +/// the plot's data -- and the flag the preference answers to, which the last leg switches off +/// and reads back from the plot. +gboolean crosshair_probe(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + switch (a->dbg_crosshair_step) { + case 0: { + // A trace under the pointer, by name on a plot: the same setup `gate_probe_step` makes + // and for the same two reasons (a channel, not `signals().front()`; and on a plot, + // because the hover needs axes with data to be worth hovering over). + { + const std::vector& signals = a->model->signals(); + for (std::size_t i = 0; i < signals.size(); ++i) { + if (signals[i].name == "sine") a->model->set_signal_enabled(i, true); + } + } + std::string name; + plot::u32 color_index = 0; + { + std::lock_guard lock(a->model->mutex()); + if (a->model->channels().empty()) return G_SOURCE_REMOVE; + const ChannelInfo& ch = a->model->channels().front(); + name = ch.name; + color_index = static_cast( + signal_color_index(a->model->signals(), ch.signal)); + } + a->rows->assign(0, 0, name, name, static_cast(color_index)); + a->relayout_pending = true; + a->dbg_crosshair_step = 1; + g_timeout_add(800, crosshair_probe, a); + break; + } + case 1: { + const RowSet::Row& r0 = a->rows->row(0); + plot::input::Controller* c = + a->area != nullptr ? a->area->row_controller(0) : nullptr; + if (r0.plot == nullptr || c == nullptr) return G_SOURCE_REMOVE; + const plot::Rect axes = r0.plot->layout().axes; + if (!(axes.w > 0.0f) || !(axes.h > 0.0f)) return G_SOURCE_REMOVE; + // A hover move into the middle of the axes: viewport pixels, the space the controller + // converts into the plot's data. No button, no transition -- a plain move. + c->on_pointer({axes.x + 0.5f * axes.w, axes.y + 0.4f * axes.h, 0.0f, 0.0f, + plot::input::Button::Left, 0u, false, false, false}); + a->dbg_crosshair_step = 2; + g_timeout_add(300, crosshair_probe, a); + break; + } + case 2: { + const RowSet::Row& r0 = a->rows->row(0); + if (r0.plot == nullptr) return G_SOURCE_REMOVE; + const plot::PlotLayout& lay = r0.plot->layout(); + const plot::CrosshairLayout& ch = lay.crosshair; + const bool y_ok = ch.y_axis_badge.w > 0.0f && + ch.y_axis_badge.x >= lay.y_label_area.x - 0.5f && + ch.y_axis_badge.right() <= lay.axes.x + 0.5f; + // The y badge is centred on the line it names: its middle row is the pointer's screen y + // (the probe injected one). A top-aligned badge -- the defect this reads for -- is a + // whole half-badge off, so one pixel of slack is generous. + const f32 pointer_y = lay.axes.y + 0.4f * lay.axes.h; + const bool y_centre = ch.y_axis_badge.w > 0.0f && + std::abs(ch.y_axis_badge.y + 0.5f * ch.y_axis_badge.h - pointer_y) <= 1.0f; + const bool x_ok = ch.x_axis_badge.w > 0.0f && + ch.x_axis_badge.x >= lay.x_label_area.x - 0.5f && + ch.x_axis_badge.x + ch.x_axis_badge.w <= lay.x_label_area.right() + 0.5f && + ch.x_axis_badge.y >= lay.axes.bottom() - 0.5f; + std::fprintf(stderr, + "[crosshair-probe] active=%d y=[%.0fx%.0f@%.0f,%.0f] x=[%.0fx%.0f@%.0f,%.0f] " + "y_ok=%d x_ok=%d y_centre=%d\n", + ch.active ? 1 : 0, ch.y_axis_badge.w, ch.y_axis_badge.h, ch.y_axis_badge.x, + ch.y_axis_badge.y, ch.x_axis_badge.w, ch.x_axis_badge.h, ch.x_axis_badge.x, + ch.x_axis_badge.y, y_ok ? 1 : 0, x_ok ? 1 : 0, y_centre ? 1 : 0); + std::fflush(stderr); + // Off again through the same call the preference makes -- the flag is the setting. + a->rows->set_crosshair_axis_labels(false); + a->dbg_crosshair_step = 3; + g_timeout_add(300, crosshair_probe, a); + break; + } + case 3: { + const RowSet::Row& r0 = a->rows->row(0); + if (r0.plot == nullptr) return G_SOURCE_REMOVE; + std::fprintf(stderr, "[crosshair-probe] off: plot_flag=%d\n", + r0.plot->crosshair_axis_labels() ? 1 : 0); + std::fflush(stderr); + a->rows->set_crosshair_axis_labels(true); + return G_SOURCE_REMOVE; + } + default: + return G_SOURCE_REMOVE; + } + return G_SOURCE_REMOVE; +} + +/// `UDPSCOPE_PROBE_MAXIMIZE_MS`: the one-click full-screen toggle, exercised through the +/// `RowSet` call the button's handler makes (a probe cannot click an overlay button the frame +/// places). Asserts that a maximize shows the row's own plot and nothing else, that the toggle +/// back restores every row with its signal, and that the overlay carries one toggle per figure +/// row while the editor is down. +gboolean maximize_probe_step(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + switch (a->dbg_maximize_step) { + case 0: { + // Two single-cell rows with one signal each: the smallest arrangement a maximize can be + // told apart in. + const std::vector& signals = a->model->signals(); + for (std::size_t i = 0; i < signals.size(); ++i) { + if (signals[i].name == "sine" || signals[i].name == "square") + a->model->set_signal_enabled(i, true); + } + std::string sine; + std::string square; + int sine_color = 0; + int square_color = 0; + { + std::lock_guard lock(a->model->mutex()); + for (const ChannelInfo& c : a->model->channels()) { + if (c.name == "sine") { + sine = c.name; + sine_color = signal_color_index(a->model->signals(), c.signal); + } else if (c.name == "square") { + square = c.name; + square_color = signal_color_index(a->model->signals(), c.signal); + } + } + } + if (sine.empty() || square.empty()) return G_SOURCE_REMOVE; + // A layout of one figure carries no full-screen toggle (there is nothing to enlarge + // *to*); the button appears together with the second figure row, below. + a->dbg_maximize_single = a->maximize_buttons.size(); + a->rows->set_cells_per_row({1, 1}); + a->rows->assign(0, 0, sine, sine, sine_color); + a->rows->assign(1, 0, square, square, square_color); + a->relayout_pending = true; + a->dbg_maximize_step = 1; + g_timeout_add(800, maximize_probe_step, a); + break; + } + case 1: { + // A frame has passed: the toggles are placed, one per figure row. + const int rows_before = a->rows->row_count(); + const int frows_before = a->rows->figure_row_count(); + const std::string row0_before = a->rows->row(0).signal_name[0]; + const std::string row1_before = a->rows->row(1).signal_name[0]; + const std::size_t buttons = a->maximize_buttons.size(); + // The toggle rebuilds the figure *object* (`RowSet::figure()` is a `unique_ptr` that + // `rebuild_all()` replaces), so the area must be re-pointed the way the button's + // `toggle_fullscreen()` does -- otherwise the render keeps a freed figure (a probe-only + // hazard: the app's every path re-points). This is the whole action minus the buttons + // and panels, which the probe reads separately. + a->rows->toggle_maximize_figure_row(0); + if (a->area != nullptr) a->area->set_figure(&a->rows->figure()); + const int rows_max = a->rows->row_count(); + const int frows_max = a->rows->figure_row_count(); + const bool flag_max = a->rows->figures_maximized(); + const std::string row0_max = a->rows->row(0).signal_name[0]; + a->rows->toggle_maximize_figure_row(0); + if (a->area != nullptr) a->area->set_figure(&a->rows->figure()); + const int rows_back = a->rows->row_count(); + const std::string row0_back = a->rows->row(0).signal_name[0]; + const std::string row1_back = a->rows->row(1).signal_name[0]; + const bool flag_back = a->rows->figures_maximized(); + std::fprintf(stderr, + "[maximize-probe] single_buttons=%zu before: rows=%d frows=%d row0=%s row1=%s " + "buttons=%zu; max: rows=%d frows=%d flag=%d row0=%s; " + "back: rows=%d row0=%s row1=%s flag=%d\n", + a->dbg_maximize_single, rows_before, frows_before, row0_before.c_str(), + row1_before.c_str(), buttons, rows_max, frows_max, flag_max ? 1 : 0, + row0_max.c_str(), rows_back, row0_back.c_str(), row1_back.c_str(), + flag_back ? 1 : 0); + std::fflush(stderr); + a->relayout_pending = true; + // The corner reveal, next: no pointer has entered the area, so the toggles must be + // out of sight; then a pointer in the first row's top-right corner must bring that + // row's toggle out and only that one. + a->dbg_maximize_step = 2; + g_timeout_add(250, maximize_probe_step, a); + break; + } + case 2: { + a->dbg_maximize_hidden = + a->maximize_buttons.empty() || !gtk_widget_get_visible(a->maximize_buttons[0]) ? 1 : 0; + a->area_pointer_x = static_cast(gtk_widget_get_width(a->area_widget)) - 8.0; + a->area_pointer_y = 8.0; + a->area_pointer_valid = true; + a->dbg_maximize_step = 3; + g_timeout_add(250, maximize_probe_step, a); + break; + } + case 3: { + const int shown = !a->maximize_buttons.empty() && + gtk_widget_get_visible(a->maximize_buttons[0]) + ? 1 + : 0; + const int other = a->maximize_buttons.size() > 1 && + gtk_widget_get_visible(a->maximize_buttons[1]) + ? 1 + : 0; + std::fprintf(stderr, "[maximize-probe] corner: hidden_when_off=%d shown_when_near=%d " + "other_row=%d\n", + a->dbg_maximize_hidden, shown, other); + std::fflush(stderr); + return G_SOURCE_REMOVE; + } + default: + return G_SOURCE_REMOVE; + } + return G_SOURCE_REMOVE; +} + +/// `UDPSCOPE_PROBE_SCOPE_MS`: opens a **scope window** twice -- once copying the main window's +/// figure 0, once empty -- and asserts what the copy is: the same signals on the same cells, +/// bound to the same stream a few frames later, while the empty one stays empty. The window +/// creation itself (a real `GtkWindow`, an own `PlotArea`, its own `RowSet`) is exercised by +/// the same call the two menu entries make. +gboolean scope_probe_step(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + switch (a->dbg_scope_step) { + case 0: { + const std::vector& signals = a->model->signals(); + for (std::size_t i = 0; i < signals.size(); ++i) { + if (signals[i].name == "sine") a->model->set_signal_enabled(i, true); + } + std::string name; + int color = 0; + { + std::lock_guard lock(a->model->mutex()); + if (!a->model->channels().empty()) { + const ChannelInfo& ch = a->model->channels().front(); + name = ch.name; + color = signal_color_index(a->model->signals(), ch.signal); + } + } + if (name.empty()) return G_SOURCE_REMOVE; + a->rows->assign(0, 0, name, name, color); + a->relayout_pending = true; + a->dbg_scope_step = 1; + g_timeout_add(800, scope_probe_step, a); + break; + } + case 1: { + open_scope_window(*a, 0); + open_scope_window(*a, -1); + a->dbg_scope_step = 2; + g_timeout_add(600, scope_probe_step, a); + break; + } + case 2: { + if (a->scopes.size() != 2) { + std::fprintf(stderr, "[scope-probe] count=%zu (expected 2)\n", a->scopes.size()); + std::fflush(stderr); + return G_SOURCE_REMOVE; + } + const App::ScopeWindow& copy = *a->scopes[0]; + const App::ScopeWindow& empty = *a->scopes[1]; + const std::string copied = copy.rows->row(0).signal_name[0]; + const bool copied_bound = copy.rows->row(0).channel[0] >= 0; + const bool empty_ok = empty.rows->row(0).signal_name[0].empty(); + const bool windows = copy.window != nullptr && empty.window != nullptr && + gtk_widget_get_visible(GTK_WIDGET(copy.window)) && + gtk_widget_get_visible(GTK_WIDGET(empty.window)); + std::fprintf(stderr, + "[scope-probe] count=2 copied='%s' bound=%d empty_ok=%d windows=%d\n", + copied.c_str(), copied_bound ? 1 : 0, empty_ok ? 1 : 0, windows ? 1 : 0); + std::fflush(stderr); + return G_SOURCE_REMOVE; + } + default: + return G_SOURCE_REMOVE; + } + return G_SOURCE_REMOVE; +} + /// `UDPSCOPE_PROBE_LEVELS_MS`: puts a pane of **N** measurements on screen -- one `above` per /// share in `UDPSCOPE_PROBE_LEVELS` (comma separated, default `50`) -- spread in turn over the /// first `UDPSCOPE_PROBE_LEVELS_CHANNELS` channels (default 1), and says how many *windows* and @@ -2985,6 +3423,24 @@ gboolean arm_probe(gpointer user) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE); std::fprintf(stderr, "[arm-probe] trigger armed\n"); std::fflush(stderr); + // The circle's states, sampled after the arm: waiting reads flash (the lit and dim halves, + // whichever half-second the sample lands in), a latched event reads green, a disarm red. + // The tick caches the state (`dbg_trigger_circle`), so this reads it without touching GTK. + for (int i = 1; i <= 4; ++i) { + g_timeout_add(static_cast(1000 * i), + +[](gpointer p) -> gboolean { + App* a = static_cast(p); + if (a->window == nullptr) return G_SOURCE_REMOVE; + static const char* const kState[] = {"green", "flash-lit", "flash-dim", + "red", "off"}; + const int c = a->dbg_trigger_circle; + std::fprintf(stderr, "[arm-probe] circle=%s\n", + c >= -1 && c < 4 ? kState[c + 1] : "?"); + std::fflush(stderr); + return G_SOURCE_REMOVE; + }, + a); + } return G_SOURCE_REMOVE; } @@ -3716,17 +4172,44 @@ gboolean prefs_probe(gpointer user) { (row_underlines(G_OBJECT(a.prefs.dir_row)) + row_underlines(G_OBJECT(a.prefs.keep_row)) + row_underlines(G_OBJECT(a.prefs.relative_row)) + row_underlines(G_OBJECT(a.prefs.theme_row)))); - std::fprintf(stderr, "[prefs-probe] opened=%d rows=%d dir=%s store=%s\n", + // The cache is keyed on the source **and its active configuration**, not on the source alone: the + // store's directory must be the dialog's directory, the source's folder and the configuration's + // folder, and pointing it at another configuration -- the helper every switch calls -- must move + // it there and back. Nothing is created: `set_configuration()` re-derives the path. + const std::string source_dir = + std::string(shown) + "/" + a.source_host + "-" + std::to_string(a.source_port); + const std::string active_cfg = active_configuration(a.source_host, a.source_port); + int cfg_keyed = 0; + int cfg_retarget = 0; + if (a.event_store != nullptr) { + cfg_keyed = a.event_store->configuration() == active_cfg && + a.event_store->dir() == source_dir + "/" + active_cfg + ? 1 + : 0; + const std::string restore_name = a.config_name; + a.config_name = "probe-other"; + retarget_event_store(a); + cfg_retarget = a.event_store->configuration() == "probe-other" && + a.event_store->dir() == source_dir + "/probe-other" + ? 1 + : 0; + a.config_name = restore_name; + retarget_event_store(a); + cfg_retarget = cfg_retarget && a.event_store->configuration() == active_cfg && + a.event_store->dir() == source_dir + "/" + active_cfg; + } + std::fprintf(stderr, "[prefs-probe] opened=%d rows=%d dir=%s store=%s cfg=%d cfg_switch=%d\n", a.prefs.dialog != nullptr ? 1 : 0, a.prefs.dir_row != nullptr ? 1 : 0, a.prefs.dir_row != nullptr ? shown : "-", - a.event_store != nullptr ? a.event_store->dir().c_str() : "-"); + a.event_store != nullptr ? a.event_store->dir().c_str() : "-", cfg_keyed, + cfg_retarget); g_timeout_add(400, +[](gpointer u) -> gboolean { App& app = *static_cast(u); prefs_probe_change(app.prefs); const Settings back = load_settings(); - std::fprintf(stderr, "[prefs-probe] dir=%s keep=%d rel=%d theme=%s live=%d\n", + std::fprintf(stderr, "[prefs-probe] dir=%s keep=%d rel=%d theme=%s xhair=%d live=%d\n", back.event_cache_dir.c_str(), back.event_cache_max, back.relative_time ? 1 : 0, - back.theme.c_str(), + back.theme.c_str(), back.crosshair_axis_labels ? 1 : 0, app.settings.theme == "dark" ? 1 : 0); // And typing the default back must not *pin* it: the file keeps "unset", so the default still // follows whatever cache directory the next run has. An empty value here is the whole point -- @@ -4705,6 +5188,50 @@ gboolean split_probe_step(gpointer user) { g_x_axes.row0.c_str(), g_x_axes.bottom.c_str(), g_x_axes.gap_ok.c_str(), g_x_axes.gap.c_str(), g_x_axes.col_gap, g_x_axes.note.c_str(), gesture); rs.set_time_window(g_x_axes.window_before); // unpins and re-arms the live follow + split_probe_next(*a); // the split row's shared-y crosshair, below + break; + } + // --- Steps 28..30: the horizontal line spans a split row (its cells share one y) --- + case 28: { + // Two cells sharing one y on figure row 0 and one cell on row 1; the next steps hover + // cell 1 and read what the row's *other* cell got. + rs.set_cells_per_row({2, 1}); + split_probe_clear_all(*a); + split_probe_assign(*a, 0, 0); + split_probe_assign(*a, 1, 1); + split_probe_restructured(*a); + split_probe_next(*a); + break; + } + case 29: { + if (rs.row(1).plot == nullptr || rs.row(0).plot == nullptr) { + split_probe_next(*a); + break; + } + // The adapter's own call, minus its pointer plumbing (which the crosshair probe covers): + // one shared x to every plot, the hovered plot's y into the figure -- a split row's cells + // share that y, so the line must land in both. + const plot::Transform tx = rs.row(1).plot->x_transform(0u); + const plot::Transform ty = rs.row(1).plot->y_transform(0u); + const plot::Rect axes = rs.row(1).plot->layout().axes; + const f64 x = tx.to_data_x(static_cast(axes.x + 0.5f * axes.w)); + const f64 y = ty.to_data_y(static_cast(axes.y + 0.4f * axes.h)); + rs.figure().set_crosshair(x, 1u, y, true); + split_probe_next(*a); + break; + } + case 30: { + const plot::CrosshairLayout& left = rs.row(0).plot->layout().crosshair; + const plot::CrosshairLayout& right = rs.row(1).plot->layout().crosshair; + const plot::CrosshairLayout& other = rs.row(2).plot->layout().crosshair; + const f32 dy = std::fabs((left.horizontal.y + 0.5f * left.horizontal.h) - + (right.horizontal.y + 0.5f * right.horizontal.h)); + std::fprintf(stderr, + "[split-probe] cell-xhair left=%d right=%d centre_dy=%.1f sibling=%d " + "(the horizontal line spans a split row's shared y, and no other row)\n", + left.horizontal.w > 0.0f ? 1 : 0, right.horizontal.w > 0.0f ? 1 : 0, + static_cast(dy), other.horizontal.h > 0.0f ? 1 : 0); + rs.figure().clear_crosshair(); break; } default: @@ -5301,17 +5828,23 @@ static void strip_report(App& a, const char* tag, const char* what) { const bool g_mea = a.strip_measure_group != nullptr && gtk_widget_get_visible(a.strip_measure_group); const bool g_sep = a.strip_sep != nullptr && gtk_widget_get_visible(a.strip_sep); const bool groups_ok = g_cur == cur_on && g_mea == mea_on && g_sep == (cur_on && mea_on); + // The `[+]` closes the Measures group: one adds a measurement from the strip, after the + // last chip (the old "Measures" panel button is gone). Structural, because a probe that + // clicked it would open a dialog on every report. + const bool plus_ok = a.strip_measure_group != nullptr && + gtk_widget_get_last_child(a.strip_measure_group) != a.strip_measure_box && + GTK_IS_BUTTON(gtk_widget_get_last_child(a.strip_measure_group)); std::fprintf(stderr, "[%s-probe] %-14s strip: visible=%d expected=%d visible_ok=%d dock_shown=%d cursors=%zu " "measures=%zu same_text=%d h=%d sens_same=%d collapsed=%d window=%d groups=%d%d%d " - "groups_ok=%d\n", + "groups_ok=%d add=%d\n", tag, what, visible ? 1 : 0, expected ? 1 : 0, visible == expected ? 1 : 0, dock_shown ? 1 : 0, a.strip_cursor_deltas.size(), a.strip_measure_labels.size(), same, a.readouts_strip != nullptr ? gtk_widget_get_height(a.readouts_strip) : -1, sens_same ? 1 : 0, collapsed ? 1 : 0, a.window != nullptr ? gtk_widget_get_width(a.window) : -1, - g_cur ? 1 : 0, g_mea ? 1 : 0, g_sep ? 1 : 0, groups_ok ? 1 : 0); + g_cur ? 1 : 0, g_mea ? 1 : 0, g_sep ? 1 : 0, groups_ok ? 1 : 0, plus_ok ? 1 : 0); } /// Puts the dock away through the wish the tick re-asserts and reads the strip with it hidden @@ -6427,8 +6960,14 @@ gboolean configs_probe(gpointer user) { } return out.empty() ? std::string("-") : out; }; - std::fprintf(stderr, "[configs] before=%s active=%s\n", joined().c_str(), - active_configuration(a->source_host, a->source_port).c_str()); + // The event cache is keyed on the *configuration*, so each line carries the store's own key: it + // must follow the active configuration through save-as and back, or a user's events for one + // configuration would be listed under another. + const auto store_key = [&]() { + return a->event_store != nullptr ? a->event_store->configuration() : std::string("-"); + }; + std::fprintf(stderr, "[configs] before=%s active=%s store=%s\n", joined().c_str(), + active_configuration(a->source_host, a->source_port).c_str(), store_key().c_str()); // A non-default band and corner before the save, so the round trip below proves the two keys are // *written*: left at their defaults, a key that never made it to the file would read back the same. @@ -6438,8 +6977,9 @@ gboolean configs_probe(gpointer user) { a->measures.front().options.hf_fc = 250.0; } const bool saved = save_configuration_as(*a, "probe", "written by the configs probe"); - std::fprintf(stderr, "[configs] save-as=%d names=%s active=%s\n", saved ? 1 : 0, - joined().c_str(), active_configuration(a->source_host, a->source_port).c_str()); + std::fprintf(stderr, "[configs] save-as=%d names=%s active=%s store=%s\n", saved ? 1 : 0, + joined().c_str(), active_configuration(a->source_host, a->source_port).c_str(), + store_key().c_str()); // The round trip: what a reopened configuration must carry, note and startup settings included. LayoutState st; @@ -6462,7 +7002,12 @@ gboolean configs_probe(gpointer user) { noise_back, hf_back); forget_configuration(a->source_host, a->source_port, "probe"); - std::fprintf(stderr, "[configs] after forget names=%s\n", joined().c_str()); + // The forgotten one was the active one: the dialog clears the name and lets the cache follow + // whatever is active now, and the probe does the same so the reading is that path's. + if (a->config_name == "probe") a->config_name.clear(); + retarget_event_store(*a); + std::fprintf(stderr, "[configs] after forget names=%s active=%s store=%s\n", joined().c_str(), + active_configuration(a->source_host, a->source_port).c_str(), store_key().c_str()); std::fflush(stderr); return G_SOURCE_REMOVE; } diff --git a/src/ui_status.cpp b/src/ui_status.cpp index 038f959..dc98ef5 100644 --- a/src/ui_status.cpp +++ b/src/ui_status.cpp @@ -56,6 +56,26 @@ const char* badge_markup(BadgePhase phase) { return "\u25CF"; } +/// What the trigger status circle is saying, by state: 0 solid green (an event is on screen, +/// or a capture is filling), 1 and 2 the bright and dim halves of the waiting flash, 3 red +/// (disarmed). Same glyph and the same colour family as the activity badge -- the two sit +/// beside each other on the status row, and two alphabets there would be a puzzle -- and one +/// glyph in every state, for the same width-locked reason. +const char* trigger_circle_markup(int state) { + switch (state) { + case 0: + case 1: + return "\u25CF"; + case 2: + return "\u25CF"; + case 3: + return "\u25CF"; + default: + break; + } + return "\u25CF"; +} + /// A short-lived line in front of the status text: the outcome of an action that the /// picture does not show by itself (a drop that changed nothing, an export that wrote a /// file). `tick()` clears it when it expires. diff --git a/src/ui_tour.cpp b/src/ui_tour.cpp index 57fe875..09d01e8 100644 --- a/src/ui_tour.cpp +++ b/src/ui_tour.cpp @@ -441,8 +441,11 @@ struct TourStep { constexpr TourStep kTourSteps[] = { {"plot", nullptr, "The plot", - "Every signal you have switched on is drawn here, all plots sharing one time axis. The wheel " - "zooms, a drag pans, and a right-click on a plot opens that plot's own menu.", + "Every signal you have switched on is drawn here, all plots sharing one time axis -- and the " + "plots of a split row share one y as well. The wheel zooms, a drag pans, and a right-click on " + "a plot opens its own menu: fit, full screen (the button also comes out in a figure's corner " + "as the pointer nears it), a second window showing that figure, and the layout editor, which " + "splits a row into up to four plots.", &App::area_overlay}, {"badges", nullptr, "The badges", "Setup is reached by the thing it configures. A badge per signal opens that signal's colour, " @@ -457,9 +460,10 @@ constexpr TourStep kTourSteps[] = { {"side", nullptr, "The dock", "The dock beside the plot is a read-only copy of the cursors and of every measurement, in a " "fixed width, with the recorded events below them: one row per latched event, newest first, and " - "one click on a row replays it. Edit, beside each heading, opens that instrument's full panel " - "over the plot, Manage opens the full list of events; a click on a measurement opens its " - "dialog. The dock button in the bar above shows and hides the dock.", + "one click on a row replays it. Each source and each of its configurations keeps its own cache. " + "Edit, beside each heading, opens that instrument's full panel over the plot, Manage opens the " + "full list of events; a click on a measurement opens its dialog. The dock button in the bar " + "above shows and hides the dock.", &App::side_box}, {"signals", "signals", "Adding a signal", "What can be added: the signals the source declares that are not acquired yet, each with the "