13 Commits
Author SHA1 Message Date
martino d47704c6ec Merge pull request 'Gated measurements' (#2) from gated-measurements into main
Reviewed-on: #2
2026-11-06 12:30:43 +01:00
Martino Ferrari 91ca29d181 scope windows: the same instruments as the main window
A scope or detached window drew its traces and nothing else: no cursors, no gate band, no trigger
marker, no measurement marks. They are not per-window state to be recreated -- the cursors are one
pair of times, the gate one band, the trigger one instant -- so the main window's row set stays the
**owner** and every window is written from it.

`sync_scope_instruments()` runs in the main tick after the main's own `sync_cursors()`/
`sync_gate()`: a gesture inside a scope window is read back from *that* window's controllers first
(those two calls only adopt while their own plot holds the grab, which is exactly "the drag
happened here"), adopted by the owner, and then every window -- the dragged one included -- is
written from the owner with the same cursor pair, mode and visibility, gate bounds and visibility,
and trigger marker. `RowSet::set_cursors()` is the new pair setter; `trigger_marker_x()`/`_visible()`
are the getters the mirror reads. The measurement marks travel by signal name
(`RowSet::MeasureMark::signal`, filled by the measures pass), so each window finds the row that
plots the signal and draws its ruler or mark there -- or none, when it does not plot it. The search
marks are times on the window and are placed on every window by the same per-frame path, and the
crosshair-axis-labels preference is restated to open windows when it changes.

Probe: `[scope-probe] instruments windows=N cursors=N gate=N trigger=N marks=N (main trigger
on=..)`, after adding a real gated `Top` measurement on `sine` -- the gate is restated visible each
frame by `update_measures()` and marks come only from `show_on_plot` measurements, so the leg uses
the app's own path. Docs: README, the cursors invariant, and the probe readings.
2026-11-06 12:21:38 +01:00
Martino Ferrari a29f38eaa9 session: remember extra windows, and reopen them from the menu
A scope or detached window was never written to the session, and a detached figure vanished from
the layout at the next start -- the figure had been removed from the main rows, and nothing kept
the window that held it.

Each window is now a `[[window]]` block in the session -- `name`, `detached`, and its own
`[[window.plot]]` rows with the same keys the main `[[plot]]` rows carry (signals, row weight,
cell width). `collect_layout()` writes the open windows' present arrangement plus the ones already
closed; loading puts **all** of them in `App::closed_windows` and never opens one: a window is a
view the user closed, and a launch that tiled windows onto the desktop would take that decision
for them (a tiling WM would tile them straight away). The overflow menu gains **Reopen closed
windows (N)**, which opens every remembered window through `open_scope_window_from_state()` -- the
one window constructor, then its rows, weights and signals dealt into its row set.

Closing a window records it, by name (a close/reopen cycle replaces its entry rather than
multiplying them), and schedules the save; **attaching** records nothing, because that window's
figure is on the main window again. A detached figure therefore survives a quit through its
window's entry.

Probe: `[scope-probe] loaded closed=N scopes=0` (remembered, none opened) and `reopen
scopes=..->.. remembered=.. left=0` (close, remember, the menu item's own click reopens).
Docs: README, the Mallard layout page, the probe readings and the session invariant.
2026-11-06 12:06:21 +01:00
Martino Ferrari 0c9333858c scope windows: their own layout editor, and the same shortcuts
A detached window is a view with an arrangement of its own -- and there was no way to change it:
the layout editor was the main window's, and a second window's rows could not be added or removed.

Every scope window now carries its own editor: `L` (or the grid toggle in its corner, revealed by
the same proximity rule as the attach button) shows the main editor's `+` / `×` / Done controls,
and they act on **that window's** row set (`set_scope_layout_editing()`, every button tagged with
its window), so a window holds as many figures as the user gives it and the two editors cannot
move each other's rows. Nothing a scope window arranges is written to the main window's session:
the windows themselves are not persisted.

The keyboard: each scope window gets a `GtkEventControllerKey` running the application's one
accelerator table -- `L` acts on the window the key arrived in, every other action reaches the
application through `run_app_action()`. The plot's own keys ('f', the arrows, Escape) already
worked there, through its plot controller.

The window struct now always builds its overlay (the corner controls' host); the attach button
stays detached-only, and both corner toggles step aside while the editor is up (Done is the way
out).

Probe: `[scope-probe] layout buttons=3 rows=1->2` -- the toggle builds 2*rows+1 buttons and a
boundary `+` adds a row. `L` in a real scope window verified by OCR ("Done" appears, the row's
`×` at its left). Docs: README, the Mallard layout page, the probe readings, and the layout
invariant (a view's arrangement is the view's).
2026-11-06 10:54:50 +01:00
Martino Ferrari ecdbf4f0e1 scope windows: mirror a pin as a range, and restore the tick rebuild
**The blank scope window in trigger mode.** A held event pins the main window to
`[-pre, +post]`, and the tick mirrors that pin onto every scope window. It passed
`pinned_range().min/max` to `pin_event_window()`, which takes **spans** (`pre, post`): `min` is
signed, so `pre` came out negative and the group range became `[+pre, +post]` — an inverted x
window. The plotter then had nothing to draw: no trace, no x-axis labels, an empty plot that
looked like a broken window. `RowSet::pin_display_range(x0, x1)` states the pin as the range a
mirroring view actually has, and the tick uses it; live mode was never affected, which is why the
defect only showed under the trigger.

Also restores the scope-window `rebuild_if_dirty()` in the tick, which a reproduction experiment
had left commented out (`REPRO-TEMP`) and a later commit carried: a window opened empty and filled
by a drop kept the figure it was built with.

Probe: `[scope-probe] pin_mirror=1 main=[..] scope=[..] x=[..]` pins the main window, reads the
scope's pin and plot x range a frame later, and reads `0` for an inverted or mismatched pin.
Docs: probe readings and the trigger/pinning invariant (range vs spans).
2026-11-06 10:39:10 +01:00
Martino Ferrari 2279e2140a scope windows: their own frame pump, and a diagnosis that names the GSK renderer
A second toplevel's frame clock is not the main window's: the tick binds, queues a render and
mirrors the span, but with nothing asking GTK for frames on *that* window the area can sit with
its queue full and present nothing -- a blank plot. Every scope window now carries a tick
callback on its own area (`gtk_gl_area_queue_render` per frame), which is the request that keeps
its frame clock running.

The "cannot paint" diagnosis now names the window's GSK renderer (`[GSK renderer: GskVulkanRenderer]`)
and says to restart with `GSK_RENDERER=gl` when it is the Vulkan one -- the interaction between
the Vulkan GSK renderer and an embedded `GtkGLArea` in a second toplevel is a known trouble spot,
and a tiling WM has no title bar to carry a title, so the sentence goes to stderr and the status
bar instead.
2026-11-06 10:09:28 +01:00
Martino Ferrari fa2d0f12fc scope windows: Fit X axis / Fit all in the menu, and a window that cannot paint says so
The scope menu carried a y fit only: with one shared time span, the x fit *is* the main window's
fit, and the next frame mirrors the result back -- so `Fit X axis` and `Fit all` now sit in the
menu beside the y entries and reset the shared range.

And a blank plot becomes a sentence: a scope window whose GL context failed, or whose area has
painted no frame two seconds after opening, writes the reason into its own title and to stderr
(`[scope] window 'plot 1' is not drawing: GL context failed: ...`). A blank rectangle is
indistinguishable from a failed context or an area that never got a frame, and the report "the
plot is not there, no x axis" is exactly what this answers: if the title stays unchanged, the
area is painting and the question moves to the compositor presenting the second window.
2026-11-06 09:03:54 +01:00
Martino Ferrari 2924f7627d scope windows: a right-click menu of their own
A scope window is a view with its own arrangement, but its plot had no menu: every right-click
went nowhere, because the main window's row menu acts on the main window's rows.

`on_scope_right_click()` wires each scope window's GL area (beside its drop target) to a menu
that acts on **that window's** row set: y axis auto scale, y axis fixed range (the main menu's
dialog, aimed at the window's rows through `FixedRangeContext::rows`), fit, and one
*Remove '<signal>'* per trace on the plot. A detached window's menu also carries **Attach back
to the main window**. What it deliberately does not carry is an x fit: the window follows the
main window's time span, so an x fit here would be undone by the next frame's mirror.

`remove_trace()` and `FixedRangeContext` are generalized to take the row set they act on (the
main window passes its own). Probe: `[scope-probe] rclick=1` asserts the click gesture is wired
per window; the menu build is exercised by the real right-clicks the smoke makes.
2026-11-06 08:55:29 +01:00
Martino Ferrari b90a99ec39 scope probe: read the windows' own GL account, and drop into the empty window
`[scope-probe]` grows the two readings a "blank scope window" report needs before it can be
blamed: `empty_drop=`/`empty_slot0=` (the same drop onto the empty window, the user's own
path) and `gl copy=/gl empty=/gl detached ok= frames=` -- each window's adapter context
status and staged-frame count. A scope whose context failed draws nothing at all: no axes,
no traces, exactly the "blank plot widget, no x axis" report -- and that is a driver/context
finding, not a layout one. The detached window is left open by the probe, so a run's
screenshots can answer "does the detached figure draw" with pixels.
2026-11-06 08:19:50 +01:00
Martino Ferrari 5ed75eb470 layout: detach a figure to its own window, and attach it back
Beside each figure row's full-screen toggle -- same corner, same show-on-approach reveal, same
one-figure/never-maximized rules -- a **detach** button moves the figure into a window of its
own: copied into a new scope window (titled "detached") and removed from the main window's rows,
which then save the change. The detached window is the one scope window with an **attach**
control: an overlay wrapping its GL area, a button in the top-right corner revealed by the same
pointer-proximity rule from the overlay's own motion controller, and one click appends the
figure back to the main window's rows (cells, weights, every slot's signal in its colour) and
closes the window.

`detach_figure()` / `attach_scope_window()`; the reveal and placement share the maximize
buttons' geometry and pointer state, per row. A detached window's rows participate everywhere a
scope window's do (drops, the signal page's *Plot on* groups), which the previous commit gave
them.

Probe: `[maximize-probe]` now counts the detach buttons (`detach_single=0` for one figure,
`detach=2` for two, absent while maximized) and reads their corner reveal
(`detach_near=1 detach_other=0`, the pointer parked on the detach button's own centre -- the
strictest zone of the pair). Real-pointer smoke: hover reveals the pair, clicking detach opens
the window, clicking attach closes it with the figure back. Docs: README, the Mallard layout
page, the probe readings, the layout invariant, and the tour's plot stop.
2026-11-05 23:35:26 +01:00
Martino Ferrari 51b4fad386 scope windows take drops, and the signal page lists them
The second windows were read-only by accident: nothing accepted a drop on them, and the
signal page's *Plot on* buttons knew only the main window's rows.

- **Drop target per scope window**: the same string payload a badge drags (the signal index),
  handled by `on_scope_drop_channel()` -- the target carries its own window, the row is that
  window's cell under the pointer, and `assign_signal_to_scope()` lands the signal in the
  window's own row set (enabling an unacquired signal first, exactly as the main drop does).
  The rebuild and the area's re-point are the window's next frame in the tick: a drop arrives
  from inside the very widget it rearranges, and a synchronous rebuild would reset the drag
  controller under it.
- **Plot on, per window**: the signal page carries one named group per scope window ("In
  \"plot 2\""), one button per plot of that window, sensitive on that window's own rows. A
  window closing refreshes the pages in its destroy handler, before the deferred delete could
  dangle a button's scope pointer.
- Probe: `[scope-probe]` drops through the wired handler (`drop=1 slot1='square'`) and counts
  the page's groups (`page_groups=2`); the section documents that it must not run with
  `UDPSCOPE_ENABLE_ALL_MS` -- the enable-all probe's second leg clears the front plot, which
  is the slot this probe copies. README, help and the layout invariant follow.
2026-11-05 19:37:42 +01:00
Martino Ferrari 706d247b58 signal page: the header names it, numbered; no duplicate title or back button
The slide-over's header already prints the open page's title beside its Close button, so the
signal page's body carried the name twice and a "Back to signals" button that only repeated
navigation the header owns. Both are gone.

The header's title is now the **numbered** name -- `<n>. <name>`, `measure_channel_number()`'s
number, the same one the signal badges and the measurements pane use -- so the panel says
*which acquired channel* it is and not only what it is called. `slideover_set_title()`
retitles a page in place (the open one included): the number follows the stream's channel
numbering, and the page is refilled whenever the model's signal list changes.

Probe: `[signalpage]` reads `title='1. sine' title_ok=1` (the header text against the numbered
name) and `no_dup=1` (no "Back to signals" button and no `title-3` label in the body); the old
`back_to_list` reading is gone with the button. Help, README and the probe doc follow.
2026-11-05 18:08:50 +01:00
Martino Ferrari bdf0ff7ddc 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).
2026-11-05 17:58:59 +01:00
44 changed files with 4075 additions and 187 deletions

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.
+108 -13
View File
@@ -123,6 +123,53 @@ src/ui_badges.cpp the badge bar under the plot: the signal badges, the + badge,
src/ui_signal_page.cpp the per-signal slide-over pages (`signal:<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*). Beside the toggle, and with the
same show-on-approach, a **detach** button moves the figure into a window of its own (removed
from the main window's rows, which save the change); the detached window carries an **attach**
button in the same corner — its figure returns to the main window's rows and the window closes.
It is a *view* — the row set
saves every row's state on the way in and re-applies it on the way out (`RowState` in
`plot_host.*`), so nothing plotted moves and nothing is saved. A plot's right-click menu has
**Open this plot in a new window**, which copies that figure — its cells, their width shares and
every slot's signal in its colour — into a second top-level window with its own `RowSet`, figure
and `PlotArea`; the overflow menu's **New scope window** opens one with an empty figure. A scope
window is another *view* of the same acquisition: the main window's tick binds and acquires its
rowset under the same model mutex, mirrors the main window's time window (or its pin) onto it
every frame, and pumps its render — so the two windows never disagree about the data, the display
mode or the span. **A signal badge dragged onto a scope window's plot lands there** — the same
drop the main plot takes, into that window's own row set, on the cell the pointer is over — and
the signal page's *Plot on* buttons list **every window**: one group per window (named, `In
"plot 2"`), one button per plot of it. **The session remembers every extra window**, but a launch never opens one: a window is a view
you closed on purpose, and a start that tiled them onto the desktop would take that decision for
you. The overflow menu's **Reopen closed windows (N)** brings them back, arrangement and all; a
window that was *attached* is not remembered (its figure is on the main window again), and a
detached figure is preserved by its window's entry, so quitting with one open no longer loses it.
A scope window draws the **same instruments as the main window** — the two cursors (draggable
there, and shared: moving one in any window moves it everywhere), the gate band, the trigger
marker and the measurements' marks on the traces each window actually plots. They have one owner
(the main window's plot set) and are written into every window each frame, so no window can drift.
A scope window also **carries its own layout editor**: `L` (or the grid button in its corner,
revealed like the others) shows the same `+`/`×`/Done controls the main window's editor has, and
they change *that* window's rows — so a second window can hold several figures as easily as the
main one. A right-click on a scope window's plot opens **its own menu** — y axis (auto or a fixed
range), *Fit X axis*, *Fit all*, remove a trace — acting on that window's rows (the x fit resets the **shared** time span, so every view follows); a detached
window's menu carries **Attach back to the main window** beside them. A window that cannot paint
says so on stderr and in the status bar after two seconds, naming its **GSK renderer**: a blank
plot is indistinguishable from a context that failed, and the GTK **Vulkan** renderer is known to
interact badly with an embedded `GtkGLArea` in a second toplevel — the sentence then says to
restart with `GSK_RENDERER=gl`. Each scope window also carries a **tick callback of its own**,
which keeps its toplevel's frame clock advancing: the main window's tick binds and queues the
render, but a second window whose clock never ticks presents nothing.
mode or the span.
**The window.** The plot is the window. Under it sits a fixed-height **badge bar** (36 px). Its left
part is the **signals**: one badge per *enabled* signal (a dot in the signal's colour, filled while the
signal is plotted, and its name), then a `+` badge that opens the **Add a signal** page (`g` does the same). That page proposes only what can be
@@ -132,7 +179,10 @@ divider's `+` for a new math channel; when every signal is acquired a dim "every
stands in for the list. While the bar has overflowed into the "N signals" badge the page lists the acquired
signals too, since their rows are then the only way to each signal's page. There is no signal lock: a
signal's page and the list always allow acquire, colour, calibration, the math editor and delete.
A signal badge opens that signal's own page in the slide-over -- the unit, gain and offset and the CONFIG's details directly under the name (aligned rows; a pencil beside the unit, gain and offset edits just that one -- a unit rename is stored per signal name, saved as `[[unit]]` and re-applied to every CONFIG), the colour and the *Plot on N*
A signal badge opens that signal's own page in the slide-over -- its title is the **numbered name**
(`2. sine`, the same number the badge and the measurements pane use) and the body carries the unit,
gain and offset and the CONFIG's details directly under it, with no second copy of the name and no
"back to the list" button (the header's Close is the way out) (aligned rows; a pencil beside the unit, gain and offset edits just that one -- a unit rename is stored per signal name, saved as `[[unit]]` and re-applied to every CONFIG), the colour and the *Plot on N*
buttons -- and is also the drag handle: drag it onto a plot to draw the signal there, or onto another
badge to reorder them. Past **six** signal badges the bar shows a single **"N signals"** badge in their
place (it opens the list, and `+` is hidden): the E-1608's 8 analog inputs and counter would not fit
@@ -163,8 +213,10 @@ page's label and to the dock's, so the two cannot disagree. **When the dock is n
with the dock button, or the window narrow enough (< 900 px, "compact mode") for the dock to be a collapsed
overlay on the plot, open or not -- the same readouts appear on **one line under the plot**, above the badge bar (the
*readouts strip*): a **Cursors** button (opens the `cursors` page) and one chip per cursor line (its name and
its *delta* value), then a **Measures** button (opens the `measure` page) and one chip per measurement (the
signal's colour chip, the type, the value; a click opens its dialog). Each of the two groups is there only
its *delta* value), then one chip per measurement (the
signal's colour chip, the type, the value; a click opens its dialog) and a **`[+]`** after the last chip that
adds a new measurement -- the strip is where the readings are watched, so it is where adding one belongs.
Each of the two groups is there only
with something to read -- the Cursors group with the cursors switched on, the Measures group with at least one
measurement, the separator with both -- and **with neither, the strip is not shown at all**.
It is a **fixed 26 px** high and its chips are as wide as the dock's cells (name 6 characters, type 10, value
@@ -396,7 +448,9 @@ edge the ticks were generated for; the caption always names the anchor the label
with, so the addition stays exact.
**Measurements** (overflow menu) reveals a pane with a `[+]` and one row per measurement:
`[badge] <type> <value> [edit]`. `[+]` opens a dialog with the signal, the type, the options
`[chips] <N>. <signal> · <type> <value> [edit]` — the number is the signal's among the
active channels, the same one the signal's badge in the bar and the dialog's signal dropdown
carry. `[+]` opens a dialog with the signal, the type, the options
that type actually uses, and "show on plot"; `edit` opens the same dialog on that
measurement, with a `Delete` beside `Save`.
@@ -507,6 +561,21 @@ Both cost: a filtered window disables the two bucket fast paths, because a bucke
samples and cannot be mixed into a filtered walk. A filtered window is still walked once and shared by every
measurement that asks for it, so a pane of five measurements at two corners costs two walks, not five.
**The gate measures a slice of the window, and it is a per-measurement choice.** A
measurement's dialog carries `Gate enabled`; every gated measurement then describes
only the samples inside one shared violet band, carried by its own pair of draggable edges on
the plots — *not* the cursors, which keep meaning what the cursors pane reads. The band is
shown exactly while some measurement is gated: gate one and it appears, gate or delete the
last one and it goes. Ungated measurements on the same signal are untouched — they share the
window walks they always shared, and a gated one costs one more walk, not one per pane. The
band lives in the same relative time the axis labels do, so it stays exactly where it was put
in both modes: on a live view "300 ms ago to 100 ms ago" stays put on screen while the data
flows under it — the gated read of the newest data a bench scope gives — and on a held event
it names a fixed slice of the frozen frame. Drag an edge to resize, grab the middle to move; a
band with no samples in it — dragged outside the view, or over a gap — reads `--` like any
window with nothing in it. The band's position and each measurement's gate flag are session
state, so a reopened scope measures what the closed one measured.
### Why a crossing is counted with hysteresis
A crossing is not read off a single level. It is read off a **band**: two thresholds a tenth of
@@ -592,7 +661,15 @@ results must still narrow with the axis (`follows=1`).
**Show on plot** marks the result on the row that plots the signal: a ruler at the level
(`top`/`bottom`), both rulers at the extremes (`min`/`max`), the pulse's or the cycle's two
edge times as vertical markers. The marks are **readouts, not instruments**: a fixed pool of
edge times as vertical markers. Each mark carries its **badge**: the ruler's own value written
in a small opaque panel straddling the ruler — in the y axis' own tick format, so it reads
exactly like the tick beside it — and the time mark's time in a box centred on the marker (in
the pane's own formatter, so a live view's badge is an age). The boxes exist for the trace
underneath: a number drawn bare over a noisy signal is a number nobody can read, and an
opaque one is a number nothing prints through. And when marks land close
together — two levels a few pixels apart, a pulse's two edges — their badges **merge into one
panel with one line per mark**, because two texts over one column is a smear and one boxed
readout is a reading. The marks are **readouts, not instruments**: a fixed pool of
cursors per row, created **unselectable** (`Plot::set_cursor_selectable()`, added for this), so
the controller's hit test never offers the pointer one — it can neither hover nor drag a
measurement's ruler. They are also re-placed every frame, because a mark is only correct for
@@ -850,15 +927,21 @@ Three things it deliberately is not, and the reasons are in
## Captured events
Trigger mode keeps the last **32 triggered acquisitions** in a cache, so an event you did not have
time to look at is still there:
time to look at is still there. The folder is the source **and its active configuration** -- an
event's trigger, channel set and units are the configuration's, so two configurations of one source
do not list each other's events:
```
~/.cache/udpscope/events/192.168.1.40-8000/
~/.cache/udpscope/events/192.168.1.40-8000/default/
20261009-123250-0001.npy # the event, in the export format
20261009-123250-0001.txt # when, how long, how many signals, the rate, the edge
20261009-123250-0001.bmp # a picture of it, drawn from the samples
```
Switching configuration re-points the cache with it, and a cache written before configurations
existed (loose files directly in `<source>/`) is adopted into the configuration's folder on the
first start.
Events are read in two places. The **dock's Events block** lists them compactly -- one row per event, newest
first, at most **24** with a dim "... N more, see Manage" row after them: a marker (a filled circle on the
event on screen, a hollow one on each member of the comparison or accumulation on screen), the time and
@@ -1067,9 +1150,13 @@ named `default`: copied into the directory, remembered as active, and the old fi
`~/.config/udpscope/settings.toml`, written by **Preferences…** -- in the overflow menu or the source
chooser -- and read at start-up. It holds the event cache's directory, how many events to keep (`0`
keeps every one), whether relative time is on, and the interface's theme. Whether anything is cached at
keeps every one), whether relative time is on, the interface's theme, and the crosshair's **axis
labels** — the two small boxes where the crosshair's lines meet the axes: the y value among the y
ticks, the time among the x ticks, each in its axis' own format. On by default; the switch applies at
once. Whether anything is cached at
all is the trigger pane's own switch, and there is deliberately no second one here; the directory is
the one place every source gets a folder inside, `<cache>/udpscope/events` by default. The cache
the one place every source gets a folder inside (and every configuration a folder within that),
`<cache>/udpscope/events` by default. The cache
directory and relative time are *start-up* settings -- the buffers are planned and the cache directory
is opened before any source is known -- and the dialog says so; the theme applies as soon as it is
chosen. A change
@@ -2292,10 +2379,17 @@ the line marks the edge, so the pre/post split can be read straight off the plot
not one of the user's cursors: it has its own colour, ignores the cursor show/hide
toggle, and is parked (invisible) when there is no event.
**The status line.** It starts with an activity badge and then the mode, the
**The status line.** It starts with the **trigger circle**, then an activity badge and then
the mode, the
connection, the measured rate, the packet/frame/gap counters, what plot 1 holds and
the buffer's size and span. Three of those are worth calling out:
the buffer's size and span. Four of those are worth calling out:
- **The trigger circle is the trigger's own light**, in the same glyph-and-colour alphabet as
the badge: solid green while the trigger has fired (an event is held, or a capture is
filling), **flashing** green while armed and waiting for an event, red while the trigger
mode is on but disarmed — and *hidden* while the trigger mode is off, because a light for a
mode the user is not in is noise, and the mode text beside it is not showing trigger words
then either. Its tooltip names the state in words.
- **The badge blinks green at 1 Hz while the pipeline is actually running** — the
stream is connected, a CONFIG has arrived, acquisition is not paused, and in trigger
mode the trigger is armed. Anything else leaves it steady, with the reason on its
@@ -2800,13 +2894,14 @@ edge.
**Rows are edited in place, not chosen from a count.** The header bar used to carry a
`plots` dropdown of 1 to 4, and a count is the wrong handle: it says how many rows you
want and leaves which row your traces land on to the renumbering. **Layout editor**
(overflow menu) instead puts the controls on the rows — a `×` at the top-left of each row
(overflow menu, a plot's right-click menu, or `l`) instead puts the controls on the rows — a `×` at the top-left of each row
removes it, a `+` on each boundary inserts one there — so the row an action names is the
row you are pointing at. `--plots` is now only the count to start with, and `RowSet::kMaxRows`
(8) is a ceiling rather than a menu: past about that a row is chrome and no plot.
The editor also carries its own way out: a **`Done`** button in the plot area's top-right
corner while it is up, because the toggle that opened it lives in the overflow menu — a
corner while it is up, because the toggle that opened it lives in the overflow menu (and in a
plot's right-click menu, as *Edit the layout...* / *Done editing the layout*) — a
long way from the plot being edited. It turns that toggle off, so there is still exactly
one piece of state, and the buttons (its own included) go with it.
+1 -1
+35
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
badge's markup is likewise rewritten on a change of phase (twice a second), never on
all sixty frames.
- **The trigger circle is the badge's sibling, in its alphabet and under its rules.** It sits
before the badge on the status row (`main-window.ui` `trigger_status`) and says what the
badge cannot: the *trigger's* state -- solid green while an event is held or a capture is
filling, flashing green (the lit and dim halves) while armed and waiting, red while the
trigger mode is on but disarmed, and **hidden while the trigger mode is off**, because a
light for a mode the user is not in is noise and the mode text beside it shows no trigger
words then either. Same width-locked glyph in every state, same half-second square for the
flash, markup rewritten on a change of the state key only (`trigger_circle_state`), and the
exit dump reads the tick's cached answer (`dbg_trigger_circle`) because it cannot ask the
widget.
- **A `notify::<prop>` handler takes three parameters, a `value-changed` handler two.**
`GObject::notify` passes `(GObject*, GParamSpec*, gpointer)` while
`GtkSpinButton::value-changed` passes `(GtkSpinButton*, gpointer)`, so one callback cannot
@@ -722,3 +732,28 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps
box drawn from that is a box over the wrong thing. Its numbers depend on the *window's* size, which
under the documented `xvfb-run` is the window's 756x894 minimum (a 640x480 screen), so a run on a
larger screen gives different rectangles for the same panels.
- **GTK4 has no `GtkWidget::destroy` signal; the lifecycle hook is `close-request`, and
`gtk_window_destroy()` never emits it.** A `g_signal_connect(window, "destroy", ...)` on a GTK4
widget is a connect to a signal that does not exist: it never fires (and its "signal not found"
warning is in the GObject domain, not GTK's, so the dump's `gtk warnings:` counter cannot see
it). That is why the dialogs use `gtk_window_close()` when their *lifetime* matters (a close
clears `App::measure_dialog`, which a destroy would not), and why the main window's own handler
-- the scope windows' teardown, the debounced layout-save flush and the application quit -- is
connected to **`close-request`** and returns `FALSE` so the default destroy still runs.
Everything that ends the main window programmatically (the exit probe's `exit_probe_step3b`)
closes it with `gtk_window_close()` for the same reason. The **scope windows go with it**:
each is a `GtkWindow` added to the application, and one left behind keeps the GApplication's
main loop alive around a window that is gone -- the hang where the exit run never reached its
dump.
- **A page's name lives in the slide-over header, once.** The header prints the open page's title
(from the `GtkStackPage`) beside its **Close** button; a page body that repeats the name is the
same word twice, and a body-level "back to the list" button duplicates a navigation the header
already owns. Signal pages therefore carry **no** name label and **no** back button
(`[signalpage] no_dup=1`), and their title is the **numbered** name -- `<channel number>. <name>`,
the same `measure_channel_number()` the badges and the measurements pane use -- so the panel says
*which acquired channel* this is rather than only what it is called. `slideover_set_title()`
retitles a page in place (the open one included) because that number changes when the stream
renumbers its channels; `fill()` sets it on every fill, and a page added by
`slideover_add_page()` gets its final title from the `fill()` that follows.
+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
group's span an ULP above it and a clamp that fires every frame is a re-frame loop, not a
clamp.
- **A row names its channel: number and signal, from one resolver.** The pane's row reads
`N. signal · type`, the dock's and the strip's lines prefix the same number to the type,
the signal's badge carries it, and the measurement dialog's signal dropdown numbers its
entries with it -- all through `measure_channel_number()`, which is the position of the
signal's channel in the model's channel list (the acquired channels in stream order), or 0
when the signal is not acquired. The readouts rule cuts both ways here: every view shows the
same number because every view asks the one function, and a view that derived a number by
its own walk of the channel list would be the second spelling that drifts. 0 is a state, not
a failure -- the unacquired signal's row is already grey -- so the readers *omit* the number
rather than printing a 0. Renumbering on every enable, disable and CONFIG is inherent (the
number answers "which active channel is this", which is what a rebuild renumbers), so a
generation change rebuilds the pane as well as the signal list.
- **The gate is a per-measurement option, and one band shared by everyone who uses it.**
`MeasureOptions::gate` is set per measurement in its dialog; every gated measurement reads
only inside the one shaded band -- a plotter `Region`, violet, its two bounds the gate's own
pair of instruments, not the cursors, which stay what the cursors pane reads -- and the band
is *shown exactly while some measurement is gated*, derived at `rebuild_measures_pane()`,
because every change of the measure set (an add, an edit, a delete, a session restore)
passes through there. Ungated measurements are keyed away from gated ones
(`MeasureWindow`'s key is `(channel, fc, gated)`), so a gated measurement costs one more
walk and changes no ungated number.
The bounds live on the `RowSet` in **display x** (`gate_x0()`/`gate_x1()`), the cursors' own
space, and that is the point: a live axis is newest-relative, so a band at `[-0.3, -0.1]`
keeps meaning "300 ms to 100 ms ago" while the data flows under it, and a held event's
stamps are edge-relative, so there the band names a fixed slice of the frozen frame. Either
way it stays where it was put, which is what makes it a gate and not a second window.
`update_measures()` clips the pane's window (viewport live, pin pinned) into the band once,
and each gated window walks the clip: every statistic, mark and "it moved" booking below
describes the gated slice through that one expression. A band entirely off the view clips to
a zero span and the pane reads the same `--` any empty window gives. Because a band that
moves can change what the *gated* measurements describe without the viewport or pin moving,
the "it moved" test asks the band as well (`measure_gate_window`, booked every pass). While
a region drag runs, the tick adopts the bounds out of the dragged row's plot
(`sync_gate(drag_row)` -- the `sync_cursors()` contract: read only while a gesture holds the
instrument, because outside one the read is our own echo) and mirrors them to every other
row; the drag's *end* saves the session, because the bounds are session state
(`gate_x0`/`gate_x1`, and each measure's `gate` flag in its `[[measure]]` section) and a
drag that is not saved is a session that lies about where the gate is. `[gate-probe]` is the
reading: the gated average differs from the ungated one on the same signal, the booked gate
window is the band moved onto the stream's clock, the band is still where it was put a
second later while `s0=` has moved under it, and a restored session brings both back.
- **A measurement must not read its window raw.** Both passes go through
`plot::data::MinMaxPyramid`, which the producer already maintains at amortised O(1) -- so the
pane inherits the summary of the new samples rather than computing one, which is what the
+51 -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.
What the cells share and what each keeps:
- **One time window.** Every cell of every row is a member of the one x link group, so a pan or zoom
in any cell moves them all, the crosshair is one x drawn in every cell (the hovered cell shows its
y), and the **vertical cursor pair is one shared time drawn in every cell** (`cursors_aligned`).
in any cell moves them all, the crosshair is one x drawn in every cell, and the **vertical cursor
pair is one shared time drawn in every cell** (`cursors_aligned`). The horizontal crosshair line is
the hovered cell's y **drawn in every cell of its figure row** -- the cells share that y, so the
line is one value spanning the row rather than stopping at the divider; no other row gets one
(`Figure::set_crosshair` spreads it, `[split-probe] cell-xhair` reads it).
A split is an arrangement of more plots, not a second time scale: nothing about the time axis changes
(see [invariants-time-axis-and-window.md](invariants-time-axis-and-window.md)).
- **One y per figure row.** The cells of a figure row share one y range (the plotter's
@@ -340,3 +343,49 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
relayout, the panels' chips, the layout save) are done once by the caller rather than per slot. Re-enabling
does **not** put the traces back: the arrangement is the user's, and guessing where they were is worse than
leaving them off.
- **The full-screen toggle and a scope window are *views*: neither edits the arrangement nor takes a second
acquisition.** The per-row toggle (`RowSet::toggle_maximize_figure_row()`) saves every row's persistent
state (`RowState` -- the fields `carry_row()` copies, because a `Row` is not copy-assignable: its
`SlotSeries` owns a `unique_ptr`) plus the cells, the weights and the plot count, rebuilds the figure with
the asked-for row's plots moved to the front, and puts every field back on the second click; it saves
nothing, so a reopened session has the arrangement, not the view. Its overlay button is on screen
*always* (`App::maximize_buttons`, a set of its own -- `layout_buttons` is the editor's and is cleared with
it), placed from the same `FigureLayout` rects as the editor's buttons. A scope window
(`App::ScopeWindow`, `open_scope_window()`) owns a `RowSet`, a `PlotArea` and the area's placeholder
`Plot`, and is driven by the **main window's tick**: the same model mutex binds and acquires its rows
(same display mode, same epoch), and its span follows the main window's window or pin every frame. It is
therefore a second *view* -- the settle rule is that a scope must never acquire, plan or epoch anything of
its own. Closing the main window destroys its scopes first: a leftover toplevel keeps the GApplication's
loop alive around a window that is gone (the exit run's hang). What a user *arranges* in a scope is the
scope's own arrangement, and it takes the same two doors the main window's rows take: a badge **dropped on
a scope window's plot** lands in that window's row set on the cell under the pointer (its own drop target,
its own `assign_signal_to_scope()`), and the signal page's *Plot on* section carries **one named group per
window** -- rebuilt out of `App::scopes` on every fill, and again when a window closes (the destroy handler
refreshes the pages before the deferred delete can dangle a button's scope pointer). The change itself is
never applied synchronously: a drop arrives from inside the very widget it rearranges, so the rebuild and
the area's re-point are the window's next frame in the tick.
- **A figure row's full-screen is one click from two places, and never from one figure.** The
toggle sits on the row's top-right corner, **out of hiding only while the pointer is near that
corner**: its own placed rect inflated by `kMaximizeRevealPx`, per row, so approaching one
row's corner reveals that row's toggle and no other. The pointer is read from a
`GtkEventControllerMotion` on the **overlay**, not on the GL area -- a revealed toggle sits
under the pointer, and a leave on the GL widget would fire the moment the button appeared and
hide it again (the flicker the ancestor avoids); a leave on the overlay means the pointer left
the plot, which is when the toggles go away. The plot menu offers the same action as *Full
screen this figure* / *Restore the layout*;
both end in the same `toggle_fullscreen()` -- the `RowState` save/restore, the area's figure,
the toggles and the signal panels -- so the two cannot drift apart. A layout of a single
figure row carries **no** toggle (and no menu entry): there is nothing to enlarge *to*, and a
visible no-op is worse than none. The button reappears while a row *is* maximized, because
there it is the way back.
- **A view's arrangement is the view's, editor included.** A scope window holds its own figure
rows and edits them with its own `+`/`×`/Done set (`set_scope_layout_editing()`,
`rebuild_scope_layout_editor()`, `sync_scope_layout_editor()`): the buttons are tagged with the
window and the handlers act on *that* window's row set, so the two editors cannot move each
other's figures. `L` acts on the window the key arrived in (a `GtkEventControllerKey` per
window; every other accelerator reaches the application through `run_app_action()`), and a
pinned layout change is **not** written to the main window's session — the windows themselves
are not persisted.
@@ -118,3 +118,26 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
marker says where the scope put the edge and a measurement mark says what a measurement measured;
neither is something a user may move. The dump prints `readout instruments locked: N`, which must
be 1 -- a drag is not something a probe can perform, so the state is what is asserted.
- **The crosshair's axis badges are painted *over* the tick labels, and that costs a flush.**
A badge is a fill in the crosshair's colour framing the value where the line meets the axis
it names — and it sits *in the label strip*, on top of the tick label at that row. The tick
labels are staged into `glyphs_over_`, the over-text batch the frame flushes **after every
line batch**, so a panel fill drawn at the ordinary moment lands *under* them: the label
prints through the badge and the backplate reads as transparent (the defect this rule was
learned from). `draw_crosshair_axis_badges()` therefore flushes the accumulated over-text
*first* — those labels are already placed, so it is one batch — then draws both fills, then
stages the badge texts, which leave with the rest of the over batch over everything. An
assertion on badge pixels inside a label's rows is the regression test: with the fill under
the text, the label's glyph pixels are still lit inside the badge's rect.
- **The instruments have one owner: the main window's row set.** The two cursors, the gate band,
the trigger marker and the measurement marks are *shared*, not copied per window. The main tick's
`sync_scope_instruments()` writes the owner's state into every scope window each frame (the
setters early-out, so a steady frame is a handful of comparisons), and a gesture inside a scope
window is read back from **that** window's controllers first -- `sync_cursors()`/`sync_gate()`
only adopt while their own plot holds the grab, which is exactly "the drag happened here" -- and
adopted by the owner before anything is written out. The measurement marks travel by **signal
name** (`RowSet::MeasureMark::signal`), so a window finds the row that plots it; a window that
does not plot the signal draws none. The search marks are times on the window and are placed on
every window by the same per-frame path.
@@ -192,3 +192,26 @@ of.
Nothing is lost by switching: the dialog saves the current session *before* loading the picked
one, which is the only ordering in which a switch cannot lose what it switches from.
- **The event cache is keyed on the source *and its active configuration*, and the key moves
with the switch.** An event's trigger, its channel set and its units are the configuration's,
so the directory is `<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.
- **The session remembers extra windows; a launch never opens them.** Each scope or detached
window is a `[[window]]` block (`name`, `detached`, and its own `[[window.plot]]` rows -- the
same keys the main `[[plot]]` rows carry), written from the open windows' present arrangement
plus the ones already closed (`collect_layout()`). Loading puts **all** of them in
`App::closed_windows`, never into `app.scopes`: the overflow menu's *Reopen closed windows (N)*
is the only path that opens them (`open_scope_window_from_state()`, the one constructor every
window uses, then its rows). Closing a window records it (by name, replaced, so close/reopen
cycles do not multiply entries); **attaching** records nothing -- its figure is on the main
window again -- and a detached figure survives a quit through its window's entry instead of
being lost from the main layout.
@@ -629,3 +629,11 @@ samples. Three rules keep that honest:
its ink is not in the widget's paint tree, so `gtk_snapshot_free_to_node()` on a widget paintable of
it returns NULL and the "screenshot" would be an empty file. Drawing it in the store also keeps that
class GTK-free, which is what makes the cache testable without a window.
- **A pin is mirrored as a *range*, never as a pair of numbers that might be spans.** `Range`
(`pinned_range()`: `{-pre, +post}`) and the pin's *spans* (`pin_event_window(pre, post)`) are two
different things that both look like a pair, and mixing them inverts the window: a second view
that handed `pinned_range().min` to `pin_event_window()` pinned to `[+pre, +post]`, and the plot
drew nothing — no trace, no axis labels — because the x range was degenerate. Views that mirror a
pin use `pin_display_range(x0, x1)`, the range's own form. `[scope-probe] pin_mirror=1` reads the
two windows' pins and the scope's x range, and an inverted pin reads `0`.
+227 -26
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
the time marks are stored in -- a live axis is a fixed `[-window, 0]`, so comparing display x
would read no drift whatever the data did. `win` is the window as of the last **tick**
(`App::measure_view_window`) and is deliberately not read back here: the dump runs from the
destroy handler, by which point the exit probe has switched the trigger on, and
(`App::measure_view_window`) and is deliberately not read back here: the dump runs after
`g_application_run` returns, by which point the exit probe has switched the trigger on, and
`stream_at_x0()` then answers for a mode no frame was ever drawn in -- which read as a 45000 %
drift on a run whose every frame was exact. `pin` is the same tick's pin (`App::measure_pin_window`,
`off` when nothing was pinned) and it is the **target** while the view is pinned: a measurement
@@ -1348,6 +1348,191 @@ mock's five signals and dies with `OSError: [Errno 90] Message too long`, so the
two steps to it, which is visible in its `ges/x1`, `ges/x2` lines. A `gave up waiting after N
polls` line means no latch (or no pin) was ever reached, and is a reading about the stream.
## `[gate-probe]`
`UDPSCOPE_PROBE_GATE_MS`. The reading for **what the per-measurement gate does**: the gated
measurement reads the shared band, the ungated one the whole window, and the band is
*relative* — fixed on screen while the data flows under it, live or held. Measured output
(port 8140, `--rate 100000 --batch 100 --freq 1`):
```
[gate-probe] on: gate=[-0.300000..-0.100000] view=[-2.000000..0.000000] s0=2.022990 for=[0.022990..2.022990] band=1 placed=1 plain=1 split=1 gwin=1 ungated=-1.36e-17 V gated=355.31 mV
[gate-probe] still: gate=[-0.300000..-0.100000] s0=[2.022990->2.852990] still=1 s0_moved=1
```
- `gate=` is the band, in **display** x; `view=` the axis; `s0=` the stream time of the
display's x = 0 (`stream_at_x0()`); `for=` the pane's own window (the ungated one), in
stream seconds. The probe measures only once the ring is older than the window (`s0 >= 2`
here): the `plain=` assertion is exact zero because a whole-seconds window of the 1 Hz sine
holds whole periods, and a young ring would clip it to a fraction of a cycle.
- `band=1` — one gated measurement was enough to show the band, because the band is shared by
every gated measurement and shown exactly while one exists. `placed=1` — the row's plot
region holds exactly the bounds the `RowSet` does: the drawing and the state are one pair of
numbers, not two.
- `plain=1` — the ungated average is zero (`-1.36e-17 V`): it read the whole window.
`split=1` — the gated average differs (`355.31 mV`, wherever in its period the stream
happened to be): it read the band. The pair is the per-measurement split on one signal.
- `gwin=1` — the pass booked `App::measure_gate_window` as the band moved onto the stream's
clock, within 5 % of the band (the probe's `s0` read and the pass's can straddle one
production interval). That is the phase-free statement of *which* slice the gated walk
read; the slice's mean value itself depends on where in its period the stream is, which is
why the assertion is on the window and not on the value.
- The second line is the **relative** assertion, one second later: `still=1` — the band
exactly where it was put — and `s0_moved=1` — the stream's clock moved under it.
`still=0` is a gesture echo adopted outside a drag; `s0_moved=0` on a live stream means the
display epoch stopped following, which is the unbound-trace state the probe's assignment
exists to prevent.
- The exit dump carries the session leg: `gate: on=1 bounds=[-0.300000..-0.100000]`, and a
second run on the same config with the probe unset restores both measurements with their
gate flags (`measurements: 2 1.sine average=-0.000005 1.sine average=-0.338823` on one
restore run -- numbered with the signal's active-channel number, the same one the pane's
rows carry) and the same band.
## `[crosshair-probe]`
`UDPSCOPE_PROBE_CROSSHAIR_MS`. The reading for **the crosshair's axis badges**: a real pointer
hover — synthesised through the GTK adapter's own controller, a plain move with no button —
must place the crosshair, and with it the two badges the plot writes in the label strips: the
y value in the y strip, the x time in the x strip, each inside its strip and outside the axes.
Measured output (viewport pixels; the absolute numbers are the headless window's own):
```
[crosshair-probe] active=1 y=[74x47@31,588] x=[59x47@749,1529] y_ok=1 x_ok=1 y_centre=1
[crosshair-probe] off: plot_flag=0
```
- `active=1` is the hover having reached the plot (`Crosshair::active`) — the adapter's
viewport conversion is what this probe exercises; the pixels themselves are the plotter
suite's (`crosshair_ink_stays_inside_the_axes`, which asserts the badges' rects against the
frame and pins the off leg too).
- `y_ok=1 x_ok=1` are the two placements: the y badge right-aligned inside the y label strip
(its right edge on the axis line, where the y ticks end), the x badge inside the x label
strip below the axes. Zero-sized badges, or one outside its strip, is the defect.
- `y_centre=1` is the y badge **centred on its line**: its middle row is the pointer's screen
y (the probe injected one). `0` is the top-aligned badge — the line running along the
badge's top edge, reading as if the number belonged to the tick below it — which is half a
badge off, well beyond the one pixel of slack.
- The second line flips the flag the preference drives (`set_crosshair_axis_labels(false)`)
and reads the plot's own flag back: `plot_flag=0` — a preference that reaches the file but
not the plots is the failure this leg names.
- The pixel-level assertions live in the plotter's suite; this probe is the adapter's half.
The preference itself is `[prefs-probe]`'s `xhair=` field, which reads the settings file
back from disk after the dialog toggles it.
## `[maximize-probe]`
`UDPSCOPE_PROBE_MAXIMIZE_MS`. The one-click **full-screen row** toggle, exercised through the
`RowSet` call the button's handler makes. **Do not run it with `UDPSCOPE_ENABLE_ALL_MS`**: the
enable-all probe's second leg clears the front plot two seconds in, and this probe's `before`
read then names an empty row (`row0=`) -- a probe race, not a defect. The full-screen recipe:
`RowSet` call the button's handler makes (a probe cannot click an overlay button the frame
places), arranged as two single-cell rows with one signal each. The two reads after it are the
**corner reveal**: no pointer over the area, then a pointer position in the first row's corner
(set directly -- the probe owns the state the motion controller fills). Measured output:
```
[maximize-probe] single_buttons=0 detach_single=0 before: rows=2 frows=2 row0=sine row1=square buttons=2 detach=2; max: rows=1 frows=1 flag=1 row0=sine; back: rows=2 row0=sine row1=square flag=0
[maximize-probe] corner: hidden_when_off=1 shown_when_near=1 other_row=0 detach_near=1 detach_other=0
```
- `single_buttons=` is the toggle count *before* the second row is added, while the layout is
the default one figure: **0** — one figure has nothing to enlarge *to*, so it carries no
button (and `buttons=` is what the same count reads once the second row exists).
`detach_single=` is the same for the **detach** buttons: **0** — detaching the only figure
would leave the main window with nothing to draw — and `detach=2` once the second row exists
(and **0** again on the `max:` line, where a maximized layout has hidden its rows).
- the corner leg's `detach_near=1 detach_other=0` read the detach buttons with the same pointer
the maximize toggle reads: both come out together at the corner, the other row's stay hidden.
The pointer sits on the detach button's own centre — its zone is the strictest of the pair.
- `before:` is the arrangement as set: two plots, two figure rows, the signals on their rows,
and `buttons=` one overlay toggle per figure row (the toggles are on screen whether or not
the layout editor is up).
- `max:` is the toggle: one plot, one figure row, `flag=1`, and the *maximized row's own*
signal (`row0=sine`) — the row that was asked for, not whatever sat first.
- `back:` is the toggle again: the arrangement, the plot count and both rows' signals exactly
as before, `flag=0`. A signal that changed here is the state save/restore (`RowState` in
`plot_host.*`) losing a field; the restore is a view change, never an edit.
- `corner:` is the proximity reveal: `hidden_when_off=1` (no pointer in the area, so nothing
shows), `shown_when_near=1` (a pointer in the first row's top-right corner brings *that*
row's toggle out), and `other_row=0` -- the second row's toggle stays hidden, because the
zone is per row. A `shown_when_near=0` names a dead reveal path (the motion hook or the zone);
`other_row=1` a zone that leaked across rows.
## `[scope-probe]`
`UDPSCOPE_PROBE_SCOPE_MS`. The **second window**: `open_scope_window()` twice — once copying
the main window's figure 0, once empty — and read back a few frames later (the main tick is the
scope's bind/acquire/repaint pump). It can run beside `UDPSCOPE_ENABLE_ALL_MS`, but that probe's
second leg clears the front plot through the signal page, so the main's slot 0 is empty by then
and `copied` names the **first non-empty** copied slot instead (`'square'` in that order, `'sine'`
alone). `copied=''` with every slot empty is a copy that moved nothing. Measured output:
```
[scope-probe] loaded closed=2 scopes=0
[scope-probe] count=2 copied='sine' bound=1 empty_ok=1 windows=1
[scope-probe] drop=1 slot1='square' page_groups=2 empty_drop=1 empty_slot0='square'
[scope-probe] detach=1 scopes=3 frows=1 attach=1 scopes_back=2 frows_back=2 slot='ramp'
[scope-probe] gl copy=ok(67f) empty=ok(39f) empty_bound=1
[scope-probe] gl detached ok=1 frames=42 rows=1 slot0='sine'
[scope-probe] rclick=1
[scope-probe] pin_mirror=1 main=[-1.000..1.000] scope=[-1.000..1.000] x=[-1.000..1.000]
```
- `count=2` is both windows having been created and retained; `windows=1` each being a visible
top-level window.
- `copied='sine'` is the copy's first non-empty slot holding the main window's figure-0 signal
(`'square'` when the enable-all probe ran first and cleared slot 0 first), and
`bound=1` the tick having resolved it to a live channel — the scope is bound to the same
model, not a snapshot taken at creation.
- `empty_ok=1` is the `-1` call producing an empty row, which is the other entry point the
overflow menu uses.
- `loaded closed=N scopes=0` is the **session remembering windows without opening any**: the
entries a previous run saved are in `App::closed_windows`, and no scope window exists at start.
`scopes` above zero is a launch that opened windows on its own.
- `reopen scopes=… remembered=… left=0` is **close → remember → the menu entry**: destroying a
window puts its arrangement into `closed_windows` (by name -- `name=1` checks it), clicking the
*Reopen closed windows* item ("clicked", the item's own handler) opens every remembered one, and
the list is empty after (`left=0`). A `remembered` that did not grow is a destroy handler that
forgot to record; `left` non-zero is an entry the menu did not take.
- `instruments windows=N cursors=N gate=N trigger=N marks=N` is the **one owner, every window**:
given a cursor pair, a gate band and the trigger marker on the main window, every scope window
reports the same (each count is how many windows matched). `marks` is the per-window measurement
mark count agreeing with what that window plots -- a window that plots the measured signal has
its ruler, one that does not has none. All four counts equal to `windows` is the reading;
`trigger` is compared against the **main** (the marker is up only while the trigger is), and the
parenthesis reports the main's state so a zero is distinguishable from an unequal one. The leg
adds a real gated `Top` measurement on `sine`, because the gate is restated visible every frame
by `update_measures()` and `marks` only come from `show_on_plot` measurements.
- The second line is the **window as a drop target and a *Plot on* group**: `drop=1` is
`on_scope_drop_channel()` — the handler the window's own target wires — putting `square` in
the copied window's second slot (`slot1=`), the rebuild and the area's re-point then left to
the window's next frame; `page_groups=2` is the signal page of an unplotted signal carrying
**one *Plot on* group per window** (`udpscope-scope-plot-on`), so a signal can be plotted
into a scope window without dragging it there. `empty_drop=1 empty_slot0='square'` is the
same drop onto the **empty** window — the user's own path, "open a scope window and drop a
signal into it" — landing in its row set. `drop=0` names a refused drop (a full plot or an
unacquirable signal); `page_groups` less than the window count is a page rebuilt before the
windows were read or a group that lost its window.
- `pin_mirror=1` is a scope window **mirroring a pin as a range**: the probe pins the main window
and reads the scope's pin and its plot's x range a frame later. Both must agree and the x range
must be ordered (`x=[min..max]`, `min < max`). A `0` with an inverted scope pin (`[+pre, +post]`)
is the defect this exists for: `pin_event_window()` takes **spans**, and passing it a range's
signed `min` turned the window inside out, so a scope in Held or Paused mode drew no trace and no
x labels at all.
- `layout buttons=3 rows=1->2` is the window's **own layout editor**: the toggle builds
`2 × rows + 1` buttons (a `×` per row, a `+` per boundary, Done is not counted) for one figure,
and emitting the first boundary `+` adds a row. A wrong count is a button set built for the main
window's rows; `rows` unchanged is a handler writing the wrong row set.
- `rclick=1` is the scope window's **own right-click menu** being wired (one click gesture with
button 3 on its GL area, beside the drop target): the menu's actions act on that window's row
set.
- The fourth line is the windows' **GL account** (`gl copy=`/`gl empty=`): `ok(Nf)` is a context
that staged N frames, `err:` carries the adapter's failure message. A scope whose context
failed draws *nothing* — no axes, no traces, exactly the "blank plot widget" report — so
`err:` there is a driver/context finding, not a layout one, and `empty_bound=1` (the dropped
signal's channel, read a frame after the drop) with `ok(...)` together mean the window is
drawing; a blank window under those two readings is a compositor problem, not the app's.
## `[search-probe]`
`UDPSCOPE_PROBE_SEARCH_MS`. The handler calls `condition_probe()` directly — the condition is
@@ -1432,10 +1617,10 @@ popover row reads `failed: <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] calibrated: 2.5 -1.25 model=2.5 -1.25
[configs] before=default active=default
[configs] save-as=1 names=probe,default active=probe
[configs] before=default active=default store=default
[configs] save-as=1 names=probe,default active=probe store=probe
[configs] round trip read=1 comment='written by the configs probe' buffer_samples=10000000 relative=0 capture=0 plots=1 signals=4 measures=0 math=0 noise=-1 hf=-1
[configs] after forget names=default
[configs] after forget names=default active=default store=default
```
`noise=` and `hf=` are the first measurement's band and corner as they came back from the file. The probe
sets them to **7.5 and 250** before saving, so a key that never reached the file reads back as the defaults
@@ -1450,16 +1635,17 @@ sets them to **7.5 and 250** before saving, so a key that never reached the file
| `[configs] names` | gains `probe`, and loses it after `forget` | a save that writes somewhere the listing does not read, or a delete that leaves the file |
| `[configs] active` | `probe` after the save-as | the `active` file not written, so the next start opens the wrong configuration |
| `[configs] round trip` | `read=1`, the note verbatim, `buffer_samples` non-zero | the note or the startup settings not persisted — a configuration that carries a layout but not the buffer size it was tuned with |
| `[configs] store` | the **event cache's configuration key**, following the active one: `default` → `probe` → `default` | a cache still keyed on the source alone (the key never moves), or a switch that renames the session but leaves the events list showing the configuration it left: `store=default` on the `save-as` line, or `store=probe` after `forget`, names it |
## `[rowmenu]`
```
[rowmenu] main 4 'Remove signal' h=18 label_x=26 accel_right=-1/174 ok=1
[rowmenu] main rows=5 bad=0 labels=[Y axis: auto scale,Y axis: fixed range...,Fit X axis,Fit all,Remove signal]
[rowmenu] remove 0 'Back' h=18 label_x=52 accel_right=-1/178 ok=1
[rowmenu] remove rows=6 bad=0 labels=[Back,sine,square,ramp,noise,All traces on this plot]
[rowmenu] summary main_rows=5 main_bad=0 remove_rows=6 remove_bad=0 after_back_rows=5 after_back_bad=0 second_rows=6 second_bad=0 remove_row_in_main=1 remove_row_gone=1 can_open_again=1
[rowmenu] remove-clicked 'sine' trace_freed=1
[rowmenu] main 6 'Remove signal' h=18 label_x=26 accel_right=-1/248 ok=1
[rowmenu] main rows=7 bad=0 labels=[Y axis: auto scale,Y axis: fixed range...,Fit X axis,Fit all,Open this plot in a new window,Edit the layout...,Remove signal]
[rowmenu] remove 0 'Back' h=18 label_x=52 accel_right=-1/118 ok=1
[rowmenu] remove rows=1 bad=0 labels=[Back]
[rowmenu] summary main_rows=7 main_bad=0 remove_rows=1 remove_bad=0 after_back_rows=7 after_back_bad=0 second_rows=1 second_bad=0 remove_row_in_main=1 remove_row_gone=1 can_open_again=1
[rowmenu] remove-clicked '' trace_freed=1
```
| field | value it must read | the defect a wrong reading names |
@@ -1467,6 +1653,7 @@ sets them to **7.5 and 250** before saving, so a key that never reached the file
| `h` | 18 for every row of both views | the plot menu reverting to Adwaita's 34 px buttons — the row style not applied (`popover.udpscope-menu`) |
| `label_x` | 26 for every row, 52 for **Back** | labels not sharing one column; the Back row's 52 is its leading icon, which every menu indents for |
| `accel_right`/`W` | equal when the row has a shortcut (`Fit all` → `F`), `-1` when it has none | the shortcut not flush right, or shown where the action has none |
| `main_rows` | 6 for a one-figure layout, **7** with `Full screen this figure` (a layout of two or more figures), plus 1 with `Restore the layout` while one is maximized | the full-screen entry missing where it would act (it is deliberately absent for a single figure: there is nothing to enlarge to), or the layout-editor entry missing: the plot menu is the third route into the editor beside `l` and the overflow menu |
| `remove_rows` | 1 per occupied trace slot, plus `All traces on this plot` when there is more than one, plus the **Back** row | a remove view built from the wrong row, or missing its way out |
| `remove_row_gone`, `remove_row_in_main` | 1 | a submenu that floats beside the menu instead of replacing it |
| `can_open_again` | 1 | the second click not reopening: the `GtkMenuButton`-owned submenu that collapsed and left its row dead |
@@ -1620,7 +1807,7 @@ run they report:
[events] after events page opened: available=1 dock_sensitive=1 page_sensitive=1 page=events
[events] open alone: page=events page_box=347
[events] preview redrawn: signals=3 stored=480x132 row_drawn=1
[events] dir=/home/u/.cache/udpscope/events/127.0.0.1-21023 n=3 newest: stem=20261013-184140-0003 dur=2.000000 pre=1.600000 post=0.400000 edge=5.660100 signals=3 samples=20001 rate=10000 seq=3 thumb_bytes=190134 recorded=1 page=1 plot_w_stable=1 plot_w=756/756
[events] dir=/home/u/.cache/udpscope/events/127.0.0.1-21023/default n=3 newest: stem=20261013-184140-0003 dur=2.000000 pre=1.600000 post=0.400000 edge=5.660100 signals=3 samples=20001 rate=10000 seq=3 thumb_bytes=190134 recorded=1 page=1 plot_w_stable=1 plot_w=756/756
[events] activated row0: replaying=1 page=events page_box=347 tick=0 window=2.000 axis=[-1.600,0.400] marker=0.000 thumbnail=1 thumb_size=138x46 thumb_visible=1 rows_kept=1 selected=2/2
[events] dock events: rows=3 pane_rows=3 same_titles=1 sensitive=1 dock_w=268 on_screen=1 marked=0 members=0 max_ok=1 expected_marked=1 at=after pane replay
[events] dock replay: clicked=1 replaying=1 waveform_changed=1
@@ -1857,9 +2044,9 @@ These ride on the `[cursors-probe]` readings (every state line gets a `dock curs
## `strip:` (the readouts strip)
```
[cursors-probe] <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
[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] start strip: visible=0 expected=0 visible_ok=1 dock_shown=1 cursors=0 measures=0 same_text=1 h=0 sens_same=1 collapsed=1 window=756 groups=000 groups_ok=1
[cursors-probe] <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 add=1
[measures-probe] start strip: visible=0 expected=0 visible_ok=1 dock_shown=1 cursors=0 measures=0 same_text=1 h=0 sens_same=1 collapsed=1 window=756 groups=000 groups_ok=1 add=1
[cursors-probe] dock hidden strip: visible=1 expected=1 visible_ok=1 dock_shown=0 cursors=4 measures=0 same_text=1 h=26 sens_same=1 ...
[cursors-probe] dock restored strip: visible=1 expected=1 visible_ok=1 dock_shown=1 ... collapsed=1 window=756
```
@@ -1868,7 +2055,7 @@ One `strip:` line follows every `dock cursors:` / `dock measures:` line, and eac
- **`visible` equals `expected`, and `expected = (!dock_shown || collapsed) && (cursors on || measures non-empty)`** (`dock_shown` is `show-sidebar`, `collapsed` the split view's, both read in the same frame as `visible`; "cursors on" is the cursors toggle, not the `cursors=` chip count, which is a rebuild's and is 1 even with them off). `visible_ok=1` is that equality; `visible_ok=0` is `sync_readouts_strip()` not run, or run before the reconciliation. A single such line right after the probe's own `update_side_visibility()` or on the very first line (the tick has not set `collapsed` yet) is the sub-frame before the next tick and not a defect.
- **What each leg reads.** `[measures-probe] start` (before the probe adds anything, from a **fresh** config: a stored session brings its measurements back) is the "neither" state: `visible=0 expected=0 groups=000 h=0`. The measures probe's later lines have measurements and the cursors off: `groups=010`. The cursors probe alone has the cursors on and no measurements: `groups=100`; run after the measures probe in the same session it has both: `groups=111`. A line with the strip visible and `groups=000`, or hidden with a group on, is `sync_readouts_strip()` not called from that path.
- **`groups=<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.
- **`visible=1 expected=1` on every line that has something to read, under `xvfb-run`**, the `dock restored` line and the lines with `dock_shown=1` included: the window is 756 px, below the 900 px `kSidebarRoom`, so the split view is collapsed, the dock is an overlay on the plot, and the strip stays. `visible=0` there is the old rule (hidden whenever the dock is shown) or `collapsed` not being read. The wide case (`collapsed=0`, `dock_shown=1`, `visible=0 expected=0`) is **not reachable from a probe**, because a window with no manager cannot be resized by `gtk_window_set_default_size()`.
- **`h` is 26 with `visible=1`, 0 with `visible=0`.** Any other visible height is the chips setting the strip's height (the button trim in `ui_widgets.cpp` or the `height-request` lost).
@@ -2118,15 +2305,22 @@ same reason.
## `[prefs-probe]`
```
[prefs-probe] opened=1 rows=1 dir=/tmp/k5/cache/udpscope/events store=/tmp/k5/cache/udpscope/events/127.0.0.1-8000
[prefs-probe] dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark live=1
[prefs-probe] opened=1 rows=1 dir=/tmp/k5/cache/udpscope/events store=/tmp/k5/cache/udpscope/events/127.0.0.1-8000/default cfg=1 cfg_switch=1
[prefs-probe] dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark xhair=0 live=1
[prefs-probe] default restored: file=''
[prefs-probe] browse: cancelled=1 icon_ok=1 field='/tmp/k5/cache/udpscope/events'
```
`UDPSCOPE_PROBE_PREFS_MS` opens the preferences, changes every row, and reads the settings file *back
from disk* after each change -- not from `App::settings`, because a dialog that edits its own copy and
never writes is exactly the failure this has to catch. **A change applies as it is made**: the rows are
never writes is exactly the failure this has to catch.
`store=` is the directory the cache is **really** using, and the `cfg=`/`cfg_switch=` pair is that
directory's key: `cfg=1` reads `store=` ends `/<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`
dialog has no Apply button for the probe to press.
@@ -2138,8 +2332,10 @@ dialog has no Apply button for the probe to press.
plus the source's folder name**, which is the reading that says the row shows the path in force rather
than a plausible string.
- The second line must echo the probe's own input: directory `/tmp/udpscope-prefs-events`, keep `7`,
relative time `1`, theme `dark`. Any field that differs means the row, the write or the parse dropped
it. `live=1` is the theme having reached the running style manager rather than only the file.
relative time `1`, theme `dark`, and `xhair=0` — the crosshair's axis-badge switch, which the
probe drives off exactly as it drives the rest, and which reaches the running plots at once
(`RowSet::set_crosshair_axis_labels()`). Any field that differs means the row, the write or the
parse dropped it. `live=1` is the theme having reached the running style manager rather than only the file.
- `default restored: file=''` closes the loop from the other side: the probe puts the default path back
into the row, and the file must end up with **no** `event_cache_dir` at all. A path in the file is a
user's choice, and leaving the default is not one -- pinning it there would stop the setting following
@@ -2152,7 +2348,7 @@ dialog has no Apply button for the probe to press.
```
[prefs-probe] opened=1 apply=1 dir=/tmp/k5/cache/udpscope/events store=/tmp/k5/cache/udpscope/events/127.0.0.1-8000
[prefs-probe] opened=1 apply=1 dir=/tmp/k5/cache/udpscope/events store=/tmp/k5/cache/udpscope/events/127.0.0.1-8000/default cfg=1 cfg_switch=1
[prefs-probe] dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark live=1
[prefs-probe] default restored: file=''
[prefs-probe] browse: toplevels=3 extra=1 icon_ok=1 field='/tmp/k5/cache/udpscope/events'
@@ -2759,7 +2955,7 @@ left the caller's `false` alone would read `dock_shown=0 default=1 ok=0`. The pr
## `[signalpage]`
```
[signalpage] name=sine open=1 pages=1 enable_visible=1 plot_buttons=1 plot_on_row0=1 plot_on_row0_disabled=1 removed=1 btn_row0=1 back_to_list=1 row_opens=1 pruned=1 other_page_kept=1 rows=1
[signalpage] name=sine open=1 pages=1 enable_visible=1 plot_buttons=1 plot_on_row0=1 plot_on_row0_disabled=1 removed=1 btn_row0=1 title='1. sine' title_ok=1 no_dup=1 row_opens=1 pruned=1 other_page_kept=1 rows=1
```
`UDPSCOPE_PROBE_SIGNAL_PAGES_MS` builds and drives the page of the first streamed signal. `open=1` (the slide-over shows `signal:<name>`), `pages=1`
@@ -2767,8 +2963,11 @@ left the caller's `false` alone would read `dock_shown=0 default=1 ok=0`. The pr
`rows=` (one "Plot on" button per figure row), `plot_on_row0=1` (`assign_signal_to_row()` returned true and the signal is plotted, from a state where the probe first took it off every plot), `plot_on_row0_disabled=1`
(after the page refilled with the signal on plot 1, that plot's "Plot on" button is insensitive, "already on this plot"; `0` is a button that would put the signal in a second slot of
the same plot), `removed=1`
(the page's "Remove from plots" took it off), `btn_row0=1` (the "Plot on 1" button, the user's path, put it back), `back_to_list=1` ("Back to signals"
target), `row_opens=1` (the probe switches the signal off so it is addable, activates its list row and the page opens; the signal is then switched back on), and `pruned=1`: a math channel's page was opened, its **Delete** button clicked, and
(the page's "Remove from plots" took it off), `btn_row0=1` (the "Plot on 1" button, the user's path, put it back),
`title=` the slide-over **header's** title while the page is open, `title_ok=1` when it is `<channel number>. <name>` -- the
same numbering the badge carries (`measure_channel_number`), so a signal page says which acquired channel it is -- and
`no_dup=1` when the page **body** carries neither a "Back to signals" button (the header's Close is the way out) nor a
second copy of the name (a `title-3` label); `0` is either duplicate back, `row_opens=1` (the probe switches the signal off so it is addable, activates its list row and the page opens; the signal is then switched back on), and `pruned=1`: a math channel's page was opened, its **Delete** button clicked, and
the slide-over closed and the page was dropped by `signal_pages_sync()`. `pruned=-1` means no math channel could be added; `0` is a stale page. `other_page_kept=1`: with the `trigger` page open, a signal page that is not on show was pruned (a math channel added, its page built, the
channel removed) and the slide-over was still on `trigger`, both by `slideover_page()` and by the stack's visible child; `0` is `signal_pages_sync()` parking the stack on the
signals list while the title and the badge marker still name `trigger`; `-1` when there is no `trigger` page or no math channel could be added.
@@ -2862,9 +3061,10 @@ inside the panel does not close it), `click_outside_closes=1` (a click on the pl
[split-probe] ruler-labels split=[row 1,row 2] mixed=[row 0,row 2]
[split-probe] shared-y y_shared=1 y_gutter=1 y_fit=1 ruler_mirror=1 ranges=[A=-3.000000..7.000000 B=-3.000000..7.000000 | fit A=-1.04..1.04 B=-1.04..1.04 data=-1..1] axes_w=[671.599976,671.599976] auto=1
[split-probe] x-axes row0_labelled=[1,0,1] bottom_labelled=[1,1,1] gap_ok=[1,1,1] gap_px=[3.0/3.0,3.0/3.0,3.0/3.0] col_gap_px=3.0 caption_ok=[1,1,1] last_plot_zoom=1
[split-probe] cell-xhair left=1 right=1 centre_dy=0.0 sibling=0 (the horizontal line spans a split row's shared y, and no other row)
```
`UDPSCOPE_PROBE_SPLIT_MS` (stderr; see [verification.md](verification.md)). 28 steps (0..27), 250 ms apart;
`UDPSCOPE_PROBE_SPLIT_MS` (stderr; see [verification.md](verification.md)). 31 steps (0..30), 250 ms apart;
each restructures and the *next* step reports the frame that was laid out with it, so a line's label
names the arrangement the line **reads**, not the call that made it. The state lines share one format:
`plots` (`row_count()`), `frows` (`figure_row_count()`), `cells=[..]` (per figure row), `widths=[..]`
@@ -2887,6 +3087,7 @@ The other lines, in order:
| `row+cell` | `cells=[2,2]`, `weights=[3,1\|-]` | row 0's cell weights lost or moved when a row and a cell were inserted below it (they belong to the figure row) |
| `caps-calls` | `insert_cell=[1,1,0] refused_cell=1 insert_row=[1,1,0] refused_row=1 plots=8` | a cap that is not reached or not held: row 0 starts with two cells and takes two more, refusing the third call at 4; two rows then fit before the plot total reaches 8 and the third is refused; `plots` must stop at **8** |
| `caps` | `plots=8 frows=4 cells=[4,2,1,1]`, `tile=1` | a ninth plot, or a fifth cell in a row |
| `cell-xhair` | `left=1 right=1 centre_dy=0.0 sibling=0` | the crosshair's horizontal line, placed through `Figure::set_crosshair()` on the **second** cell of a two-cell row: it must also be drawn in the first cell (`left=1`) at the same screen row (`centre_dy`) — the cells share one y, so the line is one value — and must not appear in the figure's other (unsplit) row (`sibling=0`). A `left=0` names a row whose cells each keep their own line; a `sibling=1` a y spread across rows that do not share one |
| `remove` | `plots=7 cells=[3,2,1,1]` | `remove_cell()` on a multi-cell row that removed the row, or that dropped a neighbour's traces |
| `roundtrip-io` | `wrote=1 read=1 plots_in_file=3` | a save that was refused, or a file that lost a `[[plot]]` table |
| `roundtrip` (state line) | `cells=[1,2] widths=[756\|524,229] weights=[-\|3,1]` and the three traces on `1`, `2a`, `2b` | the arrangement did not survive `collect_layout()` -> file -> `apply_layout()` |
+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`,
from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump
runs from the destroy handler, so the widget pointer is **freed memory**, and a critical is only
runs **after `g_application_run` returns**, so the window and every widget in it are gone -- the pointer
is **freed memory**, and a critical is only
the lucky reading of it. A run that had built and destroyed other windows first —
`UDPSCOPE_PROBE_HELP_MS`, which opens the About and shortcut dialogs and then closes them — has had
that memory reused, and the same two calls
@@ -74,7 +75,26 @@ precedent, not an exception.
then dump the whole pipeline state to stdout (frame/sample counts, per-plot painted
frames, trace bindings, panel sizes, status line in both states).
- `UDPSCOPE_ENABLE_ALL_MS`, `UDPSCOPE_ARM_TRIGGER_MS`, `UDPSCOPE_PROBE_DISARM_MS`,
`UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_PROBE_DISARM_MS` only
`UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_ARM_TRIGGER_MS` clicks the
trigger toggle and then samples the **trigger status circle** once a second for four
seconds: an armed, waiting trigger reads `circle=flash-lit` / `circle=flash-dim` (the two
halves of the 1 Hz flash, in the phase the sample lands in), a latched event `circle=green`,
a disarm `circle=red`; the exit dump carries the last tick's state as `trigger circle:`
(`off` under `UDPSCOPE_EXIT_AFTER_MS`, whose own probe goes live before it dumps).
`UDPSCOPE_PROBE_CROSSHAIR_MS` hovers the pointer over row 0 through the adapter's own
controller and asserts the crosshair's two **axis badges** landed in their label strips
(`[crosshair-probe] active=1 ... y_ok=1 x_ok=1`), then flips the flag off and reads the
plot's back; the pixel-level contract is the plotter suite's. The preference behind the
flag is `[prefs-probe]`'s `xhair=` field. `UDPSCOPE_PROBE_MAXIMIZE_MS` arranges two
single-cell rows (sine, square) and exercises the one-click **full-screen** toggle through
the same `RowSet` call the button's handler makes — `[maximize-probe] before: rows=2 frows=2
row0=sine row1=square buttons=2; max: rows=1 frows=1 flag=1 row0=sine; back: rows=2
row0=sine row1=square flag=0` — where `buttons=` is the overlay's one toggle per figure row
and the `back` leg is the proof that a maximize moves no signal. `UDPSCOPE_PROBE_SCOPE_MS`
opens the **second window** twice, once copying figure 0 and once empty, and reads it back
a few frames later: `[scope-probe] count=2 copied='sine' bound=1 empty_ok=1 windows=1` —
`bound=1` is the main tick having bound the copy's rows to the same model, and `empty_ok=1`
the empty scope staying empty. `UDPSCOPE_PROBE_DISARM_MS` only
*starts* its sequence: it switches the trigger on and then polls for a capture to open,
so the Disarm lands ~300 ms into one rather than wherever a fixed delay happened to fall.
- `UDPSCOPE_PROBE_FORCE_MS` — the trigger panel's **Force** button: one acquisition at the present
@@ -234,13 +254,16 @@ precedent, not an exception.
window prices a 15 s one at nothing (four measurements over four channels read `35.69 ms` a
recompute and a 4.58 ms tick mean at a 15 s window against `6.58 ms`/1.34 ms for one window at
the same window).
- `[measure-probe] N measurements on 'X'` (stderr, `UDPSCOPE_PROBE_MEASURE_MS`) — adds one
- `[measure-probe] N measurements on 'X' (channel C)` (stderr, `UDPSCOPE_PROBE_MEASURE_MS`) — adds one
measurement of each statistical type (average, top, peak to peak, amplitude) on the first
*channel* and forces a recompute. It must name a **channel**, not `signals().front()`: the
first signal of a stream is its timestamp, which is not acquired, and naming it adds four
measurements that all read `--` *and* makes a performance run look free (each bails before
collecting anything -- that is how the first baseline of the stall below read 0.4 ms).
The values are in the exit dump as `measurements: <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
`peak to peak=2` **exactly**, `average=0`, and the two flat-level types as their definition
says: **`top=0.957988`** and `amplitude=1.915983`. `top` is no longer 2/pi -- it is the *flat*
@@ -346,6 +369,42 @@ precedent, not an exception.
[probe-readings.md](probe-readings.md); the window rule itself is in
[invariants-measurements.md](invariants-measurements.md).
- `[gate-probe]` (stderr, `UDPSCOPE_PROBE_GATE_MS`) — the **per-measurement measurement gate**:
a gated measurement must read the shared band and an ungated one the whole window, and the
band must stay put while the data moves under it. It enables the sine, puts it on a plot
(the way `enable_all_probe` does — an unbound trace leaves the display epoch at 0, and a
still window is the state the `still=` leg asserts moves), adds **two** averages — index 0
ungated, index 1 gated, which is what makes the pane rebuild *derive* the band from the
measure set — and re-states the band's bounds to a fixed relative interval, 300 ms ago to
100 ms ago, where samples exist whatever the run's age. It first waits for the ring to be
older than the window, because the `plain=` assertion below is exact zero only while a
whole-seconds window of the mock's 1 Hz sine holds whole periods; a young ring clips the
window to a fraction of a cycle and the reading is merely small. Two lines:
```sh
python3 tools/mock_udpstreamer.py --port 8140 --rate 100000 --batch 100 --freq 1 &
sleep 1.5
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=14000 UDPSCOPE_PROBE_GATE_MS=3000 \
./build/udpscope --server 127.0.0.1 --port 8140 2>&1 | grep -E 'gate-probe'
```
The first line must read **`band=1 placed=1 plain=1 split=1 gwin=1`**: one gated
measurement showed the band, the plot's region holds exactly the bounds the `RowSet` does,
the ungated average is zero (it read the whole window — `-1.36e-17 V` on the reference run),
the gated average differs (it read the band — `355.31 mV`, wherever in its period the
stream happened to be), and the pass booked `measure_gate_window` = the band moved onto the
stream's clock (`s0=`), which is the phase-free statement of *which* slice the gated walk
read. The second line, a second later, must read **`still=1 s0_moved=1`**: the band exactly
where it was put while the stream's clock moved under it — the *relative* behaviour the
gate exists for. The probe saves the bounds the way a drag end does; the exit dump's
**`gate:` line** is the round trip — a second run on the same config, with the probe unset,
must restore both measurements (`measurements: 2 average≈0 average≠0`) and read
`gate: on=1 bounds=[-0.300000..-0.100000]`. **Not in one run with the other measurement
probes** (`UDPSCOPE_PROBE_MEASURE_MS`, `_MEASURES_MS`, `_BANDAVG_MS`, `_LEVELS_MS`,
`_MEASUREPIN_MS`): they add measurements to one pane and these lines quote indexes 0 and 1.
The lines are in [probe-readings.md](probe-readings.md); the rule is in
[invariants-measurements.md](invariants-measurements.md).
- `[pan-probe] asked=[..] was=[..]`, `[pan-probe] after=[..] held=D asked=[..]`, then
`[pan-probe] zoom asked=[..] after=[..] span=S window=W limited=L`
(stderr, `UDPSCOPE_PROBE_PAN_MS`) — **`held` must be 1**: a gesture must not move a window
+10
View File
@@ -63,6 +63,16 @@
<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
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,
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
+30 -2
View File
@@ -29,7 +29,8 @@
add a trace.</p>
</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>
on each boundary between rows, including above the first and below the last.</p>
</item>
@@ -58,12 +59,39 @@
<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
every plot. The plots of a split row also share one y range: the y labels are drawn at the
left of the first plot only, the horizontal cursors sit at the same values in every plot, and a
left of the first plot only, the horizontal cursors sit at the same values in every plot, the
hover crosshair's horizontal line spans all of them, and a
zoom, pan, fit or fixed range in any of them applies to the whole row. The time labels
sit under the bottom row, and under any row above it whose columns differ from the row below. A row holds at most 4 plots and the figure at most 8
in total; the <gui>+</gui> buttons go grey at the limit.</p>
</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> Beside the toggle, shown the same way, a <gui>detach</gui> button moves the figure into a
window of its own — it leaves the main window's rows; the detached window carries an
<gui>attach</gui> button in the same corner, which returns the figure to the main window and
closes the window.
<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. A signal
badge dragged onto a window's plot lands in that window (on the plot under the pointer), and
the signal page's <gui>Plot on</gui> section lists every window — one named group per
window, one button per plot of it. Closing one closes that view only; the main window keeps
acquiring.</p></item>
</list>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
+31 -1
View File
@@ -80,8 +80,19 @@
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>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>
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
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
@@ -136,6 +147,25 @@
</list>
</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">
<title>Example</title>
<p>On a 1 Hz ±1 sine: <code>average=0</code>, <code>top=0.957988</code>,
+2 -1
View File
@@ -30,7 +30,8 @@
</item>
<item>
<p>Once a signal is acquired its badge appears. Click it to open that signal's page: its
details directly under its name — <gui>Unit</gui>, <gui>Gain</gui> and <gui>Offset</gui>,
title carries the signal's name and its channel number (<code>2. sine</code>), and its
details follow — <gui>Unit</gui>, <gui>Gain</gui> and <gui>Offset</gui>,
each with a pencil button that edits that one value, then the type, size, range, sample
rate and timing the stream declared — its colour and <gui>Plot on</gui> buttons for the
rows.</p>
+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
pipeline you stopped (the tooltip says which), and <em>yellow</em> is a source that
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>,
<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
+3 -1
View File
@@ -169,7 +169,9 @@
<section id="cache">
<title>The event cache, replay and compare</title>
<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
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
+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; }
EventStore::EventStore(std::string source, std::string base_dir) {
if (source.empty()) return;
namespace {
/// A path component from a user-visible name: alphanumerics, `.`, `_` and `-` kept, everything else
/// replaced. The same rule the layout store applies to a configuration name, and for the same
/// reason -- a name typed with a slash in it must not be able to leave the directory.
std::string sanitize_component(const std::string& in) {
std::string safe;
for (char c : source) {
safe.reserve(in.size());
for (char c : in) {
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
c == '.' || c == '_' || c == '-';
safe.push_back(ok ? c : '_');
}
dir_ = (base_dir.empty() ? default_events_dir() : base_dir) + "/" + safe;
return safe;
}
/// The directory a source's configuration writes into: `<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) {
+25 -2
View File
@@ -45,11 +45,28 @@ std::string default_events_dir();
class EventStore {
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
/// `<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.
explicit EventStore(std::string source = {}, std::string base_dir = {});
explicit EventStore(std::string source = {}, std::string base_dir = {},
std::string configuration = {});
/// Points the store at another configuration of the same source, for the configuration switch
/// the session has: only the directory changes, and `true` means it did -- the caller then
/// refreshes whatever lists the cache. A store with no source (`empty_dir()`) never changes.
bool set_configuration(const std::string& configuration);
/// The configuration the store currently writes to (the constructor's, or `set_configuration`'s).
const std::string& configuration() const noexcept { return configuration_; }
/// How many events to keep, newest first. The oldest beyond it are deleted on every save; item 9's
/// preferences are where this becomes the user's number.
@@ -89,6 +106,12 @@ class EventStore {
void prune(std::size_t keep) const;
private:
/// The base directory every source folder lives under (an absolute path, or empty for a store
/// with no source), and the source's own sanitized key: kept so a configuration switch can
/// re-derive `dir_` without asking the caller for either again.
std::string root_;
std::string source_;
std::string configuration_;
std::string dir_;
};
+64 -4
View File
@@ -448,7 +448,8 @@ bool load_layout(const std::string& path, LayoutState& out) {
std::string line;
// What the last `[[...]]` header opened, so keys land in the right record.
enum class Section { Top, Plot, Calibration, Unit, Color, Math, Measure } section = Section::Top;
enum class Section { Top, Plot, Window, WindowPlot, Calibration, Unit, Color, Math, Measure } section =
Section::Top;
int plot_count = 0;
bool plot_section_seen = false;
while (std::getline(in, line)) {
@@ -459,6 +460,14 @@ bool load_layout(const std::string& path, LayoutState& out) {
section = Section::Plot;
plot_section_seen = true;
st.rows.emplace_back();
} else if (t == "[[window]]") {
section = Section::Window;
st.windows.emplace_back();
} else if (t == "[[window.plot]]") {
// A window's own plot rows: the same shape as the main layout's `[[plot]]`, one level in.
if (st.windows.empty()) st.windows.emplace_back();
section = Section::WindowPlot;
st.windows.back().rows.emplace_back();
} else if (t == "[[calibration]]") {
section = Section::Calibration;
st.calibration.emplace_back();
@@ -573,6 +582,11 @@ bool load_layout(const std::string& path, LayoutState& out) {
}
} else if (key == "measure_stats") {
st.measure_stats = value == "true";
} else if (key == "gate_x0" || key == "gate_x1") {
try {
(key == "gate_x0" ? st.gate_x0 : st.gate_x1) = std::stod(value);
} catch (...) {
}
} else if (key == "dock") {
st.dock_shown = value == "true";
} else if (key.rfind("trigger_", 0) == 0) {
@@ -603,6 +617,35 @@ bool load_layout(const std::string& path, LayoutState& out) {
} catch (...) {
}
}
} else if (section == Section::Window && !st.windows.empty()) {
if (key == "name") {
st.windows.back().name = strip_quote(value);
} else if (key == "detached") {
st.windows.back().detached = value == "true";
}
} else if ((section == Section::WindowPlot && !st.windows.empty() &&
!st.windows.back().rows.empty())) {
// The window's rows take exactly the main layout's keys; `rows` is reached through the
// window record so the parser stays one shape.
LayoutState::Row& row = st.windows.back().rows.back();
if (key == "signals") {
std::vector<std::string> names;
if (value.size() >= 2 && value.front() == '[' && value.back() == ']') {
parse_string_array(value.substr(1, value.size() - 2), names);
}
row.signals = names;
} else if (key == "weight") {
row.weight = std::strtod(value.c_str(), nullptr);
} else if (key == "row") {
try {
const int r = std::stoi(value);
if (r >= 0) row.row = r;
} catch (...) {
}
} else if (key == "width") {
const double w = std::strtod(value.c_str(), nullptr);
if (std::isfinite(w) && w > 0.0) row.width = w;
}
} else if (section == Section::Plot && !st.rows.empty()) {
if (key == "signals") {
std::vector<std::string> names;
@@ -685,6 +728,8 @@ bool load_layout(const std::string& path, LayoutState& out) {
m.options.above = value == "true";
} else if (key == "show") {
m.options.show_on_plot = value == "true";
} else if (key == "gate") {
m.options.gate = value == "true";
}
} else if (section == Section::Calibration && !st.calibration.empty()) {
if (key == "name") {
@@ -762,6 +807,8 @@ bool save_layout(const std::string& path, const LayoutState& state) {
o << "]\n";
}
o << "measure_stats = " << (state.measure_stats ? "true" : "false") << "\n";
o << "gate_x0 = " << state.gate_x0 << "\n";
o << "gate_x1 = " << state.gate_x1 << "\n";
o << "dock = " << (state.dock_shown ? "true" : "false") << "\n";
// The trigger, settings and state both. Written even when it is off: that is the point of it.
o << "trigger_enabled = " << (state.trigger.enabled ? "true" : "false") << "\n";
@@ -793,8 +840,7 @@ bool save_layout(const std::string& path, const LayoutState& state) {
if (state.buffer_samples > 0) o << "buffer_samples = " << state.buffer_samples << "\n";
o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n";
if (state.capture_packets > 0) o << "capture_packets = " << state.capture_packets << "\n";
for (const auto& row : state.rows) {
o << "\n[[plot]]\n";
const auto write_row = [&](const LayoutState::Row& row) {
// A row of -1 is a state built without the split (nothing in the app does that now); leaving the
// key out keeps it readable as the one-cell-per-row file it is.
if (row.row >= 0) o << "row = " << row.row << "\nwidth = " << row.width << "\n";
@@ -804,6 +850,19 @@ bool save_layout(const std::string& path, const LayoutState& state) {
o << toml_escape(row.signals[i]);
}
o << "]\n";
};
for (const auto& row : state.rows) {
o << "\n[[plot]]\n";
write_row(row);
}
// The extra windows: written so a closed one can be reopened, never opened by a launch.
for (const auto& window : state.windows) {
o << "\n[[window]]\nname = " << toml_escape(window.name)
<< "\ndetached = " << (window.detached ? "true" : "false") << "\n";
for (const auto& row : window.rows) {
o << "\n[[window.plot]]\n";
write_row(row);
}
}
for (const auto& cal : state.calibration) {
o << "\n[[calibration]]\nname = " << toml_escape(cal.name) << "\ngain = " << cal.gain
@@ -841,7 +900,8 @@ bool save_layout(const std::string& path, const LayoutState& state) {
<< "\nthreshold2 = " << m.options.threshold_pct2
<< "\nabove = " << (m.options.above ? "true" : "false") << "\nshow = "
<< (m.options.show_on_plot ? "true" : "false") << "\nnoise = " << m.options.noise_pct
<< "\nhf = " << m.options.hf_fc << "\n";
<< "\nhf = " << m.options.hf_fc
<< "\ngate = " << (m.options.gate ? "true" : "false") << "\n";
}
// A sibling temp file + rename: a crash mid-write leaves the old layout, not a
+21
View File
@@ -36,6 +36,19 @@ struct LayoutState {
double window_s = 0.0; ///< 0 keeps the default window
std::vector<Row> rows;
/// An **extra window**: a scope or detached window, remembered with the session but never opened
/// by a launch. A window is a view a user closed, and bringing one back is a decision -- the
/// *Reopen closed windows* entry asks for it, so a start never conjures windows onto a desktop
/// that does not want them (a tiling WM would tile them straight away). `name` is the window's own
/// short name (`plot 2`, `scope 3`), `detached` whether its figure came from a detach, and `rows`
/// the same plot rows the main layout carries: signals, row weights and cell widths.
struct Window {
std::string name;
bool detached = false;
std::vector<Row> rows;
};
std::vector<Window> windows;
struct Calibration {
std::string name;
double gain = 1.0;
@@ -87,6 +100,14 @@ struct LayoutState {
/// stream and mean nothing restored against another one.
bool measure_stats = false;
/// The measurement gate's **bounds** -- where the one shared band sits, in the display x the
/// axes read, the cursors' coordinate space, so they restore as the same relative offsets
/// ("half a second ago to a tenth ago") whatever the stream is doing. Whether the gate is on
/// is *not* here: it is a per-measurement option (`MeasureOptions::gate`, in `measures`
/// below), and the band is shown exactly while some measurement is gated.
double gate_x0 = 0.0;
double gate_x1 = 0.0;
/// The dock's shown state (the user's last word on the toolbar's Dock button). The one thing the dock
/// persists -- its 320 px width is the `.ui`'s. An older file's `side_tab` key is not read: the parser
/// ignores keys it does not know.
+7
View File
@@ -93,6 +93,13 @@ struct MeasureOptions {
/// -- a filtered window cannot use the LOD pyramid, because a bucket aggregates unfiltered samples --
/// so a non-zero corner costs a full raw walk.
double hf_fc = 0.0;
/// Every type: measure only inside the **gate** -- the one shaded band shared by every gated
/// measurement, clipped to the window this measurement would otherwise describe. The band is
/// carried by its own pair of draggable bounds on the plots (see `RowSet::gate_x0()`), and it
/// is shown while *any* measurement is gated: it is one area, and its bounds are one pair of
/// hands. Off by default; a measurement with it off reads the whole window and shares the
/// walks it always shared.
bool gate = false;
};
/// One entry in the measurements pane, and everything needed to recompute it: the signal by
+172 -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
/// picture that cannot be read, so the current hit is brighter than its neighbours.
constexpr plot::u32 kSearchCurrentColor = 0xE8D37Fu;
/// The measurement gate's colour: violet, a hue no instrument on screen uses -- the cursors'
/// yellow and cyan, the measurements' green, the trigger's red and a hit's blue all name
/// something else, and the gate's shaded band is the one area that must not read as any of
/// them. The fill is the same hue at the low alpha a region reads as "area of interest" through.
constexpr plot::u32 kGateColor = 0xB080FFu;
constexpr float kGateFillAlpha = 0.15f;
/// The smallest difference a `TickFormat::Time` label can *write* at the magnitude `t` -- what
/// the last digit of a label at that magnitude is worth. `format_time` writes three significant
@@ -210,24 +216,64 @@ void RowSet::set_row_count(int rows) {
rebuild_all();
}
void RowSet::carry_row(Row& dst, const Row& src) {
dst.signal_name = src.signal_name;
dst.name = src.name;
dst.channel = src.channel;
dst.color_index = src.color_index;
// A layer travels with the slot that draws it: it *is* part of what that slot holds. The tick's
// `sync_layers()` re-plans them all next frame in any case, which is what heals a row that has
// moved under a plan made for the old one.
dst.layer = src.layer;
dst.slot_alpha = src.slot_alpha;
void RowSet::toggle_maximize_figure_row(int fr) {
if (maximized_) {
// Back exactly as it was: every row's state first, then the arrangement it was indexed
// by, then the rebuild that deals the saved rows back into fresh plots.
for (int i = 0; i < saved_rows_ && i < kMaxRows; ++i) {
apply_row_state(storage_[static_cast<std::size_t>(i)],
saved_row_states_[static_cast<std::size_t>(i)]);
}
cells_per_row_ = saved_cells_;
cell_weights_ = saved_cell_weights_;
row_weights_ = saved_row_weights_;
rows_ = saved_rows_;
maximized_ = false;
saved_cells_.clear();
saved_cell_weights_.clear();
saved_row_weights_.clear();
saved_rows_ = 0;
rebuild_all();
return;
}
if (fr < 0 || fr >= figure_row_count()) return;
const int n = cells_in_row(fr);
const int first = first_plot_of_row(fr);
if (!(n > 0)) return;
// The arrangement and every row's persistent state, so the restore is the inverse and
// nothing the user plotted is lost to a view change.
for (int i = 0; i < kMaxRows; ++i) {
saved_row_states_[static_cast<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;
dst.ruler_b_y = src.ruler_b_y;
// The slot's series stays where it is, but what it is bound to belonged to the row
// this state came *from*. `bind()` re-resolves every slot next frame; clearing it here
// is what stops the frame in between from drawing the moved row's data under the new
// row's legend.
for (SlotSeries& s : dst.slots) s.bind(nullptr);
void RowSet::carry_row(Row& dst, const Row& src) {
// One spelling of "a row's persistent state": the save/restore the maximize toggle uses
// is the same copy, and a field added there is a field carried here.
apply_row_state(dst, row_state_of(src));
}
void RowSet::clear_row(Row& r) {
@@ -435,6 +481,21 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
// does not stop a drag -- the controller holds the marker for the gesture's life and follows the
// pointer between frames -- so excluding it from the hit test is the only place that can say no.
r.plot->set_cursor_selectable(r.trigger_marker, false);
// The measurement gate: one region per plot, parked until the gate is switched on (see
// `place_gate()`). Its bounds are the gate's own pair of instruments -- grabbable, the
// way a cursor is -- and its shaded fill is the area the user sees: the measurements
// describe what is inside it. The label names it, because a shaded band with no caption
// next to a plot full of readout marks is a band the user has to guess at.
plot::Region gate;
gate.x0 = kParked;
gate.x1 = kParked + 1.0;
gate.fill = plot::Color::hex(kGateColor, kGateFillAlpha);
gate.edge = plot::Color::hex(kGateColor);
gate.label = "gate";
r.gate_region = r.plot->add_region(gate);
// The crosshair's axis badges carry the set's flag onto the new plot: a rebuild keeps the
// setting, the way it keeps the cursor mode.
p.set_crosshair_axis_labels(crosshair_axis_labels_);
// No `set_x_follow(Live)`: the link group owns the window *and* the follow state, and
// `Figure::link_x()` refuses a member whose x axis still follows on its own.
}
@@ -499,6 +560,10 @@ void RowSet::rebuild_all() {
r.ruler_b_y = first.ruler_b_y;
}
place_cursors(r);
// The gate too: a rebuild builds new plots, whose regions are parked until this
// restates the shared bounds on them -- a row added while the gate was on would
// otherwise be the one row without the band.
place_gate(r);
if (trigger_marker_on_) {
r.plot->set_cursor_x(r.trigger_marker, trigger_marker_x_, plot::CursorPlacement::Free);
}
@@ -880,6 +945,16 @@ void RowSet::place_cursors(Row& r, plot::CursorPlacement placement) {
}
void RowSet::set_cursors(double a, double b) {
if (a == cursor_a_x_ && b == cursor_b_x_) return;
cursor_a_x_ = a;
cursor_b_x_ = b;
// `Free`: the pair is the owner's already-committed position (a gesture in another window, or
// this window's own last sync); snapping it here would fight the hand still moving it.
for (int i = 0; i < rows_; ++i) place_cursors(row(i), plot::CursorPlacement::Free);
place_crossings();
}
void RowSet::set_cursor_mode(CursorMode mode) {
if (mode == cursor_mode_) return;
cursor_mode_ = mode;
@@ -992,6 +1067,7 @@ void RowSet::set_measure_marks(const std::vector<MeasureMark>& marks) {
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_color(r.measure_v[mi], plot::Color::hex(m.color));
r.plot->set_cursor_label(r.measure_v[mi], m.label);
}
}
// What the measurements left over is parked rather than kept: a stale readout at the old
@@ -1001,6 +1077,7 @@ void RowSet::set_measure_marks(const std::vector<MeasureMark>& marks) {
}
for (; v < kMeasureMarks; ++v) {
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 +1392,63 @@ void RowSet::sync_cursors(int drag_row) {
place_crossings();
}
void RowSet::place_gate(Row& r) {
if (!r.plot) return;
// Off is parked, the cursors' own convention for taking an instrument off screen: bounds
// the window cannot reach contribute no clipped rect, so nothing draws and -- because the
// controller's hit test reads the same clipped geometry -- nothing is grabbable either.
const double a = gate_on_ ? gate_x0_ : kParked;
const double b = gate_on_ ? gate_x1_ : kParked + 1.0;
r.plot->set_region(r.gate_region, a, b);
}
void RowSet::set_gate_visible(bool on) {
if (on && !gate_seeded_) reset_gate();
gate_on_ = on;
for (int i = 0; i < kMaxRows; ++i) place_gate(row(i));
}
void RowSet::set_gate(double x0, double x1) {
if (!std::isfinite(x0) || !std::isfinite(x1)) return;
gate_x0_ = std::min(x0, x1);
gate_x1_ = std::max(x0, x1);
gate_seeded_ = true;
for (int i = 0; i < kMaxRows; ++i) place_gate(row(i));
}
void RowSet::reset_gate() {
if (figure_ == nullptr) return;
const plot::Range x = figure_->x_link_range(x_group_);
if (!(x.span() > 0.0) || !std::isfinite(x.min) || !std::isfinite(x.max)) return;
// The middle half of whatever is on screen -- the same quarter/three-quarter answer
// `reset_cursors()` gives the vertical pair, so both instruments start in the same place
// a user looking for them knows to look.
gate_x0_ = x.min + 0.25 * x.span();
gate_x1_ = x.min + 0.75 * x.span();
gate_seeded_ = true;
for (int i = 0; i < kMaxRows; ++i) place_gate(row(i));
}
void RowSet::sync_gate(int drag_row) {
if (!gate_on_ || drag_row < 0 || drag_row >= rows_) return;
// With the gate off the plots hold the parked pair, whose bounds read back finite --
// adopting them would be the same echo `sync_cursors()` guards against.
Row& r = row(drag_row);
if (!r.plot) return;
const plot::Region& reg = r.plot->region(r.gate_region);
if (!std::isfinite(reg.x0) || !std::isfinite(reg.x1)) return;
if (reg.x0 == gate_x0_ && reg.x1 == gate_x1_) return;
gate_x0_ = reg.x0;
gate_x1_ = reg.x1;
gate_seeded_ = true;
// Every row's region is written from the shared bounds; the dragged row already holds
// them, and `set_region()` orders them, so a bound dragged across the other lands here
// as the swap the controller already made in its own plot.
for (int i = 0; i < rows_; ++i) {
if (i != drag_row) place_gate(row(i));
}
}
void RowSet::clamp_to_data() {
if (!figure_) return;
// The rows share one window, so they share one bound: the union of what they hold.
@@ -1385,6 +1519,19 @@ void RowSet::pin_event_window(double pre_s, double post_s) {
figure_->set_x_link_range(x_group_, {-pre_s, post_s});
}
void RowSet::pin_display_range(double x0, double x1) {
if (!(x1 > x0)) return;
window_s_ = std::max(1.0e-3, x1 - x0);
pinned_ = true;
pinned_pre_ = -x0;
pinned_post_ = x1;
if (!figure_) return;
// The same two statements `pin_event_window()` makes, from the range instead of the spans: the
// group stops following the live edge and is placed on the event's own window.
figure_->set_x_link_follow(x_group_, plot::XFollow::Off);
figure_->set_x_link_range(x_group_, {x0, x1});
}
void RowSet::fit_all() {
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
@@ -1461,6 +1608,14 @@ void RowSet::set_cursors_visible(bool on) {
for (int i = 0; i < kMaxRows; ++i) place_cursors(row(i));
}
void RowSet::set_crosshair_axis_labels(bool on) {
crosshair_axis_labels_ = on;
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
if (r.plot != nullptr) r.plot->set_crosshair_axis_labels(on);
}
}
void RowSet::reset_cursors() {
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
+138 -1
View File
@@ -133,6 +133,13 @@ class RowSet {
/// it keeps its position whatever the cursor toggle says.
plot::u32 trigger_marker = 0;
/// The measurement gate: one shaded interval per plot, whose bounds are the gate's own
/// pair of instruments -- not the cursors above, which stay what the cursors pane reads.
/// Every row carries its own `Region` because each plot shades its own axes rect, but
/// they all draw the one shared interval (`gate_x0_`/`gate_x1_` below), the way the
/// vertical cursors are one time on every row.
plot::u32 gate_region = 0;
/// The measurements' readout marks: a pool created with the plot (a `Plot` cannot add or
/// drop a cursor after the fact) and parked unless a measurement is using the slot.
std::array<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
/// arrangement rebuilds the figure, since a `Figure`'s shape is fixed at construction.
void set_row_count(int rows);
/// One figure row filling the plot area, and back -- the "full screen this row" toggle.
///
/// The arrangement, the weights and **every row's state** are saved on the way in and
/// restored on the way out, so a maximize is a *view*: nothing a user plotted moves, and
/// nothing survives a restore that was not there before it. Volatile like a zoom -- the
/// session keeps the arrangement, not the view. Calling it while maximized restores, so a
/// click on the eye-catching button is its own way back; a figure row outside the current
/// arrangement (the maximized one, by definition a single row) is ignored when not
/// maximized.
void toggle_maximize_figure_row(int fr);
bool figures_maximized() const noexcept { return maximized_; }
/// Inserts an empty single-cell figure row at `at` (0 = above the first row,
/// `figure_row_count()` = below the last), pushing the rows at and below it down with their
/// cells. Returns false at `kMaxRows` plots.
@@ -322,6 +341,11 @@ class RowSet {
void set_y_range(int row, double lo, double hi);
void set_cursors_visible(bool on);
/// The crosshair's axis badges (the y and x values the crosshair's lines meet the axes at,
/// drawn in the label strips): one flag for the whole set, restated to every new plot by
/// `install()` -- a rebuilt row keeps the setting, the way it keeps the cursor mode.
void set_crosshair_axis_labels(bool on);
bool crosshair_axis_labels() const noexcept { return crosshair_axis_labels_; }
/// What the cursors are (see `CursorMode`). Switching parks the instruments the mode
/// does not use rather than removing them: a `Plot` cannot drop a cursor.
void set_cursor_mode(CursorMode mode);
@@ -331,6 +355,16 @@ class RowSet {
void place_crossings();
CursorMode cursor_mode() const noexcept { return cursor_mode_; }
/// Writes both vertical cursors at once: the two-time pair is one instrument with one owner
/// (the main window's row set), and a scope window is written from it each frame -- reading it
/// back from that window's own plots is what lets a drag *there* move the same pair.
void set_cursors(double a, double b);
/// The trigger marker's position and whether it is up: what a scope window is written from, the
/// same way it is written from the cursor pair.
double trigger_marker_x() const noexcept { return trigger_marker_x_; }
bool trigger_marker_visible() const noexcept { return trigger_marker_on_; }
/// The two vertical cursors' positions, in the **display** x the axis reads -- not stream
/// seconds, which they coincided with only while `display_epoch()` was always 0. A live view
/// is newest-relative now, so a cursor left at -1.5 s keeps meaning "1.5 s ago" and the data
@@ -369,12 +403,20 @@ class RowSet {
/// One measurement's readout mark on a row: a *level* (drawn as a horizontal ruler) or a
/// *time* (a vertical marker). Which one a measurement needs is the measurement's own
/// business; the row is the one that plots its signal.
/// business; the row is the one that plots its signal. A time mark's `label` is the badge
/// text the plot draws beside it (its own time, the pane's formatter), so a column of
/// marks reads as numbers and not as bare lines; a level ruler writes its own value, so
/// its label stays empty.
struct MeasureMark {
int row = 0;
/// The signal the mark belongs to, so a *scope window* can find the row that plots it (its
/// row numbering is its own): a mark that never carried the name could only be placed in a
/// copy by index, which a window's own layout editor invalidates.
std::string signal;
bool level = false;
double value = 0.0;
plot::u32 color = 0;
std::string label;
};
/// Places the measurements' marks and parks every pool slot they do not use. The marks are
@@ -494,6 +536,33 @@ class RowSet {
/// Called once a frame from the tick, with `drag_row` < 0 when no cursor is held.
void sync_cursors(int drag_row);
// --- the measurement gate -------------------------------------------------
/// Shows (or hides) the gate on every row. Bounds that were never seeded are seeded
/// first (`reset_gate()`), so the first enable puts the band under the data instead of
/// parking it wherever zero is.
void set_gate_visible(bool on);
/// Whether the gate is on. One flag for the whole set, like the cursors' own: a per-row
/// copy is something a rebuild can clear behind the user's back.
bool gate_visible() const noexcept { return gate_on_; }
/// The gate's bounds, in the **display** x the axes read -- the cursors' own coordinate
/// space. A live view is newest-relative, so a gate left at `[-0.5, -0.1]` keeps meaning
/// "half a second ago to a tenth of a second ago" and the data flows under it; a held
/// event's stamps are edge-relative, so there the band names a fixed slice of the frozen
/// frame. Either way it stays where it was put, which is what a gate has to do.
double gate_x0() const noexcept { return gate_x0_; }
double gate_x1() const noexcept { return gate_x1_; }
/// Restates the gate's bounds (ordered, both finite) and redraws every row.
void set_gate(double x0, double x1);
/// Puts the gate over the middle half of the current x window -- where
/// `reset_cursors()` puts the vertical pair -- and remembers it as seeded.
void reset_gate();
/// Adopts the gate bounds a gesture is moving, on `drag_row` (the row whose controller
/// holds a region), and mirrors them to every other row. Same contract as
/// `sync_cursors()`: reading outside a drag reads our own echo. Called once a frame from
/// the tick, with `drag_row` < 0 when no region drag is open.
void sync_gate(int drag_row);
/// Puts both vertical cursors at a quarter and three quarters of the x window, and
/// the two rulers at a third and two thirds of each row's y range. Also switches the
/// cursors on, so the button cannot look dead.
@@ -515,6 +584,13 @@ class RowSet {
/// 0, so nothing outside the pre+post window is visible.
void pin_event_window(double pre_s, double post_s);
/// The same pin, stated as the **range** a second view mirrors: the caller has the display
/// range (`x0 < x1`) and not the spans -- a scope window takes `pinned_range()` from the main
/// window and hands it on. `pin_event_window()` takes *spans*, and passing it a range's signed
/// `min` turned the pin inside out (a scope in Held mode pinned to `[+pre, +post]`: no trace
/// and no x labels). A degenerate or inverted range pins nothing.
void pin_display_range(double x0, double x1);
/// Fits every row's y axis to the data it holds, and puts x back on the whole time window --
/// `fit_x()`'s x half exactly, so the two cannot drift apart. Like it, **this does not change
/// the time window**: the y axes are fitted to their data, x is merely un-zoomed.
@@ -585,6 +661,46 @@ class RowSet {
/// objects the traces point at, and those belong to the row's *position*, not to its
/// contents. `rebuild_all()` re-deals them straight after.
static void carry_row(Row& dst, const Row& src);
/// One row's persistent state, without the plot-bound `slots`: a `SlotSeries` owns a
/// `unique_ptr`, so a `Row` is not copy-assignable, and the state a save needs is exactly
/// what `carry_row()` copies. The maximize toggle saves it for every row on the way in and
/// re-applies it on the way out, so a view change moves no signal.
struct RowState {
std::array<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.
static void clear_row(Row& r);
@@ -596,6 +712,9 @@ class RowSet {
/// at the end of the gesture (`Controller::end_drag()`), and a value read out of the plot
/// mid-drag has already been through that act.
void place_cursors(Row& r, plot::CursorPlacement placement = plot::CursorPlacement::Snapped);
/// Writes a row's gate region from the shared bounds -- parked outside the axis when the
/// gate is off, the cursors' own convention for taking an instrument off screen.
void place_gate(Row& r);
std::unique_ptr<plot::Figure> figure_;
/// The one x link group every visible row belongs to. Groups are numbered from 1.
@@ -635,6 +754,17 @@ class RowSet {
std::vector<plot::InvalidSpan> spans_;
CursorMode cursor_mode_ = CursorMode::Vertical;
bool cursors_on_ = false;
bool crosshair_axis_labels_ = true;
/// The maximize toggle's saved arrangement (see `toggle_maximize_figure_row()`): every
/// row's persistent state, the cells, the weights and the plot count, kept only while
/// `maximized_`. States, not `Row`s: a `Row` is not copyable (its slots own a
/// `unique_ptr`) and the figure handles belong to the position anyway.
bool maximized_ = false;
std::array<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;
double x_origin_ = 0.0;
/// What was last *written* to each row's own vertical cursors. A row is only evidence of a
@@ -648,6 +778,13 @@ class RowSet {
/// The one stream time each vertical cursor is at, whatever row's copy was last moved.
double cursor_a_x_ = 0.0;
double cursor_b_x_ = 0.0;
/// The gate's shared interval and its on/off flag (`gate_visible()`). `gate_seeded_`
/// distinguishes "never placed" from "placed at the window's own origin", which the
/// first enable needs: seeding must put the band over the data, not over x = 0.
bool gate_on_ = false;
bool gate_seeded_ = false;
double gate_x0_ = 0.0;
double gate_x1_ = 0.0;
std::array<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));
} else if (key == "theme") {
if (value == "auto" || value == "light" || value == "dark") s.theme = value;
} else if (key == "crosshair_axis_labels") {
s.crosshair_axis_labels = parse_bool(value, s.crosshair_axis_labels);
} else if (key == "contextual_help") {
s.contextual_help = parse_bool(value, s.contextual_help);
} else if (key == "tour_seen") {
@@ -94,6 +96,7 @@ bool save_settings(const Settings& settings) {
// second. `read_rows()` in the preferences starts from `load_settings()`, so a dialog save carries
// them through untouched.
out << "contextual_help = " << (settings.contextual_help ? "true" : "false") << "\n";
out << "crosshair_axis_labels = " << (settings.crosshair_axis_labels ? "true" : "false") << "\n";
out << "tour_seen = " << (settings.tour_seen ? "true" : "false") << "\n";
out.flush();
return out.good();
+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
/// uses, so the file says what the setting is rather than what an enum happens to be numbered.
std::string theme = "auto";
/// The two small badges the crosshair writes in the label strips -- the y value where its
/// horizontal line meets the y axis, the x time where its vertical line meets the x axis.
/// **On by default**: it is the axis reading an oscilloscope puts where the eye already is,
/// and off is the decision that needs a reason. Applies as it changes, like the theme.
bool crosshair_axis_labels = true;
/// The contextual-help layer: a small "?" beside each surface it can explain. **Off by default** --
/// a first run shows the interface as a user expects it and the layer is asked for, from the Help
/// menu or from the chooser's own help menu, by whoever wants it; on is then a decision that
+1355 -29
View File
File diff suppressed because it is too large. Load diff
+7
View File
@@ -153,6 +153,13 @@
<property name="margin-top">2</property>
<property name="margin-bottom">2</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>
<object class="GtkLabel" id="status_badge"/>
</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
(`measures_list`) plus the three widgets whose state outlives them.
One statistics switch for the panel rather than one per reading: statistics are either what a user is
looking at or they are not, and a per-row switch is a column of chrome paid for on every row to answer a
question asked once.
One statistics switch for the panel rather than one per reading: statistics are either what a user
is looking at or they are not, and a per-row switch is a column of chrome paid for on every row to
answer a question asked once. The gate is the opposite case — a per-measurement choice — so it
lives in the measurement dialog, not here.
-->
<interface>
<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>
</object>
</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>
<object class="AdwComboRow" id="theme_row">
<property name="title" translatable="yes">_Theme</property>
+206 -3
View File
@@ -190,6 +190,14 @@ struct App {
/// figure, so there is one widget, one context and one `::render` whatever the row
/// count is.
GtkWidget* area_widget = nullptr;
/// The pointer's last position over the plot area, in the area's coordinates (the overlay's,
/// for the toggles' corner), and whether one is over it at all: the full-screen toggles are
/// revealed by proximity, so the frame tick reads this. The overlay's own motion controller
/// keeps it current, which is why a pointer *on* a revealed toggle still counts as over the
/// area -- the toggle is an overlay child, and the event still travels the ancestor.
double area_pointer_x = 0.0;
double area_pointer_y = 0.0;
bool area_pointer_valid = false;
plot::gtk4::PlotArea* area = nullptr;
/// The GL area wrapped in a `GtkOverlay`, which is what the plot box actually holds.
/// The layout editor's buttons are overlay children: each is a small widget placed by
@@ -197,6 +205,66 @@ struct App {
/// is -- a single transparent child covering the area would swallow every pan, drop and
/// hover the editor is not interested in.
GtkWidget* area_overlay = nullptr;
/// One extra top-level scope window: its own figure and `RowSet`, bound to the same
/// `ScopeModel` by the main window's tick -- which is also its repaint pump -- so a scope
/// is a second *view* of one acquisition, never a second acquisition. The main window's
/// x window (or its pin) is mirrored onto it every frame, so the two look at the same
/// span; its own controllers can still pan and zoom, and the mirror reclaims the span on
/// the next frame.
struct ScopeWindow {
GtkWindow* window = nullptr;
plot::gtk4::PlotArea* area = nullptr;
GtkWidget* widget = nullptr;
/// The window's short name, the one the signal page's *Plot on* groups carry ("plot 2" for a
/// copy, "scope 3" for an empty one): a button must say which window it plots into.
std::string name;
/// The drop target wired onto `widget`: the handler reads the *window* off it (each window's
/// target names its own scope), and the probe calls the handler with it directly.
GtkDropTarget* drop = nullptr;
/// This window came from a **detach** -- the figure left the main window to live here -- so it
/// carries the attach control and can be put back. A copied or empty window is a plain second
/// view, and has no attach control.
bool detached = false;
/// False while attaching: the window is closing *because* its figure went back to the main
/// window, so there is nothing to remember (remembering it would offer to reopen a window
/// whose figure is on screen again).
bool remember_on_close = true;
/// The overlay wrapping the GL widget: the corner controls (attach, the layout toggle, the
/// layout editor's own buttons) all live on it. Its motion controller keeps the pointer state
/// they are revealed by.
GtkWidget* overlay = nullptr;
/// Layout editing in this window: its own editor and toggle. A scope is a view with its own
/// arrangement, so it holds as many figure rows as it is given and edits them with the same
/// `+` / `×` / Done controls the main window's editor has -- its own, never the main window's.
bool layout_editing = false;
GtkWidget* layout_toggle = nullptr;
std::vector<GtkWidget*> layout_buttons;
GtkWidget* layout_done = nullptr;
int layout_buttons_rows = -1;
std::vector<GtkWidget*> attach_buttons;
double pointer_x = 0.0;
double pointer_y = 0.0;
bool pointer_valid = false;
/// Frames this window's area was watched for its first paint: a window whose GL context failed
/// or that never paints says so in its own title (and on stderr) instead of staying a blank
/// rectangle nobody can diagnose.
int paint_checks = 0;
/// 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;
/// The **closed** extra windows, as the session remembers them: a window's arrangement is kept
/// when it is closed (and across a restart), and the *Reopen closed windows* entry opens them --
/// a launch never does, because a window is a view the user closed on purpose.
std::vector<LayoutState::Window> closed_windows;
/// The menu entry that reopens them, so its label can carry the count.
GtkWidget* reopen_scopes_item = nullptr;
/// Every empty scope window is numbered from this, so two of them never share a name.
int scope_seq = 0;
/// On while the layout editor is up. Nothing else about the plot changes: the editor is
/// a set of overlay buttons, not a mode the figure knows about.
bool layout_editing = false;
@@ -204,6 +272,15 @@ struct App {
/// The editor's buttons, in the order they were added, so they can be taken off the
/// overlay again. Rebuilt when the row count changes, repositioned when the rows move.
std::vector<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 **detach** buttons: one per figure row beside its full-screen toggle, same reveal,
/// same rebuild cadence -- but only while the layout holds two or more figures (detaching the
/// only figure would leave the main window with nothing to draw) and never while maximized.
std::vector<GtkWidget*> detach_buttons;
/// The boundary a resize drag is on, and the weights it started from: a drag is *relative* to where it
/// began, so the beginning has to be kept -- reading the weights while dragging would compound.
int layout_drag_row = -1;
@@ -261,6 +338,15 @@ struct App {
/// What the badge was last set to (`BadgePhase`), so the markup is rewritten on a
/// change of phase rather than on every one of the 60 frames a second.
int badge_phase = -1;
/// The trigger status circle beside it (`main-window.ui` `trigger_status`): green while an
/// event is held or a capture is filling, flashing green while armed and waiting, red while
/// disarmed, hidden while the trigger is off. Same width-locked logic as the badge: one
/// "●" glyph in every state, markup rewritten on a change of the state key only, and the
/// flash runs off the same half-second square the badge blinks on.
GtkWidget* trigger_status = nullptr;
int trigger_circle_state = -1;
/// The circle's state as of the last tick, for the exit dump (which cannot read widgets).
int dbg_trigger_circle = -1;
/// The display-window control: a header-bar button whose label is the current window
/// and whose popover holds the number, its unit and the two decade steppers. The label
/// is width-locked (see `kWindowLabelChars`) because the header bar sits above the plot
@@ -681,6 +767,14 @@ struct App {
plot::Range measure_window{};
bool measure_window_valid = false;
bool measure_was_held = false;
/// The **gate band** the last pass saw, in the same stream seconds, clipped to the window the
/// pane was about. The gate is per measurement, but the band is one: when any measurement is
/// gated, a band that moves -- a drag, a seed, a mode change -- changes what *those*
/// measurements describe without the viewport or the pin moving, so the "it moved" question
/// is asked of this as well. Booked even when nothing is gated (then it is the pane's own
/// window, and the comparison below repeats one the viewport has already answered).
plot::Range measure_gate_window{};
bool measure_gate_window_valid = false;
/// The window **on screen**, in the same stream seconds, as of the last *tick* -- which is the
/// only thing `measure_window` is comparable with. Captured here rather than read back in the
/// dump for the reason every other dump field is (see verification.md): the dump runs from the
@@ -704,12 +798,15 @@ struct App {
/// `MeasureChunk`).
/// One channel's window, at one HF corner, with every level accumulator the pane asked for.
///
/// Keyed by `(channel, fc)` and not by channel alone: the corner is a per-measurement setting, so two
/// measurements on one signal at different corners are reading two different signals and cannot share a
/// walk.
/// Keyed by `(channel, fc, gated)` and not by channel alone: the corner and the gate are
/// per-measurement settings, so two measurements on one signal at different corners -- or one
/// gated and one not -- are reading two different windows and cannot share a walk. A gated
/// window is walked over the gate band clipped to the pane's window; an ungated one over the
/// pane's window whole.
struct MeasureWindow {
int channel = -1;
double fc = 0.0; ///< the HF corner this window was walked at; 0 is unfiltered
bool gated = false; ///< this window is the gate band, not the pane's window whole
MeasureStats stats;
std::vector<double> pct; ///< the level shares asked for, deduplicated
std::vector<double> noise; ///< each entry's band, as a percentage; parallel to `pct`
@@ -798,6 +895,30 @@ struct App {
/// probes add measurements, so they must not be scheduled in one run (see verification.md), and a
/// shared counter would make that a silent mis-step rather than a documented prohibition.
int dbg_bandavg_step = 0;
/// The gate probe's (`UDPSCOPE_PROBE_GATE_MS`) step counter and bookkeeping: the bounds it
/// set and the `s0` it read when it set them, so the second leg can say whether the band
/// stayed still while the stream's clock moved under it.
int dbg_gate_step = 0;
double dbg_gate_x0 = 0.0;
double dbg_gate_x1 = 0.0;
double dbg_gate_s0 = 0.0;
/// The gate probe's settle polls (`dbg_gate_step == 1` re-enters while the ring is younger
/// than the window, which is what its whole-periods arithmetic needs), bounded so a stalled
/// stream still measures and prints.
int dbg_gate_wait = 0;
/// The row a gate-region drag was on last frame, or -1. The fall to -1 is the drag's end,
/// and the tick saves the session there: the bounds are session state, and a drag that is
/// not saved is a session that lies about where the gate is.
int gate_drag_last = -1;
/// Which step of the crosshair probe runs next (`UDPSCOPE_PROBE_CROSSHAIR_MS`).
int dbg_crosshair_step = 0;
/// Which step of the maximize probe runs next (`UDPSCOPE_PROBE_MAXIMIZE_MS`).
int dbg_maximize_step = 0;
std::size_t dbg_maximize_single = 0;
int dbg_maximize_hidden = -1;
std::size_t dbg_detach_single = 0;
/// Which step of the scope-window probe runs next (`UDPSCOPE_PROBE_SCOPE_MS`).
int dbg_scope_step = 0;
int cursors_pane_mode = -1;
int cursors_pane_rows = -1;
unsigned cursors_pane_generation = ~0u;
@@ -1440,6 +1561,10 @@ struct MeasureDialog {
GtkWidget* band = nullptr;
GtkWidget* above = nullptr;
GtkWidget* show = nullptr;
/// The per-measurement gate: measure only inside the one shared band. Type-independent --
/// the gate is about *which samples* answer, and every type reads samples -- so unlike the
/// rows above it is shown for every type and never retitled.
GtkWidget* gate = nullptr;
GtkWidget* window = nullptr;
/// The option rows' boxes and labels, so the dialog can *retitle* them per type: the same
/// switch means "the pulse is above the level" for a pulse and nothing at all for a period,
@@ -1549,6 +1674,9 @@ struct ExportContext {
};
struct FixedRangeContext {
App* app;
/// The row set the range applies to: `nullptr` is the main window's. A scope window's own
/// dialog writes its window's rows.
RowSet* rows = nullptr;
int row;
GtkWidget* lo;
GtkWidget* hi;
@@ -1618,6 +1746,12 @@ constexpr int kWindowLabelChars = 9;
constexpr int kRowKeyBias = 1;
constexpr int kLayoutButtonPx = 24;
constexpr int kLayoutButtonPad = 4;
/// How far from its corner a full-screen toggle comes out of hiding. The toggles are on the
/// screen only while the pointer is near them; this is the slack around the button's own rect
/// (a square corner zone a few button-widths wide), large enough that a pointer arrives without
/// having to find the exact pixel and small enough that crossing a plot's top edge leaves it
/// hidden.
constexpr int kMaximizeRevealPx = 48;
constexpr const char* kLayoutKindKey = "udpscope-layout-insert";
/// The ↕ handles' own key: what they are is *not* what the `+` is, and `sync_layout_editor()`
@@ -1627,6 +1761,9 @@ constexpr const char* kLayoutResizeKey = "udpscope-layout-resize";
constexpr const char* kLayoutCellKey = "udpscope-layout-cell";
/// Column resize handles carry the plot index of the right-hand cell.
constexpr const char* kLayoutColKey = "udpscope-layout-col";
/// The full-screen toggle's key: the figure row it acts on, +1 (0 is not a valid row). Its
/// own key because the button is on the overlay *always*, not only while the editor is up.
constexpr const char* kMaximizeKey = "udpscope-maximize-row";
/// The minimum width a cell may be dragged to, in logical pixels.
constexpr double kMinCellPx = 96.0;
/// The plotter's own per-cell floor, `kMinCellWidthLogical` in `deps/plotter/src/plot/figure.cpp`
@@ -1705,6 +1842,9 @@ int window_unit_for(double s);
std::string format_window_value(double s, int unit);
std::string format_window(double s);
const char* badge_markup(BadgePhase phase);
/// The trigger status circle's markup, by state (see `trigger_circle_markup` in
/// `ui_status.cpp`): 0 solid green, 1/2 the flash's halves, 3 red.
const char* trigger_circle_markup(int state);
std::string format_measure(double v, const std::string& unit);
void set_notice(App& app, std::string what, int seconds = 4);
void set_row_key(GtkWidget* widget, int row);
@@ -1862,6 +2002,9 @@ bool event_on_screen(const App& app, const EventRecord& r);
bool event_in_group(const App& app, const EventRecord& r);
/// The signal chip of a measurement's row, in the colour every other view names that signal with.
std::string measure_chip_markup(App& app, const std::string& signal);
/// The 1-based number of an active channel (its position among the acquired channels), or 0
/// when the signal is not acquired. The number the badge bar and the measurements pane show.
int measure_channel_number(App& app, const std::string& signal);
std::string row_unit(App& app, int row);
std::string slot_unit(App& app, int row, int slot);
std::uint32_t bound_slot_mask(App& app);
@@ -1945,6 +2088,41 @@ void row_menu_fill(RowMenuState& st);
GtkWidget* open_row_menu(App& app, int row, double x, double y, GtkWidget* parent, bool autohide = true);
void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer user);
gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, gpointer user);
/// The same drop, onto a **scope window's** plot: the target carries the window, the row is the
/// window's own cell under the pointer, and the assignment lands in that window's row set.
gboolean on_scope_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, gpointer user);
/// Plots `signal` on `row` of a scope window's row set (the signal page's *Plot on* buttons and the
/// drop target both end here). False, with a notice, when the signal cannot be acquired or the plot
/// holds four signals already.
bool assign_signal_to_scope(App& app, App::ScopeWindow& scope, std::size_t signal, int row);
/// Runs one keyboard accelerator. Scope windows carry the same shortcuts as the main window: the
/// view-level ones (`L` -- the layout editor) act on the window the key arrived in; the rest reach
/// the application through `run_app_action()`.
gboolean on_scope_key(GtkEventControllerKey* controller, guint keyval, guint keycode,
GdkModifierType state, gpointer user);
/// The session record of a window: its name, whether it was detached, and its plot rows.
LayoutState::Window scope_window_state(const App::ScopeWindow& scope);
/// Remembers a window as closed (`closed_windows`, replaced by name) -- what its destroy handler
/// does, and what keeps a closed window reopenable.
void remember_closed_window(App& app, const App::ScopeWindow& scope);
/// Opens a window from a remembered state: an empty scope window, then its rows, weights and
/// signals. Returns the opened window (nullptr when it could not be made).
App::ScopeWindow* open_scope_window_from_state(App& app, const LayoutState::Window& state);
/// Writes the main window's instruments -- the two cursors, the gate band, the trigger marker --
/// into every scope window, and adopts one dragged *there* back into the main window first. Called
/// once per frame from the main tick, after the main's own `sync_cursors()`/`sync_gate()`.
void sync_scope_instruments(App& app);
/// The main window's measurement marks, translated to each scope window's own rows (by signal
/// name) and placed there: a window that plots a measured signal draws its mark.
void mirror_measure_marks(App& app);
/// Refreshes the *Reopen closed windows* entry: its count and whether it is sensitive.
void sync_reopen_scopes_item(App& app);
/// Turns a scope window's own layout editor on or off, building or taking down its buttons.
void set_scope_layout_editing(App& app, App::ScopeWindow& scope, bool on);
/// The scope window's own right-click menu: the same row menu's actions, aimed at **that
/// window's** row set.
void on_scope_right_click(GtkGestureClick* gesture, int n_press, double x, double y,
gpointer user);
/// Reads each pane's own switch back off the header bar's button, and the other way round: the two are
/// one control in two places, and `panel_switch_sync` keeps the write from looking like a click.
void sync_panel_switches(App& app);
@@ -2002,6 +2180,11 @@ GdkTexture* scale_texture(GdkTexture* source, int width, int height);
EventThumbCache* event_thumb_cache(App& app);
GdkTexture* event_preview_texture(App& app, const std::string& path);
void refresh_events_pane(App& app);
/// Points the event cache at the configuration the session is on -- the active one when
/// `App::config_name` is empty -- and refreshes the two views of it when the directory changed.
/// Every configuration switch calls it next to setting `App::config_name`, so the list on screen
/// is always the configuration's own.
void retarget_event_store(App& app);
/// Opens the `events` page. **Every route that opens that page must use this** and never `slideover_open()`
/// directly: the page's list is rebuilt only while the page is open (see `record_event()`), and the
/// slide-over has no on-open hook, so the list is refreshed here, once, before the page shows. The refresh
@@ -2041,6 +2224,23 @@ gboolean mathwrap_probe_wait(gpointer user);
gboolean mathwrap_probe_report(gpointer user);
gboolean measure_probe(gpointer user);
gboolean levels_probe(gpointer user);
gboolean gate_probe_step(gpointer user);
gboolean crosshair_probe(gpointer user);
gboolean maximize_probe_step(gpointer user);
gboolean scope_probe_step(gpointer user);
/// A second top-level window showing the same stream through its own figure: `figure_row` >= 0
/// copies that figure of the main window (cells, widths, every slot's signal), -1 opens an
/// empty one-row scope. The main window's tick binds, acquires and repaints it.
void open_scope_window(App& app, int figure_row, bool detached = false);
/// Detaches figure row `fr` into a window of its own: the figure is copied into a new scope
/// window -- which is what makes the window *detached*, the one kind with an attach control --
/// and removed from the main window's rows. Refused, changing nothing, for a one-figure layout
/// or a maximized one: detaching then would leave the main window with nothing to draw.
void detach_figure(App& app, int fr);
/// Puts a detached window's figure back: a figure row is appended to the main window carrying
/// the window's arrangement (cells, weights, every slot's signal in its colour) and the window
/// closes. The window must be a detached one; anything else returns without changing.
void attach_scope_window(App& app, App::ScopeWindow& scope);
gboolean measurepin_probe(gpointer user);
gboolean measurepin_held_wait(gpointer user);
gboolean measurepin_pause_wait(gpointer user);
@@ -2055,6 +2255,9 @@ gboolean dock_session_probe(gpointer user); // UDPSCOPE_PROBE_DOCK_SESSION_MS
/// per setup surface, each page in a scroller of its own.
void build_slideover(App& app); // once, after the plot_overlay exists
bool slideover_add_page(App& app, const std::string& id, const std::string& title, GtkWidget* content);
/// Retitles a page in place (`slideover_add_page`'s title, for a page whose subject changed: a signal
/// page carries its channel's number, and the stream can renumber). False when the id is unknown.
bool slideover_set_title(App& app, const std::string& id, const std::string& title);
bool slideover_has_page(const App& app, const std::string& id);
/// The inverse of `slideover_add_page`: false when the id is unknown or is the open page.
bool slideover_remove_page(App& app, const std::string& id);
+10 -2
View File
@@ -122,7 +122,9 @@ gboolean on_badge_drop(GtkDropTarget*, const GValue* value, double, double, gpoi
return TRUE;
}
/// A signal's badge: its colour dot (grey when it has no trace) and its name. Words and a dot, no icon.
/// A signal's badge: its colour dot (grey when it has no trace) and its name, prefixed with
/// the channel's number -- the same number the measurements pane shows, so a badge and a
/// measurement row answer "which channel" with one word. Words and a dot, no icon.
void make_signal_badge(App& app, Badge& b, const std::vector<SignalInfo>& signals, std::size_t index) {
b.signal = index;
b.signal_name = signals[index].name;
@@ -133,7 +135,13 @@ void make_signal_badge(App& app, Badge& b, const std::vector<SignalInfo>& signal
GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget* dot = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(dot), signal_chip_markup(app, signals, index).c_str());
b.name = gtk_label_new(b.signal_name.c_str());
// The channel's number, the same one the measurements pane carries: an active channel is
// "the k-th acquired channel", and the badge and the rows name it alike. A signal the
// stream does not carry yet (a badge can outlive its channel for a tick) shows unnumbered.
const int number = measure_channel_number(app, b.signal_name);
const std::string who = number > 0 ? std::to_string(number) + ". " + b.signal_name
: b.signal_name;
b.name = gtk_label_new(who.c_str());
gtk_label_set_ellipsize(GTK_LABEL(b.name), PANGO_ELLIPSIZE_END);
gtk_label_set_max_width_chars(GTK_LABEL(b.name), 14);
gtk_box_append(GTK_BOX(row), dot);
+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.
//
// The same strings are shown a third time in the readouts strip under the plot (`readouts_strip`, one
// fixed-height line: a "Cursors" button, one chip per cursor line with its delta, a "Measures" button, one
// chip per measurement with its value), which is on screen whenever the dock is not beside the plot (hidden
// fixed-height line: a "Cursors" button, one chip per cursor line with its delta, one chip per
// measurement with its value and a `[+]` to add one), which is on screen whenever the dock is not
// beside the plot (hidden
// with the Dock button, or the window collapsed and the dock an overlay). The setters below
// write the strip's labels in the same call as the dock's, from the same argument, so there is still one
// string and no second formatter.
@@ -148,7 +149,12 @@ void dock_cursors_header(GtkGrid* grid) {
}
}
/// Fills the strip `main-window.ui` declares: the two buttons and the two boxes the rebuilds fill.
/// The strip's `[+]`: a new measurement, through the same dialog the pane's own `+` opens.
void on_add_measure(GtkButton*, gpointer user) {
open_measure_dialog(*static_cast<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) {
if (app.strip_box == nullptr) return;
clear_box(app.strip_box);
@@ -163,10 +169,13 @@ void build_readouts_strip(App& app) {
gtk_widget_set_margin_top(app.strip_sep, 5);
gtk_widget_set_margin_bottom(app.strip_sep, 5);
app.strip_measure_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_box_append(GTK_BOX(app.strip_measure_group),
strip_button("Measures", G_CALLBACK(on_edit_measures), &app,
"the measurements' full panel"));
// The chips, then the `[+]` after the last one: the compact strip is where the readings are
// watched, so that is where adding one belongs -- and it replaces the old "Measures" button,
// which only opened the panel any chip already opens its own measurement from (the dock and
// the page keep their Edit).
gtk_box_append(GTK_BOX(app.strip_measure_group), app.strip_measure_box);
gtk_box_append(GTK_BOX(app.strip_measure_group),
strip_button("+", G_CALLBACK(on_add_measure), &app, "add a measurement"));
gtk_box_append(GTK_BOX(app.strip_box), app.strip_cursor_group);
gtk_box_append(GTK_BOX(app.strip_box), app.strip_sep);
gtk_box_append(GTK_BOX(app.strip_box), app.strip_measure_group);
@@ -274,6 +283,12 @@ void dock_rebuild_measures(App& app) {
}
for (std::size_t i = 0; i < app.measures.size(); ++i) {
const Measure& m = app.measures[i];
// The row's channel number, the same one the pane's row carries: the dock line says which
// channel it is about with one word, and the chip beside it says which colour.
const int number = measure_channel_number(app, m.signal);
const std::string who =
number > 0 ? std::to_string(number) + ". " + measure_type_name(m.type)
: std::string(measure_type_name(m.type));
GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
g_object_set_data(G_OBJECT(line), "udpscope-measure-index",
GINT_TO_POINTER(static_cast<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_widget_set_tooltip_text(chip, m.signal.c_str());
gtk_box_append(GTK_BOX(line), chip);
GtkWidget* kind = fixed_label(measure_type_name(m.type), 12, GTK_ALIGN_START);
GtkWidget* kind = fixed_label(who.c_str(), 12, GTK_ALIGN_START);
gtk_widget_set_hexpand(kind, true);
gtk_widget_set_tooltip_text(kind, (m.signal + "\n" + measure_type_name(m.type)).c_str());
gtk_box_append(GTK_BOX(line), kind);
@@ -306,7 +321,10 @@ void dock_rebuild_measures(App& app) {
gtk_label_set_markup(GTK_LABEL(schip), measure_chip_markup(app, m.signal).c_str());
gtk_widget_set_tooltip_text(schip, m.signal.c_str());
gtk_box_append(GTK_BOX(sline), schip);
GtkWidget* skind = fixed_label(measure_type_name(m.type), kStripTypeChars, GTK_ALIGN_START);
const std::string swho =
number > 0 ? std::to_string(number) + ". " + measure_type_name(m.type)
: std::string(measure_type_name(m.type));
GtkWidget* skind = fixed_label(swho.c_str(), kStripTypeChars, GTK_ALIGN_START);
gtk_widget_set_tooltip_text(skind, (m.signal + "\n" + measure_type_name(m.type)).c_str());
gtk_box_append(GTK_BOX(sline), skind);
GtkWidget* svalue = fixed_label("--", kValueChars, GTK_ALIGN_END);
+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
/// dock block's compact ones. The dock follows even when the page's list does not exist. To *open* the page
/// use `open_events_page()`, which calls this only when the page is not already open.
void retarget_event_store(App& app) {
if (app.event_store == nullptr) return;
const std::string name = app.config_name.empty()
? active_configuration(app.source_host, app.source_port)
: app.config_name;
if (app.event_store->set_configuration(name)) refresh_events_pane(app);
}
void refresh_events_pane(App& app) {
// One read of the cache directory for both views (none without a store, which the page's rebuild
// and the dock's both treat as nothing to list).
+66
View File
@@ -14,6 +14,46 @@ namespace ui_impl {
/// Reads the session layout back out of the model and rows: the display window,
/// each row's slot signal names, and each signal's calibration.
LayoutState::Window scope_window_state(const App::ScopeWindow& scope) {
LayoutState::Window w;
w.name = scope.name;
w.detached = scope.detached;
if (scope.rows == nullptr) return w;
for (int i = 0; i < scope.rows->row_count(); ++i) {
LayoutState::Row row;
const RowSet::Row& r = scope.rows->row(i);
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
row.signals.push_back(r.layer[si] > 0 ? std::string() : r.signal_name[si]);
}
const int fr = scope.rows->figure_row_of(i);
const std::vector<float>& weights = scope.rows->row_weights();
row.row = fr;
if (fr >= 0 && static_cast<std::size_t>(fr) < weights.size()) {
row.weight = weights[static_cast<std::size_t>(fr)];
}
const std::vector<float>& cw = scope.rows->cell_weights(fr);
const int cell = scope.rows->cell_of(i);
if (cell >= 0 && static_cast<std::size_t>(cell) < cw.size()) row.width = cw[static_cast<std::size_t>(cell)];
w.rows.push_back(std::move(row));
}
return w;
}
void remember_closed_window(App& app, const App::ScopeWindow& scope) {
LayoutState::Window w = scope_window_state(scope);
for (LayoutState::Window& known : app.closed_windows) {
if (known.name != w.name) continue;
known = std::move(w);
sync_reopen_scopes_item(app);
schedule_layout_save(app);
return;
}
app.closed_windows.push_back(std::move(w));
sync_reopen_scopes_item(app);
schedule_layout_save(app);
}
LayoutState collect_layout(App& app) {
LayoutState st;
st.window_s = app.rows->time_window();
@@ -64,6 +104,12 @@ LayoutState collect_layout(App& app) {
if (model.signals()[si].math || !on[si]) continue;
st.enabled.push_back(model.signals()[si].name);
}
// The extra windows: the **open** ones by their present arrangement, then the closed ones as
// they were remembered. A launch never opens them; the *Reopen closed windows* entry does.
for (const std::unique_ptr<App::ScopeWindow>& scope : app.scopes) {
st.windows.push_back(scope_window_state(*scope));
}
for (const LayoutState::Window& w : app.closed_windows) st.windows.push_back(w);
// Only the signals the user actually re-coloured: the rest take the positional palette, and
// writing those out would freeze a colour that is meant to follow the signal list.
for (const auto& [name, rgb] : app.rows->signal_colors()) {
@@ -73,6 +119,8 @@ LayoutState collect_layout(App& app) {
st.math = app.math;
st.measures = app.measures;
st.measure_stats = app.measure_stats_on;
st.gate_x0 = app.rows->gate_x0();
st.gate_x1 = app.rows->gate_x1();
st.dock_shown = app.sidebar_wanted;
// The trigger, its settings *and* whether it was on. The channel goes by name, like every identity
// in this file: every rebuild renumbers the channels.
@@ -138,6 +186,7 @@ void on_config_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
app.config_name = name;
app.config_comment = st.comment;
set_active_configuration(app.source_host, app.source_port, name);
retarget_event_store(app);
apply_layout(app, st);
// A buffer size, a clock and a capture ring are decided before the first socket: saying so beats
@@ -173,6 +222,8 @@ void on_config_forget_clicked(GtkButton* button, gpointer user) {
app.config_comment.clear();
}
forget_configuration(app.source_host, app.source_port, name);
// The cleared name means "whatever is active now", which `retarget_event_store()` resolves.
retarget_event_store(app);
config_dialog_refresh(*d);
}
@@ -312,6 +363,7 @@ bool save_configuration_as(App& app, const std::string& name, const std::string&
return false;
}
set_active_configuration(app.source_host, app.source_port, name);
retarget_event_store(app);
return true;
}
@@ -431,6 +483,10 @@ void apply_layout(App& app, const LayoutState& st) {
break;
}
}
// The extra windows are **remembered, not opened**: a session's windows are views the user
// closed, and a launch that tiled them onto the desktop would take that decision for them.
app.closed_windows = st.windows;
sync_reopen_scopes_item(app);
app.measures = st.measures;
app.measure_stats_on = st.measure_stats;
app.updating_panels = true;
@@ -442,6 +498,16 @@ void apply_layout(App& app, const LayoutState& st) {
gtk_check_button_set_active(GTK_CHECK_BUTTON(app.measures_stats_check),
app.measure_stats_on);
}
// The gate's bounds, before the pane rebuild below: whether the gate is on rides on the
// restored measurements (`MeasureOptions::gate`), and `rebuild_measures_pane()` derives the
// band's visibility from them -- a session with a gated measurement gets the band back
// exactly where this file says it sat. A file that never carried bounds leaves the RowSet
// unseeded on purpose: `set_gate()` with the file's degenerate defaults would mark the
// bounds as placed, and the first gated measurement would then show a zero-width band at
// x = 0 instead of seeding the middle half of the window.
if (std::isfinite(st.gate_x0) && std::isfinite(st.gate_x1) && st.gate_x1 > st.gate_x0) {
app.rows->set_gate(st.gate_x0, st.gate_x1);
}
// The toggled handler is guarded by updating_panels, so the Reset button's sensitivity
// and the stats-label visibility are set directly from the loaded state here; the labels'
// visibility is also set per-row inside rebuild_measures_pane via measures_row().
+80 -9
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.
int measure_channel(App& app, const std::string& signal) {
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]));
app.measure_text.emplace_back();
}
// The gate band is one area shared by every gated measurement, and it is on exactly while
// one exists: shown as soon as a measurement is gated, gone when the last one stops. Every
// change of the measure set -- an add, an edit, a delete, a session restore -- passes
// through here, which is what makes this the one derivation. `set_gate_visible()` seeds the
// bounds over the middle half of the window on the first enable, so a gate asked for with
// no band ever placed still lands over the data.
bool any_gated = false;
for (const Measure& m : app.measures) any_gated = any_gated || m.options.gate;
app.rows->set_gate_visible(any_gated);
dock_rebuild_measures(app);
sync_readouts_strip(app); // the strip's Measures group (and the strip itself) follows at once, not a tick later
update_measures(app, /*force=*/true, /*accumulate=*/true);
@@ -232,6 +256,7 @@ void on_measure_dialog_apply(GtkButton*, gpointer user) {
m.options.threshold_pct2 = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->band));
m.options.above = gtk_switch_get_active(GTK_SWITCH(d->above));
m.options.show_on_plot = gtk_check_button_get_active(GTK_CHECK_BUTTON(d->show));
m.options.gate = gtk_check_button_get_active(GTK_CHECK_BUTTON(d->gate));
m.options.noise_pct = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->noise));
m.options.hf_fc = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->hf));
if (d->edit >= 0 && static_cast<std::size_t>(d->edit) < app.measures.size()) {
@@ -261,9 +286,17 @@ void place_measure_marks(App& app) {
const double s0 = app.model->stream_at_x0();
app.measure_marks_display = app.measure_marks;
for (RowSet::MeasureMark& mk : app.measure_marks_display) {
if (!mk.level) mk.value -= s0;
if (!mk.level) {
mk.value -= s0;
// The badge text the plot draws beside the mark: its own time, in the pane's
// formatter -- the reading a cursor's ruler line carries, on the axis the mark is an
// answer about. Display x (an age, on a live view), because that is the coordinate the
// axis labels and the cursors pane both read.
mk.label = format_measure(mk.value, "s");
}
}
app.rows->set_measure_marks(app.measure_marks_display);
mirror_measure_marks(app);
}
/// Recomputes every measurement over the window it describes and writes the results into the
@@ -305,8 +338,21 @@ void update_measures(App& app, bool force, bool accumulate) {
const plot::Range pin = app.rows->pinned_range();
const bool pinned_view = app.rows->window_pinned() && pin.span() > 0.0 &&
std::isfinite(pin.min) && std::isfinite(pin.max);
// The gate band, clipped to that window, for the measurements that are gated. The gate is
// per measurement (`MeasureOptions::gate`), but the band is one and shared: when no
// measurement is gated the clip below is a no-op (`gated == window`) and nothing is walked
// twice. A band entirely outside the window clips to a zero span, and a zero-span window
// reads no sample, so a gated measurement over nothing falls to the same "--" an empty
// window gives.
const plot::Range window = pinned_view ? pin : view;
plot::Range gated = window;
if (app.rows->gate_visible()) {
const double lo = std::max(app.rows->gate_x0(), window.min);
const double hi = std::min(app.rows->gate_x1(), window.max);
gated = hi > lo ? plot::Range{lo, hi} : plot::Range{lo, lo};
}
const plot::Range swin{window.min + s0, window.max + s0};
const plot::Range gswin{gated.min + s0, gated.max + s0};
// Both windows of the tick, on the stream's clock: what is **on screen**, and (while pinned)
// what the pin *is*. The dump reads them instead of calling `stream_at_x0()` itself, which by
// the time it runs answers for a mode no frame was drawn in.
@@ -328,10 +374,19 @@ void update_measures(App& app, bool force, bool accumulate) {
// change below all move `swin` -- and a settled one satisfies `swin == measure_window` exactly
// and so reads `moved == false` on its own, which is what keeps it from recomputing every
// frame (457 passes in one run is the shape that mistake took).
const bool moved = app.measure_window_valid &&
std::fabs(swin.min - app.measure_window.min) +
std::fabs(swin.max - app.measure_window.max) >
0.02 * std::max(1.0e-12, swin.span());
const bool moved = (app.measure_window_valid &&
std::fabs(swin.min - app.measure_window.min) +
std::fabs(swin.max - app.measure_window.max) >
0.02 * std::max(1.0e-12, swin.span())) ||
// The gate is the second thing that can move a measurement's window
// without anything on screen moving: a band dragged on a still frame
// changes what the gated measurements describe, and a pane whose every
// measurement is ungated answers this with its own echo (the band clip
// is the window, which the test above already compares).
(app.measure_gate_window_valid &&
std::fabs(gswin.min - app.measure_gate_window.min) +
std::fabs(gswin.max - app.measure_gate_window.max) >
0.02 * std::max(1.0e-12, gswin.span()));
// A measurement is a statistic of the visible window, and *a live view moves that window every
// frame* -- so "recompute when the window moves" is "recompute every frame", at O(visible
// samples) per measurement. That is what stalled the frame tick (0.4 ms -> 13.7 ms, a third of
@@ -365,6 +420,8 @@ void update_measures(App& app, bool force, bool accumulate) {
}
app.measure_window = swin;
app.measure_window_valid = true;
app.measure_gate_window = gswin;
app.measure_gate_window_valid = true;
app.measure_was_held = held;
app.measure_stamp_us = now_us;
app.measure_stamp_valid = true;
@@ -414,13 +471,15 @@ void update_measures(App& app, bool force, bool accumulate) {
const double fc = m.options.hf_fc > 0.0 ? m.options.hf_fc : 0.0;
std::size_t wi = 0;
while (wi < app.measure_samples.size() &&
(app.measure_samples[wi].channel != channel || app.measure_samples[wi].fc != fc)) {
(app.measure_samples[wi].channel != channel || app.measure_samples[wi].fc != fc ||
app.measure_samples[wi].gated != m.options.gate)) {
++wi;
}
if (wi == app.measure_samples.size()) {
App::MeasureWindow fresh;
fresh.channel = channel;
fresh.fc = fc;
fresh.gated = m.options.gate;
measure_filter_set(fresh.scan_filter, fc, slot_dt);
measure_filter_set(fresh.acc_filter, fc, slot_dt);
app.measure_samples.push_back(fresh);
@@ -477,7 +536,11 @@ void update_measures(App& app, bool force, bool accumulate) {
std::vector<MeasureBucket>& buckets = app.measure_bucket_scratch;
for (App::MeasureWindow& w : app.measure_samples) {
const gint64 fetch_start_us = g_get_monotonic_time();
MeasureWindowReader reader(app, static_cast<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::function<double(std::size_t)> time_of = [&reader](std::size_t i) {
return reader.time_of(i);
@@ -667,8 +730,16 @@ void update_measures(App& app, bool force, bool accumulate) {
// A time mark is stored on the **stream's** clock -- the walks report display x, so `s0`
// puts it back -- and `place_measure_marks()` converts it again on every frame it draws.
// That is what keeps the mark on its own sample as a live view rolls underneath it; a level
// mark is a y and is stored as it comes.
marks.push_back({row, spec.level, spec.level ? spec.value : spec.value + s0, color});
// mark is a y and is stored as it comes. Field by field: a partial brace-initialiser
// re-adds the missing-initialiser warning the baseline forbids (label has no room in a
// `MeasureMarkSpec` -- the badge text is built where the display conversion happens).
RowSet::MeasureMark mark;
mark.row = row;
mark.signal = m.signal;
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);
+14 -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.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.crosshair_axis_labels = adw_switch_row_get_active(f.crosshair_row) != FALSE;
// The row shows the *effective* directory, so the default is written out in full rather than
// described. Leaving it at that default is not a choice to pin it: the file keeps "unset", and the
// setting goes on following the cache directory it is named after.
@@ -67,6 +68,7 @@ void fill_rows(PrefsForm& f, const Settings& s) {
adw_switch_row_set_active(f.relative_row, s.relative_time ? TRUE : FALSE);
adw_spin_row_set_value(f.memory_row, s.memory_limit_mb);
adw_combo_row_set_selected(f.theme_row, s.theme == "light" ? 1 : (s.theme == "dark" ? 2 : 0));
adw_switch_row_set_active(f.crosshair_row, s.crosshair_axis_labels ? TRUE : FALSE);
f.filling = false;
}
@@ -146,6 +148,7 @@ void on_closed(AdwDialog*, gpointer user) {
f.relative_row = nullptr;
f.memory_row = nullptr;
f.theme_row = nullptr;
f.crosshair_row = nullptr;
f.browse = nullptr;
f.parent = nullptr;
}
@@ -160,13 +163,15 @@ void resolve_rows(PrefsForm& f) {
f.relative_row = ADW_SWITCH_ROW(gtk_builder_get_object(f.builder, "relative_row"));
f.theme_row = ADW_COMBO_ROW(gtk_builder_get_object(f.builder, "theme_row"));
f.memory_row = ADW_SPIN_ROW(gtk_builder_get_object(f.builder, "memory_row"));
f.crosshair_row = ADW_SWITCH_ROW(gtk_builder_get_object(f.builder, "crosshair_axis_labels_row"));
if (f.dir_row == nullptr || f.keep_row == nullptr || f.relative_row == nullptr ||
f.theme_row == nullptr || f.memory_row == nullptr) {
f.theme_row == nullptr || f.memory_row == nullptr || f.crosshair_row == nullptr) {
f.dir_row = nullptr;
f.keep_row = nullptr;
f.relative_row = nullptr;
f.theme_row = nullptr;
f.memory_row = nullptr;
f.crosshair_row = nullptr;
g_clear_object(&f.builder);
}
}
@@ -222,6 +227,7 @@ void prefs_open(PrefsForm& form, GtkWidget* parent, const Settings& current,
g_signal_connect(form.relative_row, "notify::active", G_CALLBACK(on_row_notify), &form);
g_signal_connect(form.memory_row, "notify::value", G_CALLBACK(on_row_notify), &form);
g_signal_connect(form.theme_row, "notify::selected", G_CALLBACK(on_row_notify), &form);
g_signal_connect(form.crosshair_row, "notify::active", G_CALLBACK(on_row_notify), &form);
if (GObject* reset = gtk_builder_get_object(form.builder, "reset_row"); reset != nullptr) {
g_signal_connect(reset, "activated", G_CALLBACK(on_reset), &form);
}
@@ -248,6 +254,7 @@ void prefs_probe_change(PrefsForm& form) {
adw_switch_row_set_active(form.relative_row, TRUE);
adw_spin_row_set_value(form.memory_row, 4096.0);
adw_combo_row_set_selected(form.theme_row, 2);
adw_switch_row_set_active(form.crosshair_row, FALSE);
}
/// A changed memory limit reaches the running model. Only when it differs from the model's own (every
@@ -277,6 +284,12 @@ void open_preferences(App& app) {
prefs_open(app.prefs, app.window, app.settings, [&app](const Settings& s, bool written) {
app.settings = s;
apply_memory_limit(app, s.memory_limit_mb);
// The crosshair's axis badges, at once like the theme: it is a drawing flag on the
// plots, and a change is the next frame's drawing.
app.rows->set_crosshair_axis_labels(s.crosshair_axis_labels);
for (const std::unique_ptr<App::ScopeWindow>& scope : app.scopes) {
if (scope->rows != nullptr) scope->rows->set_crosshair_axis_labels(s.crosshair_axis_labels);
}
// Only a failure is announced. A change arrives once per keystroke in the directory row, and a
// status line that flashed on each one would be the app shouting about its own bookkeeping.
if (!written) set_notice(app, "could not write the preferences file", 4);
+2
View File
@@ -64,6 +64,8 @@ struct PrefsForm {
AdwSwitchRow* relative_row = nullptr;
AdwSpinRow* memory_row = nullptr;
AdwComboRow* theme_row = nullptr;
/// The crosshair's axis-badge switch (`Settings::crosshair_axis_labels`).
AdwSwitchRow* crosshair_row = nullptr;
/// The folder chooser beside the directory row. A text field alone asks a user to know a path and
/// type it correctly; the chooser is how they find one, and what it returns goes into that row -- so
/// the text stays the single value and there is nothing to keep in step.
+861 -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
// window control is showing: there is no label to read it back from any more.
slideover_close(*a); // opened by step 3 for the signal list's allocations read above
if (window != nullptr) gtk_window_destroy(GTK_WINDOW(window));
// `gtk_window_close()`, not `gtk_window_destroy()`: only a close emits `close-request`,
// which is where the window's lifecycle (the scope windows, the save flush, the application
// quit) lives -- GTK4 has no `GtkWidget::destroy` to hook.
if (window != nullptr) gtk_window_close(GTK_WINDOW(window));
return G_SOURCE_REMOVE;
}
@@ -1350,13 +1353,750 @@ gboolean measure_probe(gpointer user) {
}
rebuild_measures_pane(*a);
update_measures(*a, /*force=*/true, /*accumulate=*/true);
std::fprintf(stderr, "[measure-probe] %zu measurements on '%s'\n", a->measures.size(),
name.c_str());
std::fprintf(stderr, "[measure-probe] %zu measurements on '%s' (channel %d)\n",
a->measures.size(), name.c_str(), measure_channel_number(*a, name));
std::fflush(stderr);
g_timeout_add(250, measure_trace, a);
return G_SOURCE_REMOVE;
}
/// `UDPSCOPE_PROBE_GATE_MS`: the **per-measurement gate**. It adds two averages on one
/// channel -- index 0 ungated, index 1 with `MeasureOptions::gate` -- and puts the channel on
/// a plot (the way `enable_all_probe` does it: a channel with no trace leaves the display
/// epoch still, which is the state the `still=` leg needs to see move). The pane rebuild then
/// derives the band's visibility from the measure set, and the probe re-states its bounds to a
/// known relative interval, 300 ms ago to 100 ms ago, on the mock's 1 Hz sine: a whole-seconds
/// window is whole *periods* there, so the ungated average is exactly 0 however the phase
/// sits, while the gated slice's mean is wherever in its period the stream happens to be.
/// Three lines. `on=` carries the assertions: `band=1` (one gated measurement showed the
/// band), `placed=1` (the plot's region holds exactly the bounds the `RowSet` does),
/// `plain=1` (the ungated reading is zero -- it read the whole window), `split=1` (the gated
/// reading differs -- it read the band) and `gwin=1` (the pass booked `measure_gate_window` =
/// the band moved onto the stream's clock, the phase-free statement of *which* slice the
/// gated walk read). `still=`, a second later, is the relative behaviour: the band exactly
/// where it was put while `s0` moved under it. The exit dump's `gate:` line is the session
/// leg: the probe saves the bounds the way a drag end does, and a second run on the same
/// config restores the band -- and the two measurements' gate flags -- from it. **Not in one
/// run with the other measurement probes**: they add measurements of their own, and these
/// lines quote indexes 0 and 1.
gboolean gate_probe_step(gpointer user) {
App* a = static_cast<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->dbg_detach_single = a->detach_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 detach_single=%zu before: rows=%d frows=%d "
"row0=%s row1=%s buttons=%zu detach=%zu; max: rows=%d frows=%d flag=%d row0=%s; "
"back: rows=%d row0=%s row1=%s flag=%d\n",
a->dbg_maximize_single, a->dbg_detach_single, rows_before, frows_before,
row0_before.c_str(),
row1_before.c_str(), buttons, a->detach_buttons.size(), 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;
// On the **detach** button's own centre -- its zone is the strictest of the corner pair, so a
// pointer there must reveal both it and the full-screen toggle beside it.
if (const plot::Plot* last = a->rows->row(0).plot) {
const double scale =
a->area != nullptr && a->area->device_scale() > 0.0f
? static_cast<double>(a->area->device_scale())
: 1.0;
const double right = static_cast<double>(last->layout().axes.right()) / scale;
a->area_pointer_x = right - 2 * kLayoutButtonPx - 2 * kLayoutButtonPad +
0.5 * kLayoutButtonPx;
a->area_pointer_y = kLayoutButtonPad + 0.5 * kLayoutButtonPx;
} else {
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 auto visible = [](const std::vector<GtkWidget*>& buttons, std::size_t i) {
return i < buttons.size() && gtk_widget_get_visible(buttons[i]) ? 1 : 0;
};
const int shown = visible(a->maximize_buttons, 0);
const int other = visible(a->maximize_buttons, 1);
const int dshown = visible(a->detach_buttons, 0);
const int dother = visible(a->detach_buttons, 1);
std::fprintf(stderr, "[maximize-probe] corner: hidden_when_off=%d shown_when_near=%d "
"other_row=%d detach_near=%d detach_other=%d\n",
a->dbg_maximize_hidden, shown, other, dshown, dother);
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.
/// How many descendants carry `name` -- the scope window's *Plot on* groups are counted, one per
/// window, where a single `find_named` would stop at the first.
int count_named(GtkWidget* root, const char* name) {
int n = 0;
if (root == nullptr) return 0;
if (strcmp(gtk_widget_get_name(root), name) == 0) ++n;
for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
n += count_named(c, name);
}
return n;
}
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: {
// What the session remembered, and what a launch opened: the windows are never opened at
// start, so `scopes` is one (the main window) and `closed` is the count read from the file.
std::fprintf(stderr, "[scope-probe] loaded closed=%zu scopes=%zu\n",
a->closed_windows.size(), a->scopes.size());
std::fflush(stderr);
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];
// The first non-empty slot of the copied row: the copy is slot-for-slot faithful, and slot 0
// is empty whenever an earlier probe (the enable-all one disables a signal at its end) left
// the main's slot 0 empty -- so this reads *a* copied trace without assuming which slot.
std::string copied;
bool copied_bound = false;
for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) {
const std::size_t si = static_cast<std::size_t>(sl);
if (copy.rows->row(0).signal_name[si].empty()) continue;
copied = copy.rows->row(0).signal_name[si];
copied_bound = copy.rows->row(0).channel[si] >= 0;
break;
}
bool empty_ok = true;
for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) {
if (!empty.rows->row(0).signal_name[static_cast<std::size_t>(sl)].empty()) empty_ok = false;
}
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);
// **A drop onto a window**: the handler the drop target wires, called directly with a
// cell-0 coordinate. The copied window holds 'sine' in slot 0, so the dropped signal must
// land in its second slot (the rebuild and the area's re-point are the window's next frame).
std::size_t square = std::size_t(-1);
for (std::size_t i = 0; i < a->model->signals().size(); ++i) {
if (a->model->signals()[i].name == "square") square = i;
}
GValue payload = G_VALUE_INIT;
g_value_init(&payload, G_TYPE_STRING);
g_value_set_string(&payload, std::to_string(square).c_str());
const gboolean dropped = square != std::size_t(-1) && copy.drop != nullptr
? on_scope_drop_channel(copy.drop, &payload, 40.0, 20.0, a)
: FALSE;
g_value_unset(&payload);
const std::string slot1 = copy.rows->row(0).signal_name[1];
// The same drop onto the **empty** window: its figure must gain the trace and, after the
// window's next frame, draw it (the screenshots a run of this probe takes read it back).
GValue payload2 = G_VALUE_INIT;
g_value_init(&payload2, G_TYPE_STRING);
g_value_set_string(&payload2, std::to_string(square).c_str());
const gboolean dropped2 = square != std::size_t(-1) && empty.drop != nullptr
? on_scope_drop_channel(empty.drop, &payload2, 40.0, 20.0, a)
: FALSE;
g_value_unset(&payload2);
const std::string empty_slot0 = empty.rows->row(0).signal_name[0];
// **The signal page lists the windows**: one *Plot on* group per window, in the page of a
// signal that is plotted nowhere (so every button is live).
signal_page_ensure(*a, "ramp");
signal_pages_sync(*a);
int groups = 0;
const auto page_it = a->signal_pages.find("ramp");
if (page_it != a->signal_pages.end()) groups = count_named(page_it->second, "udpscope-scope-plot-on");
std::fprintf(stderr,
"[scope-probe] drop=%d slot1='%s' page_groups=%d empty_drop=%d "
"empty_slot0='%s'\n",
dropped ? 1 : 0, slot1.c_str(), groups, dropped2 ? 1 : 0,
empty_slot0.c_str());
// **Detach and attach**: a second figure row detaches -- the window is marked detached and
// carries the attach control -- and its figure comes back on the main window.
a->rows->set_cells_per_row({1, 1});
{
std::size_t ramp = std::size_t(-1);
int color = 0;
for (std::size_t i = 0; i < a->model->signals().size(); ++i) {
if (a->model->signals()[i].name == "ramp") {
ramp = i;
color = signal_color_index(a->model->signals(), i);
}
}
if (ramp != std::size_t(-1)) a->rows->assign(1, 0, "ramp", "ramp", color);
}
a->relayout_pending = true;
detach_figure(*a, 1);
const bool detached_ok = a->scopes.size() == 3 && a->scopes[2]->detached &&
a->rows->figure_row_count() == 1 &&
a->scopes[2]->rows->row(0).signal_name[0] == "ramp" &&
a->scopes[2]->attach_buttons.size() == 1;
attach_scope_window(*a, *a->scopes[2]);
const int frows_back = a->rows->figure_row_count();
const bool attach_ok = a->scopes.size() == 2 && frows_back == 2 &&
a->rows->row(a->rows->first_plot_of_row(1)).signal_name[0] == "ramp";
std::fprintf(stderr,
"[scope-probe] detach=%d scopes=3 frows=1 attach=%d scopes_back=2 "
"frows_back=%d slot='ramp'\n",
detached_ok ? 1 : 0, attach_ok ? 1 : 0, frows_back);
std::fflush(stderr);
// And a detached window **left open**, so the screenshots a run of this probe takes can
// answer "does the detached figure draw": figure 0 (sine) moves out and stays out.
detach_figure(*a, 0);
a->dbg_scope_step = 3;
g_timeout_add(800, scope_probe_step, a);
return G_SOURCE_REMOVE;
}
case 3: {
// The windows' **own GL account**: a scope whose context failed -- which a second GL area
// on a real driver can hit where the software renderer does not -- draws *nothing*: no
// axes, no traces, exactly the "blank plot widget" report. `frames=` counts staged frames;
// `ok=0` carries the adapter's message.
const auto gl_state = [](const App::ScopeWindow& s) {
if (s.area == nullptr) return std::string("no-area");
const plot::Status st = s.area->init_status();
return st ? "ok(" + std::to_string(s.area->frames_painted()) + "f)"
: std::string("err: ") + st.message;
};
const bool empty_bound = a->scopes[1]->rows->row(0).channel[0] >= 0;
std::fprintf(stderr, "[scope-probe] gl copy=%s empty=%s empty_bound=%d\n",
gl_state(*a->scopes[0]).c_str(), gl_state(*a->scopes[1]).c_str(),
empty_bound ? 1 : 0);
if (!a->scopes.empty()) {
const App::ScopeWindow& det = *a->scopes.back();
const plot::Status st = det.area != nullptr ? det.area->init_status() : plot::Status{};
std::fprintf(stderr,
"[scope-probe] gl detached ok=%d frames=%llu rows=%d slot0='%s'\n",
(bool)st ? 1 : 0,
static_cast<unsigned long long>(det.area != nullptr
? det.area->frames_painted()
: 0),
det.rows != nullptr ? det.rows->row_count() : 0,
det.rows != nullptr && det.rows->row_count() > 0
? det.rows->row(0).signal_name[0].c_str()
: "");
}
// The scope window carries its own right-click menu (its gesture is on the GL area, wired
// beside the drop target): one click gesture whose button is 3, per window.
int right_clicks = 0;
GtkWidget* det_widget = a->scopes.empty() ? nullptr : a->scopes.back()->widget;
if (det_widget != nullptr) {
GListModel* controllers = gtk_widget_observe_controllers(det_widget);
const guint n = g_list_model_get_n_items(G_LIST_MODEL(controllers));
for (guint i = 0; i < n; ++i) {
GObject* c = G_OBJECT(g_list_model_get_item(controllers, i));
if (GTK_IS_GESTURE_SINGLE(c) &&
gtk_gesture_single_get_button(GTK_GESTURE_SINGLE(c)) == 3) {
++right_clicks;
}
g_object_unref(c);
}
g_object_unref(controllers);
}
std::fprintf(stderr, "[scope-probe] rclick=%d\n", right_clicks);
std::fflush(stderr);
// **The pin mirror**: a held event pins the main window, and every scope must be pinned to
// the *same range*. `pin_event_window()` takes spans (pre, post) and passing it a range's
// signed min turned the pin inside out (a scope in Held drew no trace and no x labels).
a->rows->pin_event_window(1.6, 0.4);
a->dbg_scope_step = 4;
g_timeout_add(350, scope_probe_step, a);
return G_SOURCE_REMOVE;
}
case 4: {
const plot::Range main_pin = a->rows->pinned_range();
const plot::Range scope_pin = a->scopes[0]->rows->pinned_range();
const plot::Range scope_x = a->scopes[0]->rows->row(0).plot != nullptr
? a->scopes[0]->rows->row(0).plot->x_range(0)
: plot::Range{};
const bool pinned = a->scopes[0]->rows->window_pinned() == a->rows->window_pinned();
const bool same = std::fabs(scope_pin.min - main_pin.min) < 1e-9 &&
std::fabs(scope_pin.max - main_pin.max) < 1e-9 &&
scope_x.max > scope_x.min;
std::fprintf(stderr,
"[scope-probe] pin_mirror=%d main=[%.3f..%.3f] scope=[%.3f..%.3f] "
"x=[%.3f..%.3f]\n",
pinned && same ? 1 : 0, main_pin.min, main_pin.max, scope_pin.min,
scope_pin.max, scope_x.min, scope_x.max);
std::fflush(stderr);
a->rows->set_time_window(2.0); // unpin: leave the window as the run found it
// **The scope window's own layout editor**: the toggle builds its buttons and a `+` adds a
// figure row -- the arrangement of a view is the view's.
set_scope_layout_editing(*a, *a->scopes[1], true);
const std::size_t layout_buttons = a->scopes[1]->layout_buttons.size();
const int rows_before = a->scopes[1]->rows->row_count();
if (layout_buttons > static_cast<std::size_t>(rows_before)) {
g_signal_emit_by_name(a->scopes[1]->layout_buttons[static_cast<std::size_t>(rows_before)],
"clicked");
}
const int rows_after = a->scopes[1]->rows->row_count();
set_scope_layout_editing(*a, *a->scopes[1], false);
std::fprintf(stderr, "[scope-probe] layout buttons=%zu rows=%d->%d\n", layout_buttons,
rows_before, rows_after);
// **Closing remembers, the menu reopens.** A closed window's arrangement stays in the
// session (`closed_windows`), a launch never opens it, and the *Reopen closed windows* entry
// brings it back -- so this leg closes the empty window, reads what was remembered, and
// clicks the entry.
const std::string closed_name = a->scopes[1]->name;
const std::size_t scopes_before = a->scopes.size();
gtk_window_destroy(GTK_WINDOW(a->scopes[1]->window));
const std::size_t remembered = a->closed_windows.size();
const bool remembered_name = remembered > 0 && a->closed_windows.back().name == closed_name &&
!a->closed_windows.back().rows.empty();
const std::size_t scopes_after_close = a->scopes.size();
if (a->reopen_scopes_item != nullptr) {
g_signal_emit_by_name(a->reopen_scopes_item, "clicked");
}
const std::size_t scopes_after = a->scopes.size();
const int back_rows = scopes_after > scopes_after_close ? a->scopes.back()->rows->row_count() : 0;
const std::string back_slot =
scopes_after > scopes_after_close && a->scopes.back()->rows->row_count() > 0
? a->scopes.back()->rows->row(0).signal_name[0]
: std::string();
std::fprintf(stderr,
"[scope-probe] reopen scopes=%zu->%zu(closed)->%zu remembered=%zu name=%d "
"rows=%d slot0='%s' left=%zu\n",
scopes_before, scopes_after_close, scopes_after, remembered,
remembered_name ? 1 : 0, back_rows, back_slot.c_str(),
a->closed_windows.size());
std::fflush(stderr);
// **The instruments are on every window**: the cursors, the gate band and the trigger
// marker are written from the main window's one owner (the next tick's
// `sync_scope_instruments()`), so the states below are asked for on the main and read on
// the scope.
a->rows->set_cursors_visible(true);
a->rows->set_cursor_mode(RowSet::CursorMode::Both);
a->rows->set_cursors(-1.0, 0.0);
a->rows->set_gate(-1.2, -0.8);
a->rows->set_gate_visible(true);
a->rows->set_trigger_marker(0.0, true);
// A real measurement on 'sine' with `show_on_plot`: the marks path is the one a user's
// measurement takes (`update_measures()` -> `place_measure_marks()` -> the windows), so a
// window that plots 'sine' must draw its ruler and one that does not must draw none.
{
Measure m;
m.signal = "sine";
m.type = MeasureType::Top;
m.options.show_on_plot = true;
// Gated: `update_measures()` restates the gate's visibility every frame (it is up while
// *any* measurement is gated), so the band is genuinely on and its bounds are the ones
// set above rather than a value the mirror would be right to hide.
m.options.gate = true;
a->measures.push_back(std::move(m));
rebuild_measures_pane(*a);
}
a->dbg_scope_step = 5;
g_timeout_add(700, scope_probe_step, a);
return G_SOURCE_REMOVE;
}
case 5: {
// A frame has passed: the tick wrote the main's instruments into every window.
int cursor_ok = 0;
int gate_ok = 0;
int trigger_ok = 0;
int marks_ok = 0;
for (const std::unique_ptr<App::ScopeWindow>& scope : a->scopes) {
if (scope->rows == nullptr) continue;
const bool cursors = scope->rows->cursors_visible() &&
scope->rows->cursor_mode() == RowSet::CursorMode::Both &&
std::fabs(scope->rows->cursor_a_x() + 1.0) < 1e-9 &&
std::fabs(scope->rows->cursor_b_x()) < 1e-9;
const bool gate = scope->rows->gate_visible() &&
std::fabs(scope->rows->gate_x0() + 1.2) < 1e-9 &&
std::fabs(scope->rows->gate_x1() + 0.8) < 1e-9;
const bool trigger =
scope->rows->trigger_marker_visible() == a->rows->trigger_marker_visible() &&
std::fabs(scope->rows->trigger_marker_x() - a->rows->trigger_marker_x()) < 1e-9;
// The mark: the window that plots 'sine' has it, one that does not has none. Which is
// which is read off the same names.
bool plots_sine = false;
for (int r = 0; r < scope->rows->row_count(); ++r) {
for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) {
if (scope->rows->row(r).signal_name[static_cast<std::size_t>(sl)] == "sine") {
plots_sine = true;
}
}
}
const int placed = scope->rows->measure_marks_placed();
const bool mark = plots_sine ? placed == 1 : placed == 0;
cursor_ok += cursors ? 1 : 0;
gate_ok += gate ? 1 : 0;
trigger_ok += trigger ? 1 : 0;
marks_ok += mark ? 1 : 0;
}
const int n = static_cast<int>(a->scopes.size());
std::fprintf(stderr,
"[scope-probe] instruments windows=%d cursors=%d gate=%d trigger=%d "
"marks=%d (main trigger on=%d x=%.3f)\n",
n, cursor_ok, gate_ok, trigger_ok, marks_ok,
a->rows->trigger_marker_visible() ? 1 : 0, a->rows->trigger_marker_x());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
default:
return G_SOURCE_REMOVE;
}
return G_SOURCE_REMOVE;
}
/// `UDPSCOPE_PROBE_LEVELS_MS`: puts a pane of **N** measurements on screen -- one `above` per
/// share in `UDPSCOPE_PROBE_LEVELS` (comma separated, default `50`) -- spread in turn over the
/// first `UDPSCOPE_PROBE_LEVELS_CHANNELS` channels (default 1), and says how many *windows* and
@@ -2985,6 +3725,24 @@ gboolean arm_probe(gpointer user) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
std::fprintf(stderr, "[arm-probe] trigger armed\n");
std::fflush(stderr);
// The circle's states, sampled after the arm: waiting reads flash (the lit and dim halves,
// whichever half-second the sample lands in), a latched event reads green, a disarm red.
// The tick caches the state (`dbg_trigger_circle`), so this reads it without touching GTK.
for (int i = 1; i <= 4; ++i) {
g_timeout_add(static_cast<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;
}
@@ -3716,17 +4474,44 @@ gboolean prefs_probe(gpointer user) {
(row_underlines(G_OBJECT(a.prefs.dir_row)) + row_underlines(G_OBJECT(a.prefs.keep_row)) +
row_underlines(G_OBJECT(a.prefs.relative_row)) +
row_underlines(G_OBJECT(a.prefs.theme_row))));
std::fprintf(stderr, "[prefs-probe] opened=%d rows=%d dir=%s store=%s\n",
// The cache is keyed on the source **and its active configuration**, not on the source alone: the
// store's directory must be the dialog's directory, the source's folder and the configuration's
// folder, and pointing it at another configuration -- the helper every switch calls -- must move
// it there and back. Nothing is created: `set_configuration()` re-derives the path.
const std::string source_dir =
std::string(shown) + "/" + a.source_host + "-" + std::to_string(a.source_port);
const std::string active_cfg = active_configuration(a.source_host, a.source_port);
int cfg_keyed = 0;
int cfg_retarget = 0;
if (a.event_store != nullptr) {
cfg_keyed = a.event_store->configuration() == active_cfg &&
a.event_store->dir() == source_dir + "/" + active_cfg
? 1
: 0;
const std::string restore_name = a.config_name;
a.config_name = "probe-other";
retarget_event_store(a);
cfg_retarget = a.event_store->configuration() == "probe-other" &&
a.event_store->dir() == source_dir + "/probe-other"
? 1
: 0;
a.config_name = restore_name;
retarget_event_store(a);
cfg_retarget = cfg_retarget && a.event_store->configuration() == active_cfg &&
a.event_store->dir() == source_dir + "/" + active_cfg;
}
std::fprintf(stderr, "[prefs-probe] opened=%d rows=%d dir=%s store=%s cfg=%d cfg_switch=%d\n",
a.prefs.dialog != nullptr ? 1 : 0, a.prefs.dir_row != nullptr ? 1 : 0,
a.prefs.dir_row != nullptr ? shown : "-",
a.event_store != nullptr ? a.event_store->dir().c_str() : "-");
a.event_store != nullptr ? a.event_store->dir().c_str() : "-", cfg_keyed,
cfg_retarget);
g_timeout_add(400, +[](gpointer u) -> gboolean {
App& app = *static_cast<App*>(u);
prefs_probe_change(app.prefs);
const Settings back = load_settings();
std::fprintf(stderr, "[prefs-probe] dir=%s keep=%d rel=%d theme=%s live=%d\n",
std::fprintf(stderr, "[prefs-probe] dir=%s keep=%d rel=%d theme=%s xhair=%d live=%d\n",
back.event_cache_dir.c_str(), back.event_cache_max, back.relative_time ? 1 : 0,
back.theme.c_str(),
back.theme.c_str(), back.crosshair_axis_labels ? 1 : 0,
app.settings.theme == "dark" ? 1 : 0);
// And typing the default back must not *pin* it: the file keeps "unset", so the default still
// follows whatever cache directory the next run has. An empty value here is the whole point --
@@ -4705,6 +5490,50 @@ gboolean split_probe_step(gpointer user) {
g_x_axes.row0.c_str(), g_x_axes.bottom.c_str(), g_x_axes.gap_ok.c_str(),
g_x_axes.gap.c_str(), g_x_axes.col_gap, g_x_axes.note.c_str(), gesture);
rs.set_time_window(g_x_axes.window_before); // unpins and re-arms the live follow
split_probe_next(*a); // the split row's shared-y crosshair, below
break;
}
// --- Steps 28..30: the horizontal line spans a split row (its cells share one y) ---
case 28: {
// Two cells sharing one y on figure row 0 and one cell on row 1; the next steps hover
// cell 1 and read what the row's *other* cell got.
rs.set_cells_per_row({2, 1});
split_probe_clear_all(*a);
split_probe_assign(*a, 0, 0);
split_probe_assign(*a, 1, 1);
split_probe_restructured(*a);
split_probe_next(*a);
break;
}
case 29: {
if (rs.row(1).plot == nullptr || rs.row(0).plot == nullptr) {
split_probe_next(*a);
break;
}
// The adapter's own call, minus its pointer plumbing (which the crosshair probe covers):
// one shared x to every plot, the hovered plot's y into the figure -- a split row's cells
// share that y, so the line must land in both.
const plot::Transform tx = rs.row(1).plot->x_transform(0u);
const plot::Transform ty = rs.row(1).plot->y_transform(0u);
const plot::Rect axes = rs.row(1).plot->layout().axes;
const f64 x = tx.to_data_x(static_cast<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;
}
default:
@@ -5301,17 +6130,23 @@ static void strip_report(App& a, const char* tag, const char* what) {
const bool g_mea = a.strip_measure_group != nullptr && gtk_widget_get_visible(a.strip_measure_group);
const bool g_sep = a.strip_sep != nullptr && gtk_widget_get_visible(a.strip_sep);
const bool groups_ok = g_cur == cur_on && g_mea == mea_on && g_sep == (cur_on && mea_on);
// The `[+]` closes the Measures group: one adds a measurement from the strip, after the
// last chip (the old "Measures" panel button is gone). Structural, because a probe that
// clicked it would open a dialog on every report.
const bool plus_ok = a.strip_measure_group != nullptr &&
gtk_widget_get_last_child(a.strip_measure_group) != a.strip_measure_box &&
GTK_IS_BUTTON(gtk_widget_get_last_child(a.strip_measure_group));
std::fprintf(stderr,
"[%s-probe] %-14s strip: visible=%d expected=%d visible_ok=%d dock_shown=%d cursors=%zu "
"measures=%zu same_text=%d h=%d sens_same=%d collapsed=%d window=%d groups=%d%d%d "
"groups_ok=%d\n",
"groups_ok=%d add=%d\n",
tag, what, visible ? 1 : 0, expected ? 1 : 0, visible == expected ? 1 : 0,
dock_shown ? 1 : 0, a.strip_cursor_deltas.size(),
a.strip_measure_labels.size(), same, a.readouts_strip != nullptr
? gtk_widget_get_height(a.readouts_strip) : -1,
sens_same ? 1 : 0, collapsed ? 1 : 0,
a.window != nullptr ? gtk_widget_get_width(a.window) : -1,
g_cur ? 1 : 0, g_mea ? 1 : 0, g_sep ? 1 : 0, groups_ok ? 1 : 0);
g_cur ? 1 : 0, g_mea ? 1 : 0, g_sep ? 1 : 0, groups_ok ? 1 : 0, plus_ok ? 1 : 0);
}
/// Puts the dock away through the wish the tick re-asserts and reads the strip with it hidden
@@ -6427,8 +7262,14 @@ gboolean configs_probe(gpointer user) {
}
return out.empty() ? std::string("-") : out;
};
std::fprintf(stderr, "[configs] before=%s active=%s\n", joined().c_str(),
active_configuration(a->source_host, a->source_port).c_str());
// The event cache is keyed on the *configuration*, so each line carries the store's own key: it
// must follow the active configuration through save-as and back, or a user's events for one
// configuration would be listed under another.
const auto store_key = [&]() {
return a->event_store != nullptr ? a->event_store->configuration() : std::string("-");
};
std::fprintf(stderr, "[configs] before=%s active=%s store=%s\n", joined().c_str(),
active_configuration(a->source_host, a->source_port).c_str(), store_key().c_str());
// A non-default band and corner before the save, so the round trip below proves the two keys are
// *written*: left at their defaults, a key that never made it to the file would read back the same.
@@ -6438,8 +7279,9 @@ gboolean configs_probe(gpointer user) {
a->measures.front().options.hf_fc = 250.0;
}
const bool saved = save_configuration_as(*a, "probe", "written by the configs probe");
std::fprintf(stderr, "[configs] save-as=%d names=%s active=%s\n", saved ? 1 : 0,
joined().c_str(), active_configuration(a->source_host, a->source_port).c_str());
std::fprintf(stderr, "[configs] save-as=%d names=%s active=%s store=%s\n", saved ? 1 : 0,
joined().c_str(), active_configuration(a->source_host, a->source_port).c_str(),
store_key().c_str());
// The round trip: what a reopened configuration must carry, note and startup settings included.
LayoutState st;
@@ -6462,7 +7304,12 @@ gboolean configs_probe(gpointer user) {
noise_back, hf_back);
forget_configuration(a->source_host, a->source_port, "probe");
std::fprintf(stderr, "[configs] after forget names=%s\n", joined().c_str());
// The forgotten one was the active one: the dialog clears the name and lets the cache follow
// whatever is active now, and the probe does the same so the reading is that path's.
if (a->config_name == "probe") a->config_name.clear();
retarget_event_store(*a);
std::fprintf(stderr, "[configs] after forget names=%s active=%s store=%s\n", joined().c_str(),
active_configuration(a->source_host, a->source_port).c_str(), store_key().c_str());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
+75 -17
View File
@@ -194,20 +194,14 @@ void fill(App& app, const std::string& name, GtkWidget* page) {
const bool enabled = app.model->signal_enabled(index);
const bool plotted = signal_plotted(app, sig.name);
// Back to the list: a page reached from a list row has no other way home.
GtkWidget* back = text_button("Back to signals", page);
gtk_widget_set_halign(back, GTK_ALIGN_START);
gtk_widget_add_css_class(back, "flat");
g_signal_connect(back, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
slideover_open(*static_cast<App*>(user), "signals");
}),
&app);
GtkWidget* title = gtk_label_new(sig.name.c_str());
gtk_widget_add_css_class(title, "title-3");
gtk_widget_set_halign(title, GTK_ALIGN_START);
gtk_label_set_ellipsize(GTK_LABEL(title), PANGO_ELLIPSIZE_END);
gtk_box_append(GTK_BOX(page), title);
// The page's name is the panel header's, not the body's: the slide-over already prints it above the
// content (with its **Close** button), so a second copy in the body is the same word twice. The
// header's title carries the channel's number -- the same one the badge and the measurements pane
// use -- so the panel says which acquired channel this is, not just what it is called.
const int channel_number = measure_channel_number(app, sig.name);
slideover_set_title(app, page_id(name),
channel_number > 0 ? std::to_string(channel_number) + ". " + sig.name
: sig.name);
if (sig.math) {
if (!sig.unit.empty()) {
GtkWidget* unit = gtk_label_new(sig.unit.c_str());
@@ -294,6 +288,48 @@ void fill(App& app, const std::string& name, GtkWidget* page) {
&app);
}
gtk_box_append(GTK_BOX(page), rows_box);
// The scope windows: each is a view with its own arrangement, and a signal is plotted there
// exactly as it is here -- one group per window (named, so two windows never blur into one),
// one button per plot of that window's figure. The buttons read *that* window's rows for
// "already on this plot", and a click lands in the window's own row set.
for (const std::unique_ptr<App::ScopeWindow>& scope : app.scopes) {
if (scope->rows == nullptr || scope->rows->row_count() <= 0) continue;
GtkWidget* scope_head = gtk_label_new(("In \"" + scope->name + "\"").c_str());
gtk_widget_add_css_class(scope_head, "heading");
gtk_widget_set_halign(scope_head, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(page), scope_head);
GtkWidget* scope_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_name(scope_box, "udpscope-scope-plot-on");
for (int r = 0; r < scope->rows->row_count(); ++r) {
GtkWidget* b = text_button(scope->rows->plot_label(r).c_str(), scope_box);
tag(b, index);
g_object_set_data(G_OBJECT(b), "udpscope-row", GINT_TO_POINTER(r));
g_object_set_data(G_OBJECT(b), "udpscope-scope", scope.get());
bool on_this_row = false;
for (int slot = 0; slot < RowSet::kSlotsPerRow; ++slot) {
if (scope->rows->row(r).signal_name[static_cast<std::size_t>(slot)] == sig.name) {
on_this_row = true;
}
}
gtk_widget_set_sensitive(b, !on_this_row);
gtk_widget_set_tooltip_text(b, on_this_row ? "already on this plot"
: "draw this signal in this window's plot");
g_signal_connect(b, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
App* a = static_cast<App*>(user);
auto* scope = static_cast<App::ScopeWindow*>(
g_object_get_data(G_OBJECT(b), "udpscope-scope"));
if (scope == nullptr) return;
outlive_the_rebuild(GTK_WIDGET(b));
assign_signal_to_scope(*a, *scope, tagged(b),
GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b),
"udpscope-row")));
}),
&app);
}
gtk_box_append(GTK_BOX(page), scope_box);
}
GtkWidget* off = text_button("Remove from plots", page);
gtk_widget_set_halign(off, GTK_ALIGN_START);
gtk_widget_set_sensitive(off, plotted);
@@ -489,8 +525,29 @@ gboolean signal_page_probe(gpointer user) {
: find_descendant(page_it->second, [](GtkWidget* w) { return is_button_labelled(w, "Remove from plots"); });
if (off != nullptr) g_signal_emit_by_name(off, "clicked");
}
// The page's *header* names it -- numbered like the badge -- and the body carries no second copy of
// the name and no "Back to signals" button: the header's Close is the way out, the badge bar and the
// list the ways in. Read before switching to the list.
const int channel_number = measure_channel_number(a, name);
const std::string want_title =
channel_number > 0 ? std::to_string(channel_number) + ". " + name : name;
// Copied: the label's text is replaced when the probe switches pages below, and a `const char*` into
// it would be read after the free (it printed as an empty title).
const std::string shown_title =
a.slideover.title != nullptr ? gtk_label_get_text(GTK_LABEL(a.slideover.title)) : "";
const int title_ok = a.slideover.page == ("signal:" + name) && want_title == shown_title ? 1 : 0;
// ...and the body really has lost the two duplicates: no "Back to signals" button (the header's
// Close is the way out) and no second copy of the name (a `title-3` label; the header carries it).
GtkWidget* body = page_it == a.signal_pages.end() ? nullptr : page_it->second;
const int no_dup =
body != nullptr &&
find_descendant(body, [](GtkWidget* w) { return is_button_labelled(w, "Back to signals"); }) ==
nullptr &&
find_descendant(body, [](GtkWidget* w) { return gtk_widget_has_css_class(w, "title-3"); }) ==
nullptr
? 1
: 0;
slideover_open(a, "signals");
const int back_to_list = slideover_page(a) == "signals" ? 1 : 0;
// Activating the signal's row in the list opens its page (the path a click takes). The list proposes what
// can be added, so the signal has a row only while it is not acquired: switch it off for the leg and back
@@ -660,9 +717,10 @@ gboolean signal_page_probe(gpointer user) {
std::fprintf(stderr,
"[signalpage] name=%s open=%d pages=%d enable_visible=%d plot_buttons=%d plot_on_row0=%d "
"plot_on_row0_disabled=%d "
"removed=%d btn_row0=%d back_to_list=%d row_opens=%d pruned=%d other_page_kept=%d rows=%d\n",
"removed=%d btn_row0=%d title='%s' title_ok=%d no_dup=%d row_opens=%d pruned=%d "
"other_page_kept=%d rows=%d\n",
name.c_str(), open, pages, enable_visible, plot_buttons, plot_on_row0, plot_on_row0_disabled,
removed, btn_row0, back_to_list, row_opens, pruned,
removed, btn_row0, shown_title.c_str(), title_ok, no_dup, row_opens, pruned,
other_page_kept, a.rows->row_count());
return G_SOURCE_REMOVE;
}
+86 -17
View File
@@ -505,24 +505,26 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
app.updating_panels = false;
}
bool assign_signal_to_row(App& app, std::size_t signal, int row) {
if (signal >= app.model->signals().size()) return false;
const auto notice = [&](const std::string& what) {
set_notice(app, what, 4);
std::fprintf(stderr, "[udpscope] drop: %s\n", what.c_str());
std::fflush(stderr);
};
/// The drop's one-line report, wherever the drop landed: the notice bar and the log, because a
/// refusal a reader cannot act on is only half an answer.
void drop_notice(App& app, const std::string& what) {
set_notice(app, what, 4);
std::fprintf(stderr, "[udpscope] drop: %s\n", what.c_str());
std::fflush(stderr);
}
/// Resolves a dropped signal to an acquired channel, enabling the signal when it has to be: the
/// channel index and its display name, or -1 when the signal cannot be acquired (a multi-element
/// signal has no channel to bind). Shared by the main window's drop and the scope windows'.
int resolve_drop_channel(App& app, std::size_t signal, std::string* name) {
// The dropped signal's channels are the ones the model built for it; take the
// first, or the first element if the signal is a vector.
const std::vector<ChannelInfo>& channels = app.model->channels();
int channel = -1;
std::string name;
for (std::size_t i = 0; i < channels.size(); ++i) {
if (channels[i].signal == signal) {
channel = static_cast<int>(i);
name = channels[i].name;
*name = channels[i].name;
break;
}
}
@@ -533,14 +535,22 @@ bool assign_signal_to_row(App& app, std::size_t signal, int row) {
for (std::size_t i = 0; i < again.size(); ++i) {
if (again[i].signal == signal) {
channel = static_cast<int>(i);
name = again[i].name;
*name = again[i].name;
break;
}
}
}
return channel;
}
bool assign_signal_to_row(App& app, std::size_t signal, int row) {
if (signal >= app.model->signals().size()) return false;
std::string name;
const int channel = resolve_drop_channel(app, signal, &name);
if (channel < 0) {
notice("cannot acquire '" + app.model->signals()[signal].name +
"' (multi-element signals are not acquired)");
drop_notice(app, "cannot acquire '" + app.model->signals()[signal].name +
"' (multi-element signals are not acquired)");
return false;
}
@@ -554,9 +564,9 @@ bool assign_signal_to_row(App& app, std::size_t signal, int row) {
if (slot < 0) {
// Say what to do about it. A refusal a reader cannot act on is only half an answer: the two ways
// out are another plot or another row.
notice("plot " + app.rows->plot_label(row) +
" holds four signals already: drop it on another plot, or add a row with the layout "
"editor (L)");
drop_notice(app, "plot " + app.rows->plot_label(row) +
" holds four signals already: drop it on another plot, or add a row with "
"the layout editor (L)");
return false;
}
// The slot remembers the *signal* (and element), never the channel index: the
@@ -567,10 +577,69 @@ bool assign_signal_to_row(App& app, std::size_t signal, int row) {
schedule_layout_save(app);
app.relayout_pending = true;
rebuild_signal_panels(app); // the row's colour chip now has a trace to name
notice("plotting '" + name + "' on plot " + app.rows->plot_label(row));
drop_notice(app, "plotting '" + name + "' on plot " + app.rows->plot_label(row));
return true;
}
bool assign_signal_to_scope(App& app, App::ScopeWindow& scope, std::size_t signal, int row) {
if (scope.rows == nullptr || signal >= app.model->signals().size()) return false;
if (row < 0 || row >= scope.rows->row_count()) return false;
std::string name;
const int channel = resolve_drop_channel(app, signal, &name);
if (channel < 0) {
drop_notice(app, "cannot acquire '" + app.model->signals()[signal].name +
"' (multi-element signals are not acquired)");
return false;
}
int slot = -1;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
if (!scope.rows->slot_bound(row, s)) {
slot = s;
break;
}
}
if (slot < 0) {
drop_notice(app, scope.name + " plot " + scope.rows->plot_label(row) + " holds four signals "
"already: drop it on another plot of that window");
return false;
}
// The slot remembers the *signal* (and element), never the channel index -- the same rule the
// main window's rows follow, for the same renumbering reason.
const std::vector<SignalInfo>& signals = app.model->signals();
const SignalInfo& sig = signals[signal];
scope.rows->assign(row, slot, sig.name, name, signal_color_index(signals, signal));
// The rebuild and the area's re-point are the window's own frame tick's (a drop arrives from
// inside this widget, and a synchronous rebuild would reset the drag controller under it). The
// signal page is refreshed now: its *Plot on* buttons read the scope's rows for "already on".
signal_pages_sync(app);
drop_notice(app, "plotting '" + name + "' on " + scope.name + " plot " +
scope.rows->plot_label(row));
return true;
}
gboolean on_scope_drop_channel(GtkDropTarget* target, const GValue* value, double x, double y,
gpointer user) {
App* app = static_cast<App*>(user);
auto* scope =
static_cast<App::ScopeWindow*>(g_object_get_data(G_OBJECT(target), "udpscope-scope"));
if (scope == nullptr || scope->area == nullptr || scope->rows == nullptr) return FALSE;
if (!G_VALUE_HOLDS_STRING(value)) return FALSE;
const char* text = g_value_get_string(value);
if (text == nullptr || *text == '\0') return FALSE;
char* end = nullptr;
const unsigned long long parsed = std::strtoull(text, &end, 10);
if (end == nullptr || *end != '\0') return FALSE;
const std::size_t signal = static_cast<std::size_t>(parsed);
// Where the drop landed decides the row *of this window*: one area covers its figure's cells.
const int row = std::clamp(static_cast<int>(scope->area->row_at(x, y)), 0,
scope->rows->row_count() - 1);
if (!assign_signal_to_scope(*app, *scope, signal, row)) return FALSE;
if (plot::input::Controller* c = scope->area->row_controller(static_cast<plot::u32>(row))) {
c->on_key(plot::input::KeyEvent{plot::input::kKeyEscape, 0, true});
}
return TRUE;
}
gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y,
gpointer user) {
App* app = static_cast<App*>(user);
+17
View File
@@ -118,6 +118,23 @@ bool slideover_add_page(App& app, const std::string& id, const std::string& titl
return true;
}
bool slideover_set_title(App& app, const std::string& id, const std::string& title) {
SlideOver& s = app.slideover;
if (s.stack == nullptr || title.empty()) return false;
GtkWidget* child = gtk_stack_get_child_by_name(GTK_STACK(s.stack), id.c_str());
if (child == nullptr) return false;
GtkStackPage* page = gtk_stack_get_page(GTK_STACK(s.stack), child);
gtk_stack_page_set_title(page, title.c_str());
if (is_source_page(id)) {
AdwToggle* t = adw_toggle_group_get_toggle_by_name(ADW_TOGGLE_GROUP(s.source_switch), id.c_str());
if (t != nullptr) adw_toggle_set_label(t, title.c_str());
}
// The header shows the *open* page's title, so a retitle of the page on show is visible at once --
// a signal page's channel number can change under it when the stream renumbers its channels.
if (s.page == id) gtk_label_set_text(GTK_LABEL(s.title), title.c_str());
return true;
}
bool slideover_has_page(const App& app, const std::string& id) {
return app.slideover.stack != nullptr &&
gtk_stack_get_child_by_name(GTK_STACK(app.slideover.stack), id.c_str()) != nullptr;
+20
View File
@@ -56,6 +56,26 @@ const char* badge_markup(BadgePhase phase) {
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
/// picture does not show by itself (a drop that changed nothing, an export that wrote a
/// file). `tick()` clears it when it expires.
+9 -5
View File
@@ -441,8 +441,11 @@ struct TourStep {
constexpr TourStep kTourSteps[] = {
{"plot", nullptr, "The plot",
"Every signal you have switched on is drawn here, all plots sharing one time axis. The wheel "
"zooms, a drag pans, and a right-click on a plot opens that plot's own menu.",
"Every signal you have switched on is drawn here, all plots sharing one time axis -- and the "
"plots of a split row share one y as well. The wheel zooms, a drag pans, and a right-click on "
"a plot opens its own menu: fit, full screen and detach (the corner buttons come out as the "
"pointer nears them), a second window showing that figure, and the layout editor, which "
"splits a row into up to four plots.",
&App::area_overlay},
{"badges", nullptr, "The badges",
"Setup is reached by the thing it configures. A badge per signal opens that signal's colour, "
@@ -457,9 +460,10 @@ constexpr TourStep kTourSteps[] = {
{"side", nullptr, "The dock",
"The dock beside the plot is a read-only copy of the cursors and of every measurement, in a "
"fixed width, with the recorded events below them: one row per latched event, newest first, and "
"one click on a row replays it. Edit, beside each heading, opens that instrument's full panel "
"over the plot, Manage opens the full list of events; a click on a measurement opens its "
"dialog. The dock button in the bar above shows and hides the dock.",
"one click on a row replays it. Each source and each of its configurations keeps its own cache. "
"Edit, beside each heading, opens that instrument's full panel over the plot, Manage opens the "
"full list of events; a click on a measurement opens its dialog. The dock button in the bar "
"above shows and hides the dock.",
&App::side_box},
{"signals", "signals", "Adding a signal",
"What can be added: the signals the source declares that are not acquired yet, each with the "