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

A batch of view and instrument work, with the probes and docs for each.

- **Full screen per figure row**: a toggle on each row's top-right corner, revealed by
  pointer proximity (`kMaximizeRevealPx` around its placed rect, per row, read from an
  overlay-level motion controller so a revealed button under the pointer cannot flicker);
  the plot menu offers the same as *Full screen this figure* / *Restore the layout*. The
  arrangement is saved and restored through `RowState`, so nothing plotted moves. A layout
  of one figure carries no toggle.
- **Second window**: `open_scope_window()` copies a figure (cells, width shares, signals in
  their colours) or opens an empty one, with its own `RowSet` and `PlotArea`; the main
  window's tick binds, acquires and repaints it, mirroring the time window and pin. Entries:
  the plot menu's *Open this plot in a new window* and the overflow menu's *New scope window*.
- **Layout editor from the plot**: the plot menu gains *Edit the layout...* / *Done editing
  the layout*, flipping the app's one toggle.
- **Measurements gate**: `MeasureOptions::gate` restricts a measurement to the held/pause
  snapshot (live falls back to the live ring), persisted with the session and shown in the
  measure dialog.
- **Crosshair axis badges**: a preference (`crosshair_axis_labels`, on by default) and the
  plotter's badges where the lines meet the axes, in the axes' own formats; the plot menu
  and the probe read them.
- **Event cache per configuration**: `EventStore` is keyed `<source>/<configuration>/`,
  `retarget_event_store()` follows every configuration switch, and a pre-configuration cache
  (loose files) is adopted into the configuration's folder on first start.
- **GTK4 lifecycle**: the main window's handlers move from the non-existent `"destroy"`
  signal to `close-request`; scope windows are closed with it and the application quits
  explicitly (the exit run's hang). The exit probe closes with `gtk_window_close()`.
- Probes: `[maximize-probe]` (single-figure rule and the corner reveal), `[scope-probe]`,
  `[split-probe]` cell crosshair leg, `[prefs-probe]` store keying, `[configs]` store key,
  `[rowmenu]` layout-editor entry; docs, help pages, README and AGENT.md updated.

Also fixes a probe-only use-after-free: `toggle_maximize_figure_row` replaces the figure
object, so the area must be re-pointed (the button's path does; the probe's raw call did not).
This commit is contained in:
Martino Ferrari committed 2026-11-05 17:58:59 +01:00
1 parent bfe13630f9
commit bdf0ff7ddc
39 files changed
+2265 -134

No files matched your search

+62
View File
@@ -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_<NAME>_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: `<area>: <what changed>`, 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.
+74 -12
View File
@@ -123,6 +123,23 @@ src/ui_badges.cpp the badge bar under the plot: the signal badges, the + badge,
src/ui_signal_page.cpp the per-signal slide-over pages (`signal:<name>`) src/ui_signal_page.cpp the per-signal slide-over pages (`signal:<name>`)
``` ```
**A row full screen, and a second window.** Each figure row carries a full-screen toggle in its
top-right corner: it comes out of hiding while the pointer is near that corner (the button's own
rect plus a `kMaximizeRevealPx` of slack, per row) and is invisible otherwise, so the plot is not
decorated by controls nobody is reaching for; one click makes that row fill
the plot area and the same button restores the whole arrangement; the plot's right-click menu
offers the same as **Full screen this figure** / **Restore the layout**, and a layout of a single
figure carries no button (there is nothing to enlarge *to*). It is a *view* — the row set
saves every row's state on the way in and re-applies it on the way out (`RowState` in
`plot_host.*`), so nothing plotted moves and nothing is saved. A plot's right-click menu has
**Open this plot in a new window**, which copies that figure — its cells, their width shares and
every slot's signal in its colour — into a second top-level window with its own `RowSet`, figure
and `PlotArea`; the overflow menu's **New scope window** opens one with an empty figure. A scope
window is another *view* of the same acquisition: the main window's tick binds and acquires its
rowset under the same model mutex, mirrors the main window's time window (or its pin) onto it
every frame, and pumps its render — so the two windows never disagree about the data, the display
mode or the span.
**The window.** The plot is the window. Under it sits a fixed-height **badge bar** (36 px). Its left **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 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 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 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 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 *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 its *delta* value), then 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 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 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**. 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 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. with, so the addition stays exact.
**Measurements** (overflow menu) reveals a pane with a `[+]` and one row per measurement: **Measurements** (overflow menu) reveals a pane with a `[+]` and one row per measurement:
`[badge] <type> <value> [edit]`. `[+]` opens a dialog with the signal, the type, the options `[chips] <N>. <signal> · <type> <value> [edit]` — the number is the signal's among the
active channels, the same one the signal's badge in the bar and the dialog's signal dropdown
carry. `[+]` opens a dialog with the signal, the type, the options
that type actually uses, and "show on plot"; `edit` opens the same dialog on that that type actually uses, and "show on plot"; `edit` opens the same dialog on that
measurement, with a `Delete` beside `Save`. 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 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. 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 ### 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 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 **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 (`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 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 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 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 ## Captured events
Trigger mode keeps the last **32 triggered acquisitions** in a cache, so an event you did not have 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.npy # the event, in the export format
20261009-123250-0001.txt # when, how long, how many signals, the rate, the edge 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 20261009-123250-0001.bmp # a picture of it, drawn from the samples
``` ```
Switching configuration re-points the cache with it, and a cache written before configurations
existed (loose files directly in `<source>/`) is adopted into the configuration's folder on the
first start.
Events are read in two places. The **dock's Events block** lists them compactly -- one row per event, newest 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 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 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 `~/.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` 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 all is the trigger pane's own switch, and there is deliberately no second one here; the directory is
the one place every source gets a folder inside, `<cache>/udpscope/events` by default. The cache the one place every source gets a folder inside (and every configuration a folder within that),
`<cache>/udpscope/events` by default. The cache
directory and relative time are *start-up* settings -- the buffers are planned and the cache directory 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 is opened before any source is known -- and the dialog says so; the theme applies as soon as it is
chosen. A change 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 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. 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 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 - **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 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 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 **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 `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** 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 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` 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. (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 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 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. one piece of state, and the buttons (its own included) go with it.
+1 -1
+24
View File
@@ -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 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 badge's markup is likewise rewritten on a change of phase (twice a second), never on
all sixty frames. 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::<prop>` handler takes three parameters, a `value-changed` handler two.** - **A `notify::<prop>` handler takes three parameters, a `value-changed` handler two.**
`GObject::notify` passes `(GObject*, GParamSpec*, gpointer)` while `GObject::notify` passes `(GObject*, GParamSpec*, gpointer)` while
`GtkSpinButton::value-changed` passes `(GtkSpinButton*, gpointer)`, so one callback cannot `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 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 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. 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.
+41
View File
@@ -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 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 group's span an ULP above it and a clamp that fires every frame is a re-frame loop, not a
clamp. 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 - **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 `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 pane inherits the summary of the new samples rather than computing one, which is what the
+35 -2
View File
@@ -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. 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: 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 - **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 in any cell moves them all, the crosshair is one x drawn in every cell, and the **vertical cursor
y), and the **vertical cursor pair is one shared time drawn in every cell** (`cursors_aligned`). 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 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)). (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 - **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 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 does **not** put the traces back: the arrangement is the user's, and guessing where they were is worse than
leaving them off. 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.
@@ -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; 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 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. 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.
@@ -192,3 +192,15 @@ of.
Nothing is lost by switching: the dialog saves the current session *before* loading the picked 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. 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 `<base>/<host>-<port>/<configuration>/` (`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 `<base>/<source>/` 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.
+155 -23
View File
@@ -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 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 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** 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 (`App::measure_view_window`) and is deliberately not read back here: the dump runs after
destroy handler, by which point the exit probe has switched the trigger on, and `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 % `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`, 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 `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 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. 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]` ## `[search-probe]`
`UDPSCOPE_PROBE_SEARCH_MS`. The handler calls `condition_probe()` directly — the condition is `UDPSCOPE_PROBE_SEARCH_MS`. The handler calls `condition_probe()` directly — the condition is
@@ -1432,10 +1551,10 @@ popover row reads `failed: <strerror>` — 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] 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 [defaults] calibrated: 2.5 -1.25 model=2.5 -1.25
[configs] before=default active=default [configs] before=default active=default store=default
[configs] save-as=1 names=probe,default active=probe [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] 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 `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 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] 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] 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] 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]`
``` ```
[rowmenu] main 4 'Remove signal' h=18 label_x=26 accel_right=-1/174 ok=1 [rowmenu] main 6 'Remove signal' h=18 label_x=26 accel_right=-1/248 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] 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/178 ok=1 [rowmenu] remove 0 'Back' h=18 label_x=52 accel_right=-1/118 ok=1
[rowmenu] remove rows=6 bad=0 labels=[Back,sine,square,ramp,noise,All traces on this plot] [rowmenu] remove rows=1 bad=0 labels=[Back]
[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] 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 'sine' trace_freed=1 [rowmenu] remove-clicked '' trace_freed=1
``` ```
| field | value it must read | the defect a wrong reading names | | 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`) | | `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 | | `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 | | `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_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 | | `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 | | `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] after events page opened: available=1 dock_sensitive=1 page_sensitive=1 page=events
[events] open alone: page=events page_box=347 [events] open alone: page=events page_box=347
[events] preview redrawn: signals=3 stored=480x132 row_drawn=1 [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] 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 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 [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) ## `strip:` (the readouts strip)
``` ```
[cursors-probe] <what> 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 [cursors-probe] <what> 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] <what> 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] <what> 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 [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 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 [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. - **`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. - **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=<cursors><measures><separator>`** 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=<cursors><measures><separator>`** 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. - **`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()`. - **`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). - **`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]`
``` ```
[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] 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 live=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] default restored: file=''
[prefs-probe] browse: cancelled=1 icon_ok=1 field='/tmp/k5/cache/udpscope/events' [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 `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 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 `/<source>/<active configuration>/` (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` libadwaita's (`AdwEntryRow`, `AdwSpinRow`, `AdwSwitchRow`, `AdwComboRow`), and the `preferences.ui`
dialog has no Apply button for the probe to press. 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 plus the source's folder name**, which is the reading that says the row shows the path in force rather
than a plausible string. than a plausible string.
- The second line must echo the probe's own input: directory `/tmp/udpscope-prefs-events`, keep `7`, - 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 relative time `1`, theme `dark`, and `xhair=0` — the crosshair's axis-badge switch, which the
it. `live=1` is the theme having reached the running style manager rather than only the file. 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 - `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 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 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] dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark live=1
[prefs-probe] default restored: file='' [prefs-probe] default restored: file=''
[prefs-probe] browse: toplevels=3 extra=1 icon_ok=1 field='/tmp/k5/cache/udpscope/events' [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] 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] 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] 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 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: 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=[..]` `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) | | `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-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 | | `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 | | `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-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()` | | `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()` |
+63 -4
View File
@@ -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`, 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 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 — 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 `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 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 then dump the whole pipeline state to stdout (frame/sample counts, per-plot painted
frames, trace bindings, panel sizes, status line in both states). frames, trace bindings, panel sizes, status line in both states).
- `UDPSCOPE_ENABLE_ALL_MS`, `UDPSCOPE_ARM_TRIGGER_MS`, `UDPSCOPE_PROBE_DISARM_MS`, - `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, *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. 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 - `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 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 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). 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 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 *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 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 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). 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: <type>=<value>`; on the fast mock The values are in the exit dump as `measurements: N.signal <type>=<value>`, 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 (`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 `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* 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 [probe-readings.md](probe-readings.md); the window rule itself is in
[invariants-measurements.md](invariants-measurements.md). [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] asked=[..] was=[..]`, `[pan-probe] after=[..] held=D asked=[..]`, then
`[pan-probe] zoom asked=[..] after=[..] span=S window=W limited=L` `[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 (stderr, `UDPSCOPE_PROBE_PAN_MS`) — **`held` must be 1**: a gesture must not move a window
+10
View File
@@ -63,6 +63,16 @@
<item><p>A marker past the newest sample reads <code>--</code>. With only one marker <item><p>A marker past the newest sample reads <code>--</code>. With only one marker
over data the row reads <code>partial</code>: a difference between a number and nothing over data the row reads <code>partial</code>: a difference between a number and nothing
is not a number.</p></item> is not a number.</p></item>
<item><p>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 <gui>Crosshair axis labels</gui> turns the pair off.</p></item>
<item><p>In a split row the horizontal line runs across <em>every</em> 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.</p></item>
<item><p>In <gui>mix</gui> each marker also places a crossing annotation per trace, <item><p>In <gui>mix</gui> 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 <em>on which is what lets you see on the plot which sample a number came from. It is <em>on
that sample</em>, so when the marker falls between two it sits a little off its own that sample</em>, so when the marker falls between two it sits a little off its own
+24 -2
View File
@@ -29,7 +29,8 @@
add a trace.</p> add a trace.</p>
</item> </item>
<item> <item>
<p>Press <key>l</key>, or pick <gui>Layout editor</gui> from the overflow menu, to add <p>Press <key>l</key>, pick <gui>Layout editor</gui> from the overflow menu, or pick
<gui>Edit the layout…</gui> from a plot's right-click menu, to add
and remove rows: a <gui>×</gui> appears at the top left of each plot and a <gui>+</gui> and remove rows: a <gui>×</gui> appears at the top left of each plot and a <gui>+</gui>
on each boundary between rows, including above the first and below the last.</p> on each boundary between rows, including above the first and below the last.</p>
</item> </item>
@@ -58,12 +59,33 @@
<p>All the plots of a row, and of the whole figure, share one time window: zooming or <p>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 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 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 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 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 <gui>+</gui> buttons go grey at the limit.</p> in total; the <gui>+</gui> buttons go grey at the limit.</p>
</section> </section>
<section id="fullscreen">
<title>A row full screen, and a second window</title>
<list>
<item><p>Every row carries a <gui>full-screen</gui> 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 <gui>Full screen this
figure</gui> (and <gui>Restore the layout</gui> while one is enlarged). A layout of a single
figure has nothing to enlarge *to*, so it carries no button.</p></item>
<item><p>A plot's right-click menu has <gui>Open this plot in a new window</gui>: 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
<gui>New scope window</gui> opens the same kind of window with an empty figure. Closing one
closes that view only; the main window keeps acquiring.</p></item>
</list>
</section>
<section id="knowing"> <section id="knowing">
<title>Worth knowing</title> <title>Worth knowing</title>
<list> <list>
+31 -1
View File
@@ -80,8 +80,19 @@
shorter than a level's own resolution, or one read through <gui>hf</gui> — where shorter than a level's own resolution, or one read through <gui>hf</gui> — where
<gui>top</gui>/<gui>bottom</gui> fall back to the mean of each side of the split and <gui>top</gui>/<gui>bottom</gui> fall back to the mean of each side of the split and
<gui>amplitude</gui> to the window's peak to peak.</p></item> <gui>amplitude</gui> to the window's peak to peak.</p></item>
<item><p>Each row names the <em>channel</em> 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 — <code>1. sine · average</code>. 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".</p></item>
<item><p>The on-plot marks come in two kinds and the mix is the reading: a <em>level</em> <item><p>The on-plot marks come in two kinds and the mix is the reading: a <em>level</em>
mark is a ruler at a value, a <em>time</em> 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 <em>time</em> 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 <em>and</em> a time mark at the first draws one level mark; a max draws a level mark <em>and</em> a time mark at the first
sample that reached it; <gui>peak to peak</gui> draws a ruler at each extreme and sample that reached it; <gui>peak to peak</gui> draws a ruler at each extreme and
<gui>amplitude</gui> a ruler at each of its two flat levels — the pair is the span, so <gui>amplitude</gui> a ruler at each of its two flat levels — the pair is the span, so
@@ -136,6 +147,25 @@
</list> </list>
</section> </section>
<section id="gate">
<title>The gate</title>
<p>A measurement's dialog carries <gui>Gate enabled</gui>. Every measurement
with it set reads only the samples inside one shared band — violet, labelled <em>gate</em>,
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.</p>
<p>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.</p>
<p>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 <code>--</code> like any empty window. The band's
position and each measurement's gate flag are saved with the session.</p>
</section>
<section id="example"> <section id="example">
<title>Example</title> <title>Example</title>
<p>On a 1 Hz ±1 sine: <code>average=0</code>, <code>top=0.957988</code>, <p>On a 1 Hz ±1 sine: <code>average=0</code>, <code>top=0.957988</code>,
+5
View File
@@ -29,6 +29,11 @@
not a working badge. Of the two steady colours, <em>grey</em> is no stream at all or a not a working badge. Of the two steady colours, <em>grey</em> is no stream at all or a
pipeline you stopped (the tooltip says which), and <em>yellow</em> is a source that pipeline you stopped (the tooltip says which), and <em>yellow</em> is a source that
answered but has sent no configuration.</p></item> answered but has sent no configuration.</p></item>
<item><p>Left of the badge is the <em>trigger circle</em>, and it is the trigger's own
light: solid green while the trigger has fired (an event is held, or a capture is
filling), <em>flashing</em> 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.</p></item>
<item><p>The state field is one word from a fixed set: <code>connecting</code>, <item><p>The state field is one word from a fixed set: <code>connecting</code>,
<code>live</code>, <code>paused</code>, <code>armed</code>, <code>capturing</code>, <code>live</code>, <code>paused</code>, <code>armed</code>, <code>capturing</code>,
<code>event held</code>, <code>disarmed</code>. Two states add one field each: in trigger <code>event held</code>, <code>disarmed</code>. Two states add one field each: in trigger
+3 -1
View File
@@ -169,7 +169,9 @@
<section id="cache"> <section id="cache">
<title>The event cache, replay and compare</title> <title>The event cache, replay and compare</title>
<p><em>Every triggered acquisition is kept.</em> Latches are written to the event cache <p><em>Every triggered acquisition is kept.</em> Latches are written to the event cache
(<file>~/.cache/udpscope/events/&lt;source&gt;/</file>) as a <file>.npy</file> of the (<file>~/.cache/udpscope/events/&lt;source&gt;/&lt;configuration&gt;/</file> -- one folder per
source and configuration, so two configurations of one source keep their events apart) as a
<file>.npy</file> of the
event, a <file>.txt</file> of what it was, and a <file>.bmp</file> picture drawn from the event, a <file>.txt</file> of what it was, and a <file>.bmp</file> picture drawn from the
samples. The <gui>Events</gui> block at the foot of the dock lists them, newest first, one samples. The <gui>Events</gui> 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 short row each: a marker for the event on screen, its title and its number. It is dimmed
+64 -4
View File
@@ -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; } std::string default_events_dir() { return std::string(cache_dir()) + kEventsSubdir; }
EventStore::EventStore(std::string source, std::string base_dir) { namespace {
if (source.empty()) return;
/// 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; 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') || const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
c == '.' || c == '_' || c == '-'; c == '.' || c == '_' || c == '-';
safe.push_back(ok ? 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: `<root>/<source>/<configuration>/`, 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 `<root>/<source>/`; 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) { std::string EventStore::save(const ExportTable& table, const EventRecord& meta, std::size_t keep) {
+25 -2
View File
@@ -45,11 +45,28 @@ std::string default_events_dir();
class EventStore { class EventStore {
public: public:
/// `source` is the `<host>-<port>` pair, the same key the packet capture uses. /// `source` is the `<host>-<port>` 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
/// `<base>/<source>/<configuration>/`; an empty configuration is the legacy layout
/// (`<base>/<source>/`), 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 /// `base_dir` is the directory every source's own folder is created under; empty means
/// `<cache>/udpscope/events`. It comes from the preferences (item 9), because a user with a small /// `<cache>/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. /// 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 /// 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. /// preferences are where this becomes the user's number.
@@ -89,6 +106,12 @@ class EventStore {
void prune(std::size_t keep) const; void prune(std::size_t keep) const;
private: 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_; std::string dir_;
}; };
+11 -1
View File
@@ -573,6 +573,11 @@ bool load_layout(const std::string& path, LayoutState& out) {
} }
} else if (key == "measure_stats") { } else if (key == "measure_stats") {
st.measure_stats = value == "true"; 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") { } else if (key == "dock") {
st.dock_shown = value == "true"; st.dock_shown = value == "true";
} else if (key.rfind("trigger_", 0) == 0) { } 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"; m.options.above = value == "true";
} else if (key == "show") { } else if (key == "show") {
m.options.show_on_plot = value == "true"; m.options.show_on_plot = value == "true";
} else if (key == "gate") {
m.options.gate = value == "true";
} }
} else if (section == Section::Calibration && !st.calibration.empty()) { } else if (section == Section::Calibration && !st.calibration.empty()) {
if (key == "name") { if (key == "name") {
@@ -762,6 +769,8 @@ bool save_layout(const std::string& path, const LayoutState& state) {
o << "]\n"; o << "]\n";
} }
o << "measure_stats = " << (state.measure_stats ? "true" : "false") << "\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"; 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. // 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"; 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 << "\nthreshold2 = " << m.options.threshold_pct2
<< "\nabove = " << (m.options.above ? "true" : "false") << "\nshow = " << "\nabove = " << (m.options.above ? "true" : "false") << "\nshow = "
<< (m.options.show_on_plot ? "true" : "false") << "\nnoise = " << m.options.noise_pct << (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 // A sibling temp file + rename: a crash mid-write leaves the old layout, not a
+8
View File
@@ -87,6 +87,14 @@ struct LayoutState {
/// stream and mean nothing restored against another one. /// stream and mean nothing restored against another one.
bool measure_stats = false; 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 /// 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 /// 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. /// ignores keys it does not know.
+7
View File
@@ -93,6 +93,13 @@ struct MeasureOptions {
/// -- a filtered window cannot use the LOD pyramid, because a bucket aggregates unfiltered samples -- /// -- a filtered window cannot use the LOD pyramid, because a bucket aggregates unfiltered samples --
/// so a non-zero corner costs a full raw walk. /// so a non-zero corner costs a full raw walk.
double hf_fc = 0.0; 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 /// One entry in the measurements pane, and everything needed to recompute it: the signal by
+149 -17
View File
@@ -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 /// 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. /// picture that cannot be read, so the current hit is brighter than its neighbours.
constexpr plot::u32 kSearchCurrentColor = 0xE8D37Fu; 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 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 /// 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(); rebuild_all();
} }
void RowSet::carry_row(Row& dst, const Row& src) { void RowSet::toggle_maximize_figure_row(int fr) {
dst.signal_name = src.signal_name; if (maximized_) {
dst.name = src.name; // Back exactly as it was: every row's state first, then the arrangement it was indexed
dst.channel = src.channel; // by, then the rebuild that deals the saved rows back into fresh plots.
dst.color_index = src.color_index; for (int i = 0; i < saved_rows_ && i < kMaxRows; ++i) {
// A layer travels with the slot that draws it: it *is* part of what that slot holds. The tick's apply_row_state(storage_[static_cast<std::size_t>(i)],
// `sync_layers()` re-plans them all next frame in any case, which is what heals a row that has saved_row_states_[static_cast<std::size_t>(i)]);
// moved under a plan made for the old one. }
dst.layer = src.layer; cells_per_row_ = saved_cells_;
dst.slot_alpha = src.slot_alpha; 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<std::size_t>(i)] =
row_state_of(storage_[static_cast<std::size_t>(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<std::size_t>(k)],
saved_row_states_[static_cast<std::size_t>(first + k)]);
}
cells_per_row_.assign(1, n);
cell_weights_.assign(1, fr < static_cast<int>(saved_cell_weights_.size())
? saved_cell_weights_[static_cast<std::size_t>(fr)]
: std::vector<float>{});
// 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<int>(kMaxRows));
maximized_ = true;
rebuild_all();
}
dst.ruler_y = src.ruler_y; void RowSet::carry_row(Row& dst, const Row& src) {
dst.ruler_b_y = src.ruler_b_y; // One spelling of "a row's persistent state": the save/restore the maximize toggle uses
// The slot's series stays where it is, but what it is bound to belonged to the row // is the same copy, and a field added there is a field carried here.
// this state came *from*. `bind()` re-resolves every slot next frame; clearing it here apply_row_state(dst, row_state_of(src));
// 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::clear_row(Row& r) { 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 // 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. // 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); 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 // 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. // `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; r.ruler_b_y = first.ruler_b_y;
} }
place_cursors(r); 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_) { if (trigger_marker_on_) {
r.plot->set_cursor_x(r.trigger_marker, trigger_marker_x_, plot::CursorPlacement::Free); 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<MeasureMark>& marks) {
const std::size_t mi = static_cast<std::size_t>(v++); const std::size_t mi = static_cast<std::size_t>(v++);
r.plot->set_cursor_x(r.measure_v[mi], m.value, plot::CursorPlacement::Free); 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_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 // 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<MeasureMark>& marks) {
} }
for (; v < kMeasureMarks; ++v) { for (; v < kMeasureMarks; ++v) {
r.plot->set_cursor_x(r.measure_v[static_cast<std::size_t>(v)], kParked); r.plot->set_cursor_x(r.measure_v[static_cast<std::size_t>(v)], kParked);
r.plot->set_cursor_label(r.measure_v[static_cast<std::size_t>(v)], "");
} }
} }
} }
@@ -1315,6 +1382,63 @@ void RowSet::sync_cursors(int drag_row) {
place_crossings(); 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() { void RowSet::clamp_to_data() {
if (!figure_) return; if (!figure_) return;
// The rows share one window, so they share one bound: the union of what they hold. // 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)); 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() { void RowSet::reset_cursors() {
for (int i = 0; i < kMaxRows; ++i) { for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i); Row& r = row(i);
+117 -1
View File
@@ -133,6 +133,13 @@ class RowSet {
/// it keeps its position whatever the cursor toggle says. /// it keeps its position whatever the cursor toggle says.
plot::u32 trigger_marker = 0; 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 /// 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. /// drop a cursor after the fact) and parked unless a measurement is using the slot.
std::array<plot::u32, kMeasureMarks> measure_h{}; std::array<plot::u32, kMeasureMarks> measure_h{};
@@ -211,6 +218,18 @@ class RowSet {
/// Shows `rows` figure rows of one cell each (so `rows` plots, any split undone). A changed /// 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. /// arrangement rebuilds the figure, since a `Figure`'s shape is fixed at construction.
void set_row_count(int rows); 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, /// 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 /// `figure_row_count()` = below the last), pushing the rows at and below it down with their
/// cells. Returns false at `kMaxRows` plots. /// cells. Returns false at `kMaxRows` plots.
@@ -322,6 +341,11 @@ class RowSet {
void set_y_range(int row, double lo, double hi); void set_y_range(int row, double lo, double hi);
void set_cursors_visible(bool on); 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 /// 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. /// does not use rather than removing them: a `Plot` cannot drop a cursor.
void set_cursor_mode(CursorMode mode); 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 /// 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 /// *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 { struct MeasureMark {
int row = 0; int row = 0;
bool level = false; bool level = false;
double value = 0.0; double value = 0.0;
plot::u32 color = 0; plot::u32 color = 0;
std::string label;
}; };
/// Places the measurements' marks and parks every pool slot they do not use. The marks are /// 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. /// Called once a frame from the tick, with `drag_row` < 0 when no cursor is held.
void sync_cursors(int drag_row); 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 /// 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 /// 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. /// 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 /// objects the traces point at, and those belong to the row's *position*, not to its
/// contents. `rebuild_all()` re-deals them straight after. /// contents. `rebuild_all()` re-deals them straight after.
static void carry_row(Row& dst, const Row& src); 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<std::string, kSlotsPerRow> signal_name;
std::array<std::string, kSlotsPerRow> name;
std::array<int, kSlotsPerRow> channel;
std::array<int, kSlotsPerRow> color_index;
std::array<int, kSlotsPerRow> layer;
std::array<float, kSlotsPerRow> 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. /// Back to an empty row: nothing plotted, the default palette entries, no cursors.
static void clear_row(Row& r); 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 /// 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. /// mid-drag has already been through that act.
void place_cursors(Row& r, plot::CursorPlacement placement = plot::CursorPlacement::Snapped); 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<plot::Figure> figure_; std::unique_ptr<plot::Figure> figure_;
/// The one x link group every visible row belongs to. Groups are numbered from 1. /// The one x link group every visible row belongs to. Groups are numbered from 1.
@@ -635,6 +733,17 @@ class RowSet {
std::vector<plot::InvalidSpan> spans_; std::vector<plot::InvalidSpan> spans_;
CursorMode cursor_mode_ = CursorMode::Vertical; CursorMode cursor_mode_ = CursorMode::Vertical;
bool cursors_on_ = false; 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<RowState, static_cast<std::size_t>(kMaxRows)> saved_row_states_{};
std::vector<int> saved_cells_;
std::vector<std::vector<float>> saved_cell_weights_;
std::vector<float> saved_row_weights_;
int saved_rows_ = 0;
bool relative_x_ = false; bool relative_x_ = false;
double x_origin_ = 0.0; double x_origin_ = 0.0;
/// What was last *written* to each row's own vertical cursors. A row is only evidence of a /// 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. /// The one stream time each vertical cursor is at, whatever row's copy was last moved.
double cursor_a_x_ = 0.0; double cursor_a_x_ = 0.0;
double cursor_b_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<bool, static_cast<std::size_t>(kMaxRows)> row_dirty_{}; std::array<bool, static_cast<std::size_t>(kMaxRows)> row_dirty_{};
}; };
+3
View File
@@ -70,6 +70,8 @@ Settings load_settings() {
if (mb >= 16) s.memory_limit_mb = static_cast<int>(std::min<long>(mb, 1000000000L)); if (mb >= 16) s.memory_limit_mb = static_cast<int>(std::min<long>(mb, 1000000000L));
} else if (key == "theme") { } else if (key == "theme") {
if (value == "auto" || value == "light" || value == "dark") s.theme = value; 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") { } else if (key == "contextual_help") {
s.contextual_help = parse_bool(value, s.contextual_help); s.contextual_help = parse_bool(value, s.contextual_help);
} else if (key == "tour_seen") { } 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 // second. `read_rows()` in the preferences starts from `load_settings()`, so a dialog save carries
// them through untouched. // them through untouched.
out << "contextual_help = " << (settings.contextual_help ? "true" : "false") << "\n"; 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 << "tour_seen = " << (settings.tour_seen ? "true" : "false") << "\n";
out.flush(); out.flush();
return out.good(); return out.good();
+5
View File
@@ -39,6 +39,11 @@ struct Settings {
/// "auto", "light" or "dark": what the interface should look like. Kept as the three names libadwaita /// "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. /// uses, so the file says what the setting is rather than what an enum happens to be numbered.
std::string theme = "auto"; 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** -- /// 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 /// 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 /// menu or from the chooser's own help menu, by whoever wants it; on is then a decision that
+460 -20
View File
@@ -547,6 +547,25 @@ GtkWidget* make_area(App& app) {
gtk_overlay_set_child(GTK_OVERLAY(app.area_overlay), widget); gtk_overlay_set_child(GTK_OVERLAY(app.area_overlay), widget);
gtk_widget_set_hexpand(app.area_overlay, true); gtk_widget_set_hexpand(app.area_overlay, true);
gtk_widget_set_vexpand(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<App*>(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<App*>(user)->area_pointer_valid = false;
}),
&app);
gtk_widget_add_controller(app.area_overlay, GTK_EVENT_CONTROLLER(motion));
return app.area_overlay; return app.area_overlay;
} }
@@ -559,6 +578,85 @@ void relayout_areas(App& app) {
if (app.area != nullptr) app.area->set_figure(&app.rows->figure()); 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<App::ScopeWindow>();
scope->rows = std::make_unique<RowSet>();
if (figure_row >= 0) {
const int cells = app.rows->cells_in_row(figure_row);
scope->rows->set_cells_per_row(std::vector<int>(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<std::size_t>(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<App*>(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<App::ScopeWindow*>(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<App*>(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 --------------------------------------------------------- // --- panel rebuilds ---------------------------------------------------------
/// Defined below, forward-declared because placing or freeing a trace slot has to /// 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 /// 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. /// 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<GtkWidget*>& list) {
GtkWidget* b = gtk_button_new_from_icon_name(icon); GtkWidget* b = gtk_button_new_from_icon_name(icon);
gtk_widget_set_tooltip_text(b, tooltip); gtk_widget_set_tooltip_text(b, tooltip);
gtk_widget_add_css_class(b, "circular"); gtk_widget_add_css_class(b, "circular");
gtk_widget_add_css_class(b, "osd"); gtk_widget_add_css_class(b, "osd");
gtk_widget_set_size_request(b, kLayoutButtonPx, kLayoutButtonPx); gtk_widget_set_size_request(b, kLayoutButtonPx, kLayoutButtonPx);
gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), b); gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), b);
app.layout_buttons.push_back(b); list.push_back(b);
return 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<double>(app.area->device_scale())
: 1.0;
const double width = static_cast<double>(gtk_widget_get_width(app.area_widget));
const double height = static_cast<double>(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<int>(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<double>(last->layout().axes.right()) / scale
: static_cast<double>(fl.rows[static_cast<std::size_t>(fr)].right()) / scale;
const double top =
static_cast<double>(fl.rows[static_cast<std::size_t>(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<int>(std::clamp(top, 0.0, std::max(0.0, height - kLayoutButtonPx)) + 0.5);
gtk_widget_set_margin_top(b, static_cast<int>(by));
const double bx = static_cast<int>(std::clamp(right - kLayoutButtonPx - kLayoutButtonPad, 0.0,
std::max(0.0, width - kLayoutButtonPx)) + 0.5);
gtk_widget_set_margin_start(b, static_cast<int>(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<App*>(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<App*>(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<App*>(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) { void rebuild_layout_editor(App& app) {
clear_layout_editor(app); clear_layout_editor(app);
if (!app.layout_editing || app.area_overlay == nullptr) return; 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_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_widget_set_tooltip_text(sig_chip, m.signal.c_str());
gtk_box_append(GTK_BOX(box), sig_chip); 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 ? "<b>" + std::to_string(number) + ". " + esc + "</b> · " +
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_halign(kind, GTK_ALIGN_START);
gtk_widget_set_hexpand(kind, true); gtk_widget_set_hexpand(kind, true);
gtk_label_set_ellipsize(GTK_LABEL(kind), PANGO_ELLIPSIZE_END); 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 // 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. // 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<std::string> sig_display;
sig_display.reserve(sig_names.size());
std::vector<const char*> sig_cstrs; std::vector<const char*> sig_cstrs;
sig_cstrs.reserve(sig_names.size() + 1); 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); sig_cstrs.push_back(nullptr);
if (editing != nullptr) { if (editing != nullptr) {
const auto it = std::find(sig_names.begin(), sig_names.end(), editing->signal); 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"); "at the time it happened, the pulse's edges as markers");
gtk_box_append(GTK_BOX(box), show); 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, auto* d = new MeasureDialog{&app, edit_index, signal_drop, type_drop,
threshold, band, above, show, threshold, band, above, show,
window, threshold_row, threshold_label, band_row, gate, window, threshold_row, threshold_label,
band_label, side_row, side_label, band_row, band_label, side_row, side_label,
noise, noise_row, hf, hf_row, noise, noise_row, hf, hf_row,
sig_names}; sig_names};
app.measure_dialog = window; 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_spin_button_set_value(GTK_SPIN_BUTTON(hf), editing->options.hf_fc);
gtk_switch_set_active(GTK_SWITCH(above), editing->options.above); 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(show), editing->options.show_on_plot);
gtk_check_button_set_active(GTK_CHECK_BUTTON(gate), editing->options.gate);
} else { } else {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(threshold), 50.0); gtk_spin_button_set_value(GTK_SPIN_BUTTON(threshold), 50.0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(band), MeasureOptions{}.threshold_pct2); 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); update_measures(*a, /*force=*/true, /*accumulate=*/true);
}), }),
&app); &app);
// The heading row -- the .ui's anonymous first child, carrying the title and the `+`. It is where // 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`). // the contextual help's "?" joins (see `ui_context_help.cpp`).
app.measures_head = gtk_widget_get_first_child(box); 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. // 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 X axis", nullptr, G_CALLBACK(on_fit_x_clicked), &app);
add_row("Fit all", "F", G_CALLBACK(on_fit_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()); 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 // 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); reset_measure_stats(*app);
app->search.clear(); app->search.clear();
rebuild_signal_panels(*app); 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 // 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. // 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 // (`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). // otherwise, so reading outside a gesture reads our own echo).
int cursor_drag_row = -1; 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) { if (app->area != nullptr) {
for (int i = 0; i < app->rows->row_count(); ++i) { for (int i = 0; i < app->rows->row_count(); ++i) {
plot::input::Controller* c = app->area->row_controller(static_cast<plot::u32>(i)); plot::input::Controller* c = app->area->row_controller(static_cast<plot::u32>(i));
if (c != nullptr && c->grabbed_cursor() != plot::kInvalid) { if (c == nullptr) continue;
cursor_drag_row = i; if (cursor_drag_row < 0 && c->grabbed_cursor() != plot::kInvalid) cursor_drag_row = i;
break; if (gate_drag_row < 0 && c->grabbed_region() != plot::kInvalid) gate_drag_row = i;
}
} }
} }
app->rows->sync_cursors(cursor_drag_row); 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_cursors_pane(*app);
update_measures(*app, /*force=*/false, /*accumulate=*/true); 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 // 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->bind(model, display);
app->rows_b->acquire(model); 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<App::ScopeWindow>& 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 // 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 // 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 // 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. // acquisition thread mutates, so it belongs under this lock and not after it.
app->rows->set_invalid_spans(model, display); 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<App::ScopeWindow>& 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 // 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 // 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, // "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 // 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. // follow a resize and the gutter settling without being told.
if (app->layout_editing) sync_layout_editor(*app); 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 // 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 // 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]); 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; int bound = 0;
std::size_t samples = 0; std::size_t samples = 0;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
@@ -5169,6 +5521,15 @@ GtkWidget* build_toolbar(App& app) {
app.menu_help_row = help_row; app.menu_help_row = help_row;
gtk_box_append(GTK_BOX(menu_box), 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<App*>(user), -1);
}),
&app);
gtk_box_append(GTK_BOX(menu_box), scope_item);
GtkWidget* prefs_item = menu_button("Preferences\u2026", nullptr); GtkWidget* prefs_item = menu_button("Preferences\u2026", nullptr);
g_signal_connect(prefs_item, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { g_signal_connect(prefs_item, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
open_preferences(*static_cast<App*>(user)); open_preferences(*static_cast<App*>(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_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->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->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_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")); 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 // 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. // 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); 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 // The destroy handler below needs the application, and a captureless callback cannot close
// pointers to widgets that are finalized with the window. // over it: kept on the window, which outlives the handler.
g_signal_connect(app->window, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer user) { 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<App*>(user); App* a = static_cast<App*>(user);
// A pending debounced save will never fire now: flush it, so // Collected first: each scope's own destroy handler erases it from
// quitting right after a change keeps the change. // `scopes` as it fires.
std::vector<GtkWindow*> scope_windows;
scope_windows.reserve(a->scopes.size());
for (const std::unique_ptr<App::ScopeWindow>& 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) { if (a->layout_save_source != 0) {
g_source_remove(a->layout_save_source); g_source_remove(a->layout_save_source);
a->layout_save_source = 0; a->layout_save_source = 0;
save_layout_now(*a); save_layout_now(*a);
} }
a->window = nullptr; 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); app);
// The header bar is the window's own titlebar, and it is *not* in the `.ui`: every button on it is // 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. // tooltip says which phase the badge is *not* reporting.
gtk_label_set_markup(GTK_LABEL(app->status_badge), badge_markup(BadgePhase::Idle)); 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"); 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 // 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 // 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.model = &model;
app.acq = &acq; app.acq = &acq;
app.rows = &rows; 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. // 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 // 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 // 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); 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(); 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 // The event cache is keyed on the source **and its active configuration**, like the packet
// file-mode run has no cache either -- there are no latches in a file. // 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) { if (!app.source_host.empty() && app.source_port != 0) {
app.event_store = std::make_unique<EventStore>(app.source_host + "-" + if (app.config_name.empty()) {
std::to_string(app.source_port), app.settings.event_cache_dir); app.config_name = active_configuration(app.source_host, app.source_port);
}
app.event_store = std::make_unique<EventStore>(
app.source_host + "-" + std::to_string(app.source_port), app.settings.event_cache_dir,
app.config_name);
} }
refresh_events_pane(app); 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")) { if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), measure_probe, &app); g_timeout_add(static_cast<guint>(std::atoi(env)), measure_probe, &app);
} }
if (const char* env = g_getenv("UDPSCOPE_PROBE_GATE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), gate_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_CROSSHAIR_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), crosshair_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_MAXIMIZE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), maximize_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_SCOPE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), scope_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_LEVELS_MS")) { if (const char* env = g_getenv("UDPSCOPE_PROBE_LEVELS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), levels_probe, &app); g_timeout_add(static_cast<guint>(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()); std::printf("[udpscope] measurements: %zu", app.measures.size());
for (std::size_t i = 0; i < app.measures.size() && i < app.measure_results.size(); ++i) { for (std::size_t i = 0; i < app.measures.size() && i < app.measure_results.size(); ++i) {
const MeasureResult& r = app.measure_results[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() : "--"); r.valid ? std::to_string(r.primary).c_str() : "--");
if (r.has_secondary) std::printf("(%s)", std::to_string(r.secondary).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); : 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", 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[0], app.dbg_badge_frames[1], app.dbg_badge_frames[2],
app.dbg_badge_frames[3]); app.dbg_badge_frames[3]);
+7
View File
@@ -153,6 +153,13 @@
<property name="margin-top">2</property> <property name="margin-top">2</property>
<property name="margin-bottom">2</property> <property name="margin-bottom">2</property>
<property name="margin-start">6</property> <property name="margin-start">6</property>
<child>
<!-- The trigger's own circle: green while an event is on screen (or a capture
is filling), flashing green while armed and waiting, red while disarmed,
and hidden while the trigger is off: a light for a mode the user is not
in is noise. Markup is written by the tick on a change of state only. -->
<object class="GtkLabel" id="trigger_status"/>
</child>
<child> <child>
<object class="GtkLabel" id="status_badge"/> <object class="GtkLabel" id="status_badge"/>
</child> </child>
+4 -3
View File
@@ -9,9 +9,10 @@
readout row — so the rows are built and rebuilt in code, and this file holds the frame they land in 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. (`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 One statistics switch for the panel rather than one per reading: statistics are either what a user
looking at or they are not, and a per-row switch is a column of chrome paid for on every row to answer a is looking at or they are not, and a per-row switch is a column of chrome paid for on every row to
question asked once. answer a question asked once. The gate is the opposite case — a per-measurement choice — so it
lives in the measurement dialog, not here.
--> -->
<interface> <interface>
<object class="GtkBox" id="measures_panel"> <object class="GtkBox" id="measures_panel">
+7
View File
@@ -68,6 +68,13 @@
<property name="subtitle" translatable="yes">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.</property> <property name="subtitle" translatable="yes">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.</property>
</object> </object>
</child> </child>
<child>
<object class="AdwSwitchRow" id="crosshair_axis_labels_row">
<property name="title" translatable="yes">Crosshair _axis labels</property>
<property name="use-underline">True</property>
<property name="subtitle" translatable="yes">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.</property>
</object>
</child>
<child> <child>
<object class="AdwComboRow" id="theme_row"> <object class="AdwComboRow" id="theme_row">
<property name="title" translatable="yes">_Theme</property> <property name="title" translatable="yes">_Theme</property>
+109 -3
View File
@@ -190,6 +190,14 @@ struct App {
/// figure, so there is one widget, one context and one `::render` whatever the row /// figure, so there is one widget, one context and one `::render` whatever the row
/// count is. /// count is.
GtkWidget* area_widget = nullptr; 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; plot::gtk4::PlotArea* area = nullptr;
/// The GL area wrapped in a `GtkOverlay`, which is what the plot box actually holds. /// 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 /// 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 /// is -- a single transparent child covering the area would swallow every pan, drop and
/// hover the editor is not interested in. /// hover the editor is not interested in.
GtkWidget* area_overlay = nullptr; 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<RowSet> rows;
};
std::vector<std::unique_ptr<ScopeWindow>> scopes;
/// On while the layout editor is up. Nothing else about the plot changes: the editor is /// 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. /// a set of overlay buttons, not a mode the figure knows about.
bool layout_editing = false; 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 /// 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. /// overlay again. Rebuilt when the row count changes, repositioned when the rows move.
std::vector<GtkWidget*> layout_buttons; std::vector<GtkWidget*> 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<GtkWidget*> 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 /// 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. /// began, so the beginning has to be kept -- reading the weights while dragging would compound.
int layout_drag_row = -1; 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 /// 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. /// change of phase rather than on every one of the 60 frames a second.
int badge_phase = -1; 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 /// 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 /// 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 /// is width-locked (see `kWindowLabelChars`) because the header bar sits above the plot
@@ -681,6 +721,14 @@ struct App {
plot::Range measure_window{}; plot::Range measure_window{};
bool measure_window_valid = false; bool measure_window_valid = false;
bool measure_was_held = 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 /// 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 /// 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 /// dump for the reason every other dump field is (see verification.md): the dump runs from the
@@ -704,12 +752,15 @@ struct App {
/// `MeasureChunk`). /// `MeasureChunk`).
/// One channel's window, at one HF corner, with every level accumulator the pane asked for. /// 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 /// Keyed by `(channel, fc, gated)` and not by channel alone: the corner and the gate are
/// measurements on one signal at different corners are reading two different signals and cannot share a /// per-measurement settings, so two measurements on one signal at different corners -- or one
/// walk. /// 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 { struct MeasureWindow {
int channel = -1; int channel = -1;
double fc = 0.0; ///< the HF corner this window was walked at; 0 is unfiltered 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; MeasureStats stats;
std::vector<double> pct; ///< the level shares asked for, deduplicated std::vector<double> pct; ///< the level shares asked for, deduplicated
std::vector<double> noise; ///< each entry's band, as a percentage; parallel to `pct` std::vector<double> 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 /// 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. /// shared counter would make that a silent mis-step rather than a documented prohibition.
int dbg_bandavg_step = 0; 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_mode = -1;
int cursors_pane_rows = -1; int cursors_pane_rows = -1;
unsigned cursors_pane_generation = ~0u; unsigned cursors_pane_generation = ~0u;
@@ -1440,6 +1514,10 @@ struct MeasureDialog {
GtkWidget* band = nullptr; GtkWidget* band = nullptr;
GtkWidget* above = nullptr; GtkWidget* above = nullptr;
GtkWidget* show = 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; GtkWidget* window = nullptr;
/// The option rows' boxes and labels, so the dialog can *retitle* them per type: the same /// 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, /// 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 kRowKeyBias = 1;
constexpr int kLayoutButtonPx = 24; constexpr int kLayoutButtonPx = 24;
constexpr int kLayoutButtonPad = 4; 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"; constexpr const char* kLayoutKindKey = "udpscope-layout-insert";
/// The ↕ handles' own key: what they are is *not* what the `+` is, and `sync_layout_editor()` /// 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"; constexpr const char* kLayoutCellKey = "udpscope-layout-cell";
/// Column resize handles carry the plot index of the right-hand cell. /// Column resize handles carry the plot index of the right-hand cell.
constexpr const char* kLayoutColKey = "udpscope-layout-col"; 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. /// The minimum width a cell may be dragged to, in logical pixels.
constexpr double kMinCellPx = 96.0; constexpr double kMinCellPx = 96.0;
/// The plotter's own per-cell floor, `kMinCellWidthLogical` in `deps/plotter/src/plot/figure.cpp` /// 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_value(double s, int unit);
std::string format_window(double s); std::string format_window(double s);
const char* badge_markup(BadgePhase phase); 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); std::string format_measure(double v, const std::string& unit);
void set_notice(App& app, std::string what, int seconds = 4); void set_notice(App& app, std::string what, int seconds = 4);
void set_row_key(GtkWidget* widget, int row); 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); 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. /// 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); 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 row_unit(App& app, int row);
std::string slot_unit(App& app, int row, int slot); std::string slot_unit(App& app, int row, int slot);
std::uint32_t bound_slot_mask(App& app); 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); EventThumbCache* event_thumb_cache(App& app);
GdkTexture* event_preview_texture(App& app, const std::string& path); GdkTexture* event_preview_texture(App& app, const std::string& path);
void refresh_events_pane(App& app); 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()` /// 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 /// 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 /// 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 mathwrap_probe_report(gpointer user);
gboolean measure_probe(gpointer user); gboolean measure_probe(gpointer user);
gboolean levels_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_probe(gpointer user);
gboolean measurepin_held_wait(gpointer user); gboolean measurepin_held_wait(gpointer user);
gboolean measurepin_pause_wait(gpointer user); gboolean measurepin_pause_wait(gpointer user);
+10 -2
View File
@@ -122,7 +122,9 @@ gboolean on_badge_drop(GtkDropTarget*, const GValue* value, double, double, gpoi
return TRUE; 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<SignalInfo>& signals, std::size_t index) { void make_signal_badge(App& app, Badge& b, const std::vector<SignalInfo>& signals, std::size_t index) {
b.signal = index; b.signal = index;
b.signal_name = signals[index].name; b.signal_name = signals[index].name;
@@ -133,7 +135,13 @@ void make_signal_badge(App& app, Badge& b, const std::vector<SignalInfo>& signal
GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget* dot = gtk_label_new(nullptr); GtkWidget* dot = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(dot), signal_chip_markup(app, signals, index).c_str()); 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_ellipsize(GTK_LABEL(b.name), PANGO_ELLIPSIZE_END);
gtk_label_set_max_width_chars(GTK_LABEL(b.name), 14); gtk_label_set_max_width_chars(GTK_LABEL(b.name), 14);
gtk_box_append(GTK_BOX(row), dot); gtk_box_append(GTK_BOX(row), dot);
+26 -8
View File
@@ -10,8 +10,9 @@
// layout does not depend on what a value reads; the full text is the label's tooltip. // 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 // 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 // fixed-height line: a "Cursors" button, one chip per cursor line with its delta, one chip per
// chip per measurement with its value), which is on screen whenever the dock is not beside the plot (hidden // 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 // 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 // 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. // 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<App*>(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) { void build_readouts_strip(App& app) {
if (app.strip_box == nullptr) return; if (app.strip_box == nullptr) return;
clear_box(app.strip_box); 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_top(app.strip_sep, 5);
gtk_widget_set_margin_bottom(app.strip_sep, 5); gtk_widget_set_margin_bottom(app.strip_sep, 5);
app.strip_measure_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); app.strip_measure_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_box_append(GTK_BOX(app.strip_measure_group), // The chips, then the `[+]` after the last one: the compact strip is where the readings are
strip_button("Measures", G_CALLBACK(on_edit_measures), &app, // watched, so that is where adding one belongs -- and it replaces the old "Measures" button,
"the measurements' full panel")); // 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), 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_cursor_group);
gtk_box_append(GTK_BOX(app.strip_box), app.strip_sep); gtk_box_append(GTK_BOX(app.strip_box), app.strip_sep);
gtk_box_append(GTK_BOX(app.strip_box), app.strip_measure_group); 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) { for (std::size_t i = 0; i < app.measures.size(); ++i) {
const Measure& m = app.measures[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); GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
g_object_set_data(G_OBJECT(line), "udpscope-measure-index", g_object_set_data(G_OBJECT(line), "udpscope-measure-index",
GINT_TO_POINTER(static_cast<int>(i) + 1)); GINT_TO_POINTER(static_cast<int>(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_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_widget_set_tooltip_text(chip, m.signal.c_str());
gtk_box_append(GTK_BOX(line), chip); 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_hexpand(kind, true);
gtk_widget_set_tooltip_text(kind, (m.signal + "\n" + measure_type_name(m.type)).c_str()); gtk_widget_set_tooltip_text(kind, (m.signal + "\n" + measure_type_name(m.type)).c_str());
gtk_box_append(GTK_BOX(line), kind); 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_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_widget_set_tooltip_text(schip, m.signal.c_str());
gtk_box_append(GTK_BOX(sline), schip); 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_widget_set_tooltip_text(skind, (m.signal + "\n" + measure_type_name(m.type)).c_str());
gtk_box_append(GTK_BOX(sline), skind); gtk_box_append(GTK_BOX(sline), skind);
GtkWidget* svalue = fixed_label("--", kValueChars, GTK_ALIGN_END); GtkWidget* svalue = fixed_label("--", kValueChars, GTK_ALIGN_END);
+8
View File
@@ -929,6 +929,14 @@ static void rebuild_events_pane_list(App& app, std::vector<EventRecord>& records
/// Both views of the cache, rebuilt from the same list: the events page's rows (with their pictures) and the /// 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 /// 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. /// 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) { void refresh_events_pane(App& app) {
// One read of the cache directory for both views (none without a store, which the page's rebuild // 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). // and the dock's both treat as nothing to list).
+16
View File
@@ -73,6 +73,8 @@ LayoutState collect_layout(App& app) {
st.math = app.math; st.math = app.math;
st.measures = app.measures; st.measures = app.measures;
st.measure_stats = app.measure_stats_on; 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; st.dock_shown = app.sidebar_wanted;
// The trigger, its settings *and* whether it was on. The channel goes by name, like every identity // 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. // 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_name = name;
app.config_comment = st.comment; app.config_comment = st.comment;
set_active_configuration(app.source_host, app.source_port, name); set_active_configuration(app.source_host, app.source_port, name);
retarget_event_store(app);
apply_layout(app, st); apply_layout(app, st);
// A buffer size, a clock and a capture ring are decided before the first socket: saying so beats // 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(); app.config_comment.clear();
} }
forget_configuration(app.source_host, app.source_port, name); 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); config_dialog_refresh(*d);
} }
@@ -312,6 +317,7 @@ bool save_configuration_as(App& app, const std::string& name, const std::string&
return false; return false;
} }
set_active_configuration(app.source_host, app.source_port, name); set_active_configuration(app.source_host, app.source_port, name);
retarget_event_store(app);
return true; 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), gtk_check_button_set_active(GTK_CHECK_BUTTON(app.measures_stats_check),
app.measure_stats_on); 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 // 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' // 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(). // visibility is also set per-row inside rebuild_measures_pane via measures_row().
+76 -7
View File
@@ -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<std::mutex> lock(app.model->mutex());
const std::vector<ChannelInfo>& channels = app.model->channels();
for (std::size_t c = 0; c < channels.size(); ++c) {
if (channels[c].name == signal) return static_cast<int>(c) + 1;
}
return 0;
}
/// The channel a measurement's signal resolved to, or -1 while the stream does not carry it. /// 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) { int measure_channel(App& app, const std::string& signal) {
std::lock_guard<std::mutex> lock(app.model->mutex()); std::lock_guard<std::mutex> 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])); gtk_box_append(GTK_BOX(app.measures_list), measures_row(app, i, app.measures[i]));
app.measure_text.emplace_back(); 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); dock_rebuild_measures(app);
sync_readouts_strip(app); // the strip's Measures group (and the strip itself) follows at once, not a tick later 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); 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.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.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.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.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)); m.options.hf_fc = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->hf));
if (d->edit >= 0 && static_cast<std::size_t>(d->edit) < app.measures.size()) { if (d->edit >= 0 && static_cast<std::size_t>(d->edit) < app.measures.size()) {
@@ -261,7 +286,14 @@ void place_measure_marks(App& app) {
const double s0 = app.model->stream_at_x0(); const double s0 = app.model->stream_at_x0();
app.measure_marks_display = app.measure_marks; app.measure_marks_display = app.measure_marks;
for (RowSet::MeasureMark& mk : app.measure_marks_display) { 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); 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 plot::Range pin = app.rows->pinned_range();
const bool pinned_view = app.rows->window_pinned() && pin.span() > 0.0 && const bool pinned_view = app.rows->window_pinned() && pin.span() > 0.0 &&
std::isfinite(pin.min) && std::isfinite(pin.max); 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; 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 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) // 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 // 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. // 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 // 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 // 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). // frame (457 passes in one run is the shape that mistake took).
const bool moved = app.measure_window_valid && const bool moved = (app.measure_window_valid &&
std::fabs(swin.min - app.measure_window.min) + std::fabs(swin.min - app.measure_window.min) +
std::fabs(swin.max - app.measure_window.max) > std::fabs(swin.max - app.measure_window.max) >
0.02 * std::max(1.0e-12, swin.span()); 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 // 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 // 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 // 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 = swin;
app.measure_window_valid = true; app.measure_window_valid = true;
app.measure_gate_window = gswin;
app.measure_gate_window_valid = true;
app.measure_was_held = held; app.measure_was_held = held;
app.measure_stamp_us = now_us; app.measure_stamp_us = now_us;
app.measure_stamp_valid = true; 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; const double fc = m.options.hf_fc > 0.0 ? m.options.hf_fc : 0.0;
std::size_t wi = 0; std::size_t wi = 0;
while (wi < app.measure_samples.size() && 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; ++wi;
} }
if (wi == app.measure_samples.size()) { if (wi == app.measure_samples.size()) {
App::MeasureWindow fresh; App::MeasureWindow fresh;
fresh.channel = channel; fresh.channel = channel;
fresh.fc = fc; fresh.fc = fc;
fresh.gated = m.options.gate;
measure_filter_set(fresh.scan_filter, fc, slot_dt); measure_filter_set(fresh.scan_filter, fc, slot_dt);
measure_filter_set(fresh.acc_filter, fc, slot_dt); measure_filter_set(fresh.acc_filter, fc, slot_dt);
app.measure_samples.push_back(fresh); app.measure_samples.push_back(fresh);
@@ -477,7 +535,11 @@ void update_measures(App& app, bool force, bool accumulate) {
std::vector<MeasureBucket>& buckets = app.measure_bucket_scratch; std::vector<MeasureBucket>& buckets = app.measure_bucket_scratch;
for (App::MeasureWindow& w : app.measure_samples) { for (App::MeasureWindow& w : app.measure_samples) {
const gint64 fetch_start_us = g_get_monotonic_time(); const gint64 fetch_start_us = g_get_monotonic_time();
MeasureWindowReader reader(app, static_cast<std::size_t>(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<std::size_t>(w.channel), wwindow);
const std::size_t n = reader.size(); const std::size_t n = reader.size();
const std::function<double(std::size_t)> time_of = [&reader](std::size_t i) { const std::function<double(std::size_t)> time_of = [&reader](std::size_t i) {
return reader.time_of(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` // 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. // 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 // 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. // mark is a y and is stored as it comes. Field by field: a partial brace-initialiser
marks.push_back({row, spec.level, spec.level ? spec.value : spec.value + s0, color}); // 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); app.measure_marks = std::move(marks);
+11 -1
View File
@@ -39,6 +39,7 @@ Settings read_rows(const PrefsForm& f) {
s.relative_time = adw_switch_row_get_active(f.relative_row) != FALSE; s.relative_time = adw_switch_row_get_active(f.relative_row) != FALSE;
s.memory_limit_mb = static_cast<int>(adw_spin_row_get_value(f.memory_row)); s.memory_limit_mb = static_cast<int>(adw_spin_row_get_value(f.memory_row));
s.theme = theme_name(adw_combo_row_get_selected(f.theme_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 // 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 // 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. // 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_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_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_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; f.filling = false;
} }
@@ -146,6 +148,7 @@ void on_closed(AdwDialog*, gpointer user) {
f.relative_row = nullptr; f.relative_row = nullptr;
f.memory_row = nullptr; f.memory_row = nullptr;
f.theme_row = nullptr; f.theme_row = nullptr;
f.crosshair_row = nullptr;
f.browse = nullptr; f.browse = nullptr;
f.parent = 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.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.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.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 || 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.dir_row = nullptr;
f.keep_row = nullptr; f.keep_row = nullptr;
f.relative_row = nullptr; f.relative_row = nullptr;
f.theme_row = nullptr; f.theme_row = nullptr;
f.memory_row = nullptr; f.memory_row = nullptr;
f.crosshair_row = nullptr;
g_clear_object(&f.builder); 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.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.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.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) { if (GObject* reset = gtk_builder_get_object(form.builder, "reset_row"); reset != nullptr) {
g_signal_connect(reset, "activated", G_CALLBACK(on_reset), &form); 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_switch_row_set_active(form.relative_row, TRUE);
adw_spin_row_set_value(form.memory_row, 4096.0); adw_spin_row_set_value(form.memory_row, 4096.0);
adw_combo_row_set_selected(form.theme_row, 2); 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 /// 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) { prefs_open(app.prefs, app.window, app.settings, [&app](const Settings& s, bool written) {
app.settings = s; app.settings = s;
apply_memory_limit(app, s.memory_limit_mb); 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 // 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. // 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); if (!written) set_notice(app, "could not write the preferences file", 4);
+2
View File
@@ -64,6 +64,8 @@ struct PrefsForm {
AdwSwitchRow* relative_row = nullptr; AdwSwitchRow* relative_row = nullptr;
AdwSpinRow* memory_row = nullptr; AdwSpinRow* memory_row = nullptr;
AdwComboRow* theme_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 /// 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 /// 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. /// the text stays the single value and there is nothing to keep in step.
+559 -14
View File
@@ -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 // `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. // 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 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; return G_SOURCE_REMOVE;
} }
@@ -1350,13 +1353,448 @@ gboolean measure_probe(gpointer user) {
} }
rebuild_measures_pane(*a); rebuild_measures_pane(*a);
update_measures(*a, /*force=*/true, /*accumulate=*/true); update_measures(*a, /*force=*/true, /*accumulate=*/true);
std::fprintf(stderr, "[measure-probe] %zu measurements on '%s'\n", a->measures.size(), std::fprintf(stderr, "[measure-probe] %zu measurements on '%s' (channel %d)\n",
name.c_str()); a->measures.size(), name.c_str(), measure_channel_number(*a, name));
std::fflush(stderr); std::fflush(stderr);
g_timeout_add(250, measure_trace, a); g_timeout_add(250, measure_trace, a);
return G_SOURCE_REMOVE; 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<App*>(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<SignalInfo>& 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<std::mutex> 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<plot::u32>(
signal_color_index(a->model->signals(), ch.signal));
}
a->rows->assign(0, 0, name, name, static_cast<int>(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<App*>(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<SignalInfo>& 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<std::mutex> 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<plot::u32>(
signal_color_index(a->model->signals(), ch.signal));
}
a->rows->assign(0, 0, name, name, static_cast<int>(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<App*>(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<SignalInfo>& 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<std::mutex> 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<double>(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<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
switch (a->dbg_scope_step) {
case 0: {
const std::vector<SignalInfo>& 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<std::mutex> 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 /// `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 /// 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 /// 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); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
std::fprintf(stderr, "[arm-probe] trigger armed\n"); std::fprintf(stderr, "[arm-probe] trigger armed\n");
std::fflush(stderr); 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<guint>(1000 * i),
+[](gpointer p) -> gboolean {
App* a = static_cast<App*>(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; 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.dir_row)) + row_underlines(G_OBJECT(a.prefs.keep_row)) +
row_underlines(G_OBJECT(a.prefs.relative_row)) + row_underlines(G_OBJECT(a.prefs.relative_row)) +
row_underlines(G_OBJECT(a.prefs.theme_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.dialog != nullptr ? 1 : 0, a.prefs.dir_row != nullptr ? 1 : 0,
a.prefs.dir_row != nullptr ? shown : "-", 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 { g_timeout_add(400, +[](gpointer u) -> gboolean {
App& app = *static_cast<App*>(u); App& app = *static_cast<App*>(u);
prefs_probe_change(app.prefs); prefs_probe_change(app.prefs);
const Settings back = load_settings(); 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.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); app.settings.theme == "dark" ? 1 : 0);
// And typing the default back must not *pin* it: the file keeps "unset", so the default still // 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 -- // 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.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); 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 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<f64>(axes.x + 0.5f * axes.w));
const f64 y = ty.to_data_y(static_cast<f64>(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<double>(dy), other.horizontal.h > 0.0f ? 1 : 0);
rs.figure().clear_crosshair();
break; break;
} }
default: 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_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 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); 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, std::fprintf(stderr,
"[%s-probe] %-14s strip: visible=%d expected=%d visible_ok=%d dock_shown=%d cursors=%zu " "[%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 " "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, tag, what, visible ? 1 : 0, expected ? 1 : 0, visible == expected ? 1 : 0,
dock_shown ? 1 : 0, a.strip_cursor_deltas.size(), dock_shown ? 1 : 0, a.strip_cursor_deltas.size(),
a.strip_measure_labels.size(), same, a.readouts_strip != nullptr a.strip_measure_labels.size(), same, a.readouts_strip != nullptr
? gtk_widget_get_height(a.readouts_strip) : -1, ? gtk_widget_get_height(a.readouts_strip) : -1,
sens_same ? 1 : 0, collapsed ? 1 : 0, sens_same ? 1 : 0, collapsed ? 1 : 0,
a.window != nullptr ? gtk_widget_get_width(a.window) : -1, 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 /// 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; return out.empty() ? std::string("-") : out;
}; };
std::fprintf(stderr, "[configs] before=%s active=%s\n", joined().c_str(), // The event cache is keyed on the *configuration*, so each line carries the store's own key: it
active_configuration(a->source_host, a->source_port).c_str()); // 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 // 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. // *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; a->measures.front().options.hf_fc = 250.0;
} }
const bool saved = save_configuration_as(*a, "probe", "written by the configs probe"); 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, 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()); 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. // The round trip: what a reopened configuration must carry, note and startup settings included.
LayoutState st; LayoutState st;
@@ -6462,7 +7002,12 @@ gboolean configs_probe(gpointer user) {
noise_back, hf_back); noise_back, hf_back);
forget_configuration(a->source_host, a->source_port, "probe"); 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); std::fflush(stderr);
return G_SOURCE_REMOVE; return G_SOURCE_REMOVE;
} }
+20
View File
@@ -56,6 +56,26 @@ const char* badge_markup(BadgePhase phase) {
return "<span foreground=\"#5A5A5A\">\u25CF</span>"; return "<span foreground=\"#5A5A5A\">\u25CF</span>";
} }
/// 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 "<span foreground=\"#45E045\">\u25CF</span>";
case 2:
return "<span foreground=\"#1C5A1C\">\u25CF</span>";
case 3:
return "<span foreground=\"#E04545\">\u25CF</span>";
default:
break;
}
return "<span foreground=\"#5A5A5A\">\u25CF</span>";
}
/// A short-lived line in front of the status text: the outcome of an action that the /// 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 /// picture does not show by itself (a drop that changed nothing, an export that wrote a
/// file). `tick()` clears it when it expires. /// file). `tick()` clears it when it expires.
+9 -5
View File
@@ -441,8 +441,11 @@ struct TourStep {
constexpr TourStep kTourSteps[] = { constexpr TourStep kTourSteps[] = {
{"plot", nullptr, "The plot", {"plot", nullptr, "The plot",
"Every signal you have switched on is drawn here, all plots sharing one time axis. The wheel " "Every signal you have switched on is drawn here, all plots sharing one time axis -- and the "
"zooms, a drag pans, and a right-click on a plot opens that plot's own menu.", "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}, &App::area_overlay},
{"badges", nullptr, "The badges", {"badges", nullptr, "The badges",
"Setup is reached by the thing it configures. A badge per signal opens that signal's colour, " "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", {"side", nullptr, "The dock",
"The dock beside the plot is a read-only copy of the cursors and of every measurement, in a " "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 " "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 " "one click on a row replays it. Each source and each of its configurations keeps its own cache. "
"over the plot, Manage opens the full list of events; a click on a measurement opens its " "Edit, beside each heading, opens that instrument's full panel over the plot, Manage opens the "
"dialog. The dock button in the bar above shows and hides the dock.", "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}, &App::side_box},
{"signals", "signals", "Adding a signal", {"signals", "signals", "Adding a signal",
"What can be added: the signals the source declares that are not acquired yet, each with the " "What can be added: the signals the source declares that are not acquired yet, each with the "