Compare commits
24
Commits
8a37e9d412
..
0.1
No files matched your search
@@ -13,6 +13,25 @@ cmake --build build
|
|||||||
./build/udpscope --server 10.0.0.5 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9
|
./build/udpscope --server 10.0.0.5 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The app can be launched from the shell rather than only from a terminal, but only on request:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --build build --target desktop-entry
|
||||||
|
```
|
||||||
|
|
||||||
|
That writes `org.udpscope.app.desktop` plus both app icons under `$XDG_DATA_HOME` (default
|
||||||
|
`~/.local/share`), runs `desktop-file-validate` as a **hard failure** (when it is on `PATH`;
|
||||||
|
absent, it says so and installs anyway), refreshes `update-desktop-database` and
|
||||||
|
`gtk4-update-icon-cache` when they are on `PATH`, and prints the three paths it wrote — which is
|
||||||
|
the uninstall instructions, since there is no uninstall target. `cmake --build build` alone never
|
||||||
|
touches `$XDG_DATA_HOME`.
|
||||||
|
|
||||||
|
The entry's `Exec` is the built binary's **absolute path in the build tree**, so **moving or
|
||||||
|
deleting the build directory breaks it.** That is the price of having no install step and it is
|
||||||
|
deliberate: the entry then always launches exactly the binary that was last built. No
|
||||||
|
`StartupWMClass` is needed — GTK takes the WM class from the application ID, and the desktop
|
||||||
|
file's basename is that same string, which is what associates a running window with the entry.
|
||||||
|
|
||||||
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per
|
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per
|
||||||
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row
|
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row
|
||||||
count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards).
|
count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards).
|
||||||
@@ -25,20 +44,29 @@ form for a new host. Every probe below passes `--server`, so none of them sees i
|
|||||||
vendored C99 source pair compiled into a static lib.
|
vendored C99 source pair compiled into a static lib.
|
||||||
|
|
||||||
**libadwaita ≥ 1.8 is a system dependency** (`pkg_check_modules(ADWAITA ... libadwaita-1>=1.8)`),
|
**libadwaita ≥ 1.8 is a system dependency** (`pkg_check_modules(ADWAITA ... libadwaita-1>=1.8)`),
|
||||||
for exactly one widget: the shortcut overview is an `AdwShortcutsDialog`. GTK4's own
|
for two widgets: the shortcut overview (`AdwShortcutsDialog`) and the About dialog
|
||||||
`GtkShortcutsWindow` is deprecated as of 4.18 and that is its replacement. Two consequences
|
(`AdwAboutDialog`). GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and
|
||||||
worth knowing before editing. `adw_init()` is called — in `run_ui()` and, since the chooser
|
`AdwShortcutsDialog` is its replacement; `GtkAboutDialog` still exists but
|
||||||
runs first and must be styled the same, in `choose_source()` in place of its `gtk_init()`
|
`adw_about_window_new()` is deprecated in favour of `AdwAboutDialog`, which shows the
|
||||||
(both are idempotent) — so **the whole app is drawn with libadwaita's stylesheet**, not
|
application icon as the page's own header. Two consequences worth knowing before editing.
|
||||||
GTK's: a metric change there reaches every widget, and `layout over N frames` /
|
`adw_init()` is called — in `run_ui()` and, since the chooser runs first and must be styled
|
||||||
`axes.w changes` is what says whether the layout still settles. And an `AdwDialog` is
|
the same, in `choose_source()` in place of its `gtk_init()` (both are idempotent) — so
|
||||||
**not** a `GtkWindow`: `show_shortcuts()` keeps an `AdwDialog*`, clears it on `::closed`
|
**the whole app is drawn with libadwaita's stylesheet**, not GTK's: a metric change there
|
||||||
(there is no `::close-request`) and closes it with `adw_dialog_close()`. Presenting it on
|
reaches every widget, and `layout over N frames` / `axes.w changes` is what says whether
|
||||||
this app's plain `GtkApplicationWindow` is supported and warning-free; it was checked.
|
the layout still settles. And an `AdwDialog` is **not** a `GtkWindow`: both `show_about()`
|
||||||
|
and `show_shortcuts()` keep an `AdwDialog*`, cleared on `::closed` (there is no
|
||||||
|
`::close-request`) and closed with `adw_dialog_close()`. They share one `on_dialog_closed()`
|
||||||
|
handler keyed by a pointer-to-pointer (`gpointer slot` cast to `AdwDialog**`), so one
|
||||||
|
handler clears either pointer without knowing which dialog it is. Presenting either on this
|
||||||
|
app's plain `GtkApplicationWindow` is supported and warning-free; it was checked.
|
||||||
|
|
||||||
`deps/plotter` is upstream's own repository and this app's figure support was added
|
`deps/plotter` is upstream's own repository and this app's figure support was added
|
||||||
there rather than worked around here: `Figure::set_min_y_gutter()` (a floor under the
|
there rather than worked around here: `Figure::set_min_y_gutter()` (a floor under the
|
||||||
common left gutter), and in the GTK4 adapter `PlotArea`'s **figure mode** —
|
common left gutter), `Figure::set_right_gutter()` / `Plot::set_right_gutter()` (an empty
|
||||||
|
band on the *other* side, which the layout had no concept of — `axes` reached the
|
||||||
|
viewport's right edge, so the rightmost x tick label was centred on a line it had half of
|
||||||
|
itself outside of; the guard is `figure_rows_share_one_right_gutter`), and in the GTK4
|
||||||
|
adapter `PlotArea`'s **figure mode** —
|
||||||
`set_figure()`, one `input::Controller` per row, nearest-row event routing, a
|
`set_figure()`, one `input::Controller` per row, nearest-row event routing, a
|
||||||
figure-wide crosshair — plus `PlotArea::row_at()` for naming the row a pointer is over.
|
figure-wide crosshair — plus `PlotArea::row_at()` for naming the row a pointer is over.
|
||||||
|
|
||||||
@@ -48,9 +76,14 @@ it, and `LineProgram::set_snap()` / `u_snap` rounds an axis-aligned segment's in
|
|||||||
whole pixels for the chrome that frames a plot (grid and axis lines), never for anything
|
whole pixels for the chrome that frames a plot (grid and axis lines), never for anything
|
||||||
drawn at a data position. `generate_ticks()` also stores the tick at the origin as
|
drawn at a data position. `generate_ticks()` also stores the tick at the origin as
|
||||||
exactly `0.0` — the walk starts at `ceil(min/stride)*stride` and so never lands on zero
|
exactly `0.0` — the walk starts at `ceil(min/stride)*stride` and so never lands on zero
|
||||||
when the stride is not a power of two, which labelled it `-139a`. All three carry
|
when the stride is not a power of two, which labelled it `-139a`. `format_time()` picks its
|
||||||
|
unit rung by the **rounded** mantissa (`t < u.scale * 0.9995`, and the same slack on the
|
||||||
|
whole-second branch) — the `>= 999.5` spill guard `format_si()` in the same file always had —
|
||||||
|
because a tick a hair under a rung's top rounds to 1000 at three significant figures and
|
||||||
|
`%.3g` writes that `1e+03`, which is how a one-second tick read `1e+3ms`. All four carry
|
||||||
upstream tests — `the_atlas_is_filtered_not_point_sampled`,
|
upstream tests — `the_atlas_is_filtered_not_point_sampled`,
|
||||||
`grid_hairlines_land_on_whole_pixels` and `the_tick_at_the_origin_reads_zero`. The minor
|
`grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero` and
|
||||||
|
`format_time_never_lets_a_field_reach_its_radix`. The minor
|
||||||
grid's look is set there too, not here: `grid_minor` carries 50% alpha, `grid_minor_width` is
|
grid's look is set there too, not here: `grid_minor` carries 50% alpha, `grid_minor_width` is
|
||||||
0.75 (sub-pixel, expressed as coverage at 1x) and `grid_minor_dash_on`/`_off` are 3/3 — a
|
0.75 (sub-pixel, expressed as coverage at 1x) and `grid_minor_dash_on`/`_off` are 3/3 — a
|
||||||
fragment discard, no extra geometry — so the subdivisions read as texture under the labelled
|
fragment discard, no extra geometry — so the subdivisions read as texture under the labelled
|
||||||
@@ -76,6 +109,24 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
|||||||
./build/udpscope --server 127.0.0.1 --port 8000
|
./build/udpscope --server 127.0.0.1 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Two baselines every run is read against. A clean build emits **five** warnings and no more: two
|
||||||
|
`-Wmissing-field-initializers` on `MathNode::source`, one unused `output_built`, and
|
||||||
|
`-Woverlength-strings` on **both** generated GResource `.c` files — the help pages' and the
|
||||||
|
icons', whose blob crossed C99's 4095-byte string-literal limit when the two app icons joined the
|
||||||
|
four action ones (a note about a generated file, which GCC itself supports). And an exit-dump run
|
||||||
|
emits **no** `Gtk-CRITICAL` at all. A sixth warning, or any critical, is a finding.
|
||||||
|
|
||||||
|
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
|
||||||
|
the lucky reading of it. A run that had built and destroyed other windows first —
|
||||||
|
`UDPSCOPE_PROBE_HELP_MS`, which opens three — has had that memory reused, and the same two calls
|
||||||
|
then **SIGSEGV in the middle of the dump**, which reads as a truncated dump rather than as a
|
||||||
|
crash in a debug line. Every size the dump prints is therefore captured on the *tick*, while the
|
||||||
|
window is alive (`App::dbg_*`), and **no `gtk_widget_*` call may appear in the dump** — the
|
||||||
|
`panel controls:` line was already written that way for the same reason, and that comment is the
|
||||||
|
precedent, not an exception.
|
||||||
|
|
||||||
- `UDPSCOPE_EXIT_AFTER_MS` — run the trigger-on → go-live probe, close the window,
|
- `UDPSCOPE_EXIT_AFTER_MS` — run the trigger-on → go-live probe, close the window,
|
||||||
then dump the whole pipeline state to stdout (frame/sample counts, per-plot painted
|
then dump the whole pipeline state to stdout (frame/sample counts, per-plot painted
|
||||||
frames, trace bindings, panel sizes, status line in both states).
|
frames, trace bindings, panel sizes, status line in both states).
|
||||||
@@ -175,8 +226,8 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
|||||||
since `set_time_window()` frames the newest span before the pin clamps it — both are expected.
|
since `set_time_window()` frames the newest span before the pin clamps it — both are expected.
|
||||||
- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are
|
- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are
|
||||||
measured on a still picture. It now also asserts what a pause *is*
|
measured on a still picture. It now also asserts what a pause *is*
|
||||||
(`[pause-probe] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]` on
|
(`[pause-probe] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]
|
||||||
stderr):
|
glance=[..]` on stderr):
|
||||||
it presses the **button**, not `Acquisition::set_paused()`, because pausing is a display
|
it presses the **button**, not `Acquisition::set_paused()`, because pausing is a display
|
||||||
state as well as an acquisition one and the view snapshot and axis pin live in the
|
state as well as an acquisition one and the view snapshot and axis pin live in the
|
||||||
handler. Across the three lines **`ring_produced` must grow** (the producer never
|
handler. Across the three lines **`ring_produced` must grow** (the producer never
|
||||||
@@ -185,6 +236,17 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
|||||||
the ring) and **`frozen_x` must not move between the pause and the resume**; after the
|
the ring) and **`frozen_x` must not move between the pause and the resume**; after the
|
||||||
resume `event_size` is 0 and the window is back on the newest data. Pausing used to stop
|
resume `event_size` is 0 and the window is back on the newest data. Pausing used to stop
|
||||||
the producer, which is why the counts grew by nothing and the resume left a hole.
|
the producer, which is why the counts grew by nothing and the resume left a hole.
|
||||||
|
`frozen_x` is the *rebased* snapshot — the pause view's own stamps are relative to the freeze
|
||||||
|
instant now, so the range reads `[-window..0]`-ish rather than stream seconds, and a
|
||||||
|
`frozen_x` in the thousands is the rebase gone.
|
||||||
|
**`glance=` is the assertion for the paused status field**, and this is the only line that can
|
||||||
|
make it: the exit dump's `status bar:` is read live and its two `mode …` lines are the live and
|
||||||
|
triggered states. While paused it must read `paused · <rate> · <window> · for <elapsed>`, with
|
||||||
|
the elapsed figure **growing between the two paused lines** and **absent on the resume line**.
|
||||||
|
It is **wall** time (`g_get_monotonic_time()`), not stream time, because the producer never
|
||||||
|
stops while paused and "how long have I been looking at this" is a question about the user, not
|
||||||
|
about the stream; it is written through `format_elapsed()` so the field steps to a coarser unit
|
||||||
|
instead of growing digits (see the layout rule).
|
||||||
- `UDPSCOPE_EXPORT_MS` (+ optional `UDPSCOPE_EXPORT_PATH`, default
|
- `UDPSCOPE_EXPORT_MS` (+ optional `UDPSCOPE_EXPORT_PATH`, default
|
||||||
`/tmp/udpscope-export`) — collect the export table and write it both ways, skipping
|
`/tmp/udpscope-export`) — collect the export table and write it both ways, skipping
|
||||||
only the file chooser.
|
only the file chooser.
|
||||||
@@ -202,10 +264,18 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
monotone (`plot_host.cpp`), its one-shot widest-label reservation can land a frame or
|
monotone (`plot_host.cpp`), its one-shot widest-label reservation can land a frame or
|
||||||
two after the first measured frame, and the figure serves its floor out of the label
|
two after the first measured frame, and the figure serves its floor out of the label
|
||||||
strip (`y_width = max(measured, floor - y_title_w)`, `plot.cpp`) so the reservation
|
strip (`y_width = max(measured, floor - y_title_w)`, `plot.cpp`) so the reservation
|
||||||
converges over two layouts rather than one. A `y-gutter 44..74` dragging
|
converges over two layouts rather than one. A `y-gutter 88..104` dragging
|
||||||
`axes.x 67..80`/`axes.w 546..559` with it is that settle, not a twitch — confirm it
|
`axes.x 88..104`/`axes.w 707..782` with it is that settle, not a twitch — confirm it
|
||||||
with the `changes` line below. A range that keeps moving is the "twitching axis" bug:
|
with the `changes` line below. A range that keeps moving is the "twitching axis" bug:
|
||||||
the plot being re-measured every frame. **`x-desync` must read 0**: it counts frames
|
the plot being re-measured every frame. **A range that keeps *climbing* to 200 is the
|
||||||
|
other one**: `note_y_gutter()` is handed the gutter the row was laid out with, which
|
||||||
|
already includes the floor imposed last frame, so slack added to it is added again every
|
||||||
|
frame and the gutter ratchets to its own 200 px cap — `axes.x 88..200 / y-gutter 88..200
|
||||||
|
/ axes.w changes: 17` on an 870 px viewport is what that reads as, and 17 changes looks
|
||||||
|
like a settle. The reservation's own padding is the only slack there may be.
|
||||||
|
`axes.w` also no longer reaches the viewport's right edge: `apply_gutters()` reserves an
|
||||||
|
empty band there (~59 px of 870), so `viewport.w - axes.x - axes.w` is a *constant*, not
|
||||||
|
a zero. **`x-desync` must read 0**: it counts frames
|
||||||
in which any row's x range differed from row 0's by more than a pixel of data, i.e.
|
in which any row's x range differed from row 0's by more than a pixel of data, i.e.
|
||||||
frames the x link group did not reach. `x-ticks` is content, not layout, and
|
frames the x link group did not reach. `x-ticks` is content, not layout, and
|
||||||
legitimately varies by one as a live
|
legitimately varies by one as a live
|
||||||
@@ -225,7 +295,11 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
edge and `axis=` must stay at `[-pre..+post]` across consecutive probes while
|
edge and `axis=` must stay at `[-pre..+post]` across consecutive probes while
|
||||||
`data=`'s upper bound climbs towards `+post` — an axis that moves instead means the
|
`data=`'s upper bound climbs towards `+post` — an axis that moves instead means the
|
||||||
capture is being treated as a live view (`clamp_to_data()` reaching a pinned window).
|
capture is being treated as a live view (`clamp_to_data()` reaching a pinned window).
|
||||||
`epoch=` must be back to `0.0000` in every other state. Note the probe reads
|
`epoch=` is **minus the newest acquired stamp** in `live` and `nothing` — it is what makes the
|
||||||
|
live axis `[-window, 0]`, so there it grows (in magnitude) with the stream and a `0.0000` is
|
||||||
|
the defect, not the healthy reading. It is back to `0.0000` only in `event(frozen)`, where the
|
||||||
|
snapshot carries its own origin (`EventSnapshot::x_offset`) and the display epoch has nothing
|
||||||
|
left to do. Note the probe reads
|
||||||
`pending=` fresher than `display=` (which is last tick's decision), so a single
|
`pending=` fresher than `display=` (which is last tick's decision), so a single
|
||||||
`pending=0 display=live(capturing)` line is one frame of sampling skew, not a defect.
|
`pending=0 display=live(capturing)` line is one frame of sampling skew, not a defect.
|
||||||
And `snap=[a..b]` must equal `axis=[a..b]`, with `b` positive by roughly
|
And `snap=[a..b]` must equal `axis=[a..b]`, with `b` positive by roughly
|
||||||
@@ -272,19 +346,24 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
`events` does not move**: no event fired, so the partial publish deliberately leaves
|
`events` does not move**: no event fired, so the partial publish deliberately leaves
|
||||||
`trigger_latches()` alone — which is also what keeps the `trigger events: N total, M at
|
`trigger_latches()` alone — which is also what keeps the `trigger events: N total, M at
|
||||||
the disarm` line in the dump meaning what it says.
|
the disarm` line in the dump meaning what it says.
|
||||||
- `[export-probe] mode=.. axis=[a..b] rows=N cols=M time=[c..d] stream=[..]` (stderr,
|
- `[export-probe] mode=.. axis=[a..b] rows=N cols=M time=[c..d] abs=[..]` (stderr,
|
||||||
`UDPSCOPE_EXPORT_MS`) — **`time=` must equal `axis=`** and **`rows=` must be the
|
`UDPSCOPE_EXPORT_MS`) — **`time=` must equal `axis=`** and **`rows=` must be the
|
||||||
span times the stream's real rate, plus one** (2 s at 10 kS/s is 20001). The export
|
span times the stream's real rate, plus one** (2 s at 10 kS/s is 20001). The export
|
||||||
is the *visible* range at the *stored* rate, so a count far short of that is a table
|
is the *visible* range at the *stored* rate, so a count far short of that is a table
|
||||||
cut from the LOD pyramid, and a `time=` narrower than `axis=` is a window searched on
|
cut from the LOD pyramid, and a `time=` narrower than `axis=` is a window searched on
|
||||||
the wrong clock (the live branch subtracts `display_epoch()` before searching the
|
the wrong clock (the live branch puts the window back on the stream's clock with
|
||||||
ring). In `mode=event`, `stream=` must be the edge ± the window; in `mode=live` it
|
`stream_at_x0()` before searching the ring, and that offset is a whole window while
|
||||||
equals `time=`.
|
streaming — not the 0 it once was).
|
||||||
The one exception to both: an export taken **mid-capture** reports `mode=live` with
|
**`abs - time` must be a single constant across both ends of a line**, and that constant is
|
||||||
`time=[-pre..e]` where `e` is how far the post window has filled, and `stream=` offset
|
where the display's origin sits in the stream's own clock: `epoch()` plus the newest stamp in
|
||||||
from it by the edge (so `stream - time` reads the edge exactly) — the export is what is
|
`mode=live`, `epoch()` plus the accepted edge in `mode=event`. That is the one assertion on
|
||||||
on screen, and during a capture that is a partly filled window. Pin the probe outside a
|
the second column, and it replaces the old "`stream=` must be the edge ± the window" reading,
|
||||||
capture when checking the `rows=` count.
|
which only ever held because the display clock *was* the stream clock. A `mode=live` line
|
||||||
|
whose `abs - time` differs between the two ends is the two columns computed from different
|
||||||
|
epochs — the defect `abs_base` exists as a single `const` to prevent.
|
||||||
|
An export taken **mid-capture** reports `mode=live` with `time=[-pre..e]` where `e` is how far
|
||||||
|
the post window has filled — the export is what is on screen, and during a capture that is a
|
||||||
|
partly filled window. Pin the probe outside a capture when checking the `rows=` count.
|
||||||
The two `bytes=` lines are the writers: the `.npy` is
|
The two `bytes=` lines are the writers: the `.npy` is
|
||||||
`64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)`, so a size that is not that is a
|
`64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)`, so a size that is not that is a
|
||||||
mispacked record. Read it back with `pd.DataFrame(np.load(path))` after changing
|
mispacked record. Read it back with `pd.DataFrame(np.load(path))` after changing
|
||||||
@@ -368,11 +447,17 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
(a min/max showing both its marks counts two of its own). `measures window:` is the
|
(a min/max showing both its marks counts two of its own). `measures window:` is the
|
||||||
timeliness assertion -- the window the numbers were computed over against the one on screen.
|
timeliness assertion -- the window the numbers were computed over against the one on screen.
|
||||||
**The reading that must hold is `marks_in_window=K/K`**: every *time* mark inside the window
|
**The reading that must hold is `marks_in_window=K/K`**: every *time* mark inside the window
|
||||||
on screen. `drift` itself is a **single sample of a sawtooth** and is not an assertion on its
|
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.
|
||||||
|
`drift` itself is a **single sample of a sawtooth** and is not an assertion on its
|
||||||
own: a live view scrolls between recomputes, so it rises to one `kMeasureRefreshUs` of scroll
|
own: a live view scrolls between recomputes, so it rises to one `kMeasureRefreshUs` of scroll
|
||||||
and drops to zero at each pass. On a 2 s window at the 100 ms cadence the top of that sawtooth
|
and drops to zero at each pass. The figure is `(|Δmin| + |Δmax|) / span`, so a pure scroll
|
||||||
is `drift=5%`, which is what a dump landing just before a scheduled pass reads -- a `0.0000%`
|
counts **twice** and the top of the sawtooth is `2 × refresh / window`: ~10 % on a 2 s window
|
||||||
is a dump that landed just after one, not a better state. Read it against the recompute count:
|
at the 100 ms cadence, and a few *hundred* percent on a window shorter than the interval (a
|
||||||
|
50 ms window reads 254 % for 63.6 ms of scroll, and its ceiling is 400 %). Compute the ceiling
|
||||||
|
for the window the run actually used before calling a figure large. A `0.0000%`
|
||||||
|
is a dump that landed just after a pass, not a better state. Read it against the recompute count:
|
||||||
*more* recomputes with a *higher* drift sample is the sawtooth, while a drift that exceeds one
|
*more* recomputes with a *higher* drift sample is the sawtooth, while a drift that exceeds one
|
||||||
refresh interval of scroll, or marks outside the window, is the defect. A measurement is
|
refresh interval of scroll, or marks outside the window, is the defect. A measurement is
|
||||||
recomputed when the window moves (2 % of its span) or the held/live state changes, never on a
|
recomputed when the window moves (2 % of its span) or the held/live state changes, never on a
|
||||||
@@ -402,27 +487,58 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
*not* every channel, and never a stream's timestamp signal, which is what an acquired-but-
|
*not* every channel, and never a stream's timestamp signal, which is what an acquired-but-
|
||||||
unplotted list put first and auto-selected), and `drop_h` must be a combo box's height
|
unplotted list put first and auto-selected), and `drop_h` must be a combo box's height
|
||||||
(tens of pixels — a scrolled-window wrapper asked it to grow to 160).
|
(tens of pixels — a scrolled-window wrapper asked it to grow to 160).
|
||||||
- `[timelabels-probe] <action> relative=D window=.. origin=.. note=[..] ticks=[..]
|
- `[timelabels-probe] <action> relative=D distinct=D window=.. origin=.. note=[..] ticks=[..]
|
||||||
first_value=.. first_rel=.. range=[..]` + the dump's `time labels: relative=D origin=..
|
first_value=.. first_rel=.. range=[..]` + the dump's `time labels: relative=D distinct=D
|
||||||
note=[..] x_origin_set=.. ticks=[..] first_rel=..` (stderr,
|
origin=.. note=[..] x_origin_set=.. ticks=[..] first_rel=..` (stderr,
|
||||||
`UDPSCOPE_PROBE_TIMELABELS_MS`) — the x label form across a zoom, driven through
|
`UDPSCOPE_PROBE_TIMELABELS_MS`) — the x label form across a zoom, driven through
|
||||||
`apply_time_window()`: **under the figure's x link group the group owns the window, so a
|
`apply_time_window()`: **under the figure's x link group the group owns the window, so a
|
||||||
row-level `set_time_window()` does nothing** (a probe that changes the window must use the
|
row-level `set_time_window()` does nothing** (a probe that changes the window must use the
|
||||||
app's path). Absolute at 2 s and at 500 ms, `relative=1` below a second, and the caption
|
app's path). **`distinct` must be 1 on every line, and that is the assertion** — it says no two
|
||||||
must be non-empty exactly then. The probe *races the paint* — it reads the axis on a timer,
|
major labels on the axis are the same string, which is the one thing the relative form exists to
|
||||||
|
buy and the only reading that holds whatever the stream's uptime is. `relative=D` is **expected
|
||||||
|
to read 0 on every line now**, and that is a consequence of the newest-relative axis rather than
|
||||||
|
a change to the rule: the form is decided by the tick step against the label resolution at the
|
||||||
|
axis' **magnitude** (see the invariant below), and no display mode carries a large magnitude any
|
||||||
|
more — live and waiting are `[-window, 0]`, a capture and a held event are edge-relative, a pause
|
||||||
|
is freeze-relative. The 50 ms window that used to read absolute at 2 s of uptime (`2.13s, 2.14s,
|
||||||
|
2.15s`) and relative at 17 s (`100µs, 10.1ms, 20.1ms`, with the caption) now reads
|
||||||
|
`-40ms, -30ms, -20ms, -10ms` at any uptime. The rule and the caption are kept because they are
|
||||||
|
the answer for *any* axis magnitude and nothing in them is mode-specific — do not delete them on
|
||||||
|
the strength of this reading — but what the probe asserts is `distinct`, not the form. The
|
||||||
|
**caption must be non-empty exactly when `relative=1`**, which here means empty throughout. Run it after
|
||||||
|
`UDPSCOPE_ARM_TRIGGER_MS` as well, which is the case the span rule got wrong: a triggered 50 ms
|
||||||
|
window must read `relative=0 note=[] ticks=[-20ms,-10ms,0,10ms]` — a `t0` caption on an axis
|
||||||
|
that is already edge-relative re-anchors the ticks away from the trigger, which is the one
|
||||||
|
position the axis exists to show. The probe *races the paint* — it reads the axis on a timer,
|
||||||
and the ticks it sees are the previous layout's — so its `first_rel` (the first major minus
|
and the ticks it sees are the previous layout's — so its `first_rel` (the first major minus
|
||||||
the axis' origin) is a fraction of a step; the dump runs after the last paint and is the
|
the axis' origin) is a fraction of a step; the dump runs after the last paint and is the
|
||||||
race-free reading. Either way the assertion that matters is the *sum*: `first_value` must
|
race-free reading. Either way the assertion that matters is the *sum*: `first_value` must
|
||||||
equal `origin + first_rel` as a *tick label* plus its caption, i.e. the labels and the
|
equal `origin + first_rel` as a *tick label* plus its caption, i.e. the labels and the
|
||||||
caption must share one anchor. `x_origin_set` is read back out of the plot; it must be 0 in
|
caption must share one anchor. `x_origin_set` is read back out of the plot; it must be 0 in
|
||||||
the absolute form.
|
the absolute form. Note the last two steps both report `window=0.05`: `RowSet::set_time_window()`
|
||||||
- `[fit-probe] <action> window=.. x=[..] span=.. y=[..]` (stderr, `UDPSCOPE_PROBE_FIT_MS`) —
|
floors the window there, so the 20 ms step is clamped.
|
||||||
the two fits, through the same `RowSet` calls the right-click menu wires. It first puts
|
- `[fit-probe] <action> pinned=D window=.. event_span=.. x=[..] span=.. y=[..]` (stderr,
|
||||||
the y scale and the window where the data is *not* (a fixed `[-100, 100]`, a 50 ms
|
`UDPSCOPE_PROBE_FIT_MS`) — the two fits, through the **menu handlers** rather than the bare
|
||||||
window), so the assertions have something to be wrong about: **`fit x` must widen the
|
`RowSet` calls, because the notice and the control are the handler's half. A fit is the **undo
|
||||||
window to the data's span and leave `y` exactly as it was**, and **`fit all` must then
|
of an x zoom**, so the probe performs one: it moves the y scale off the data (a fixed
|
||||||
move `y` to the data's own range**. A `y` that changed on the `fit x` line is the two
|
`[-100, 100]`), sets a 2 s window through the control's own path, then zooms row 0 to a quarter
|
||||||
paths aliased.
|
of it the way a gesture does — on the *member* axis — and fits. Four assertions.
|
||||||
|
**`window` and `event_span` must be unchanged on every line** and equal to the typed 2 s: a fit
|
||||||
|
is a plot interaction and may not restate the time scale (see the invariant below), and they are
|
||||||
|
the only two readings that can see it try. The `zoomed` line's `span` must be a quarter of the
|
||||||
|
window, or the fits have nothing to undo and the lines below mean nothing. **`fit x` must put
|
||||||
|
`span` back to the whole window and leave `y` exactly as it was**, and **`fit all` must do the
|
||||||
|
same and move `y` to the data's own range** — a `y` that changed on the `fit x` line is the two
|
||||||
|
paths aliased. `x`'s *position* advances between lines on a live view and is not an assertion:
|
||||||
|
the follow is re-armed on the newest data, which is what the whole window means there.
|
||||||
|
Run it **inside a pause** as well (`UDPSCOPE_PROBE_PAUSE_MS=2000 UDPSCOPE_PROBE_FIT_MS=2200`),
|
||||||
|
which is the other branch and the one that can silently revert: `pinned=1`, and `fit x` must
|
||||||
|
restore the *pin's* full range — the snapshot's own stamps, legitimately narrower than the
|
||||||
|
requested window (`span=1.179900` against `window=2.000000` is correct, not a finding) — and
|
||||||
|
must still read `pinned=1` afterwards. The pin is restored by writing the **members** as well as
|
||||||
|
the group, because `hold_pinned_window()` prefers a member that has moved since `pinned_stated_`
|
||||||
|
— that is how it tells a gesture from its own echo — so a fit that wrote the group alone reads
|
||||||
|
back as the zoomed range one frame later.
|
||||||
- `[layout-probe] <action> rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] traces=[..]`
|
- `[layout-probe] <action> rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] traces=[..]`
|
||||||
(stderr, `UDPSCOPE_PROBE_LAYOUT_MS`) — **`buttons` must be `2N+1`** while the editor is
|
(stderr, `UDPSCOPE_PROBE_LAYOUT_MS`) — **`buttons` must be `2N+1`** while the editor is
|
||||||
up (one `×` per row, one `+` per boundary) and **0 on the `editor off` line**: the
|
up (one `×` per row, one `+` per boundary) and **0 on the `editor off` line**: the
|
||||||
@@ -460,6 +576,28 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
substitutes a missing-image placeholder, the button still works, and every other line of the
|
substitutes a missing-image placeholder, the button still works, and every other line of the
|
||||||
dump still passes. Changing an icon name is a one-line change in `build_toolbar()` and this
|
dump still passes. Changing an icon name is a one-line change in `build_toolbar()` and this
|
||||||
is the only check that it names something that exists.
|
is the only check that it names something that exists.
|
||||||
|
- `app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16)` (in the exit dump,
|
||||||
|
beside `toolbar icons:`) — the application icon, named and present at the size its own root
|
||||||
|
`<svg>` element declares. **Both entries must read `ok` at those sizes.** `ok` is
|
||||||
|
`gtk_icon_theme_has_icon()` *and* the resource bytes being found under the registered path, so a
|
||||||
|
`MISSING` is a resource path that was never registered or a file that was renamed, and a size
|
||||||
|
other than 128 or 16 is a canvas that drifted off the HIG's. **Read it together with
|
||||||
|
`toolbar icons:`, and trust that line further for the registration**: the resource-path
|
||||||
|
registration is one call in `run_ui()` serving both the window's icon and the toolbar's buttons,
|
||||||
|
so a `MISSING` on either line is the same defect — but on a machine where
|
||||||
|
`--target desktop-entry` has been run, *this* line's `has_icon` half is also satisfied by the
|
||||||
|
**installed** copy under `$XDG_DATA_HOME/icons/hicolor`, so a broken registration still reads
|
||||||
|
`ok` here. That was measured: with the resource path deliberately misspelt, `toolbar icons:`
|
||||||
|
reported four `MISSING` (nothing installs the action icons) while `app icons:` stayed `ok`
|
||||||
|
until the two installed SVGs were moved aside, whereupon it read `MISSING 128x128` /
|
||||||
|
`MISSING 16x16`. The line is still worth its place — the *present* half (the resource bytes) and
|
||||||
|
the declared sizes are read from the binary and nothing else can satisfy them — but a developer
|
||||||
|
machine is the one where it is least sensitive.
|
||||||
|
**It deliberately does not claim the icon renders**, because no runtime GTK call can:
|
||||||
|
`gtk_icon_theme_lookup_icon()` never returns `NULL`, its paintable's intrinsic size is the size
|
||||||
|
it was *asked* for, and a valid icon, a malformed one and a name that does not exist at all all
|
||||||
|
snapshot to the same `GSK_TEXTURE_NODE` of the requested bounds — GTK substitutes
|
||||||
|
`image-missing`. A malformed SVG is a *build* error instead; see the icon-authoring rules.
|
||||||
- `[sources] N known: HOST:PORT plots=P signals=S [multicast=G] [interface=I] | ...` (stderr,
|
- `[sources] N known: HOST:PORT plots=P signals=S [multicast=G] [interface=I] | ...` (stderr,
|
||||||
`UDPSCOPE_PROBE_SOURCES=1`) — the startup chooser's discovery, printed and exited **before
|
`UDPSCOPE_PROBE_SOURCES=1`) — the startup chooser's discovery, printed and exited **before
|
||||||
`gtk_init()`**, so this one needs no display and no `xvfb-run`. **Only sources with a named
|
`gtk_init()`**, so this one needs no display and no `xvfb-run`. **Only sources with a named
|
||||||
@@ -482,15 +620,25 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
first run's values, and the signals it names must come back *acquired* (a `row 0 slot 0
|
first run's values, and the signals it names must come back *acquired* (a `row 0 slot 0
|
||||||
-> channel ... samples=N` line). Delete the file before a run whose layout you did not
|
-> channel ... samples=N` line). Delete the file before a run whose layout you did not
|
||||||
mean to inherit (`127.0.0.1_9540.toml` and friends accumulate otherwise).
|
mean to inherit (`127.0.0.1_9540.toml` and friends accumulate otherwise).
|
||||||
- `[udpscope] starved: connected (config=D), reconnects=N, no new frames in the last
|
- `[udpscope] starved: connected (config=1), reconnects=N, no new frames in the last
|
||||||
second ...` (stderr, once per second while the state holds, plus a status-line notice) —
|
second ...` (stderr, once per second while the state holds, plus a status-line notice) —
|
||||||
"connected but nothing is arriving after a reconnect", which over unicast is the second
|
"connected but nothing is arriving after a reconnect", which over unicast is the second
|
||||||
instance taking the source's single session. Two ways to see it without a second client:
|
instance taking the source's single session. Check it does **not** fire against a live
|
||||||
point the app at a UDP port where nothing answers (the client CONNECTs, times out,
|
source. The status line carries ` | N reconnects` only when N > 0, which is the same
|
||||||
reconnects; `config=0`), and check it does **not** fire against a live source. The status
|
counter without the prose.
|
||||||
line carries ` | N reconnects` only when N > 0, which is the same counter without the
|
**The same stall test has two outcomes, split on `have_config`, and they are different
|
||||||
prose.
|
diagnoses.** With a signal list in hand, the above. With **no CONFIG at all** it prints
|
||||||
- **The export's `time`/`stream_time` columns must be strictly increasing and evenly
|
`[udpscope] connecting: connected (config=0), reconnects=N, no configuration from the
|
||||||
|
stream ...` and the notice reads *trying to connect* — nothing was taken away, because
|
||||||
|
with no signal list nothing can be enabled and no DATA frame is owed, and the old wording
|
||||||
|
blamed a second reader that does not exist. That state is also named *without* waiting for
|
||||||
|
the three-sample stall: the glance word is `connecting` and the badge is **yellow** from the
|
||||||
|
first frame (see `badge frames:`). The way to see it with no second client is to point the
|
||||||
|
app at a UDP port where nothing answers (the client CONNECTs, times out, reconnects), which
|
||||||
|
is now the `config=0` branch and no longer exercises the `starved:` one at all — a check of
|
||||||
|
the unicast-contention wording needs a real second reader, or a source that configures and
|
||||||
|
then goes quiet.
|
||||||
|
- **The export's `time`/`abs_seconds` columns must be strictly increasing and evenly
|
||||||
spaced.** Check the written file, not just the probe: `np.diff(t)` with no `<= 0` and
|
spaced.** Check the written file, not just the probe: `np.diff(t)` with no `<= 0` and
|
||||||
`rows / (t[-1] - t[0])` equal to the stored rate (the `ch` line's figure). Duplicate or
|
`rows / (t[-1] - t[0])` equal to the stored rate (the `ch` line's figure). Duplicate or
|
||||||
backwards stamps mean the recorded timeline holds two clocks or a collapsed cadence —
|
backwards stamps mean the recorded timeline holds two clocks or a collapsed cadence —
|
||||||
@@ -498,13 +646,30 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
README) and a per-sample step derived from the client's `lost` counter (now the
|
README) and a per-sample step derived from the client's `lost` counter (now the
|
||||||
stream's measured arrival cadence). A `rows=` count far below the span times the rate
|
stream's measured arrival cadence). A `rows=` count far below the span times the rate
|
||||||
is the other signature, that of a table cut from the LOD pyramid.
|
is the other signature, that of a table cut from the LOD pyramid.
|
||||||
- `badge frames: lit=L dim=D idle=I` — the activity badge's phase per frame. Over a run
|
**The step must hold to a few ULP of the column's own magnitude**, and this is the check that
|
||||||
that acquired anything **both `lit` and `dim` must be non-zero**: that is what "it
|
needs a long run at a high rate: the old form of this assertion was "`set(np.diff(t))` holds
|
||||||
|
exactly one value", and that was only true while `time` *was* the stored stamp. Both columns
|
||||||
|
are derived now — `time` is `stamp - stream_at_x0()` and `abs_seconds` is that plus a base of a
|
||||||
|
wholly different magnitude — so each subtraction costs a rounding and `set()` legitimately
|
||||||
|
holds a handful of neighbouring doubles. Measured: four values spanning ~1.5 ULP, 6.8e-16 s.
|
||||||
|
The thing that must not come back is the **plateau**: equal stamps, or a diff taking two or
|
||||||
|
three values a few *hundred nanoseconds* apart, is the time ring losing precision with uptime
|
||||||
|
(see the `f64` time-ring invariant below). The figure to compute is the ULP of `t[0]` and of
|
||||||
|
`abs_seconds[0]` — the defect that invariant guards is microseconds, nine orders of magnitude
|
||||||
|
above the arithmetic noise, so the two readings cannot be confused.
|
||||||
|
- `badge frames: lit=L dim=D idle=I connecting=C` — the activity badge's phase per frame. Over
|
||||||
|
a run that acquired anything **both `lit` and `dim` must be non-zero**: that is what "it
|
||||||
blinks" means, and a badge stuck lit is indistinguishable from a working one in a
|
blinks" means, and a badge stuck lit is indistinguishable from a working one in a
|
||||||
screenshot. One or two `idle` frames at the start are the frames before the socket
|
screenshot. One or two `idle` frames at the start are the frames before the socket
|
||||||
connected; a large `idle` is correct only when the run paused or disarmed (a
|
connected; a large `idle` is correct only when the run paused or disarmed (a
|
||||||
`UDPSCOPE_PROBE_DISARM_MS` run should show hundreds). The `mode ...` lines carry the
|
`UDPSCOPE_PROBE_DISARM_MS` run should show hundreds). **`connecting` must be 0 on a run
|
||||||
rest of the status line, so `rate` and `last event ... ago` are checked there.
|
against a live source** — it is the steady *yellow* phase, "the socket answered but the
|
||||||
|
stream has sent no CONFIG", and a non-zero count against a working mock means a signal list
|
||||||
|
that arrived late or not at all. It is the *only* non-zero phase on a dead-port run
|
||||||
|
(measured: `lit=0 dim=0 idle=90 connecting=446`, the `idle` being the frames before the
|
||||||
|
first CONNECT), which is also the check that a source that has sent nothing never gets the
|
||||||
|
blinking green. The `mode ...` lines carry the rest of the status line, so `rate` and
|
||||||
|
`last event ... ago` are checked there.
|
||||||
- `frame cost: tick A/B ms over N, draw C/D ms (max at frame F), paint E/G ms over M` and
|
- `frame cost: tick A/B ms over N, draw C/D ms (max at frame F), paint E/G ms over M` and
|
||||||
`frame path: raw=R lod=L frames, segments S/T (mean/max), visible max V samples` — the
|
`frame path: raw=R lod=L frames, segments S/T (mean/max), visible max V samples` — the
|
||||||
drawing cost, split into the frame tick, `PlotArea`'s own draw (timed from the frame
|
drawing cost, split into the frame tick, `PlotArea`'s own draw (timed from the frame
|
||||||
@@ -522,10 +687,12 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
lines and the disarm probe's `axis=`/`snap=` need a fast source to say anything —
|
lines and the disarm probe's `axis=`/`snap=` need a fast source to say anything —
|
||||||
`tools/mock_udpstreamer.py --rate 1300000 --batch 20000` — since at the default 10 kS/s a
|
`tools/mock_udpstreamer.py --rate 1300000 --batch 20000` — since at the default 10 kS/s a
|
||||||
whole window fits in the raw path and the publish is too short to lose a race.
|
whole window fits in the raw path and the publish is too short to lose a race.
|
||||||
- `trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%` plus a
|
- `trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%, worst
|
||||||
|
drawn-vs-quoted D px over R raw paints` plus a
|
||||||
`[ink] paint N <flags> mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=..
|
`[ink] paint N <flags> mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=..
|
||||||
ink=[..] cover=..% x0=.. x_at=[..] bounds=[..] rel=[..] dx=.. kept_fixed_x0=.. drops=[..]
|
ink=[..] cover=..% x0=.. x_at=[..] bounds=[..] rel=[..] dx=.. drops=[..]
|
||||||
back=..` line per anomalous frame (stderr) — **`collapsed` and `staircase` must both be 0**.
|
back=..` line per anomalous frame (stderr) — **`collapsed` and `staircase` must both be 0**,
|
||||||
|
and **`drawn-vs-quoted` must stay far under a pixel** (0.0000 is what a clean run reads).
|
||||||
This is the only check that can see a frame whose *waveform* vanished: `staged_segments`
|
This is the only check that can see a frame whose *waveform* vanished: `staged_segments`
|
||||||
counts geometry including the grid and the instruments, and `lod_traces` counts a trace whose
|
counts geometry including the grid and the instruments, and `lod_traces` counts a trace whose
|
||||||
points are then collapsed, so both read healthy through the flicker. The probe runs on every
|
points are then collapsed, so both read healthy through the flicker. The probe runs on every
|
||||||
@@ -535,11 +702,22 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
`collapsed` is fewer than two distinct positions left (nothing to draw at all), `staircase`
|
`collapsed` is fewer than two distinct positions left (nothing to draw at all), `staircase`
|
||||||
is the filter dropping over three quarters of the points, `short` is ink reaching under a
|
is the filter dropping over three quarters of the points, `short` is ink reaching under a
|
||||||
quarter of the axis (reported only once the data spans the window, since a filling ring is
|
quarter of the axis (reported only once the data spans the window, since a filling ring is
|
||||||
*meant* to leave its left side empty). `kept_fixed_x0` is the same filter with `x0()` hoisted
|
*meant* to leave its left side empty). `drawn-vs-quoted` is the other reading, and it is the
|
||||||
out of the loop: it exceeding `kept` means `x0()` moved *during* the walk. **`back` ≈ one ring
|
one that names a *defect of position* rather than of presence: at a raw level a gathered pair
|
||||||
span is the signature** of the torn `x0()` read (see the cached-first-stamp invariant); so is
|
**is** sample `first + k`, so the line differences where a sample is drawn
|
||||||
an `ink=` range disjoint from `axis=`, or an `idx=` covering the whole ring with `cover` far
|
(`axis.min + gather[2k]`, the renderer's own reconstruction) against the x a readout quotes
|
||||||
over 100%. It is a race, so it needs load and a reason for long frames —
|
for it (`x_at()`, exact f64) and reports the worst of them in pixels. That is the
|
||||||
|
gather-origin invariant below read from the screen's side; it was over a hundred pixels at a
|
||||||
|
deep zoom before the origin became the caller's. It is measured on the raw levels only — above
|
||||||
|
them a gathered point is a bucket's envelope and has no single sample to be off from — so `R`
|
||||||
|
is legitimately below `N`. **`back` ≈ one ring span is the signature** of the torn `x0()` read
|
||||||
|
(see the cached-first-stamp invariant); so is an `ink=` range disjoint from `axis=`, or an
|
||||||
|
`idx=` covering the whole ring with `cover` far over 100%. There was a `kept_fixed_x0` field
|
||||||
|
beside `kept` — the same filter with `x0()` hoisted out of the loop, since the renderer used to
|
||||||
|
re-read `x0()` per point and a ring-backed one advances with the producer. The origin is a
|
||||||
|
local of the renderer's now, so that failure mode is structurally impossible and the field is
|
||||||
|
gone; the `[ink]` walk and the renderer still agree because both add `axis.min`. It is a race,
|
||||||
|
so it needs load and a reason for long frames —
|
||||||
`tools/mock_udpstreamer.py --rate 1300000 --batch 2000 --clock-jitter 0.4` with the
|
`tools/mock_udpstreamer.py --rate 1300000 --batch 2000 --clock-jitter 0.4` with the
|
||||||
measurements *and* cursors panes up:
|
measurements *and* cursors panes up:
|
||||||
`UDPSCOPE_ENABLE_ALL_MS=1000 UDPSCOPE_PROBE_MEASURES_MS=2000 UDPSCOPE_PROBE_CURSORS_MS=3500
|
`UDPSCOPE_ENABLE_ALL_MS=1000 UDPSCOPE_PROBE_MEASURES_MS=2000 UDPSCOPE_PROBE_CURSORS_MS=3500
|
||||||
@@ -564,10 +742,29 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the
|
so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the
|
||||||
per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget
|
per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget
|
||||||
tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read.
|
tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read.
|
||||||
- `[help] pages=14 parsed=14 shortest='Overview' 1381 B; shortcuts: app=12 plot=13; about version=… commit=… built=… settings=…; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — **`parsed` must equal `pages`** and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window *looks* fine and only this count notices. `settings=` must be a real path (the same one the About dialog shows). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of three windows built, presented and closed in one run as it is a state check. Two of the three are `GtkWindow`s and close with `gtk_window_close()`; the shortcut overview is an `AdwDialog` and closes with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape.
|
- `[help] pages=14 parsed=14 shortest='Pause and play' 1434 B; shortcuts: app=12 plot=13; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — **`parsed` must equal `pages`** and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window *looks* fine and only this count notices. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of three windows built, presented and closed in one run as it is a state check, and a stronger one than it was: the three are now closed by **two different calls**, so a mismatched pair crashes or leaks here. One of the three is a `GtkWindow` (the help window, `gtk_window_close()`); the About dialog and the shortcut overview are `AdwDialog`s and close with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape.
|
||||||
|
- `[menu] N 'label' accel='key' h=P label_x=X accel_right=R/W order=D ok=O`, one line per row,
|
||||||
|
then `[menu] rows=R expected=E order=D rows_bad=B popover_visible=V mapped=M button_mapped=B`
|
||||||
|
and `[menu] status pos=top bubble=WxH button_top=T button_right=R fits=F` (stderr,
|
||||||
|
`UDPSCOPE_PROBE_MENUS_MS`) — the two popovers the toolbar and the status bar open, measured with
|
||||||
|
the side panel hidden. **`rows_bad` must be 0 and `fits` must be 1.** What it asserts is what a
|
||||||
|
screenshot can only show an eye: every row's label starts at the *same* x (`label_x=26` for all
|
||||||
|
seven — a menu whose checkable rows indent their text and whose others do not is exactly what
|
||||||
|
"the text is not left-aligned" looks like), every shortcut ends at the row's own right edge
|
||||||
|
(`accel_right == W`), no row is over `kMenuRowMaxPx` (30) tall — Adwaita's button minimum is 34,
|
||||||
|
and seven of those is what made the popover read as a stack of dialogs until `.udpscope-menu`
|
||||||
|
trimmed it — and the rows arrive in the order its `kExpected` names. `[menu] status` is the ⓘ
|
||||||
|
bubble: **`pos` must read `top`**, which is set *after* `gtk_menu_button_set_popover()` because a
|
||||||
|
`GtkMenuButton` states a position of its own and the last word wins, and `button_top` must exceed
|
||||||
|
the bubble's height, because "below the button" is off the window's bottom edge — the bubble's
|
||||||
|
top edge stayed at the button with its body clipped away, which reads as an arrow disconnected
|
||||||
|
from its body. Run it **alone**: another probe that opens a window dismisses the popover, and
|
||||||
|
every row then reports `h=0`. It turns autohide off for its own measurement — a popover dismisses
|
||||||
|
itself when it loses the grab, and `xvfb-run` has no window manager to hand it one — and restores
|
||||||
|
it afterwards.
|
||||||
- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `<key>: <widget-description>: <before>-><after>` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values).
|
- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `<key>: <widget-description>: <before>-><after>` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values).
|
||||||
- `[zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1` (stderr, `UDPSCOPE_PROBE_ZOOMKEYS_MS`) — **both `_held` must be 1 and both zooms must still happen.** Run it inside a pinned state, where a frame cannot re-frame the other axis for its own reasons: `UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200`. A `_held=0` is the modifier not reaching the plotter's controller; an unchanged range on the *zoomed* axis is the opposite failure, the key doing nothing. Ctrl restricts to x and Alt to y; Ctrl+Alt zooms neither (no special case needed — neither flag alone fires).
|
- `[zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1` (stderr, `UDPSCOPE_PROBE_ZOOMKEYS_MS`) — **both `_held` must be 1 and both zooms must still happen.** Run it inside a pinned state, where a frame cannot re-frame the other axis for its own reasons: `UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200`. A `_held=0` is the modifier not reaching the plotter's controller; an unchanged range on the *zoomed* axis is the opposite failure, the key doing nothing. Ctrl restricts to x and Alt to y; Ctrl+Alt zooms neither (no special case needed — neither flag alone fires).
|
||||||
- `status bar: glance=[● live · 1.09 MS/s · 2 s window] max_glance_chars=53 detail_fields=11 popover_rebuilds=0` (exit dump) — **`max_glance_chars` must stay small** (under about 60) and **`popover_rebuilds` must be 0**. The status row is directly above the plot, so this is the "layout must not depend on content" rule read from the bar's own side: the state field is one word from a fixed set, the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), and the only field trigger mode adds is the elapsed time, which steps units rather than growing digits. `detail_fields` is the popover's label count, a literal, so adding a label without thinking about layout shows up here. The glance rate format matters: `%.3g` drops the trailing zero that a `%.4g` or a fixed-precision format would keep — a rate that reads differently in the dump and in the help page is a format disagreement, and `14-status.txt`'s example must match it.
|
- `status bar: glance=[● live · 1.09 MS/s · 2 s window] max_glance_chars=53 detail_fields=11 popover_rebuilds=0` (exit dump) — **`max_glance_chars` must stay small** (under about 60) and **`popover_rebuilds` must be 0**. The status row is directly above the plot, so this is the "layout must not depend on content" rule read from the bar's own side: the state field is one word from a fixed set (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`, `disarmed` — a word added to it must be **no wider than the widest already there**, which is what let `connecting` join at ten characters beside `event held`; the dead-port glance reads `max_glance_chars=51` against a live run's 54), the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), and the two fields a *state* adds are both elapsed times — trigger mode's `last event … ago` and a pause's `for <elapsed>` — which step units rather than growing digits (`format_elapsed()`). A **paused** glance is not in the dump at all: it is read by `[pause-probe]`'s `glance=` field, since the dump's own line is taken live. `detail_fields` is the popover's label count, a literal, so adding a label without thinking about layout shows up here. The glance rate format matters: `%.3g` drops the trailing zero that a `%.4g` or a fixed-precision format would keep — a rate that reads differently in the dump and in the help page is a format disagreement, and `14-status.txt`'s example must match it.
|
||||||
|
|
||||||
The probes are the regression net — when changing UI or plot-host behaviour, extend
|
The probes are the regression net — when changing UI or plot-host behaviour, extend
|
||||||
the probe/dump in `src/ui.cpp` rather than adding a separate harness.
|
the probe/dump in `src/ui.cpp` rather than adding a separate harness.
|
||||||
@@ -603,13 +800,16 @@ immutable copy of the captured window; see README for why two live rings were dr
|
|||||||
What a trace binds to is `ScopeModel::display_mode()`, one of four states, and
|
What a trace binds to is `ScopeModel::display_mode()`, one of four states, and
|
||||||
`RowSet::bind()` takes it as an argument rather than recomputing it:
|
`RowSet::bind()` takes it as an argument rather than recomputing it:
|
||||||
|
|
||||||
- `Live` — no trigger: the live ring on the stream clock.
|
- `Live` — no trigger: the live ring, on the clock of the **newest sample**
|
||||||
|
(`display_epoch() == -newest`), so the axis is a fixed `[-window, 0]` and x reads as an age.
|
||||||
- `Capturing` — an edge has been accepted (so the pre window was full) and the post
|
- `Capturing` — an edge has been accepted (so the pre window was full) and the post
|
||||||
window is still filling: the live ring, on the coming event's own clock
|
window is still filling: the live ring, on the coming event's own clock
|
||||||
(`display_epoch() == -edge`), inside an axis already pinned to `[-pre, +post]`. The
|
(`display_epoch() == -edge`), inside an axis already pinned to `[-pre, +post]`. The
|
||||||
post side fills in view and the marker sits at x = 0 throughout.
|
post side fills in view and the marker sits at x = 0 throughout.
|
||||||
- `Held` — the frozen `EventSnapshot` of the last event.
|
- `Held` — the frozen `EventSnapshot` of the last event, whose stamps are already edge-relative,
|
||||||
- `Waiting` — armed, nothing captured yet: blank.
|
so the display epoch is 0.
|
||||||
|
- `Waiting` — armed, nothing captured yet: blank, with the live epoch (there is nothing bound,
|
||||||
|
but the axis is still a live one).
|
||||||
|
|
||||||
**Disarm during a capture publishes what the capture got to**, then disarms: the producer
|
**Disarm during a capture publishes what the capture got to**, then disarms: the producer
|
||||||
cuts the window at the newest sample and that partial snapshot becomes the held event, so
|
cuts the window at the newest sample and that partial snapshot becomes the held event, so
|
||||||
@@ -632,6 +832,35 @@ and hands it to `bind()`). The producer can open or close a capture between any
|
|||||||
reads, and two reads in one frame put the bind, the epoch, the pin and the status line on
|
reads, and two reads in one frame put the bind, the epoch, the pin and the status line on
|
||||||
different clocks.
|
different clocks.
|
||||||
|
|
||||||
|
**The display clock is not the stream clock, and `ScopeModel::stream_at_x0()` is the only
|
||||||
|
conversion.** `display_x = stream_t - stream_at_x0()` in **every** mode, and the offset lives in
|
||||||
|
one of two places: a snapshot carries its own (`EventSnapshot::x_offset`, frozen with the
|
||||||
|
picture) and a live view carries it as minus `display_epoch()`. Anything crossing between the
|
||||||
|
two clocks goes through that one accessor rather than picking the branch itself — the export's
|
||||||
|
`abs_seconds` column, the pause handler putting a display window back on the ring to cut it, the
|
||||||
|
measurement marks — because there are four modes and picking the branch by hand gets one of them
|
||||||
|
wrong. It reads atomics only, so it is safe to call inside an existing `mutex()` guard.
|
||||||
|
Three things about the live epoch specifically:
|
||||||
|
- **It is written after `bind()` and `acquire_all()`, not before.** It must be minus the newest
|
||||||
|
sample the *traces* can report, and asking before the acquire is one frame of production
|
||||||
|
stale — at 1 MS/s over a 50 ms window that is a third of the screen to the right of the axis.
|
||||||
|
- **There is no feedback loop**, even though the newest stamp is read through the epoch that is
|
||||||
|
about to change: `RowSet::live_edge()` reports in the epoch currently in force, so
|
||||||
|
`display_epoch() - live_edge()` is minus the *raw* stamp and the fixed point is reached in one
|
||||||
|
frame rather than converged on.
|
||||||
|
- **`Waiting` takes the live epoch too.** Nothing is bound, but the axis is still a live one, and
|
||||||
|
an epoch of 0 there would make the axis jump on the frame a capture closes.
|
||||||
|
|
||||||
|
**A measurement mark is glued to its sample; a cursor stays on screen.** The two instruments move
|
||||||
|
in opposite directions under a rolling axis and that is deliberate — a bench scope's cursors sit
|
||||||
|
where the user put them while the waveform rolls under, and a measurement's mark names the sample
|
||||||
|
it measured. The cursors need no code: their x *is* a display coordinate. The marks are stored in
|
||||||
|
**stream** seconds and reconverted every frame by `place_measure_marks()`, since a mark left in
|
||||||
|
display coordinates would crawl forward at one window per window. `App::measure_window` is
|
||||||
|
stream-clocked for the same reason, and that one is load-bearing: the recompute trigger is "the
|
||||||
|
window moved by 2 % of its span", and in display coordinates a live window is a fixed
|
||||||
|
`[-window, 0]` — it never moves, so the measurements would never be recomputed again.
|
||||||
|
|
||||||
Layers to keep straight:
|
Layers to keep straight:
|
||||||
|
|
||||||
- `SignalInfo` — one signal as the CONFIG described it. The unit of enable/disable.
|
- `SignalInfo` — one signal as the CONFIG described it. The unit of enable/disable.
|
||||||
@@ -670,6 +899,47 @@ far the largest file.
|
|||||||
again until the next latch. `PlotArea::create()` is handed `App::placeholder_plot`,
|
again until the next latch. `PlotArea::create()` is handed `App::placeholder_plot`,
|
||||||
never a row: the area keeps a reference to the plot it was created with, and a rebuild
|
never a row: the area keeps a reference to the plot it was created with, and a rebuild
|
||||||
destroys every row.
|
destroys every row.
|
||||||
|
- **A readout quotes a stored sample, never a value between two of them.** The hover
|
||||||
|
crosshair and the cursors pane's `mix` values are one call, `Plot::crosshair_value()`, and it
|
||||||
|
reports the **nearer** of the two samples bracketing the x (a tie reads the earlier). Upstream
|
||||||
|
interpolated linearly, and that is wrong twice over here: ingest decimation is a *boxcar
|
||||||
|
average* over `time_stride` samples, so a number between two stored samples is a second
|
||||||
|
estimate laid over the first that nothing on screen or in an export holds; and past the sample
|
||||||
|
spacing the polyline is a line the renderer drew, so a point on it reports the drawing. The
|
||||||
|
change is in `deps/plotter`, which keeps the blend as `CrosshairValue::Interpolated` for a host
|
||||||
|
whose x is a quantity it is willing to interpolate — the nearest sample is the **default** and
|
||||||
|
**udpscope never states the other**, so a `set_crosshair_value_mode()` call appearing here is a
|
||||||
|
regression, not a feature.
|
||||||
|
**Both the mark and the number come from one call, `Plot::crosshair_point()`**, which returns
|
||||||
|
the quoted sample's own **x** as well as its y — so every mark this app draws for a readout is
|
||||||
|
*snapped to the sample it quotes*. The library's `CrosshairRow::marker` and the cursors pane's
|
||||||
|
`mix` crossing annotations (`place_crossings()`, which must use `crosshair_point()` and not
|
||||||
|
`crosshair_value()`) are both placed from it. Two consequences that look like defects and are
|
||||||
|
not: the mark **sits beside the polyline at a deep zoom** (one y feeding both the number and the
|
||||||
|
dot is what keeps them from disagreeing), and a crossing dot **sits a little off its own
|
||||||
|
vertical cursor line** — up to half a sample period, invisible at a whole-window view and the
|
||||||
|
width of the panel at a few samples across, which is the only zoom at which anyone is asking
|
||||||
|
which point it is. The line says where the *marker* is; the dot says which *datum* is quoted.
|
||||||
|
The hover mark is also deliberately *larger* than a trace's own sample dots
|
||||||
|
(`Style::crosshair_marker_px` 9 against `marker_px` 3), because a snapped mark lands among them
|
||||||
|
and at their size reads as one more sample. The guards are the library's
|
||||||
|
`crosshair_value_is_the_nearest_sample`, `the_crosshair_marker_snaps_to_the_sample_it_reports`,
|
||||||
|
`crosshair_interpolated_mode_blends_the_two_samples` and
|
||||||
|
`the_crosshair_mark_is_bigger_than_a_sample_dot`, plus `[cursors-probe]`'s `first mark at x=..
|
||||||
|
y=..` agreeing with the pane's first value.
|
||||||
|
- **`format_measure()` is the one formatter for both panes, and its SI prefix does not depend on
|
||||||
|
there being a unit.** The cursors pane's values and the measurements pane's `text=` go through
|
||||||
|
it, so a change reaches both. The prefix ladder used to be inside `if (!unit.empty())`, which
|
||||||
|
made two decimals of a small unitless value read `0.00` — the reading gone, not merely coarse —
|
||||||
|
and that is a whole class of channels, not a corner: `measure_unit()` returns `""` for any signal
|
||||||
|
whose CONFIG declares no unit, and a **math channel never has one**. The regression net could not
|
||||||
|
see it because `tools/mock_udpstreamer.py` declared `unit="V"` for every data signal; it now takes
|
||||||
|
**`--unit ''`**, and that is the flag to run the measures and cursors probes under when touching
|
||||||
|
this (`text=[636.62m]`, `● sine=585.24m`). With no unit the prefix is written **tight** against
|
||||||
|
the number — `1.34m`, not `1.34 m` — because the space exists to separate the number from its
|
||||||
|
unit and a spaced `m` alone reads as metres; with one it stays spaced and the prefix joins the
|
||||||
|
unit (`636.62 mV`). Below `1e-12` the ladder runs out, and `%.2f` would print `0.00` again, so
|
||||||
|
that range falls back to `%.3g`.
|
||||||
- **The cursors are read back from the plots only while a gesture is on that row.** `Cursor::x`
|
- **The cursors are read back from the plots only while a gesture is on that row.** `Cursor::x`
|
||||||
is the *free* pointer position while a drag runs and the snapped value otherwise -- which is
|
is the *free* pointer position while a drag runs and the snapped value otherwise -- which is
|
||||||
what the app itself wrote a frame earlier, or the snap of it. A tick that reads a row on
|
what the app itself wrote a frame earlier, or the snap of it. A tick that reads a row on
|
||||||
@@ -800,15 +1070,32 @@ far the largest file.
|
|||||||
`gather()`, `x_at()`, `neighbours()` and `x_bounds()` use the real time ring.
|
`gather()`, `x_at()`, `neighbours()` and `x_bounds()` use the real time ring.
|
||||||
Nothing may walk that grid to find *which* samples a range covers — the renderer did,
|
Nothing may walk that grid to find *which* samples a range covers — the renderer did,
|
||||||
and it lost the two end segments (see the margin invariant above). `gather()` emits x
|
and it lost the two end segments (see the margin invariant above). `gather()` emits x
|
||||||
relative to `x0()` because absolute epoch seconds do not survive `f32`.
|
relative to an origin because absolute epoch seconds do not survive `f32`.
|
||||||
- **`gather()` emits x relative to `x0()` in *every* series here**, `EventSeries`
|
- **`gather()` emits x relative to an origin *the caller names*, in every series here**,
|
||||||
included, and the renderer adds `x0()` back. An event's stamps are small (edge
|
`EventSeries` included, and the consumer adds that same origin back in `f64`. Two separate
|
||||||
relative, `[-pre, +post]`), so emitting them absolute does not overflow anything and
|
rules live here and both were learned from a defect.
|
||||||
does not look like a bug — it just draws the whole capture `x0()` = one pre-trigger
|
(1) **It must be relative at all.** An event's stamps are small (edge relative,
|
||||||
window to the left of the axis. That was the "half the plot is empty / the marker sits
|
`[-pre, +post]`), so emitting them absolute does not overflow anything and does not look
|
||||||
at the end of the data / nothing is drawn but the crosshair reads values" defect: the
|
like a bug — it just draws the whole capture `x0()` = one pre-trigger window to the left of
|
||||||
crosshair reads `x_at()`, which was right all along. The probe's `data=` vs `drawn=`
|
the axis. That was the "half the plot is empty / the marker sits at the end of the data /
|
||||||
is the guard.
|
nothing is drawn but the crosshair reads values" defect: the crosshair reads `x_at()`, which
|
||||||
|
was right all along. `[trig-probe]`'s `data=` vs `drawn=` is the guard.
|
||||||
|
(2) **The origin is the *view's* minimum, not `x0()`.** `x0()` is the start of the
|
||||||
|
**retained window**, so the f32 had to resolve 2^-24 of the whole ring however little of it
|
||||||
|
was on screen: 1.9 µs over 20 s of retained data, which at a view 10 µs wide across a 716 px
|
||||||
|
axis is over a hundred pixels between where a sample is drawn and the x `x_at()` quotes for
|
||||||
|
it — and it halved on every zoom out, so it read as "zooming in makes the crosshair select a
|
||||||
|
point beside the data". `Renderer::draw_traces` passes `x_range.min`, making the error a fixed
|
||||||
|
4e-5 of a pixel at any zoom. The parameter sits **before** `out_xy` deliberately, so a call
|
||||||
|
site that was not thought about is a compile error rather than silent drift. A caller that has
|
||||||
|
no view in mind may still pass `x0()`, and `[trig-probe]`'s `drawn=`/`data=` does exactly that
|
||||||
|
on purpose: it reads the series' *whole* span, which is the widest relative value a gather can
|
||||||
|
be asked to stage. The two guards are upstream's
|
||||||
|
`a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes` and the
|
||||||
|
`trace ink:` line's `drawn-vs-quoted` figure.
|
||||||
|
**A probe that recomputes what the renderer gathers must pass the same origin the renderer
|
||||||
|
does** (`[ink]` and `[zoom-probe]` both pass the axis minimum), or its reconstruction stops
|
||||||
|
being the renderer's and its pixel readings mean nothing.
|
||||||
- **Every series bound to a trace must carry a real `MinMaxPyramid`**, including an
|
- **Every series bound to a trace must carry a real `MinMaxPyramid`**, including an
|
||||||
immutable one. `MinMaxPyramid::level_for()` descends to level 0 — raw samples — on a
|
immutable one. `MinMaxPyramid::level_for()` descends to level 0 — raw samples — on a
|
||||||
pyramid with no levels, whatever `n` is, so a series that answers `pyramid()` with an
|
pyramid with no levels, whatever `n` is, so a series that answers `pyramid()` with an
|
||||||
@@ -824,6 +1111,32 @@ far the largest file.
|
|||||||
draw a bucket's extremes. It needs no index rebasing, unlike the ring: the snapshot's
|
draw a bucket's extremes. It needs no index rebasing, unlike the ring: the snapshot's
|
||||||
pyramid is built from sample 0 and nothing is ever dropped from it, so `window_base()` is
|
pyramid is built from sample 0 and nothing is ever dropped from it, so `window_base()` is
|
||||||
0 and a window index *is* the absolute one.
|
0 and a window index *is* the absolute one.
|
||||||
|
- **The time ring is `f64` and the value rings are `f32`, and the asymmetry is the point.**
|
||||||
|
A value's precision is a property of the signal (24 bits is plenty for a sample); a
|
||||||
|
timestamp's is a property of *how long the scope has been running*, because the stamps are
|
||||||
|
stored relative to an epoch fixed at the first sample ever seen and so grow without bound
|
||||||
|
while the interval they must resolve stays one sample period. `f32`'s ULP is 1.9 µs at 16 s
|
||||||
|
of uptime, 3.8 µs at 32 s, 7.6 µs at 64 s and 61 µs over a 600 s window — so at 1 MS/s two
|
||||||
|
to four consecutive samples shared a stamp after half a minute, eight after a minute, and
|
||||||
|
the LOD envelope over such a plateau drew a zero-width segment, i.e. a vertical spike where
|
||||||
|
the waveform should be. That is "zoom in to ~10 µs and three to five points share a
|
||||||
|
timestamp, and min/max produces artefacts". **Do not widen the values to match**: they are
|
||||||
|
the memory budget, and nothing about them is magnitude-bound this way. Three consequences:
|
||||||
|
`plan_partition()` can no longer count slots in its growth loop (a time slot and a value
|
||||||
|
sample are different sizes, so it weighs the budget in **bytes** — `sizeof(f64)` for the
|
||||||
|
clock plus `sizeof(f32) / stride[i]` per channel — and `bytes_for()` is also what bounds
|
||||||
|
the doubling against overflow); `live_window_locked()` compares its bounds in the ring's own
|
||||||
|
type with **no rounding step**, the old `f32` round-trip having existed only so that a
|
||||||
|
sample sitting exactly on a bound was kept whatever the stream's absolute time was; and
|
||||||
|
`EventSnapshot::stamps` is `f64` because it is cut from this ring and is therefore in the same
|
||||||
|
units, whatever a given snapshot's own origin is (both kinds are rebased now — an event's
|
||||||
|
stamps are edge-relative, a pause's freeze-relative — so neither is magnitude-bound the way the
|
||||||
|
ring is, and the width is for the cut rather than for the coordinate). `RingBuffer<T>` stores
|
||||||
|
slots as `std::atomic<T>` behind a `static_assert(is_always_lock_free)`, which `double`
|
||||||
|
satisfies on every supported target. The guard is the **export**: over a multi-million-row
|
||||||
|
`.npy`, `np.diff(t)` must hold one step to within a few ULP of the column's magnitude (see
|
||||||
|
the export guard above for why it is no longer exactly one value). Three steps at 2, 2.5 and
|
||||||
|
3 ULP of *the stream's uptime* — microseconds, not femtoseconds — is this defect.
|
||||||
- **A dropped time slot is clamped, never answered with a sentinel.** `acquire()` pins
|
- **A dropped time slot is clamped, never answered with a sentinel.** `acquire()` pins
|
||||||
the published window to the value ring's base and both rings keep sliding afterwards —
|
the published window to the value ring's base and both rings keep sliding afterwards —
|
||||||
their capacities are equal, so there is no slack — which means the oldest few samples
|
their capacities are equal, so there is no slack — which means the oldest few samples
|
||||||
@@ -841,14 +1154,19 @@ far the largest file.
|
|||||||
sample 0 is the ring's oldest, the very slot the producer is about to overwrite. `time_of()`
|
sample 0 is the ring's oldest, the very slot the producer is about to overwrite. `time_of()`
|
||||||
checks the bounds and then copies, and when the producer laps the slot in between the stamp it
|
checks the bounds and then copies, and when the producer laps the slot in between the stamp it
|
||||||
returns is **one whole ring-span too new**. Everything else on a `ChannelBuffer` is read at
|
returns is **one whole ring-span too new**. Everything else on a `ChannelBuffer` is read at
|
||||||
paint time with no lock, and `x0()` is the origin the renderer reconstructs every trace point
|
paint time with no lock, and `x0()` *was* the origin the renderer reconstructed every trace
|
||||||
from (`gather[i] + series.x0()`), read both inside its increasing-suffix filter and again at
|
point from (`gather[i] + series.x0()`), read both inside its increasing-suffix filter and again
|
||||||
`stage_xy()` — so one torn read of that one slot produced three pictures, all of them "the
|
at `stage_xy()` — so one torn read of that one slot produced three pictures, all of them "the
|
||||||
waveform is gone while the grid and the axes stay": `neighbours()` tests against `x_at(0)` and
|
waveform is gone while the grid and the axes stay": `neighbours()` tests against `x_at(0)` and
|
||||||
refused the whole visible range (the renderer then gathered the *entire* ring at a coarse level
|
refused the whole visible range (the renderer then gathered the *entire* ring at a coarse level
|
||||||
and drew it off the axis — `idx=[0..8388608) level=7 cover=320%`); `x0()` changing *during* the
|
and drew it off the axis — `idx=[0..8388608) level=7 cover=320%`); `x0()` changing *during* the
|
||||||
filter loop dropped three quarters of the points; and `stage_xy()` shifted the finished polyline
|
filter loop dropped three quarters of the points; and `stage_xy()` shifted the finished polyline
|
||||||
a ring-span off its axis. That is the "with measurements and cursors active the plot flickers as
|
a ring-span off its axis. The gather origin is the *view's* minimum now (see the `gather()`
|
||||||
|
invariant), a local of the renderer's, so the second and third of those are structurally
|
||||||
|
impossible — but **the first is not**, `x_at(0)` is still what `neighbours()` tests the visible
|
||||||
|
range against, and that is the one that gathered the whole ring. The cache is load-bearing for
|
||||||
|
that reason; do not read the slot at paint time because two of its three symptoms are gone.
|
||||||
|
That is the "with measurements and cursors active the plot flickers as
|
||||||
if there were no data, at no constant frequency" defect. It is **live only** (a snapshot is
|
if there were no data, at no constant frequency" defect. It is **live only** (a snapshot is
|
||||||
immutable, so `x0()` is constant for `Held`/`Paused`), it is a race against packet arrival so it
|
immutable, so `x0()` is constant for `Held`/`Paused`), it is a race against packet arrival so it
|
||||||
is irregular, and the measurements pane makes it both likelier and larger: the pane holds
|
is irregular, and the measurements pane makes it both likelier and larger: the pane holds
|
||||||
@@ -878,8 +1196,18 @@ far the largest file.
|
|||||||
Its pan clamping is likewise gated on the data being wider than the view, or the empty
|
Its pan clamping is likewise gated on the data being wider than the view, or the empty
|
||||||
left-hand side drags the live edge off the right-hand edge.
|
left-hand side drags the live edge off the right-hand edge.
|
||||||
- **Layout must not depend on content.** The y gutter is reserved at the widest label
|
- **Layout must not depend on content.** The y gutter is reserved at the widest label
|
||||||
`TickFormat::Si` can produce and only ever grows; the status label is ellipsized so
|
`TickFormat::Si` can produce *for an axis with no unit* — a sign, three significant
|
||||||
|
figures and a one-glyph prefix, measured and not counted in characters — and only ever
|
||||||
|
grows; the status label is ellipsized so
|
||||||
its text cannot drive the paned divider. Both exist to stop per-frame relayout.
|
its text cannot drive the paned divider. Both exist to stop per-frame relayout.
|
||||||
|
The reservation deliberately does **not** cover `format_si()`'s `%.3g` fallback
|
||||||
|
(`-1.23e-45`), which it used to: that form needs a value past 1e24, so it bought a third
|
||||||
|
of the gutter for a label no stream produces, and the floor being a monotone maximum over
|
||||||
|
the *measured* need means one that did appear widens the gutter for good anyway. Erring
|
||||||
|
wide is not free here — it is a third of the plot's width.
|
||||||
|
And **nothing may add slack to the gutter it is told**: `note_y_gutter()`'s argument is
|
||||||
|
`layout().y_axis_width`, which is `max(measured, floor)` — partly our own answer coming
|
||||||
|
back — so a constant added to it compounds. See the ratchet reading under Verification.
|
||||||
The same rule is why the activity badge is one glyph in three colours rather than
|
The same rule is why the activity badge is one glyph in three colours rather than
|
||||||
three glyphs, why the `rate` field collapses to one figure or a range instead of a
|
three glyphs, why the `rate` field collapses to one figure or a range instead of a
|
||||||
per-channel list, and why `format_elapsed()` steps to a coarser unit instead of
|
per-channel list, and why `format_elapsed()` steps to a coarser unit instead of
|
||||||
@@ -889,6 +1217,16 @@ far the largest file.
|
|||||||
plotter for this), not on the rows: `Figure::layout()` clears every row's
|
plotter for this), not on the rows: `Figure::layout()` clears every row's
|
||||||
`set_min_y_gutter()` to measure what its own labels need, so a floor written onto the
|
`set_min_y_gutter()` to measure what its own labels need, so a floor written onto the
|
||||||
rows survives exactly until the next layout.
|
rows survives exactly until the next layout.
|
||||||
|
- **The right-hand band is a want, and the left gutter is a measurement.** The two sides of
|
||||||
|
the plot are not symmetric in kind: the left holds the y labels, so it is measured and
|
||||||
|
floored; the right holds nothing, so there is nothing to measure and `RowSet` simply asks
|
||||||
|
for half the widest time label plus the label padding (`right_gutter_px_`, reserved in the
|
||||||
|
same one-shot block). Half a label is the figure that matters: an x tick label is
|
||||||
|
*centred* on its tick and a live axis is `[-window, 0]`, so there is always a tick exactly
|
||||||
|
on the axes' right edge, and with `axes` flush to the viewport half of that label was
|
||||||
|
drawn outside the plot. Both go on the **figure** and for the same reason — the rows have
|
||||||
|
to end on one column as well as start on one — which is why `apply_gutters()` applies the
|
||||||
|
pair and `rebuild_all()` calls it: a new `Figure` carries neither.
|
||||||
- **Signals start disabled** — enabling is what starts acquisition. A trigger on a
|
- **Signals start disabled** — enabling is what starts acquisition. A trigger on a
|
||||||
signal nobody acquires can never fire, so selecting/arming enables it.
|
signal nobody acquires can never fire, so selecting/arming enables it.
|
||||||
- **The trigger's event window is separate from the display window**, and
|
- **The trigger's event window is separate from the display window**, and
|
||||||
@@ -1226,12 +1564,72 @@ far the largest file.
|
|||||||
that grew from `1 s` to `123.456 ms` would widen the bar and relayout everything under it
|
that grew from `1 s` to `123.456 ms` would widen the bar and relayout everything under it
|
||||||
on a mere change of window. That is also why the window is typed into a popover rather
|
on a mere change of window. That is also why the window is typed into a popover rather
|
||||||
than shown in the bar.
|
than shown in the bar.
|
||||||
- **`apply_time_window()` is the only place the display window changes.** It clamps to
|
- **No plot interaction changes the time scale; only the window control does.** The *time scale*
|
||||||
`window_limit()` and moves `RowSet::set_time_window()` and `ScopeModel::set_event_span()`
|
is more than the axis: `RowSet::window_s_` also feeds `ScopeModel::set_event_span()` (the
|
||||||
together, because the trigger's pre-trigger share is a share of *this* window. Paths that
|
trigger's pre-trigger share is a share of *this* window), `ScopeModel::set_target_span()`
|
||||||
change the span for their own reasons (Fit, an event pin) call `sync_window_display()`
|
(which may re-plan the partition, move the decimation and **drop the history**), the window
|
||||||
instead, which only restates the control and never moves the plot — calling `apply_` there
|
control's readout and the session file. A **zoom or a pan** does not touch it by construction —
|
||||||
would clamp a legitimately short capture up to the requested window.
|
the controller writes the *member* axis and `clamp_to_data()` caps the span at `window_s_`, so
|
||||||
|
nothing has to be written to keep it that way. **Neither do the two fits**: a fit is the
|
||||||
|
*undo of a zoom*, so `RowSet::fit_x()` restores the window that is already set and reads
|
||||||
|
`window_s_` without ever writing it. Both fits used to frame the *data* instead and move
|
||||||
|
`window_s_` alone, which was wrong twice over — a gesture-triggered re-plan of the rings, and
|
||||||
|
an event span left on the *old* window until the next decade step or unit change applied the
|
||||||
|
fitted value for real (one action, two changes of time scale).
|
||||||
|
- **`apply_time_window()` is the only place the time scale changes**, and the window control is
|
||||||
|
its only caller. It clamps to `window_limit()` and moves `set_target_span()`,
|
||||||
|
`set_event_span()`, `RowSet::set_time_window()`, the control and the session save together —
|
||||||
|
the partition **before** the axis, so the axis is framed over the rings that will exist. A fit
|
||||||
|
must not come here for two reasons: it is not entitled to the scale at all, and
|
||||||
|
`set_time_window()` unpins and re-arms the live follow, which would throw a held event or a
|
||||||
|
paused view onto a live edge it has nothing bound to. Paths that change the span for their own
|
||||||
|
reasons (an event pin) call `sync_window_display()` alone, which only restates the control and
|
||||||
|
never moves the plot — calling `apply_time_window()` there would clamp a legitimately short
|
||||||
|
capture up to the requested window.
|
||||||
|
- **The x labels go relative on the tick *step against the label resolution at the axis'
|
||||||
|
magnitude*, never on the span.** `RowSet::update_time_labels()`'s question is only ever "can
|
||||||
|
this step be written at this magnitude": `TickFormat::Time` carries three significant figures
|
||||||
|
below a minute, **whole seconds** through `m:ss`/`h:mm:ss` up to a day, and three figures of
|
||||||
|
days above — so the resolution is a step function of the magnitude, which `time_label_resolution()`
|
||||||
|
states and nothing else may re-derive. The old test was `span < 1.0` and it was wrong in both
|
||||||
|
directions: a 2 s window at twenty minutes of uptime reads `20:00` on every tick and stayed
|
||||||
|
absolute, and a **triggered** sub-second window — whose axis is already `[-25ms, +25ms]` about
|
||||||
|
the edge — went relative and so re-anchored its ticks *away from the trigger*, with a `t0`
|
||||||
|
caption that had nothing to explain. Three details hold it up. The step is read back from the
|
||||||
|
majors the **last layout** produced, which is not a feedback loop (a tick's value depends on the
|
||||||
|
range and the axis' pixel width, never on the label form) but *is* absent before the first
|
||||||
|
layout — and with no ticks the form must be **left alone**, because a step guessed from the span
|
||||||
|
would be latched by the hysteresis below. The hysteresis is a 1.05 band on *leaving* the relative
|
||||||
|
form. And *entering* it needs a ULP of slack (`1 - 1e-9`): a step differenced from two tick
|
||||||
|
values and a resolution from `pow(10, n)` differ in their last bits when they are the same
|
||||||
|
number, and `step == res` is a readable axis (`1.73s`, `1.74s`, `1.75s` — one in the last digit
|
||||||
|
is exactly enough), so the bare comparison picked a different form from one frame to the next.
|
||||||
|
The guard is `[timelabels-probe]`'s `distinct=1`, which asserts what the form is *for* rather
|
||||||
|
than which form a given window gets.
|
||||||
|
- **The `t0 = …` caption is chrome, never an annotation.** It is `Plot::set_x_note()` (added to
|
||||||
|
`deps/plotter` for this), drawn by `Renderer::draw_x_note()` on the legend's own
|
||||||
|
`draw_readout_panel()` backplate in the bottom-left corner of the axes, in **pixels**. It was an
|
||||||
|
`Annotation` first, and an annotation's anchor is a *data* coordinate: the caption therefore
|
||||||
|
crawled across the plot under a pan and drifted under a zoom, which is what "[t0=...s] move
|
||||||
|
slightly around when user zoom/pan" was. Three things follow. The **text is the host's**, because
|
||||||
|
the caption has to name the anchor precisely enough that label + caption is the exact absolute
|
||||||
|
time and `format_time()` would round it to three figures (`"t0 = %.6g s"`); only the **bottom
|
||||||
|
row** of the figure carries one, every other row being set to `""`; and the renderer measures it
|
||||||
|
rather than `Plot::layout()` — it reserves no space and nothing can be picked on it, so there is
|
||||||
|
no rect to read back, and a caption explaining the x labels must not be able to move the layout
|
||||||
|
those labels were measured into. The guards are the library's
|
||||||
|
`the_x_note_is_chrome_that_a_pan_or_a_zoom_does_not_move` (the footprint is the pixels a frame
|
||||||
|
with the caption differs from one without, and a pan plus a 64× zoom must give the same box to
|
||||||
|
the pixel) and `[timelabels-probe]`'s `note=`, which reads `Plot::x_note()` back.
|
||||||
|
- **`fit_x()` restores the *whole of what the view may show*, which is not always `window_s_`.**
|
||||||
|
Live, that is the newest `window_s_` at the live edge (`set_time_window(window_s_)`, the same
|
||||||
|
path a resume takes). Pinned — a held event, a filling capture or a paused view — it is the
|
||||||
|
**pin's** full range, which is the snapshot's own stamps and may be legitimately *narrower*
|
||||||
|
than the requested window. It must write the per-row **member** axes as well as the link group:
|
||||||
|
`hold_pinned_window()` prefers a member that differs from `pinned_stated_`, which is how it
|
||||||
|
tells a real gesture from its own echo, so writing the group alone is reverted to the zoomed
|
||||||
|
range on the next frame. `fit_all()` is the y fit plus this, shared rather than duplicated so
|
||||||
|
the two cannot drift apart.
|
||||||
- **The header bar's buttons are icon-only**, so headless probes must reach them
|
- **The header bar's buttons are icon-only**, so headless probes must reach them
|
||||||
through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via
|
through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via
|
||||||
`find_labelled_button()`. The **pane switches are toolbar buttons** too (`cursors_toggle`,
|
`find_labelled_button()`. The **pane switches are toolbar buttons** too (`cursors_toggle`,
|
||||||
@@ -1247,25 +1645,54 @@ far the largest file.
|
|||||||
runs after the window is destroyed and asking GTK about a widget then is four criticals, which is
|
runs after the window is destroyed and asking GTK about a widget then is four criticals, which is
|
||||||
how the first version of that line earned them. What stays in the menu is what acts on the *data*
|
how the first version of that line earned them. What stays in the menu is what acts on the *data*
|
||||||
(Fit, Export) and the two editors that are not panes (Layout, Signal list). The Arm/Disarm button in the trigger panel still has text.
|
(Fit, Export) and the two editors that are not panes (Layout, Signal list). The Arm/Disarm button in the trigger panel still has text.
|
||||||
- **Four of the toolbar icons are this app's own, and they are a `GResource` compiled into the
|
- **Six of this app's icons are its own, and they are a `GResource` compiled into the
|
||||||
binary.** No icon theme has a glyph for an oscilloscope trigger, a pair of cursors or a
|
binary.** No icon theme has a glyph for an oscilloscope trigger, a pair of cursors or a
|
||||||
measurement ruler, and the stock names those buttons used to carry said something else
|
measurement ruler, and the stock names those buttons used to carry said something else
|
||||||
entirely: a *record dot* for the trigger and three different **magnifiers** for cursors,
|
entirely: a *record dot* for the trigger and three different **magnifiers** for cursors,
|
||||||
measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,math}-symbolic.svg`
|
measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,math}-symbolic.svg`
|
||||||
are bundled by `src/icons/udpscope.gresource.xml` (CMake runs `glib-compile-resources`) and
|
are the four action icons; beside them in `src/icons/scalable/apps/` are the two
|
||||||
reached through the `gtk_icon_theme_add_resource_path()` at the top of `build_toolbar()`.
|
**application** icons, `org.udpscope.app.svg` (128px colour artwork) and
|
||||||
|
`org.udpscope.app-symbolic.svg` (16px). The `apps` context is where an icon theme keeps an
|
||||||
|
application's own icon and is where the **in-process** window icon resolves from; it is also
|
||||||
|
the layout that `cmake/install_desktop_entry.cmake` mirrors when it copies the same two files
|
||||||
|
into `$XDG_DATA_HOME/icons/hicolor/scalable/apps` and `.../symbolic/apps` — those installed
|
||||||
|
copies are what makes `Icon=org.udpscope.app` in the desktop entry resolve (a desktop shell
|
||||||
|
cannot read a GResource compiled into the binary).
|
||||||
|
All six are bundled by `src/icons/udpscope.gresource.xml` (CMake runs
|
||||||
|
`glib-compile-resources`) and reached through **one**
|
||||||
|
`gtk_icon_theme_add_resource_path()` in `run_ui()`, right after `adw_init()` — not at the top
|
||||||
|
of `build_toolbar()`, where it used to be: there are two consumers now (the window's own icon,
|
||||||
|
set before the toolbar is built, and the toolbar's buttons) and `run_ui()` is the one place
|
||||||
|
ahead of both. `adw_init()` calls `gtk_init()`, which is what makes
|
||||||
|
`gdk_display_get_default()` valid there.
|
||||||
A resource rather than files beside the binary because udpscope is run straight out of the
|
A resource rather than files beside the binary because udpscope is run straight out of the
|
||||||
build tree and has no install step. Five authoring rules, each of which fails quietly:
|
build tree and has no install step. Five authoring rules, each of which fails quietly:
|
||||||
* **GTK's symbolic recolour only reaches `fill` on `path`, `rect` and `circle`.** It wraps the
|
* **GTK's symbolic recolour only reaches `fill` on `path`, `rect` and `circle`.** It wraps the
|
||||||
SVG in a stylesheet for those selectors, so a `stroke`, a `line` or a `polyline` keeps the
|
SVG in a stylesheet for those selectors, so a `stroke`, a `line` or a `polyline` keeps the
|
||||||
colour written in the file and stays dark on a dark theme. Everything is a filled `path`.
|
colour written in the file and stays dark on a dark theme. **In a symbolic icon everything is
|
||||||
|
therefore a filled `path`** — including anything that wants to look stroked, which is drawn as
|
||||||
|
a closed band (two offset curves joined) instead. The rule is scoped to symbolic icons on
|
||||||
|
purpose: `org.udpscope.app.svg` is 128px colour artwork that is never recoloured, so it strokes
|
||||||
|
its sine freely, which is the sane way to draw a smooth one. Do not "fix" one to match the
|
||||||
|
other.
|
||||||
* **Whole-pixel coordinates**, because these are drawn at 16 px far more often than not: the
|
* **Whole-pixel coordinates**, because these are drawn at 16 px far more often than not: the
|
||||||
first draft placed features on half-pixels and the whole icon was a grey smear at 1x.
|
first draft placed features on half-pixels and the whole icon was a grey smear at 1x.
|
||||||
* `fill-rule="evenodd"` is how the ruler's body is a hole *and* its ticks still fill -- a tick
|
* `fill-rule="evenodd"` is how the ruler's body is a hole *and* its ticks still fill -- a tick
|
||||||
inside the hole is three crossings deep, which is odd. Nonzero would fill the hole back in.
|
inside the hole is three crossings deep, which is odd. Nonzero would fill the hole back in.
|
||||||
* Overlapping subpaths under the default nonzero rule must be **wound the same way** or they
|
* Overlapping subpaths under the default nonzero rule must be **wound the same way** or they
|
||||||
cancel instead of uniting. All of them run clockwise.
|
cancel instead of uniting. All of them run clockwise.
|
||||||
* `--` is illegal inside an XML comment and librsvg refuses the whole file for it.
|
* **A malformed SVG is a build error, and that is the only place it can be caught.** Every
|
||||||
|
`<file>` in `src/icons/udpscope.gresource.xml` carries `preprocess="xml-stripblanks"`, which
|
||||||
|
makes `glib-compile-resources` pipe it through `xmllint` — so `--` inside an XML comment, which
|
||||||
|
is illegal and which librsvg refuses the whole file for, fails the build with a file, a line
|
||||||
|
and a caret under the offending character (`…app-symbolic.svg:5: parser error : Double hyphen
|
||||||
|
within comment`). It has to be here because **no runtime check can see it**: librsvg refuses
|
||||||
|
the file but the icon *name* still resolves, so `gtk_icon_theme_has_icon()` returns true and
|
||||||
|
the icon draws nothing. `gtk_icon_theme_lookup_icon()` cannot tell either (see the `app icons:` line under
|
||||||
|
Verification), `GMarkup` accepts the double hyphen because it is a lenient subset parser, and
|
||||||
|
`gdk_pixbuf` goes through glycin here, which sandboxes a subprocess per load and refuses this
|
||||||
|
project's own icons outright. The cost is that the `xmllint` binary is a build dependency; it
|
||||||
|
ships with libxml2, which GTK already requires.
|
||||||
`gtk_icon_theme_has_icon()` is checked for every toolbar button and reported by the dump (see
|
`gtk_icon_theme_has_icon()` is checked for every toolbar button and reported by the dump (see
|
||||||
`toolbar icons:` under Verification): a resource path that was never added does not raise an
|
`toolbar icons:` under Verification): a resource path that was never added does not raise an
|
||||||
error, it draws `image-missing`.
|
error, it draws `image-missing`.
|
||||||
@@ -1311,23 +1738,31 @@ far the largest file.
|
|||||||
Those two used to read the *label*, and the label no longer carries `packets`, `gaps`,
|
Those two used to read the *label*, and the label no longer carries `packets`, `gaps`,
|
||||||
`plot 1:` or the buffer figures — so a `gtk_label_get_text()` there would silently reduce
|
`plot 1:` or the buffer figures — so a `gtk_label_get_text()` there would silently reduce
|
||||||
every documented `mode …:` assertion to four fields. The glance line's state field is **one
|
every documented `mode …:` assertion to four fields. The glance line's state field is **one
|
||||||
word from a fixed set** (`live`, `paused`, `armed`, `capturing`, `event held`, `disarmed`) for
|
word from a fixed set** (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`,
|
||||||
the layout reason above; a seventh word, or a field whose width tracks the stream, breaks the
|
`disarmed`) for the layout reason above; a word **wider than the widest already there**, or a
|
||||||
row the plot is laid out under.
|
field whose width tracks the stream, breaks the row the plot is laid out under. The set may
|
||||||
|
grow — `connecting` was added for the no-CONFIG state — but only on that condition, and the
|
||||||
|
check is the dump's `max_glance_chars`. `connecting` is derived from `have_config` alone and
|
||||||
|
sits **ahead of every other branch**, including the display mode's: with no signal list, every
|
||||||
|
other word claims something that does not exist yet. The `mode` prose branches the same way for
|
||||||
|
the same reason, which is what keeps the two tiers from disagreeing.
|
||||||
- **The ⓘ popover's widgets are built once and only their text is set.** It is visible when the
|
- **The ⓘ popover's widgets are built once and only their text is set.** It is visible when the
|
||||||
tick updates it, so a rebuild is a per-frame relayout of a widget on screen.
|
tick updates it, so a rebuild is a per-frame relayout of a widget on screen.
|
||||||
`update_status_popover()` only ever calls `gtk_label_set_text()`, and `popover_rebuilds` in
|
`update_status_popover()` only ever calls `gtk_label_set_text()`, and `popover_rebuilds` in
|
||||||
the dump is the assertion.
|
the dump is the assertion.
|
||||||
- **The About dialog, the help window and the shortcut overview are opened by `show_about()`,
|
- **The About dialog, the help window and the shortcut overview are opened by `show_about()`,
|
||||||
`show_help()` and `show_shortcuts()`, and each closes with the matching call.** The help window
|
`show_help()` and `show_shortcuts()`, and each closes with the matching call.** There is
|
||||||
and the About dialog are `GtkWindow`s: they close with `gtk_window_close()`, which emits
|
exactly **one `GtkWindow`** among the three — the paged help window. It closes with
|
||||||
`close-request` so the handler that nulls the file-scope pointer runs. `gtk_window_destroy()`
|
`gtk_window_close()`, which emits `close-request` so the handler that nulls the file-scope
|
||||||
does not emit that signal and must not be used — the math dialog fell into exactly this trap,
|
pointer runs. `gtk_window_destroy()` does **not** emit that signal and must not be used — the
|
||||||
and the pointer then points at a dead widget until the next attempt to open the same window.
|
math dialog fell into exactly this trap, and the pointer then points at a dead widget until the
|
||||||
The shortcut overview is an `AdwDialog` — it is **not** a `GtkWindow` — closed with
|
next attempt to open the same window. The other two — the About dialog (an `AdwAboutDialog`)
|
||||||
`adw_dialog_close()` and cleared on `::closed` (there is no `::close-request` on `AdwDialog`).
|
and the shortcut overview (an `AdwShortcutsDialog`) — are **`AdwDialog`s and not `GtkWindow`s**:
|
||||||
One `GtkWidget*` (or `AdwDialog*`) per window, nulled in its close handler, is the shape that
|
they close with `adw_dialog_close()` and clear on `::closed`, there being no `::close-request`
|
||||||
prevents the double-open defect across all three.
|
on an `AdwDialog`. Both share one `on_dialog_closed()` handler keyed by a **pointer to the
|
||||||
|
pointer** it must null (`&g_about`, `&g_shortcuts`), which is what keeps one handler honest for
|
||||||
|
two dialogs. One `GtkWidget*` (or `AdwDialog*`) per window, nulled in its own close handler, is
|
||||||
|
the shape that prevents the double-open defect across all three.
|
||||||
- **`build_info.cpp` is generated, never committed.** `cmake/write_build_info.cmake` runs at
|
- **`build_info.cpp` is generated, never committed.** `cmake/write_build_info.cmake` runs at
|
||||||
configure time (so `add_executable` can name the file) and again on every build through
|
configure time (so `add_executable` can name the file) and again on every build through
|
||||||
`udpscope_build_info` (so the commit and the timestamp belong to the binary that is running).
|
`udpscope_build_info` (so the commit and the timestamp belong to the binary that is running).
|
||||||
@@ -1346,6 +1781,10 @@ far the largest file.
|
|||||||
generated symbols cannot collide with the icons'), validated with `pango_parse_markup()` at
|
generated symbols cannot collide with the icons'), validated with `pango_parse_markup()` at
|
||||||
load; a page that fails either lookup or parse renders a one-line stand-in and is excluded from
|
load; a page that fails either lookup or parse renders a one-line stand-in and is excluded from
|
||||||
`parsed`. `UDPSCOPE_PROBE_HELP_MS`'s `parsed` must equal `pages`.
|
`parsed`. `UDPSCOPE_PROBE_HELP_MS`'s `parsed` must equal `pages`.
|
||||||
|
A page may also carry screenshots (`HelpPage::images`, a null-terminated list of resources).
|
||||||
|
They are `GtkPicture`s **with a height request**: one created `can_shrink` asks for *no* height
|
||||||
|
at all inside a box, so without the request it is allocated nothing and the page renders its text
|
||||||
|
with a silent gap where its screenshots should be — which is what the first version did.
|
||||||
- **`find_accel()` masks the modifiers before comparing.** Lock, Num-Lock and the pointer buttons
|
- **`find_accel()` masks the modifiers before comparing.** Lock, Num-Lock and the pointer buttons
|
||||||
all arrive in `GdkModifierType`, so an unmasked compare matches no table row at all once
|
all arrive in `GdkModifierType`, so an unmasked compare matches no table row at all once
|
||||||
Caps Lock is on.
|
Caps Lock is on.
|
||||||
|
|||||||
+30
-2
@@ -39,7 +39,7 @@ if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
|
|||||||
target_compile_options(udpsclient PRIVATE -Wall -Wextra)
|
target_compile_options(udpsclient PRIVATE -Wall -Wextra)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# --- icons: four symbolic SVGs compiled into the binary -----------------------
|
# --- icons: six SVGs compiled into the binary --------------------------------
|
||||||
# There is no stock icon for an oscilloscope trigger, a pair of cursors or a measurement
|
# There is no stock icon for an oscilloscope trigger, a pair of cursors or a measurement
|
||||||
# ruler, so this app draws its own. They go in a GResource rather than beside the binary
|
# ruler, so this app draws its own. They go in a GResource rather than beside the binary
|
||||||
# because udpscope is run straight out of the build tree and has no install step: a
|
# because udpscope is run straight out of the build tree and has no install step: a
|
||||||
@@ -48,7 +48,8 @@ endif()
|
|||||||
find_program(GLIB_COMPILE_RESOURCES NAMES glib-compile-resources REQUIRED)
|
find_program(GLIB_COMPILE_RESOURCES NAMES glib-compile-resources REQUIRED)
|
||||||
set(UDPSCOPE_ICON_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/icons)
|
set(UDPSCOPE_ICON_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/icons)
|
||||||
file(GLOB UDPSCOPE_ICON_FILES CONFIGURE_DEPENDS
|
file(GLOB UDPSCOPE_ICON_FILES CONFIGURE_DEPENDS
|
||||||
${UDPSCOPE_ICON_DIR}/scalable/actions/*.svg)
|
${UDPSCOPE_ICON_DIR}/scalable/actions/*.svg
|
||||||
|
${UDPSCOPE_ICON_DIR}/scalable/apps/*.svg)
|
||||||
add_custom_command(
|
add_custom_command(
|
||||||
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
|
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
|
||||||
COMMAND ${GLIB_COMPILE_RESOURCES}
|
COMMAND ${GLIB_COMPILE_RESOURCES}
|
||||||
@@ -125,3 +126,30 @@ add_dependencies(udpscope udpscope_build_info)
|
|||||||
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
||||||
target_compile_options(udpscope PRIVATE -Wall -Wextra -Wpedantic)
|
target_compile_options(udpscope PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# --- the desktop entry: opt-in, never part of `cmake --build` ------------------
|
||||||
|
# `Exec` is the built binary's absolute path in the build tree, so the entry always launches
|
||||||
|
# exactly the binary that was last built -- and moving or deleting the build directory breaks
|
||||||
|
# it. That is the price of having no install step, and it is the right one here: an install()
|
||||||
|
# rule would create a second copy of the app that can diverge from the build tree, and a
|
||||||
|
# documented `cp` would need the binary on PATH, which it is not.
|
||||||
|
#
|
||||||
|
# configure_file() cannot evaluate a generator expression and file(GENERATE) cannot substitute
|
||||||
|
# an @VAR@, so the two run in sequence: the first writes the genex out literally, the second
|
||||||
|
# evaluates it. This is the documented idiom.
|
||||||
|
set(UDPSCOPE_EXEC "$<TARGET_FILE:udpscope>")
|
||||||
|
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/data/org.udpscope.app.desktop.in
|
||||||
|
${CMAKE_CURRENT_BINARY_DIR}/desktop-entry.in @ONLY)
|
||||||
|
file(GENERATE OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/org.udpscope.app.desktop
|
||||||
|
INPUT ${CMAKE_CURRENT_BINARY_DIR}/desktop-entry.in)
|
||||||
|
|
||||||
|
# No ALL: this runs only when it is named, so `cmake --build build` is unaffected.
|
||||||
|
add_custom_target(desktop-entry
|
||||||
|
COMMAND ${CMAKE_COMMAND}
|
||||||
|
-DENTRY=${CMAKE_CURRENT_BINARY_DIR}/org.udpscope.app.desktop
|
||||||
|
-DICON=${UDPSCOPE_ICON_DIR}/scalable/apps/org.udpscope.app.svg
|
||||||
|
-DSYMBOLIC=${UDPSCOPE_ICON_DIR}/scalable/apps/org.udpscope.app-symbolic.svg
|
||||||
|
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/install_desktop_entry.cmake
|
||||||
|
COMMENT "Installing the udpscope desktop entry into XDG_DATA_HOME"
|
||||||
|
VERBATIM)
|
||||||
|
add_dependencies(desktop-entry udpscope)
|
||||||
@@ -41,6 +41,15 @@ both states, the frame and per-channel sample counts, each plot's painted-frame
|
|||||||
count, which channel each trace slot holds, and the allocated size and reveal
|
count, which channel each trace slot holds, and the allocated size and reveal
|
||||||
state of both side panels.
|
state of both side panels.
|
||||||
|
|
||||||
|
Every size it prints is read from a value the frame tick captured while the window was alive, not
|
||||||
|
from the widget: the dump runs from the destroy handler, where the widget pointer is already freed
|
||||||
|
memory. It used to ask two of them directly, which is where the long-standing "an exit-dump run
|
||||||
|
emits exactly two `Gtk-CRITICAL`s" baseline came from — and a critical was only the lucky reading.
|
||||||
|
`UDPSCOPE_PROBE_HELP_MS` builds and destroys three windows of its own before the dump, so by then
|
||||||
|
the memory had been reused and the same two calls **crashed in the middle of the dump**, which
|
||||||
|
looks like a truncated log rather than like a segfault in a debug line. The dump now makes no
|
||||||
|
`gtk_widget_*` call at all and a clean run carries no critical.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -63,27 +72,101 @@ frame and the GUI thread takes once per frame around binding the traces.
|
|||||||
Samples cross the boundary through `plot::data::RingBuffer` (single producer,
|
Samples cross the boundary through `plot::data::RingBuffer` (single producer,
|
||||||
single consumer), never through the lock.
|
single consumer), never through the lock.
|
||||||
|
|
||||||
## Measurements
|
## The x axis is relative to now
|
||||||
|
|
||||||
|
**A live axis is fixed at `[-window, 0]`, with the newest sample at 0.** That is what a bench
|
||||||
|
scope in roll mode shows and what the window control's figure means: a 2 s window reads `-2s` to
|
||||||
|
`0` and keeps reading that for as long as the stream runs, with the trace flowing right to left
|
||||||
|
under it. The axis used to carry the stream's own clock, so the labels counted the app's uptime
|
||||||
|
upwards (`20:00`, `20:01`, …) and the one number a user actually wants — how long ago a feature
|
||||||
|
happened — had to be worked out by subtraction.
|
||||||
|
|
||||||
|
Every mode now expresses x as *seconds from the thing the view is about*, and the rule is one
|
||||||
|
line: `display_x = stream_t - ScopeModel::stream_at_x0()`, where `stream_at_x0()` is the stream
|
||||||
|
time of display x = 0.
|
||||||
|
|
||||||
|
| display mode | x = 0 is | `stream_at_x0()` |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Live`, `Waiting` | the newest acquired sample | the newest stamp |
|
||||||
|
| `Capturing` | the accepted edge | the edge |
|
||||||
|
| `Held` | the event's edge | the snapshot's `x_offset` |
|
||||||
|
| `Paused` | the instant of the freeze | the snapshot's `x_offset` |
|
||||||
|
|
||||||
|
The live answer is written by the frame tick **after** `bind()` and `acquire()`, because it has to
|
||||||
|
be minus the newest sample the *traces* can report: asking the model before they are bound puts
|
||||||
|
the newest data a frame's production to the right of the axis, which at 1 MS/s over a 50 ms window
|
||||||
|
is a third of the screen. It converges in exactly one frame and cannot oscillate — `live_edge()`
|
||||||
|
reports in the epoch currently in force, so `new = old - live_edge()` is `-raw_newest` whatever the
|
||||||
|
old value was.
|
||||||
|
|
||||||
|
A snapshot carries its own offset (`EventSnapshot::x_offset`) rather than leaning on the model's
|
||||||
|
epoch, because its coordinates outlive the epoch that produced them. One consequence is a reversal
|
||||||
|
of an earlier deliberate choice: **a pause snapshot is now rebased** like an event's. Leaving its
|
||||||
|
stamps on the stream's clock would have thrown the frozen picture a whole uptime to the right of an
|
||||||
|
axis that is now newest-relative. `x_offset` keeps the stream clock recoverable, which is what the
|
||||||
|
export's second column reads — and it fixed a latent bug on the way, the export's snapshot branch
|
||||||
|
having added `trigger_time()` to a *paused* view's stamps: an unrelated edge, or none at all before
|
||||||
|
the first latch.
|
||||||
|
|
||||||
|
Two things move in **opposite** directions under a rolling axis, and the asymmetry is deliberate.
|
||||||
|
A **cursor** stays on screen: placed at -1.5 s it keeps meaning "1.5 s ago" and the data flows
|
||||||
|
under it, which is a bench scope's roll behaviour and is free here, the cursors already being
|
||||||
|
stored in display coordinates. A **measurement mark** is glued to the sample it measured, because
|
||||||
|
a mark is a *result* rather than an instrument — so the marks are stored in stream seconds and
|
||||||
|
reconverted every frame (`place_measure_marks()`), which is what makes them drift left with their
|
||||||
|
own data between recomputes. `App::measure_window` is on the stream's clock for a related reason:
|
||||||
|
"has the window moved" is the question that triggers a recompute, and a fixed `[-window, 0]` makes
|
||||||
|
it invisible in display coordinates, so the pane would have computed once and never again.
|
||||||
|
|
||||||
**Pause freezes the picture, not the acquisition.** The producer keeps writing the live ring
|
**Pause freezes the picture, not the acquisition.** The producer keeps writing the live ring
|
||||||
while paused — the same thing it does when the trigger is off — and the plot binds a *snapshot*
|
while paused — the same thing it does when the trigger is off — and the plot binds a *snapshot*
|
||||||
of the window that was on screen at the pause, with the axes pinned there. That is the same
|
of the window that was on screen at the pause, with the axes pinned there. That is the same
|
||||||
immutable-series path a held trigger event uses (`DisplayMode::Paused` sits beside `Held`); the
|
immutable-series path a held trigger event uses (`DisplayMode::Paused` sits beside `Held`).
|
||||||
difference is the anchoring: an event's stamps are edge-relative, a pause's are the stream's own,
|
Resuming drops the snapshot and
|
||||||
so the times on the axis do not change when the pause begins. Resuming drops the snapshot and
|
|
||||||
re-frames on the newest data, so the seconds that passed while paused are *there*, not a hole —
|
re-frames on the newest data, so the seconds that passed while paused are *there*, not a hole —
|
||||||
which is what pausing used to leave, because it stopped the producer outright.
|
which is what pausing used to leave, because it stopped the producer outright. The status bar
|
||||||
|
says how long it has been frozen (`paused · 10 kS/s · 2 s window · for 12 s`), in **wall**
|
||||||
|
seconds: the producer never stopped, so the stream's own clock says nothing about how long the
|
||||||
|
user has been looking at a still picture. It is written through `format_elapsed()`, which steps to
|
||||||
|
a coarser unit instead of growing digits, because the status row sits directly above the plot and
|
||||||
|
a field whose width tracks the wait would relayout it.
|
||||||
|
|
||||||
**Sub-second windows read relative.** The x ticks are absolute until the window's span goes
|
## Measurements
|
||||||
under a second (with a band to 1.05 s, so the form cannot flicker while a zoom is nudged), and
|
|
||||||
relative below it: the ticks are then generated on a grid anchored at the window's left edge —
|
**The x ticks read relative when, and only when, they could not be written absolutely.** A
|
||||||
so they read `0`, `5ms`, `10ms` instead of `12.345671s`, `12.345672s`, which three significant
|
`TickFormat::Time` label carries three significant digits, so the test is the tick **step**
|
||||||
digits cannot even tell apart — and a caption names that anchor in the axes' top-left corner:
|
against the label's own **resolution at the axis' magnitude**: `12.345671s` and `12.345672s` are
|
||||||
`t0 = 12.3456 s`. The labels scale through the prefixed units the SI formatter uses — `500ms`,
|
the same string, and there the ticks are re-anchored on a grid at the window's left edge — so
|
||||||
`500us`, `5us`, `5ns` — so a microsecond window reads in microseconds instead of `0.005ms`. Adding the two is the tick's absolute time. The caption is an annotation
|
they read `0`, `5ms`, `10ms` — with a caption naming that anchor, `t0 = 12.3456 s`. Adding the
|
||||||
inside the axes rather than the axis title on purpose: its text changes with the window, and a
|
two is the tick's absolute time. The labels scale through the prefixed units the SI formatter
|
||||||
title of a changing width would relayout the plot every frame. Everything a user copies out —
|
uses — `500ms`, `500us`, `5us`, `5ns` — so a microsecond window reads in microseconds instead of
|
||||||
the cursors' `T0`/`T1`/`dT`, a measurement, an export — stays in absolute stream seconds.
|
`0.005ms`.
|
||||||
|
|
||||||
|
The rule used to be the *span* alone (relative under a second, with a band to 1.05 s), and a
|
||||||
|
span says nothing about whether a label can be written: it was wrong in **both** directions. A
|
||||||
|
2 s window at twenty minutes of uptime is labelled `20:00` on every tick — `m:ss` carries whole
|
||||||
|
seconds — and was left absolute and unreadable. And a **triggered** sub-second window, whose
|
||||||
|
axis is already `[-25ms, +25ms]` about the edge, reads `-20ms`, `-10ms`, `0`, `10ms` perfectly
|
||||||
|
well, yet was re-anchored anyway: the one position a triggered axis exists to show was displaced
|
||||||
|
by a `t0` caption that had nothing to explain. The step is read back from the majors the last
|
||||||
|
layout produced (a function of the range and the axis width, not of the label form, so no
|
||||||
|
feedback loop), the magnitude is the larger of the two endpoints, and the comparison carries a
|
||||||
|
1.05 band leaving the relative form plus a ULP of slack entering it — a step *equal* to the
|
||||||
|
resolution is readable (`1.73s`, `1.74s`, `1.75s`: one in the last digit is exactly enough), so a
|
||||||
|
decision taken on the last bits of two differently computed doubles picked a different form from
|
||||||
|
one frame to the next. The caption is **chrome** — `Plot::set_x_note()`, added to the plotter for
|
||||||
|
it, drawn on the legend's own backplate in the bottom-left corner of the axes — rather than the
|
||||||
|
axis title or an annotation. Not the title, because its text changes with the window and a title
|
||||||
|
of a changing width would relayout the plot every frame; not an annotation, because an
|
||||||
|
annotation's anchor is a *data* coordinate, so the caption crawled across the plot under a pan and
|
||||||
|
drifted under a zoom, which is what it was reported as. The library test is
|
||||||
|
`the_x_note_is_chrome_that_a_pan_or_a_zoom_does_not_move`: the footprint is measured as the pixels
|
||||||
|
a frame with the caption differs from the same frame without one, and a pan to another decade plus
|
||||||
|
a 64× zoom must produce the same box to the pixel. Everything a user reads off the screen is in
|
||||||
|
the *display* coordinate the labels name — the cursors' `T0`/`T1`/`dT`, a measurement's time
|
||||||
|
marks, the crosshair — so live they are negative ages and in trigger mode they are offsets from
|
||||||
|
the edge. Only the **export** carries the stream's own clock as well, in a second column
|
||||||
|
(`abs_seconds`); see *Export*.
|
||||||
|
|
||||||
The anchor is taken by the *axis* on every layout (`Axis::origin_follows_range`), not written
|
The anchor is taken by the *axis* on every layout (`Axis::origin_follows_range`), not written
|
||||||
by the app: the window's edge is derived from the newest sample during the layout, so a value
|
by the app: the window's edge is derived from the newest sample during the layout, so a value
|
||||||
@@ -252,6 +335,80 @@ The same reason the window control is a fixed-width label in the header bar
|
|||||||
not expand: a header-bar child that expands is measured into the title's centre
|
not expand: a header-bar child that expands is measured into the title's centre
|
||||||
box, so the control stretched to the title and every window resize moved it.
|
box, so the control stretched to the title and every window resize moved it.
|
||||||
|
|
||||||
|
## The icon, the About dialog and the desktop entry
|
||||||
|
|
||||||
|
**The icon is the instrument face**: a dark panel carrying one bold green trace. That is the
|
||||||
|
clearest metaphor available for this program -- it is what the thing being imitated looks like --
|
||||||
|
and it is the kind of physical object the GNOME HIG's top-and-front depth language was written
|
||||||
|
for, so the guidelines apply as written rather than by analogy. Its silhouette is also what makes
|
||||||
|
it survive being scaled down, which is why two alternatives were dropped: a bare waveform (no
|
||||||
|
silhouette at all, so nothing is left of it in a dock) and a colour version of the trigger glyph
|
||||||
|
the toolbar already carries (a *control*, not the application, and it would mean two icons that
|
||||||
|
have to stay in step).
|
||||||
|
|
||||||
|
It is built to the HIG's own numbers. A 128px canvas the artwork does not fill (it spans
|
||||||
|
x 12..116, y 22..106), every feature on the 2px grid, and **one** depth treatment: a 4px front
|
||||||
|
profile along the bottom, clipped to the chassis silhouette so it does not protrude past the
|
||||||
|
rounded corners. The rest is flat colour -- no gradients, no shadows -- because every surface
|
||||||
|
here is straight, and the palette is GNOME's (Dark 3 chassis, Dark 2 profile, Dark 4 screen,
|
||||||
|
Green 1 trace).
|
||||||
|
|
||||||
|
**Two things were deliberately left out.** There is no graticule: the grid lines a real scope
|
||||||
|
face carries cannot survive 32px, and the trace carries the metaphor without them. And the trace
|
||||||
|
is one cycle -- a single crest, a single trough -- because at 32px the screen is about 22x16 px
|
||||||
|
and anything busier reads as a smudge.
|
||||||
|
|
||||||
|
The two forms are authored differently on purpose. The colour icon **strokes** its sine, which is
|
||||||
|
the sane way to draw a smooth curve; the 16px symbolic one cannot, because GTK's symbolic
|
||||||
|
recolour wraps the file in a stylesheet that reaches `fill` on `path`, `rect` and `circle` only,
|
||||||
|
so a stroked line keeps the colour written in the file and stays dark on a dark theme. Its wave is
|
||||||
|
therefore a closed 2px band -- a sine along the top edge and the same sine 2px lower along the
|
||||||
|
bottom. Do not "fix" one to match the other.
|
||||||
|
|
||||||
|
Both are compiled into the binary as a `GResource` under `scalable/apps/`, for the same reason
|
||||||
|
the four action icons are: udpscope is run straight out of its build tree and has no install step.
|
||||||
|
The one consequence worth knowing is that a **malformed** SVG cannot be caught at runtime -- the
|
||||||
|
icon *name* still resolves, so GTK reports the icon as present and draws nothing -- which is why
|
||||||
|
every `<file>` in the manifest carries `preprocess="xml-stripblanks"` and the build fails with a
|
||||||
|
file and line number (plus a caret under the offending character) instead.
|
||||||
|
|
||||||
|
**The About dialog is an `AdwAboutDialog`** rather than a `GtkAboutDialog`, and that migration is
|
||||||
|
what the app has an icon *for*: the dialog shows it in its own header, which `GtkAboutDialog`
|
||||||
|
would too but only after being handed one, and nothing else in the program had asked for a
|
||||||
|
128px rendering. Two further gains came with it. The debug information is a **Troubleshooting
|
||||||
|
page with a copy button**, where `system_information` on the old dialog was inert text a user
|
||||||
|
reporting a problem had to retype. And the string it shows is a `std::string`, which removed a
|
||||||
|
`char[1024]` that truncated a long settings path in silence -- the one failure mode of a fixed
|
||||||
|
buffer that no one notices until the path matters.
|
||||||
|
|
||||||
|
What it cost: an `AdwAboutDialog` is an `AdwDialog` and **not** a `GtkWindow`, so its close path
|
||||||
|
differs from the help window sitting beside it -- `adw_dialog_close()` and `::closed`, rather than
|
||||||
|
`gtk_window_close()` and `::close-request`. That is the same trap the shortcut overview already
|
||||||
|
had, and the two now share one `::closed` handler keyed by the pointer it must null. What was
|
||||||
|
deliberately left out: credits (one author, already named), release notes (there are no releases)
|
||||||
|
and an issue-tracker URL (the website link reaches the repository, which carries the tracker).
|
||||||
|
|
||||||
|
**The desktop entry is opt-in.** `cmake --build build` never touches `$XDG_DATA_HOME`; asking for
|
||||||
|
it does:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --build build --target desktop-entry
|
||||||
|
```
|
||||||
|
|
||||||
|
That writes `org.udpscope.app.desktop` and both app icons under `$XDG_DATA_HOME` (default
|
||||||
|
`~/.local/share`), validates the entry with `desktop-file-validate` as a hard failure when the tool is on `PATH`, refreshes
|
||||||
|
`update-desktop-database` and `gtk4-update-icon-cache` if they are on `PATH`, and prints the three
|
||||||
|
paths it wrote. Those three lines **are** the uninstall instructions: there is no uninstall
|
||||||
|
target, because three `rm`s the build already told you about are less to maintain than a target
|
||||||
|
that has to guess what a previous version installed.
|
||||||
|
|
||||||
|
The caveat is the entry's `Exec`, which is the built binary's **absolute path in the build tree**:
|
||||||
|
moving or deleting `build/` breaks the launcher. That is the price of having no install step, and
|
||||||
|
it is the right trade here -- the entry then always launches exactly the binary that was last
|
||||||
|
built, which for a program under development is what you want. No `StartupWMClass` is needed:
|
||||||
|
GTK takes the WM class from the application ID, and the desktop file's basename is that same
|
||||||
|
string, which is what associates a running window with the entry.
|
||||||
|
|
||||||
## How the spec maps onto the code
|
## How the spec maps onto the code
|
||||||
|
|
||||||
**One buffer of `N+1` slices, plus frozen event snapshots.** `ScopeModel`
|
**One buffer of `N+1` slices, plus frozen event snapshots.** `ScopeModel`
|
||||||
@@ -269,7 +426,9 @@ clock is resampled too (`Partition::time_stride`, an averaging decimation — se
|
|||||||
"A window longer than the buffer is resampled"). Because the split is by
|
"A window longer than the buffer is resampled"). Because the split is by
|
||||||
*time span* rather than by bytes, every channel and the time base retain the same
|
*time span* rather than by bytes, every channel and the time base retain the same
|
||||||
history -- which is what makes one shared time slice meaningful. With no
|
history -- which is what makes one shared time slice meaningful. With no
|
||||||
decimation the two rules coincide and every slice is the same size.
|
decimation the two rules coincide and every slice holds the same number of samples
|
||||||
|
(not the same number of bytes: the shared clock is `f64` and the values are `f32`, so
|
||||||
|
the growth loop weighs the budget in bytes -- see "The time ring is `f64`" below).
|
||||||
|
|
||||||
The ring is always filling, whatever the trigger is doing. The pre-trigger history
|
The ring is always filling, whatever the trigger is doing. The pre-trigger history
|
||||||
has to already be there when an edge arrives; while acquisition followed the
|
has to already be there when an edge arrives; while acquisition followed the
|
||||||
@@ -382,11 +541,12 @@ the post window had been waiting for. That cost one sample of twenty thousand on
|
|||||||
|
|
||||||
The axis is still pinned to what the snapshot actually captured, read off its own
|
The axis is still pinned to what the snapshot actually captured, read off its own
|
||||||
stamps rather than off the request: the two can differ by a sample at each end, and
|
stamps rather than off the request: the two can differ by a sample at each end, and
|
||||||
pinning the request would claim coverage the data does not have. The window's bounds
|
pinning the request would claim coverage the data does not have. The cut's bounds are
|
||||||
are rounded to the time ring's own `f32` before being compared against it, so a sample
|
compared in the ring's own type, which is now the `f64` they arrive in — see "The time
|
||||||
sitting exactly on a bound is kept whatever the stream's absolute time happens to be —
|
ring is `f64`" below. While the ring was `f32` the bounds had to be *rounded to it*
|
||||||
in f64 that decision fell to how the stamp rounded, which cost a sample at 10 kS/s and
|
before comparing, or a sample sitting exactly on a bound fell to how its stamp
|
||||||
a third of the capture at 1 S/s.
|
rounded, which cost a sample at 10 kS/s and a third of the capture at 1 S/s. That
|
||||||
|
rule is gone with its cause.
|
||||||
|
|
||||||
**Where the capture is drawn was a separate bug from what it contained**, and it
|
**Where the capture is drawn was a separate bug from what it contained**, and it
|
||||||
outlived the fixes above. The plotting library's `gather()` emits x *relative to*
|
outlived the fixes above. The plotting library's `gather()` emits x *relative to*
|
||||||
@@ -717,6 +877,40 @@ signature. The same torn read still exists for a small `i > 0` — the oldest fr
|
|||||||
renderer's increasing-suffix filter exists to absorb, so it is left alone rather than fixed
|
renderer's increasing-suffix filter exists to absorb, so it is left alone rather than fixed
|
||||||
speculatively.
|
speculatively.
|
||||||
|
|
||||||
|
**The time ring is `f64`; the value rings stay `f32`.** It was `f32` like everything else,
|
||||||
|
with the stamps stored relative to an epoch fixed at the first sample ever seen — so the
|
||||||
|
number being stored grew for as long as the app ran while the interval it had to resolve
|
||||||
|
stayed one sample period. `f32` carries 24 bits of mantissa, which is a *relative*
|
||||||
|
precision, so the ULP at 16 s of uptime is 1.9 µs, at 32 s 3.8 µs, at 64 s 7.6 µs and over
|
||||||
|
a 600 s window 61 µs. At 1 MS/s the sample period is 1 µs, so after half a minute two to
|
||||||
|
four consecutive samples rounded onto the same stamp, after a minute eight, and so on
|
||||||
|
without bound. That is the "zoom in to about 10 µs and there are three to five points with
|
||||||
|
the same timestamp" report, and the export showed it unambiguously: `np.diff(t)` over two
|
||||||
|
million rows held **three** distinct steps, 0.953 µs, 1.192 µs and 1.431 µs — exactly
|
||||||
|
2, 2.5 and 3 ULP of the magnitude the stamps had reached, a stair instead of a clock. The
|
||||||
|
min/max artefacts are the same cause read through the LOD pyramid: a bucket of samples
|
||||||
|
sharing one x is a zero-width segment, so an envelope drew as a vertical spike at a
|
||||||
|
plateau's edge rather than as the waveform.
|
||||||
|
|
||||||
|
Nothing was wrong with the arithmetic, so no amount of care at a call site could have
|
||||||
|
fixed it; the type was. **A value's precision is a property of the signal and a
|
||||||
|
timestamp's is a property of how long the scope has been running** — 24 bits is plenty for
|
||||||
|
a sample and runs out on a clock. Widening only the clock keeps the cost where it belongs:
|
||||||
|
the rings are the memory budget, and the values are the overwhelming majority of them. The
|
||||||
|
one consequence worth knowing is that `plan_partition()`'s growth loop can no longer count
|
||||||
|
slots, because a time slot and a value sample are now different sizes — it weighs the
|
||||||
|
budget in **bytes** (`sizeof(f64)` for the shared clock plus `sizeof(f32) / stride[i]` per
|
||||||
|
channel) and the power-of-two rounding absorbs the rest: a three-channel stream at
|
||||||
|
`--buffer-mb 200` retains the same 8.39 s span it did before. `EventSnapshot::stamps` is
|
||||||
|
`f64` for the same reason rather than for symmetry — an *event's* stamps are rebased onto
|
||||||
|
its edge and so are small, but a *pause* view is deliberately not rebased and carries full
|
||||||
|
stream time, which quantised at exactly the rate the live ring did.
|
||||||
|
|
||||||
|
`RingBuffer<T>` stores its slots as `std::atomic<T>` behind a
|
||||||
|
`static_assert(is_always_lock_free)`, so this was a question worth asking rather than
|
||||||
|
assuming: `std::atomic<double>` is lock-free on every target this app builds for, and the
|
||||||
|
assertion is a compile-time guard rather than a silent fallback.
|
||||||
|
|
||||||
**A held event carries its own LOD pyramid**, and the lack of one is what "the plot gets
|
**A held event carries its own LOD pyramid**, and the lack of one is what "the plot gets
|
||||||
laggy with large data" was. The renderer picks a level per trace per frame with
|
laggy with large data" was. The renderer picks a level per trace per frame with
|
||||||
`MinMaxPyramid::level_for()`, which descends to level 0 — *raw samples* — on a pyramid
|
`MinMaxPyramid::level_for()`, which descends to level 0 — *raw samples* — on a pyramid
|
||||||
@@ -749,9 +943,21 @@ returning.
|
|||||||
*while watching a signal*: the time window, pause/resume, the trigger toggle, the
|
*while watching a signal*: the time window, pause/resume, the trigger toggle, the
|
||||||
side-panel toggle and an overflow menu. Those
|
side-panel toggle and an overflow menu. Those
|
||||||
four buttons are icons with tooltips; nine text buttons was a wall of words to read
|
four buttons are icons with tooltips; nine text buttons was a wall of words to read
|
||||||
past every time you wanted the one you needed. The overflow menu holds **Fit to
|
past every time you wanted the one you needed. The overflow menu holds **Fit to data**, the
|
||||||
data**, the **Layout editor**, **Cursors**, **Reset cursors**,
|
**Layout editor**, the **Signal list** toggle, **Export visible range...**, **Keyboard
|
||||||
**Export visible range…** and the **Signal list** toggle — the things you set up once. The side column and the two panel toggles stay hidden until a
|
shortcuts**, **Help** and **About udpscope**, in that order — the shape a GNOME menu has, and
|
||||||
|
drawn as one: every row's label starts at the same left edge (and the rows that can be checked
|
||||||
|
keep that column whether or not they are checked), a shortcut sits at the row's right edge, a row
|
||||||
|
is ~18 px tall rather than Adwaita's 34, and the groups are separated by rules. The ⓘ on the
|
||||||
|
status bar opens its detail **above** the button: below is off the window's bottom edge, and a
|
||||||
|
bubble whose body is clipped away while its top edge stays at the button is what "the arrow is
|
||||||
|
detached from the body" looks like.
|
||||||
|
|
||||||
|
The **help window** (`F1`) carries the same screenshots as `docs/`: the overview page shows the
|
||||||
|
main window and a held capture above its text, and the trigger and math pages carry theirs. A
|
||||||
|
page's images are `GtkPicture`s **with a height request** — a shrinkable one asks for no height at
|
||||||
|
all inside a box, so without it the page renders its text with a silent gap where the screenshots
|
||||||
|
should be. The side column and the two panel toggles stay hidden until a
|
||||||
CONFIG arrives — there is nothing to configure before the stream describes itself —
|
CONFIG arrives — there is nothing to configure before the stream describes itself —
|
||||||
and the first CONFIG reveals the signal panel (the trigger panel stays behind its
|
and the first CONFIG reveals the signal panel (the trigger panel stays behind its
|
||||||
own toggle).
|
own toggle).
|
||||||
@@ -798,13 +1004,21 @@ connection, the measured rate, the packet/frame/gap counters, what plot 1 holds
|
|||||||
the buffer's size and span. Three of those are worth calling out:
|
the buffer's size and span. Three of those are worth calling out:
|
||||||
|
|
||||||
- **The badge blinks green at 1 Hz while the pipeline is actually running** — the
|
- **The badge blinks green at 1 Hz while the pipeline is actually running** — the
|
||||||
stream is connected, acquisition is not paused, and in trigger mode the trigger is
|
stream is connected, a CONFIG has arrived, acquisition is not paused, and in trigger
|
||||||
armed. Anything else leaves it a steady grey, with the reason on its tooltip
|
mode the trigger is armed. Anything else leaves it steady, with the reason on its
|
||||||
(`paused`, `trigger disarmed`, `waiting for the stream`). *Blinking* is the signal,
|
tooltip (`paused`, `trigger disarmed`, `waiting for the stream`). *Blinking* is the
|
||||||
not the colour: a steady badge is a stopped pipeline, and that distinction survives
|
signal, not the colour: a steady badge is a stopped pipeline, and that distinction
|
||||||
a screenshot. It is one glyph whose colour is all that changes, so blinking it
|
survives a screenshot. It is one glyph whose colour is all that changes, so blinking it
|
||||||
cannot change its width — a badge that grew and shrank twice a second would move the
|
cannot change its width — a badge that grew and shrank twice a second would move the
|
||||||
label beside it and re-lay out the plot with it.
|
label beside it and re-lay out the plot with it.
|
||||||
|
There are two steady colours, and the difference is how far the handshake got.
|
||||||
|
**Grey** is a socket with nothing behind it, or a pipeline the user stopped.
|
||||||
|
**Yellow** is a source that answered CONNECT but has sent **no CONFIG**: there is no
|
||||||
|
signal list, so nothing can be enabled and no DATA frame is owed. The glance line reads
|
||||||
|
`connecting` in both, because neither has the signal list every other state word claims;
|
||||||
|
the colour is what separates "no socket yet" from "a socket and no stream behind it". A
|
||||||
|
blinking green over a source that has sent nothing would claim a working pipeline, which
|
||||||
|
is the one reading the badge exists to prevent.
|
||||||
- **`rate 9.99 kS/s` is the rate the buffer actually stored over the last second**, not
|
- **`rate 9.99 kS/s` is the rate the buffer actually stored over the last second**, not
|
||||||
the rate the CONFIG declared. The two agree unless the requested window is too long
|
the rate the CONFIG declared. The two agree unless the requested window is too long
|
||||||
to hold at the wire rate, in which case the ingest resamples (see "A window longer
|
to hold at the wire rate, in which case the ingest resamples (see "A window longer
|
||||||
@@ -860,9 +1074,9 @@ that changes nothing still says why. Each plot's y axis has a right-click menu
|
|||||||
with auto scale, a fixed range, **Fit X axis**, **Fit all**, and one
|
with auto scale, a fixed range, **Fit X axis**, **Fit all**, and one
|
||||||
**Remove '<signal>' from plot N** entry per occupied trace slot (a plot with nothing on
|
**Remove '<signal>' from plot N** entry per occupied trace slot (a plot with nothing on
|
||||||
it says so instead). The two fits are the same pair the overflow menu and the `f` key
|
it says so instead). The two fits are the same pair the overflow menu and the `f` key
|
||||||
offer, split by what they touch: **Fit X axis** frames the window on the data the ring
|
offer, split by what they touch: **Fit X axis** puts the x view back on the whole time
|
||||||
holds and leaves every y scale exactly as the user set it, **Fit all** does that and
|
window and leaves every y scale exactly as the user set it, **Fit all** does that and
|
||||||
auto-scales the y axes too.
|
auto-scales the y axes too. Neither changes the time window itself — see below.
|
||||||
|
|
||||||
**The plot does not twitch.** Two independent causes were fixed, both of which
|
**The plot does not twitch.** Two independent causes were fixed, both of which
|
||||||
showed up as the x axis moving a few pixels every frame — including while paused,
|
showed up as the x axis moving a few pixels every frame — including while paused,
|
||||||
@@ -878,13 +1092,40 @@ which is how the second one was found:
|
|||||||
what its labels need, so a floor written onto the rows would survive exactly
|
what its labels need, so a floor written onto the rows would survive exactly
|
||||||
until the next layout. The figure seeds its common gutter — the maximum over
|
until the next layout. The figure seeds its common gutter — the maximum over
|
||||||
the rows, which is also what aligns their axes — with that reservation.
|
the rows, which is also what aligns their axes — with that reservation.
|
||||||
|
The reservation is the widest label `format_si()` writes for an axis with no
|
||||||
|
unit (a sign, three significant figures and a one-glyph prefix), not the
|
||||||
|
wider `%.3g` fallback it used to cover: that form needs a value past 1e24, so
|
||||||
|
it cost a third of the gutter for a label no real signal produces — and since
|
||||||
|
the floor is a monotone maximum over the *measured* need, one that did appear
|
||||||
|
would widen the gutter for good anyway.
|
||||||
2. *The status line drove the pane width.* A `GtkLabel`'s minimum width is its
|
2. *The status line drove the pane width.* A `GtkLabel`'s minimum width is its
|
||||||
text, and the status line is rewritten every frame; as its counters and
|
text, and the status line is rewritten every frame; as its counters and
|
||||||
messages changed width they moved the paned divider, which resized the plot,
|
messages changed width they moved the paned divider, which resized the plot,
|
||||||
which re-laid out the axes and their ticks. The label is now ellipsized, so its
|
which re-laid out the axes and their ticks. The label is now ellipsized, so its
|
||||||
minimum is “…”; the plot keeps the width it was given.
|
minimum is “…”; the plot keeps the width it was given.
|
||||||
|
3. *The gutter was a ratchet.* `note_y_gutter()` is handed the gutter the row was
|
||||||
|
**laid out with** — already the maximum of its labels and the floor imposed on
|
||||||
|
the frame before — and it added six pixels of slack to it. Those six pixels came
|
||||||
|
back next frame and were added again, so the left margin climbed a decade of
|
||||||
|
pixels a second until it hit the 200 px cap and stopped: 200 px of an 870 px
|
||||||
|
viewport, with `axes.w changes: 17` beside it, which reads as a settle and is a
|
||||||
|
runaway that ran out of room. The reservation's own padding is the slack, once;
|
||||||
|
nothing is added to a measurement that is partly our own answer.
|
||||||
|
|
||||||
The self-check reports both. `viewport.w` must collapse to a single value, and
|
**The two sides are balanced.** The axes rect used to reach the viewport's right
|
||||||
|
edge, because that is all the library's layout had ever reserved — so the
|
||||||
|
rightmost x tick label, *centred* on its tick, had half of itself outside the plot
|
||||||
|
(a live axis is `[-window, 0]`, so there is always a tick exactly on that edge) and
|
||||||
|
the newest sample's ink landed on the frame. `Plot::set_right_gutter()` and
|
||||||
|
`Figure::set_right_gutter()` were added upstream for this: an empty band, served
|
||||||
|
from the same half-viewport allowance as the label and title strips, set once on
|
||||||
|
every row because the rows' axes have to end on a common column as well as start on
|
||||||
|
one. `RowSet` asks for half the widest time label plus the label padding, which is
|
||||||
|
exactly what the last label needs to be drawn whole. With the two fixes above the
|
||||||
|
figure now sits in a 104 px left gutter holding the y labels and a 59 px empty band
|
||||||
|
on the right, where it was 200 px and nothing.
|
||||||
|
|
||||||
|
The self-check reports all of it. `viewport.w` must collapse to a single value, and
|
||||||
`axes.x`, `axes.w` and `y-gutter` must settle: the one-shot label reservation
|
`axes.x`, `axes.w` and `y-gutter` must settle: the one-shot label reservation
|
||||||
lands a few frames after the first measured frame, and because the figure serves
|
lands a few frames after the first measured frame, and because the figure serves
|
||||||
its floor out of the label strip the reservation converges over two layouts, so
|
its floor out of the label strip the reservation converges over two layouts, so
|
||||||
@@ -895,9 +1136,9 @@ bug. `x-ticks` is content, and varies by one as a live window slides over a roun
|
|||||||
number:
|
number:
|
||||||
|
|
||||||
```
|
```
|
||||||
layout over 952 frames: axes.x 67..80, axes.w 546..559, viewport.w 627..627,
|
layout over 583 frames: axes.x 88..104, axes.w 707..782, viewport.w 870..870,
|
||||||
y-gutter 44..74, x-ticks 20..21, x-desync 0
|
y-gutter 88..104, x-ticks 20..41, x-desync 0
|
||||||
axes.w changes: 2 (last at frame 35, side panel up=1)
|
axes.w changes: 1 (last at frame 1, side panel up=1)
|
||||||
```
|
```
|
||||||
|
|
||||||
Set `UDPSCOPE_PROBE_PAUSE_MS=<ms>` to pause the acquisition part-way through a
|
Set `UDPSCOPE_PROBE_PAUSE_MS=<ms>` to pause the acquisition part-way through a
|
||||||
@@ -963,6 +1204,16 @@ A third, found the same way, is in the tick generator rather than the shaders:
|
|||||||
— where the same residue read `3.19e-13ms`. A range whose ticks are *legitimately*
|
— where the same residue read `3.19e-13ms`. A range whose ticks are *legitimately*
|
||||||
that small keeps them.
|
that small keeps them.
|
||||||
|
|
||||||
|
4. *One second was labelled `1e+3ms`.* `format_time()` picked its rung by comparing the raw
|
||||||
|
value against each unit's scale and then wrote the mantissa with `%.3g` — so a tick a hair
|
||||||
|
under a rung's top (a 1 s tick computed as 0.9999999 s, which is what differencing a live
|
||||||
|
axis gives) stayed on the rung below and its mantissa *rounded up to 1000*, which `%.3g`
|
||||||
|
writes in exponential form. The fix is to pick the rung by the **rounded** mantissa
|
||||||
|
(`t < u.scale * 0.9995`), which is exactly the `>= 999.5` spill guard `format_si()` in the
|
||||||
|
same file already had; the whole-second branch takes the same slack. The test is
|
||||||
|
`format_time_never_lets_a_field_reach_its_radix`, extended with the boundary of every rung
|
||||||
|
from seconds down to nanoseconds and its negative.
|
||||||
|
|
||||||
**The minor grid is texture, the labelled grid is structure.** The subdivisions used to be
|
**The minor grid is texture, the labelled grid is structure.** The subdivisions used to be
|
||||||
drawn exactly like the labelled lines — same width, same opacity — so a plot dense enough to
|
drawn exactly like the labelled lines — same width, same opacity — so a plot dense enough to
|
||||||
need them read as two competing sets of ticks. They are now three things the style states
|
need them read as two competing sets of ticks. They are now three things the style states
|
||||||
@@ -989,6 +1240,7 @@ so it is one path, and it costs what the crosshair's own lookup costs.
|
|||||||
|
|
||||||
Each carries a test upstream (`the_atlas_is_filtered_not_point_sampled`,
|
Each carries a test upstream (`the_atlas_is_filtered_not_point_sampled`,
|
||||||
`grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero`,
|
`grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero`,
|
||||||
|
`format_time_never_lets_a_field_reach_its_radix`,
|
||||||
`a_sparse_window_between_samples_still_inks_both_margins`), and each
|
`a_sparse_window_between_samples_still_inks_both_margins`), and each
|
||||||
was verified to fail with its own fix reverted.
|
was verified to fail with its own fix reverted.
|
||||||
|
|
||||||
@@ -1016,10 +1268,64 @@ is what made a dragged cursor spring to where it had been. A ruler is per row: e
|
|||||||
two different values, and the pane names the row.
|
two different values, and the pane names the row.
|
||||||
|
|
||||||
`mix` needs no rulers: it reports the value the trace actually has at each marker, read
|
`mix` needs no rulers: it reports the value the trace actually has at each marker, read
|
||||||
through the library's `Plot::crosshair_value()` — the two bracketing samples,
|
through the library's `Plot::crosshair_value()` — **the nearer of the two bracketing
|
||||||
interpolated — so it is the answer to "what is this signal at these two times" rather
|
samples**, never a number between them — so it is the answer to "which datum of this
|
||||||
than "drag a ruler until it crosses the trace". A readout is `--` when the trace has
|
signal is at these two times" rather than "drag a ruler until it crosses the trace". A
|
||||||
nothing at that time and `partial` when only one of the two markers is over samples.
|
readout is `--` when the trace has nothing at that time and `partial` when only one of
|
||||||
|
the two markers is over samples.
|
||||||
|
|
||||||
|
The value used to be a linear interpolation between the brackets, which is upstream's
|
||||||
|
old convention and was wrong for this app in two ways at once. The samples here are
|
||||||
|
**already averaged** — ingest decimation is a boxcar over `time_stride` samples (see
|
||||||
|
"Decimation at ingest is an average") — so a number computed *between* two of them is a
|
||||||
|
second, undeclared estimate laid over the first, and nothing on screen or in an export
|
||||||
|
holds it. And at a zoom past the sample spacing it is visibly not the datum the pointer
|
||||||
|
is over: the polyline between two samples is a straight line the renderer drew, and
|
||||||
|
quoting a point on it reports the drawing rather than the data. A tie reads the earlier
|
||||||
|
sample. The fix is upstream (`deps/plotter`, `crosshair_span()`'s `t` is a selector rather
|
||||||
|
than a blend factor), so the library's own hover crosshair changed with the pane — which is
|
||||||
|
the point, since both are the same lookup and a user comparing them must not see two
|
||||||
|
numbers. Upstream keeps the blend as `CrosshairValue::Interpolated` for hosts whose x is a
|
||||||
|
quantity they are willing to interpolate; the nearest sample is the default and udpscope
|
||||||
|
never states the other.
|
||||||
|
|
||||||
|
**The crossing dot is on the sample, not on the marker's column.** The dot is placed
|
||||||
|
through `Plot::crosshair_point()` — the same lookup as the value, returning the quoted
|
||||||
|
sample's own x as well as its y — so the mark the eye follows and the number in the pane
|
||||||
|
are the same datum. The two x's differ by up to half a sample period: nothing at a
|
||||||
|
whole-window view, and the whole width of the panel once a few samples span the axes, which
|
||||||
|
is exactly the state in which a reader is asking "which point is that?". It therefore leaves
|
||||||
|
the dot a little off its own vertical line by design — the line says where the *marker* is
|
||||||
|
and the dot says which *datum* is being quoted, and when the marker is between two samples
|
||||||
|
those are two places. The library's hover crosshair snaps the same way and draws its mark
|
||||||
|
`Style::crosshair_marker_px` across (9 logical px against a trace's own 3 px sample dots),
|
||||||
|
since a snapped mark lands among those dots and at their size would read as one more sample
|
||||||
|
instead of as the answer to the question the hover asks.
|
||||||
|
|
||||||
|
**A zoomed-in trace is drawn on the x its readout quotes, which needed the gather's origin to
|
||||||
|
become the caller's.** Separately from the half-sample offset above — which is a property of
|
||||||
|
*which datum* is quoted — there was an offset in *where the polyline was drawn*, and it grew
|
||||||
|
with the zoom. `plot::data::Series::gather()` stages x as `f32`, which cannot hold an absolute
|
||||||
|
epoch time, so it emits a value relative to an origin the consumer adds back in `f64`. That
|
||||||
|
origin was fixed at `x0()`, the start of the **retained window**: the f32 then had to resolve
|
||||||
|
2^-24 of the whole ring however little of it was on screen — 1.9 µs over 20 s of retained data
|
||||||
|
— while a readout reads `x_at()`, exact `f64`. At a view 10 µs wide across a 716 px axis
|
||||||
|
(14 ns a pixel) that is over a hundred pixels between the drawn sample and the x reported for
|
||||||
|
it, and it halves with every zoom *out*, which is why it read as "zooming in makes the
|
||||||
|
crosshair pick the wrong point".
|
||||||
|
|
||||||
|
The origin is now an argument, and `Renderer::draw_traces` passes the **visible range's
|
||||||
|
minimum** — so the quantisation is 2^-24 of the span on screen, a fixed 4e-5 of a pixel, at
|
||||||
|
every zoom. The fix is upstream (`deps/plotter`), where the parameter was deliberately placed
|
||||||
|
*before* the output buffer so that every one of the library's and this app's call sites was a
|
||||||
|
compile error rather than a silent change of behaviour. One simplification fell out of it:
|
||||||
|
`stage_xy()` took a gather origin and a draw origin, those are now always the same value, and
|
||||||
|
the linear case hands the gathered buffer straight to the line batch with no per-point copy.
|
||||||
|
Two guards: the library's
|
||||||
|
`a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes`, which measures the
|
||||||
|
error in pixels both ways (under 0.05 px with the view's minimum, over 10 px with `x0()`), and
|
||||||
|
this app's `trace ink:` line, which now reports `worst drawn-vs-quoted P px` over the raw-level
|
||||||
|
paints of a whole run.
|
||||||
|
|
||||||
The readouts are a **table**: a header naming the columns, then one row per instrument —
|
The readouts are a **table**: a header naming the columns, then one row per instrument —
|
||||||
`| who | A | B | delta |`. The first row is the two times and their difference, and the
|
`| who | A | B | delta |`. The first row is the two times and their difference, and the
|
||||||
@@ -1033,7 +1339,16 @@ Every readout is a **measurement**, not a raw number: at most two decimals, scal
|
|||||||
SI prefix so the digits sit in `1..999`, and the unit the stream declared appended —
|
SI prefix so the digits sit in `1..999`, and the unit the stream declared appended —
|
||||||
`10.52 mV`, `-9.8 V`, `dT -398.67 ms`, `1.06 pV`. Two decimals is the resolution the
|
`10.52 mV`, `-9.8 V`, `dT -398.67 ms`, `1.06 pV`. Two decimals is the resolution the
|
||||||
instruments can claim (a marker lands on a sample, and the sample is an `f32`), and a
|
instruments can claim (a marker lands on a sample, and the sample is an `f32`), and a
|
||||||
trailing zero is dropped, so `1.50 V` reads `1.5 V`. A row's rulers use its signals' unit
|
trailing zero is dropped, so `1.50 V` reads `1.5 V`. The prefix is applied **whether or not
|
||||||
|
the stream declared a unit**, and that is not a detail: a signal whose CONFIG declares none is
|
||||||
|
ordinary, a *math* channel never has one, and two decimals of an unprefixed small value is
|
||||||
|
`0.00` — the reading was simply gone for an entire class of channels, which went unnoticed
|
||||||
|
because the mock declares `V` for everything (it now takes `--unit ''`, which is the only way
|
||||||
|
a probe can see this). With no unit the prefix is written *tight* against the number —
|
||||||
|
`585.24m`, not `585.24 m` — because the space is there to separate the number from its unit,
|
||||||
|
and a spaced `m` with nothing after it reads as metres. Below `1e-12`, where no prefix can
|
||||||
|
help, the value falls back to three significant digits in whatever form fits rather than to
|
||||||
|
`0.00`. A row's rulers use its signals' unit
|
||||||
when they agree on one and print bare digits when they do not: a ruler is on the row's y
|
when they agree on one and print bare digits when they do not: a ruler is on the row's y
|
||||||
axis, and two units on one axis make "the" unit a guess. A trace's line carries the
|
axis, and two units on one axis make "the" unit a guess. A trace's line carries the
|
||||||
signal's **colour chip and its name** — `● sine 616.49 mV -24.57 mV -641.06 mV` — and the
|
signal's **colour chip and its name** — `● sine 616.49 mV -24.57 mV -641.06 mV` — and the
|
||||||
@@ -1124,10 +1439,24 @@ logged with both rates and the new anti-alias corner. A window change that leave
|
|||||||
limitations below); only the shared clock is decimated.
|
limitations below); only the shared clock is decimated.
|
||||||
|
|
||||||
**Fit to data** (overflow menu, the `f` key, or **Fit all** in a plot's right-click menu)
|
**Fit to data** (overflow menu, the `f` key, or **Fit all** in a plot's right-click menu)
|
||||||
auto-scales every row's y axis and re-frames the x window on the data the ring holds,
|
auto-scales every row's y axis to the data it holds and puts the x view back on the whole time
|
||||||
propagated to every row. **Fit X axis** is the same re-framing without the y scaling, for
|
window, propagated to every row. **Fit X axis** is that x half alone, for the common case where
|
||||||
the common case where the window should be the data's but the levels were placed by hand.
|
a zoom should be undone but the levels were placed by hand. The status line acknowledges either
|
||||||
The status line acknowledges either (`x fitted to the buffered data (1.25 s)`).
|
(`x back to the 2 s window`).
|
||||||
|
|
||||||
|
**Nothing you do on the plot changes the time window** — only the window control does. A fit is
|
||||||
|
the *undo of a zoom*: a 2 s window zoomed into reads 2 s again. That matters because the window
|
||||||
|
is not just the axis. It is also the trigger's event span, how the samples are stored (a window
|
||||||
|
long enough to move the decimation re-plans the rings and **drops the history**), the control's
|
||||||
|
readout and what the session file records — so a wheel notch or a Fit that redefined it would
|
||||||
|
quietly throw the buffer away. A zoom and a pan are view-only by construction; the fits read the
|
||||||
|
window and never write it.
|
||||||
|
|
||||||
|
Which 2 s depends on the state, and the fit restores the whole of whatever the view is allowed to
|
||||||
|
show: live, the newest window at the live edge; and for a held event or a paused view, the pin's
|
||||||
|
full range — the snapshot's own span, which is legitimately *narrower* than the requested window
|
||||||
|
when the ring had not filled it. A fit of a frozen view keeps it frozen rather than throwing it
|
||||||
|
back onto the stream.
|
||||||
|
|
||||||
**Shared x axis.** The rows are the rows of **one `plot::Figure`**, drawn by one
|
**Shared x axis.** The rows are the rows of **one `plot::Figure`**, drawn by one
|
||||||
`plot::gtk4::PlotArea` in figure mode, and they all join a single x link group.
|
`plot::gtk4::PlotArea` in figure mode, and they all join a single x link group.
|
||||||
@@ -1137,7 +1466,7 @@ them all with nothing to propagate by hand. The live follow is the *group's*
|
|||||||
(`set_x_link_follow`), not each row's — `Figure::link_x()` refuses a member whose
|
(`set_x_link_follow`), not each row's — `Figure::link_x()` refuses a member whose
|
||||||
own axis still follows, because a following axis would reinterpret the pushed
|
own axis still follows, because a following axis would reinterpret the pushed
|
||||||
range as its own span and offset behind its own edge, which is per-row drift by
|
range as its own span and offset behind its own edge, which is per-row drift by
|
||||||
construction. The figure also owns the left gutter and the crosshair, so a
|
construction. The figure also owns both gutters and the crosshair, so a
|
||||||
vertical feature lands in the same pixel column in every row.
|
vertical feature lands in the same pixel column in every row.
|
||||||
|
|
||||||
The self-check's layout line carries `x-desync N`, the number of frames in which
|
The self-check's layout line carries `x-desync N`, the number of frames in which
|
||||||
@@ -1188,16 +1517,30 @@ division.
|
|||||||
over the shared x window, at the **stored** sample rate — never the LOD pyramid's
|
over the shared x window, at the **stored** sample rate — never the LOD pyramid's
|
||||||
buckets, which are the envelope the renderer drew rather than the acquisition. A
|
buckets, which are the envelope the renderer drew rather than the acquisition. A
|
||||||
signal on several rows is one column; the columns come out in channel order, so the
|
signal on several rows is one column; the columns come out in channel order, so the
|
||||||
file reads in the same order as the signal panel. The table has two time columns:
|
file reads in the same order as the signal panel.
|
||||||
`time` is the axis as displayed (stream seconds live, `[-pre, +post]` while an event
|
|
||||||
is held) and `stream_time` is always the stream's own clock, so two exports taken in
|
The table has **two time columns**, which is the whole answer to "relative or absolute":
|
||||||
different modes line up.
|
|
||||||
|
- **`time`** is the axis exactly as it was displayed — negative ages ending at 0 live, offsets
|
||||||
|
from the edge in trigger mode — so a row's `time` is the x the user read a value at.
|
||||||
|
- **`abs_seconds`** is the stream's own clock for the same sample: `epoch() + stream_at_x0() +
|
||||||
|
time[i]`, one expression correct in all four display modes (see *The x axis is relative to
|
||||||
|
now*). Two exports taken in different modes therefore line up on this column, and
|
||||||
|
`abs - time` is a constant per file naming where the display's origin sat in the stream.
|
||||||
|
|
||||||
|
Both are `f64` seconds and not the wire's integer, because the wire's integer never exists in
|
||||||
|
this process: `udps_frame_t::values` is a `const double*`, so the time signal has already been
|
||||||
|
widened by the client before udpscope sees it. One caveat follows from that and from `time`
|
||||||
|
being a *derived* coordinate: `abs_seconds` is a sum of two numbers of very different
|
||||||
|
magnitudes, so consecutive differences wobble by a few ULP of the stream's uptime
|
||||||
|
(femtoseconds at any plausible uptime — note that the `f32` defect this used to guard against
|
||||||
|
was *microseconds*, four orders of magnitude up).
|
||||||
|
|
||||||
Two formats, picked by the extension the save dialog ends up with:
|
Two formats, picked by the extension the save dialog ends up with:
|
||||||
|
|
||||||
| file | what it is |
|
| file | what it is |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `.npy` | one NumPy structured array, `time`/`stream_time` as `<f8` and every signal as `<f4` — the width the ring stores, so the file invents no precision and is about half an all-`f8` one. `pd.DataFrame(np.load(path))` is the whole reader. |
|
| `.npy` | one NumPy structured array, `time`/`abs_seconds` as `<f8` and every signal as `<f4` — the width the ring stores, so the file invents no precision and is about half an all-`f8` one. `pd.DataFrame(np.load(path))` is the whole reader. |
|
||||||
| `.csv` | the same table as text, `%.17g` for the times and `%.9g` for the values: the shortest widths that round-trip each type exactly. |
|
| `.csv` | the same table as text, `%.17g` for the times and `%.9g` for the values: the shortest widths that round-trip each type exactly. |
|
||||||
|
|
||||||
Parquet (the original ask) would have meant linking Arrow — tens of megabytes of
|
Parquet (the original ask) would have meant linking Arrow — tens of megabytes of
|
||||||
@@ -1266,7 +1609,8 @@ fails validation renders a one-line stand-in so the window always opens rather t
|
|||||||
no `Gtk-CRITICAL`.
|
no `Gtk-CRITICAL`.
|
||||||
|
|
||||||
**The status bar is two tiers.** The bar itself shows one glance line: the activity badge, the
|
**The status bar is two tiers.** The bar itself shows one glance line: the activity badge, the
|
||||||
display state (one word from a fixed set), the stored rate and the window. Everything else —
|
display state (one word from a fixed set — `connecting`, `live`, `paused`, `armed`, `capturing`,
|
||||||
|
`event held`, `disarmed`, none wider than ten characters), the stored rate and the window. Everything else —
|
||||||
packet and frame counts, gaps, buffer size and span, per-plot trace counts — is behind the ⓘ
|
packet and frame counts, gaps, buffer size and span, per-plot trace counts — is behind the ⓘ
|
||||||
button at the bar's right-hand end, opening a popover. The two moved there because a bar that
|
button at the bar's right-hand end, opening a popover. The two moved there because a bar that
|
||||||
carried all of them was too wide to read while watching a signal, and because the bar sits
|
carried all of them was too wide to read while watching a signal, and because the bar sits
|
||||||
@@ -1351,6 +1695,17 @@ status line as `N reconnects` once it is nonzero) it says so in the status line
|
|||||||
leaving an empty plot to be puzzled over, and prints a `starved:` line to stderr while the
|
leaving an empty plot to be puzzled over, and prints a `starved:` line to stderr while the
|
||||||
state holds.
|
state holds.
|
||||||
|
|
||||||
|
That notice is **split on whether a CONFIG ever arrived**, because the two states are
|
||||||
|
different diagnoses and only one of them is about another reader. With a signal list in
|
||||||
|
hand, a stream that stops is the fight above: something took the session away, and the
|
||||||
|
notice says so (`starved:` on stderr). With **no CONFIG at all** nothing was ever taken —
|
||||||
|
there is no signal list, so nothing can be enabled and no DATA frame is owed — and the app
|
||||||
|
says `trying to connect` instead (`connecting:` on stderr), with the glance line reading
|
||||||
|
`connecting` and the badge yellow from the first frame rather than after the three-second
|
||||||
|
stall test. Calling that one "connected, but no data is arriving" blamed a second reader
|
||||||
|
that does not exist; the usual cause is a streamer that is not running, or one serving a
|
||||||
|
different port.
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
- **Multi-element (vector/matrix) signals are acquired with a zero-order hold.**
|
- **Multi-element (vector/matrix) signals are acquired with a zero-order hold.**
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
#
|
||||||
|
# The `desktop-entry` target's body. Copies the generated entry and the two app icons into
|
||||||
|
# $XDG_DATA_HOME, validates the entry, refreshes the caches that make the shell notice, and
|
||||||
|
# prints every path it wrote.
|
||||||
|
#
|
||||||
|
# This runs only when the target is named. The project deliberately has no install step -- that
|
||||||
|
# is the stated reason its icons are a GResource -- and a target that does nothing until asked
|
||||||
|
# preserves that while still producing a working entry.
|
||||||
|
#
|
||||||
|
# Expects -DENTRY=, -DICON= and -DSYMBOLIC= on the command line.
|
||||||
|
|
||||||
|
if(DEFINED ENV{XDG_DATA_HOME} AND NOT "$ENV{XDG_DATA_HOME}" STREQUAL "")
|
||||||
|
set(data_home "$ENV{XDG_DATA_HOME}")
|
||||||
|
else()
|
||||||
|
set(data_home "$ENV{HOME}/.local/share")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Fail visibly if both fallback paths are unusable. The script needs a directory to install into;
|
||||||
|
# a missing $HOME in a sandboxed CI environment should not silently install to the filesystem root.
|
||||||
|
if(NOT DEFINED ENV{XDG_DATA_HOME} OR "$ENV{XDG_DATA_HOME}" STREQUAL "")
|
||||||
|
if(NOT DEFINED ENV{HOME} OR "$ENV{HOME}" STREQUAL "")
|
||||||
|
message(FATAL_ERROR "XDG_DATA_HOME and HOME are both unset or empty; cannot determine install directory")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(apps_dir "${data_home}/applications")
|
||||||
|
set(icon_dir "${data_home}/icons/hicolor/scalable/apps")
|
||||||
|
set(sym_dir "${data_home}/icons/hicolor/symbolic/apps")
|
||||||
|
|
||||||
|
# The icons are copied from the source tree rather than extracted from the GResource: the same
|
||||||
|
# two files serve both the in-binary resource and the installed theme, so there is one copy to
|
||||||
|
# edit. `file(COPY)` creates the directories.
|
||||||
|
file(COPY "${ENTRY}" DESTINATION "${apps_dir}")
|
||||||
|
file(COPY "${ICON}" DESTINATION "${icon_dir}")
|
||||||
|
file(COPY "${SYMBOLIC}" DESTINATION "${sym_dir}")
|
||||||
|
|
||||||
|
get_filename_component(entry_name "${ENTRY}" NAME)
|
||||||
|
get_filename_component(icon_name "${ICON}" NAME)
|
||||||
|
get_filename_component(sym_name "${SYMBOLIC}" NAME)
|
||||||
|
|
||||||
|
# Validated here rather than left to the shell, which ignores a malformed entry in silence: the
|
||||||
|
# failure belongs at the moment the entry is asked for.
|
||||||
|
find_program(DESKTOP_FILE_VALIDATE desktop-file-validate)
|
||||||
|
if(DESKTOP_FILE_VALIDATE)
|
||||||
|
execute_process(COMMAND "${DESKTOP_FILE_VALIDATE}" "${apps_dir}/${entry_name}"
|
||||||
|
RESULT_VARIABLE validate_result)
|
||||||
|
if(NOT validate_result EQUAL 0)
|
||||||
|
message(FATAL_ERROR "desktop-file-validate rejected ${apps_dir}/${entry_name}")
|
||||||
|
endif()
|
||||||
|
message(STATUS "desktop-file-validate: ok")
|
||||||
|
else()
|
||||||
|
message(STATUS "desktop-file-validate: not on PATH, entry not validated")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
find_program(UPDATE_DESKTOP_DATABASE update-desktop-database)
|
||||||
|
if(UPDATE_DESKTOP_DATABASE)
|
||||||
|
execute_process(COMMAND "${UPDATE_DESKTOP_DATABASE}" "${apps_dir}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
find_program(GTK4_UPDATE_ICON_CACHE gtk4-update-icon-cache)
|
||||||
|
if(GTK4_UPDATE_ICON_CACHE)
|
||||||
|
execute_process(COMMAND "${GTK4_UPDATE_ICON_CACHE}" -f -t "${data_home}/icons/hicolor")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Printed because there is no uninstall target: undoing this is these three paths.
|
||||||
|
message(STATUS "wrote ${apps_dir}/${entry_name}")
|
||||||
|
message(STATUS "wrote ${icon_dir}/${icon_name}")
|
||||||
|
message(STATUS "wrote ${sym_dir}/${sym_name}")
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=udpscope
|
||||||
|
GenericName=Oscilloscope
|
||||||
|
Comment=An oscilloscope for UDPStreamer sources
|
||||||
|
Exec=@UDPSCOPE_EXEC@
|
||||||
|
Icon=org.udpscope.app
|
||||||
|
Terminal=false
|
||||||
|
Categories=Development;Science;Electronics;
|
||||||
|
Keywords=oscilloscope;UDP;signal;waveform;trigger;
|
||||||
|
StartupNotify=true
|
||||||
Vendored
+1
-1
Submodule deps/plotter updated: 26a7c82a80...bee318870f.
@@ -0,0 +1,201 @@
|
|||||||
|
# udpscope — user guide
|
||||||
|
|
||||||
|
A viewer for a UDPStreamer source: it connects, decodes the CONFIG, plots the signals you enable,
|
||||||
|
and lets you freeze, measure and derive from them. This is the *user* guide; `README.md` documents
|
||||||
|
what was built and why, and `SPECS.md` is the requirement document.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*The main window with a source streaming: the signal panel on the right, the plot in the middle, the
|
||||||
|
status line directly under it, and the toolbar above. The window control — `2 s` — is the first thing
|
||||||
|
on the left.*
|
||||||
|
|
||||||
|
## 1. Connecting
|
||||||
|
|
||||||
|
```sh
|
||||||
|
udpscope # asks which source to open
|
||||||
|
udpscope --server 10.0.0.5 --port 8000 # connect straight away
|
||||||
|
udpscope --server 239.1.2.3 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9
|
||||||
|
```
|
||||||
|
|
||||||
|
With no `--server` the app shows a chooser: the sessions it has stored, each naming its source, plus
|
||||||
|
a form for a new host. A source opened from the list reconnects to the multicast group and interface
|
||||||
|
it was stored with.
|
||||||
|
|
||||||
|
**A unicast source serves one client at a time.** A second viewer's `CONNECT` displaces the first, and
|
||||||
|
they take turns — the status line says so when it happens. Watching one source from several instances
|
||||||
|
needs `--multicast <group>` with `--interface <local address>`.
|
||||||
|
|
||||||
|
Every launch is its own process, so running two scopes side by side is fine.
|
||||||
|
|
||||||
|
## 2. The status line
|
||||||
|
|
||||||
|
Directly under the plot, it reads `● <state> · <rate> · <window>`, and the ⓘ beside it opens the
|
||||||
|
detail: packets, frames, gaps, per-channel sample counts, the buffer's span and its decimation.
|
||||||
|
|
||||||
|
- **The state word is one word** from a fixed set: `live`, `paused`, `armed`, `capturing`,
|
||||||
|
`event held`, `disarmed`. `no data` appears in the rate's place when nothing is arriving.
|
||||||
|
- **The rate is the *stored* rate**, which is the wire rate only while the whole requested window
|
||||||
|
fits in the buffer. A window wider than the budget allows is bought by averaging groups of samples,
|
||||||
|
so the rate falls and the status line says so — that is the anti-alias filter doing its job.
|
||||||
|
- **`connected, but no data is arriving`** appears after three seconds in which no frame arrived while
|
||||||
|
connected: the usual cause is the unicast point above. It clears the moment data returns.
|
||||||
|
- **`N reconnects`** is appended when the client has had to re-establish the session.
|
||||||
|
|
||||||
|
## 3. Signals
|
||||||
|
|
||||||
|
The signal panel lists every signal the CONFIG described, with a switch each. **Signals start
|
||||||
|
disabled — enabling one is what starts acquiring it**, and a signal nobody enables costs nothing.
|
||||||
|
Drag a signal from the panel onto a plot row to add a trace; drag it again to another row to plot the
|
||||||
|
same signal twice. The colour is per *signal*, so the same signal is the same colour wherever it
|
||||||
|
appears.
|
||||||
|
|
||||||
|
The panel also holds the per-signal calibration: gain and offset, applied at ingest, so the
|
||||||
|
measurements and the export carry the same numbers the plot shows.
|
||||||
|
|
||||||
|
## 4. The window and navigation
|
||||||
|
|
||||||
|
The header's window control (`2 s`) sets the time span on screen; the popover takes a number and a
|
||||||
|
unit, with a decade stepper either side. The trigger's event window is a share of *this* span, so a
|
||||||
|
window change moves the trigger with it.
|
||||||
|
|
||||||
|
- **Wheel** zooms about the pointer; **Shift+wheel** on x only, **Ctrl+wheel** y only.
|
||||||
|
- **Ctrl `+` / `-`** zooms x, **Alt `+` / `-`** y, about the plot centre.
|
||||||
|
- **Drag** pans, **right-drag** too (the right button also grabs a cursor at the pointer).
|
||||||
|
- **Ctrl+drag** frames x to a box, **Alt+drag** y.
|
||||||
|
- **Fit to data** (menu) auto-scales both axes and frames the whole buffered range.
|
||||||
|
|
||||||
|
The view is bounded by the **time window**, not by the buffer: zooming out stops at the span you set,
|
||||||
|
and panning stops where the data starts. When the buffer holds *less* than the window — right after a
|
||||||
|
signal is enabled — the window is left empty on its left, which is what a scope that has just started
|
||||||
|
should look like.
|
||||||
|
|
||||||
|
## 5. Trigger
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Single mode with a capture held: the axis is the event's own `[-pre, +post]`, the trigger marker
|
||||||
|
sits at the edge, and the status line reads `event held`.*
|
||||||
|
|
||||||
|
The trigger panel has the edge, the pre/post split (as a share of the event span), Normal or Single,
|
||||||
|
and Arm/Disarm. Selecting a signal for the trigger enables it.
|
||||||
|
|
||||||
|
- **While a capture fills** the plot keeps drawing live data on the event's clock, so the post side
|
||||||
|
fills in view; the axis does not move.
|
||||||
|
- **A held event is navigable within its own data** — zooming in works, panning stops at the capture's
|
||||||
|
edges — and it is never quietly replaced by live data.
|
||||||
|
- **Disarming mid-capture** publishes what the capture got and holds that, so the picture on screen
|
||||||
|
when you disarm is the picture you keep.
|
||||||
|
- The event window is counted in **stream seconds** end to end, so a source whose clock advances in
|
||||||
|
jumps still gets the window it asked for.
|
||||||
|
|
||||||
|
## 6. Pause
|
||||||
|
|
||||||
|
Pause freezes the *picture*, not the acquisition: the producer keeps filling the buffer behind it, and
|
||||||
|
the frozen view is a snapshot of the window that was on screen. Resuming shows the data that arrived
|
||||||
|
meanwhile — there is no hole. A paused view is held like a held event, so zooming in works and panning
|
||||||
|
does not walk it off its snapshot.
|
||||||
|
|
||||||
|
## 7. Cursors
|
||||||
|
|
||||||
|
The cursors toolbar toggle switches on two instruments per row:
|
||||||
|
|
||||||
|
- **vertical** — two times, shared by every row;
|
||||||
|
- **horizontal** — two rulers per row;
|
||||||
|
- **both** — the two together;
|
||||||
|
- **mix** — the two times, read off each *trace* where they cross it.
|
||||||
|
|
||||||
|
The pane beside it reports each trace's values at the markers, and **Reset** puts them back at the
|
||||||
|
window's edges. Values read `--` when a marker is past the newest sample and `partial` when only one
|
||||||
|
marker is over data.
|
||||||
|
|
||||||
|
## 8. Measurements
|
||||||
|
|
||||||
|
The measurements pane computes statistics of the **visible window** of a signal: average, top,
|
||||||
|
bottom, min, max, peak to peak, amplitude, pulse duration, period and frequency. `+` adds one, the
|
||||||
|
row's pencil edits it, and **show on plot** draws its result as a ruler (levels) or markers (times).
|
||||||
|
|
||||||
|
`top` and `bottom` take a *split* as a share of the window's own range — the percentage travels
|
||||||
|
between signals where a threshold in volts would not. `period` and `frequency` deliberately have no
|
||||||
|
split: they count rising crossings of the mid level, one edge per cycle, and a split there is a
|
||||||
|
setting you can only get wrong.
|
||||||
|
|
||||||
|
The values are refreshed on a cadence — at most every 100 ms while the window is scrolling, once when
|
||||||
|
it stops, and once per mode change — so a live view cannot cost you frames. The pane's cost is
|
||||||
|
reported in the exit dump with the app's self-check.
|
||||||
|
|
||||||
|
## 9. Math channels
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*The compact node editor: each node is a box, its inputs the squares on the left edge and its output
|
||||||
|
the square on the right. The orange dot marks the node that carries the channel's value.*
|
||||||
|
|
||||||
|
A math channel is derived from the others and is then an ordinary channel: enable it and it is
|
||||||
|
acquired, plotted, measured and exported like a streamed signal, and another math channel can use it
|
||||||
|
as a source.
|
||||||
|
|
||||||
|
Operations: `channel` (a source), `gain + offset`, `abs`, `sqrt`, `clamp`, `low pass`, `high pass`,
|
||||||
|
`band pass` (biquads, corner in Hz against the stored rate), `sum`, `difference`, `product`, `ratio`.
|
||||||
|
|
||||||
|
- **Click** a node to select it; its parameters appear in the form beside the canvas.
|
||||||
|
- **Click an output square, then an input square**, to wire them. An input is only offered the nodes
|
||||||
|
above it — the graph is a list walked in order, so a cycle cannot be drawn.
|
||||||
|
- **Drag** a box to move it. **add node**, **delete node** and **set as output** are below the form.
|
||||||
|
- The **output is the last node** of a graph you build top to bottom, and the orange dot says which;
|
||||||
|
*set as output* changes it. A graph whose output is the source is valid and just passes the input
|
||||||
|
through — that is the usual reason an operation "does nothing".
|
||||||
|
- **Save** refuses an incomplete graph and says why: an unconnected input, no output, or a source that
|
||||||
|
is not a channel the scope is acquiring.
|
||||||
|
|
||||||
|
The samples are produced at ingest, once per stored slot, so the filters carry real history — a low
|
||||||
|
pass is the filter you asked for rather than a window recomputed every frame.
|
||||||
|
|
||||||
|
## 10. Sessions
|
||||||
|
|
||||||
|
Everything above is saved per source in `~/.config/udpscope/<host>_<port>.toml`: the window, the rows
|
||||||
|
and which signal each slot holds, the calibration, **which signals were enabled**, **the math channels
|
||||||
|
with their graphs** and **the measurements**. Reopening the same source brings the scope back as you
|
||||||
|
left it.
|
||||||
|
|
||||||
|
The file is plain TOML and safe to edit — a malformed line is skipped rather than refusing the whole
|
||||||
|
file. Deleting it starts from the defaults.
|
||||||
|
|
||||||
|
## 11. Export
|
||||||
|
|
||||||
|
**Export visible range…** writes the plotted signals over the visible x range at the *stored* rate to
|
||||||
|
`.npy` or `.csv`, picked by the file name's extension. The table is collected when you click, not when
|
||||||
|
the chooser closes, so a live view cannot scroll under the dialog. The `.npy` is a 2-D array: one row
|
||||||
|
per sample, `time`, `stream_time`, then one column per signal.
|
||||||
|
|
||||||
|
## 12. Keyboard
|
||||||
|
|
||||||
|
| key | action |
|
||||||
|
|---|---|
|
||||||
|
| `space` | pause / resume |
|
||||||
|
| `a` | arm / disarm the trigger |
|
||||||
|
| `t` | the trigger panel |
|
||||||
|
| `l` | the layout editor |
|
||||||
|
| `g` | go live |
|
||||||
|
| `f` | fit to data |
|
||||||
|
| `d` | add the first free signal to the focused row |
|
||||||
|
| `c` | cursors on/off |
|
||||||
|
| `m` | measurements pane |
|
||||||
|
| `e` | export |
|
||||||
|
| `?` | shortcuts |
|
||||||
|
|
||||||
|
`Ctrl+E` exports, `Ctrl+Q` quits, `Ctrl+plus`/`Ctrl+minus` zoom x and `Alt+plus`/`Alt+minus` zoom y.
|
||||||
|
A keystroke is ignored while a text field has focus, so typing in the window control never arms the
|
||||||
|
trigger.
|
||||||
|
|
||||||
|
## 13. Troubleshooting
|
||||||
|
|
||||||
|
| symptom | cause |
|
||||||
|
|---|---|
|
||||||
|
| `connected, but no data is arriving` | another client holds a unicast source — use `--multicast` |
|
||||||
|
| the plot is empty, the status reads `no data` | nothing is enabled: enable a signal in the panel |
|
||||||
|
| the trace is blank while the trigger is armed | `Waiting` — armed and nothing latched yet |
|
||||||
|
| the axis has a margin at the right | a capture is filling: the post side arrives as it is acquired |
|
||||||
|
| a math channel equals its input | its output node is the source — see §9 |
|
||||||
|
| a very wide window shows a low rate | the ingest's anti-alias filter, not a fault |
|
||||||
|
| a level reads `--` | the measurement is invalid in this window (e.g. a period with one edge) |
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,252 @@
|
|||||||
|
# App icon, Adwaita About dialog, and desktop entry
|
||||||
|
|
||||||
|
Date: 2026-10-04
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Three related pieces of desktop integration udpscope currently lacks:
|
||||||
|
|
||||||
|
1. An application icon drawn to the GNOME HIG, in both full colour and symbolic form.
|
||||||
|
2. An About dialog that shows it, built on `AdwAboutDialog` rather than `GtkAboutDialog`.
|
||||||
|
3. A `.desktop` entry, so the app can be launched from the shell rather than only from a terminal.
|
||||||
|
|
||||||
|
## Decisions taken before design
|
||||||
|
|
||||||
|
**The application ID stays `org.udpscope.app`.** GNOME convention is that the application ID, the
|
||||||
|
icon name and the desktop file's basename are one string, so that string is `org.udpscope.app`
|
||||||
|
throughout: the icon is named `org.udpscope.app`, the desktop file is
|
||||||
|
`org.udpscope.app.desktop`, and `gtk_application_new()` is left exactly as it is. The alternative
|
||||||
|
(moving to `it.ferrarinas.Udpscope`, the reverse-DNS of the project's actual forge domain) was
|
||||||
|
offered and declined. Nothing else in the tree depends on the ID — the config directory is
|
||||||
|
`~/.config/udpscope` and the app is `G_APPLICATION_NON_UNIQUE` — so this choice costs nothing
|
||||||
|
beyond the naming itself.
|
||||||
|
|
||||||
|
**The icon is the instrument face**, not a bare waveform and not a colour version of the existing
|
||||||
|
trigger icon. A dark panel carrying one bold trace is the clearest available metaphor, it is the
|
||||||
|
kind of physical object the HIG's top-and-front depth language was written for, and its silhouette
|
||||||
|
survives being scaled down better than either alternative.
|
||||||
|
|
||||||
|
**The desktop entry is installed by an opt-in CMake target**, not by `install()` rules and not by
|
||||||
|
documented `cp` commands. The project deliberately has no install step — that is the stated reason
|
||||||
|
its icons are a GResource — and a target that does nothing until asked preserves that while still
|
||||||
|
producing a working entry. `install()` rules were rejected because they would create a second copy
|
||||||
|
of the app that can diverge from the build tree; manual `cp` was rejected because `Exec` would need
|
||||||
|
the binary on `PATH`, which it is not.
|
||||||
|
|
||||||
|
## The HIG constraints this is built to
|
||||||
|
|
||||||
|
Taken from <https://developer.gnome.org/hig/guidelines/app-icons.html>:
|
||||||
|
|
||||||
|
- Nominal canvas **128×128**, which the artwork should *not* fill.
|
||||||
|
- A **2px grid** for all detail.
|
||||||
|
- Depth comes from combining the top and front of an object: a front profile along the bottom,
|
||||||
|
**no taller than 2 detail units (4 nominal px)**.
|
||||||
|
- Flat colour on straight surfaces; **gradients reserved for curved surfaces**.
|
||||||
|
- Shadows **avoided** where possible.
|
||||||
|
- The standard GNOME palette.
|
||||||
|
- A **symbolic version is required**, reusing the same metaphor, following the UI-icon (single
|
||||||
|
colour) rules.
|
||||||
|
- The icon must still read at **64px** and **32px**.
|
||||||
|
|
||||||
|
## Component 1 — the artwork
|
||||||
|
|
||||||
|
Two new source files, both added to the existing `src/icons/udpscope.gresource.xml` under the
|
||||||
|
`scalable/apps/` tree (the icons already there are `scalable/actions/`).
|
||||||
|
|
||||||
|
### `src/icons/scalable/apps/org.udpscope.app.svg`
|
||||||
|
|
||||||
|
Full colour, 128×128, every coordinate on the 2px grid:
|
||||||
|
|
||||||
|
| element | geometry | palette colour |
|
||||||
|
|---|---|---|
|
||||||
|
| chassis | rounded rect (12, 22)–(116, 106), radius 8 | Dark 3 `#3d3846` |
|
||||||
|
| front profile | the bottom band of that same silhouette, y 102–106 | Dark 2 `#5e5c64` |
|
||||||
|
| screen | rounded rect (20, 30)–(108, 94), radius 4 | Dark 4 `#241f31` |
|
||||||
|
| trace | one sine cycle, centred y = 62, amplitude 18, over x 24–104, 6 px thick | Green 1 `#8ff0a4` |
|
||||||
|
|
||||||
|
The chassis is **one** rounded rect and the front profile is the bottom 4px *of that silhouette*,
|
||||||
|
drawn as a path that follows its bottom corners — not a separate full-width rectangle underneath it.
|
||||||
|
A plain rect from (12, 102) to (116, 106) would protrude past the radius-8 corners, which at 128px
|
||||||
|
reads as a misdrawn bump and at 32px as a dirty edge.
|
||||||
|
|
||||||
|
That band is exactly the HIG's 4px maximum and is the only depth treatment: no gradients (every
|
||||||
|
surface here is straight, so flat colour is what the HIG asks for) and no shadows.
|
||||||
|
|
||||||
|
The artwork occupies x 12–116 and y 22–106, leaving 12px side margins and 22px top and bottom, so it
|
||||||
|
does not fill the canvas. Every coordinate above is even.
|
||||||
|
|
||||||
|
Two deliberate omissions. There is **no graticule** — the grid lines a real scope face carries
|
||||||
|
cannot survive 32px, and the trace already carries the metaphor without them. And the trace is
|
||||||
|
**one** cycle, a single trough and a single crest, rather than several: at 32px the screen is about
|
||||||
|
22×16 px, and anything busier reads as a smudge.
|
||||||
|
|
||||||
|
### `src/icons/scalable/apps/org.udpscope.app-symbolic.svg`
|
||||||
|
|
||||||
|
16×16, the same metaphor reduced to a screen frame with a wave inside it. Unlike the colour icon
|
||||||
|
this is **filled paths only, with an `evenodd` hole for the screen's interior**, because GTK's
|
||||||
|
symbolic recolour only reaches `fill` on `path`, `rect` and `circle` — a stroke keeps the colour
|
||||||
|
written in the file and stays dark on a dark theme. Whole-pixel coordinates throughout.
|
||||||
|
|
||||||
|
**This asymmetry is the trap worth documenting:** CLAUDE.md's existing icon-authoring rules say
|
||||||
|
"everything is a filled `path`" without qualifying it, because every icon in the tree so far has
|
||||||
|
been symbolic. A 128px colour icon is never recoloured, so it may stroke freely — and stroking is
|
||||||
|
the sane way to draw a smooth sine. The rule becomes "fills only in a symbolic icon".
|
||||||
|
|
||||||
|
## Component 2 — the About dialog
|
||||||
|
|
||||||
|
`adw_about_dialog_new()` returns an `AdwDialog*`. An `AdwDialog` is **not** a `GtkWindow`, which is
|
||||||
|
the trap CLAUDE.md already documents for the shortcuts overview, so in `src/help_window.cpp`:
|
||||||
|
|
||||||
|
- `g_about` changes from `GtkWidget*` to `AdwDialog*`.
|
||||||
|
- It is cleared on `::closed`, not `::close-request` (`AdwDialog` has no such signal).
|
||||||
|
- It closes with `adw_dialog_close()`, not `gtk_window_close()`.
|
||||||
|
- It is presented with `adw_dialog_present()`.
|
||||||
|
- Both `AdwDialog`s can share one `::closed` handler keyed by a pointer-to-pointer, leaving
|
||||||
|
`on_window_close` to the help window alone.
|
||||||
|
|
||||||
|
`close_help_windows()` therefore becomes one `GtkWindow` (help) plus two `AdwDialog`s (About,
|
||||||
|
shortcuts), and both its doc comment and the CLAUDE.md invariant that says "two of the three are
|
||||||
|
`GtkWindow`s" have to be revised to "one of the three".
|
||||||
|
|
||||||
|
Field mapping:
|
||||||
|
|
||||||
|
| `GtkAboutDialog` | `AdwAboutDialog` |
|
||||||
|
|---|---|
|
||||||
|
| `program_name` | `application_name` |
|
||||||
|
| `version` | `version` |
|
||||||
|
| `comments` | `comments` |
|
||||||
|
| `license_type` | `license_type` |
|
||||||
|
| `system_information` | `debug_info` |
|
||||||
|
| — | `application_icon` = `"org.udpscope.app"` |
|
||||||
|
| — | `developer_name`, `website` |
|
||||||
|
|
||||||
|
Two gains are taken while mapping. `debug_info` renders as a Troubleshooting page with a copy
|
||||||
|
button, where `system_information` was inert text. And the string behind it is built as a
|
||||||
|
`std::string` rather than the `char details[1024]` a previous review flagged for silent truncation
|
||||||
|
on a long settings path — removing the ceiling rather than widening it, in code already being
|
||||||
|
rewritten.
|
||||||
|
|
||||||
|
Credits, release notes and an issue URL are deliberately **not** added; nothing asked for them.
|
||||||
|
|
||||||
|
One ordering detail: `gtk_icon_theme_add_resource_path()` currently runs at the top of
|
||||||
|
`build_toolbar()`. The main window's own icon is now set from a name in the same resource, so the
|
||||||
|
registration moves up into `run_ui()`, before both `gtk_window_set_icon_name()` and the
|
||||||
|
`build_toolbar()` call — otherwise whichever of the two runs first resolves its name to nothing.
|
||||||
|
|
||||||
|
## Component 3 — the desktop entry
|
||||||
|
|
||||||
|
### `data/org.udpscope.app.desktop.in`
|
||||||
|
|
||||||
|
```
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=udpscope
|
||||||
|
GenericName=Oscilloscope
|
||||||
|
Comment=An oscilloscope for UDPStreamer sources
|
||||||
|
Exec=@UDPSCOPE_EXEC@
|
||||||
|
Icon=org.udpscope.app
|
||||||
|
Terminal=false
|
||||||
|
Categories=Development;Science;Electronics;
|
||||||
|
Keywords=oscilloscope;UDP;signal;waveform;trigger;
|
||||||
|
StartupNotify=true
|
||||||
|
```
|
||||||
|
|
||||||
|
`@UDPSCOPE_EXEC@` is configured to the built binary's absolute path in the build tree. The
|
||||||
|
consequence to accept knowingly: **moving or deleting the build directory breaks the entry.** That
|
||||||
|
is the price of having no install step, and it is the right price here because the entry then always
|
||||||
|
launches exactly the binary that was last built.
|
||||||
|
|
||||||
|
No `StartupWMClass` is needed. GTK takes the WM class from the application ID, and the desktop
|
||||||
|
file's basename already matches it, which is what associates a running window with this entry.
|
||||||
|
|
||||||
|
### The `desktop-entry` target
|
||||||
|
|
||||||
|
A custom target driving `cmake/install_desktop_entry.cmake`, which writes three files under
|
||||||
|
`$XDG_DATA_HOME` (defaulting to `~/.local/share`):
|
||||||
|
|
||||||
|
- `applications/org.udpscope.app.desktop`
|
||||||
|
- `icons/hicolor/scalable/apps/org.udpscope.app.svg`
|
||||||
|
- `icons/hicolor/symbolic/apps/org.udpscope.app-symbolic.svg`
|
||||||
|
|
||||||
|
The two SVGs are copied from `src/icons/scalable/apps/` in the source tree, not extracted from the
|
||||||
|
GResource: the same two files serve both the in-binary resource and the installed icon theme, so
|
||||||
|
there is one copy to edit.
|
||||||
|
|
||||||
|
It then refreshes `update-desktop-database` and `gtk4-update-icon-cache` if either is on `PATH`, runs
|
||||||
|
`desktop-file-validate` if it is available, and prints every path it wrote.
|
||||||
|
|
||||||
|
`cmake --build build` is unaffected: the target runs only when named. No uninstall target is
|
||||||
|
provided — the three paths are printed, so undoing it is three `rm`s.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Two silent failures are possible, and they fail differently.
|
||||||
|
|
||||||
|
A GResource path that was never registered draws `image-missing` while every other line of the dump
|
||||||
|
passes; the existing `toolbar icons:` line already guards that class with
|
||||||
|
`gtk_icon_theme_has_icon()`. But a **malformed SVG is worse**: librsvg refuses the whole file,
|
||||||
|
`has_icon()` still returns true because the *name* resolves, and the icon draws nothing. `has_icon`
|
||||||
|
cannot see it — and these SVGs have already been bitten once by librsvg, which rejects a file for a
|
||||||
|
`--` inside an XML comment.
|
||||||
|
|
||||||
|
The two are therefore caught in two different places, because **no runtime GTK call can see the
|
||||||
|
malformed one.** This was measured rather than assumed, and three candidate checks were tried and
|
||||||
|
rejected:
|
||||||
|
|
||||||
|
- `gtk_icon_theme_lookup_icon()` never returns `NULL` and its paintable's intrinsic size is the
|
||||||
|
size that was *asked for*, so it reads `128x128` for a valid icon, a malformed one and a name
|
||||||
|
that does not exist at all. The same three cases snapshot to an identical `GSK_TEXTURE_NODE` of
|
||||||
|
the requested bounds, since GTK substitutes `image-missing`. There is nothing to compare.
|
||||||
|
- `GMarkup` accepts `<!-- a -- b -->` without complaint. It is a deliberately lenient subset
|
||||||
|
parser, so it cannot stand in for the strict one that refuses the file.
|
||||||
|
- `gdk_pixbuf_new_from_file()` on an SVG goes through glycin on this system, which sandboxes a
|
||||||
|
subprocess per load, emits fontconfig errors, and refuses this project's existing icons outright
|
||||||
|
for reasons of its own. Not a usable oracle.
|
||||||
|
|
||||||
|
So the malformed file is caught at **build time**, which is better than a probe anyway: every
|
||||||
|
`<file>` line in `src/icons/udpscope.gresource.xml` gains `preprocess="xml-stripblanks"`, which
|
||||||
|
makes `glib-compile-resources` pipe each SVG through `xmllint`. A double hyphen inside a comment
|
||||||
|
then **fails the build** with the file, line and column, instead of drawing nothing at runtime.
|
||||||
|
The four existing icons already pass it. The cost is that the `xmllint` binary becomes a build
|
||||||
|
dependency; it ships with libxml2, which GTK already requires.
|
||||||
|
|
||||||
|
The runtime line then asserts what a runtime check genuinely can:
|
||||||
|
|
||||||
|
```
|
||||||
|
app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16)
|
||||||
|
```
|
||||||
|
|
||||||
|
`ok` is `gtk_icon_theme_has_icon()` **and** the resource bytes being present under the registered
|
||||||
|
path, and the size is the one the file's root `<svg>` element declares, read out of those bytes.
|
||||||
|
**Both entries must read `ok` at those sizes.** That covers the unregistered-path case, a renamed
|
||||||
|
or mis-pathed file, and a canvas size that drifted off the HIG's 128 — and it is computed on the
|
||||||
|
first tick beside `toolbar icons:`, not in the dump, which runs after the window is destroyed.
|
||||||
|
|
||||||
|
Alongside it:
|
||||||
|
|
||||||
|
- The `[help]` probe gains `about icon=org.udpscope.app`, read back through
|
||||||
|
`adw_about_dialog_get_application_icon()`, so the dialog is asserted to carry the icon rather than
|
||||||
|
merely to open.
|
||||||
|
- That probe's existing `closed ok` becomes a stronger crash test, because About is now an
|
||||||
|
`AdwDialog` closed by a different call than the help window beside it. It must still produce no
|
||||||
|
`Gtk-CRITICAL` beyond the two already baselined.
|
||||||
|
- `desktop-file-validate` runs inside the `desktop-entry` target, so a malformed entry fails at the
|
||||||
|
moment it is asked for rather than being silently ignored by the shell later.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- **CLAUDE.md**: the `app icons:` dump line as a documented assertion, and the `xml-stripblanks`
|
||||||
|
build-time check beside the five existing icon-authoring rules — the sixth rule is that the
|
||||||
|
malformed-SVG failure is a *build* error now and no runtime check can replace it; the windows invariant revised
|
||||||
|
to one `GtkWindow` and two `AdwDialog`s; the `desktop-entry` target and the build-path caveat; and
|
||||||
|
the icon-authoring rules amended so "filled paths only" is scoped to symbolic icons.
|
||||||
|
- **README.md**: the feature narrative — the icon's HIG constraints and the two omissions
|
||||||
|
(graticule, multiple cycles) with the 32px reasoning, the move to `AdwAboutDialog` and what it
|
||||||
|
gained, and how to get the desktop entry.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- An AppStream `metainfo.xml`. Nothing asked for it, and it is only required for store submission.
|
||||||
|
- Redrawing the four existing action icons.
|
||||||
|
- A nightly/devel icon variant.
|
||||||
|
- An uninstall target for the desktop entry.
|
||||||
+10
-3
@@ -183,9 +183,16 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
|
|||||||
used_stream_time = true;
|
used_stream_time = true;
|
||||||
break;
|
break;
|
||||||
case UDPS_TIME_FIRST_SAMPLE: {
|
case UDPS_TIME_FIRST_SAMPLE: {
|
||||||
|
// Only element 0 is meaningful here: the producer stamps the batch's first
|
||||||
|
// sample and says nothing about the rest, so the cadence has to space them --
|
||||||
|
// exactly as LAST_SAMPLE below dates its batch backwards from its anchor.
|
||||||
|
// Deriving a step from `value_of(n - 1)` instead cannot work: a scalar signal
|
||||||
|
// in Accumulate mode still occupies `num_samples` slots on the wire, and a
|
||||||
|
// producer fills them by repeating the anchor, so the step came out as zero
|
||||||
|
// and every sample of the frame landed on one x. The degeneracy guard below
|
||||||
|
// then threw the whole stream onto the arrival clock.
|
||||||
const double anchor = value_of(0);
|
const double anchor = value_of(0);
|
||||||
const double step = n > 1 ? (value_of(n - 1) - anchor) / double(n - 1) : period;
|
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = anchor + double(k) * cadence;
|
||||||
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = anchor + double(k) * step;
|
|
||||||
used_stream_time = true;
|
used_stream_time = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -511,7 +518,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
time_acc_ += rel;
|
time_acc_ += rel;
|
||||||
++time_acc_n_;
|
++time_acc_n_;
|
||||||
if (tick % g == g - 1) {
|
if (tick % g == g - 1) {
|
||||||
store.time().push(static_cast<f32>(time_acc_ / static_cast<double>(time_acc_n_)));
|
store.time().push(time_acc_ / static_cast<double>(time_acc_n_));
|
||||||
time_acc_ = 0.0;
|
time_acc_ = 0.0;
|
||||||
time_acc_n_ = 0;
|
time_acc_n_ = 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-13
@@ -24,7 +24,7 @@ const plot::data::MinMaxPyramid& null_pyramid() {
|
|||||||
// --- ChannelBuffer ----------------------------------------------------------
|
// --- ChannelBuffer ----------------------------------------------------------
|
||||||
|
|
||||||
ChannelBuffer::ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
|
ChannelBuffer::ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
|
||||||
std::shared_ptr<const plot::data::RingBuffer<f32>> times, u64 stride,
|
std::shared_ptr<const plot::data::RingBuffer<f64>> times, u64 stride,
|
||||||
f64 epoch, f64 nominal_dt)
|
f64 epoch, f64 nominal_dt)
|
||||||
: values_(std::move(values)), times_(std::move(times)), stride_(stride < 1 ? 1 : stride),
|
: values_(std::move(values)), times_(std::move(times)), stride_(stride < 1 ? 1 : stride),
|
||||||
epoch_(epoch), dt_(nominal_dt) {}
|
epoch_(epoch), dt_(nominal_dt) {}
|
||||||
@@ -64,11 +64,11 @@ void ChannelBuffer::acquire() noexcept {
|
|||||||
|
|
||||||
void ChannelBuffer::invalidate() noexcept {
|
void ChannelBuffer::invalidate() noexcept {
|
||||||
published_ = 0;
|
published_ = 0;
|
||||||
window_x0_ = 0.0f;
|
window_x0_ = 0.0;
|
||||||
pyramid_.clear();
|
pyramid_.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
f32 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
|
f64 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
|
||||||
// Channel sample `j` is produced on the tick that also pushes time slot
|
// Channel sample `j` is produced on the tick that also pushes time slot
|
||||||
// `j*stride + stride - 1` (see `Acquisition::on_data`): the producer stamps the
|
// `j*stride + stride - 1` (see `Acquisition::on_data`): the producer stamps the
|
||||||
// time ring every `time_stride` ticks and the channel every `stride` of those,
|
// time ring every `time_stride` ticks and the channel every `stride` of those,
|
||||||
@@ -102,10 +102,10 @@ f32 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
|
|||||||
// whole trace's geometry collapsing.
|
// whole trace's geometry collapsing.
|
||||||
const u64 base = times_->base();
|
const u64 base = times_->base();
|
||||||
const u64 produced = times_->produced();
|
const u64 produced = times_->produced();
|
||||||
if (produced == 0) return 0.0f;
|
if (produced == 0) return 0.0;
|
||||||
u64 slot = index < base ? base : index;
|
u64 slot = index < base ? base : index;
|
||||||
if (slot >= produced) slot = produced - 1;
|
if (slot >= produced) slot = produced - 1;
|
||||||
f32 out = 0.0f;
|
f64 out = 0.0;
|
||||||
times_->copy_from(slot, 1, &out);
|
times_->copy_from(slot, 1, &out);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -121,7 +121,7 @@ f64 ChannelBuffer::dx() const {
|
|||||||
|
|
||||||
f64 ChannelBuffer::x0() const {
|
f64 ChannelBuffer::x0() const {
|
||||||
if (pyramid_.size() == 0) return epoch_;
|
if (pyramid_.size() == 0) return epoch_;
|
||||||
return epoch_ + f64(window_x0_);
|
return epoch_ + window_x0_;
|
||||||
}
|
}
|
||||||
|
|
||||||
f64 ChannelBuffer::x_at(u64 i) const {
|
f64 ChannelBuffer::x_at(u64 i) const {
|
||||||
@@ -131,7 +131,7 @@ f64 ChannelBuffer::x_at(u64 i) const {
|
|||||||
// ring-span too new made it refuse the whole visible range -- which left the renderer
|
// ring-span too new made it refuse the whole visible range -- which left the renderer
|
||||||
// gathering the entire ring at a coarse level and drawing it off the axis.
|
// gathering the entire ring at a coarse level and drawing it off the axis.
|
||||||
if (i == 0) return x0();
|
if (i == 0) return x0();
|
||||||
return epoch_ + f64(time_of(absolute(i)));
|
return epoch_ + time_of(absolute(i));
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ChannelBuffer::neighbours(f64 x, u64& i0, u64& i1) const {
|
bool ChannelBuffer::neighbours(f64 x, u64& i0, u64& i1) const {
|
||||||
@@ -171,7 +171,7 @@ u64 ChannelBuffer::snap_index(f64 x) const {
|
|||||||
plot::Range ChannelBuffer::x_bounds() const {
|
plot::Range ChannelBuffer::x_bounds() const {
|
||||||
const u64 n = pyramid_.size();
|
const u64 n = pyramid_.size();
|
||||||
if (n == 0) return plot::Range{epoch_, epoch_};
|
if (n == 0) return plot::Range{epoch_, epoch_};
|
||||||
return plot::Range{x0(), epoch_ + f64(time_of(absolute(n - 1)))};
|
return plot::Range{x0(), epoch_ + time_of(absolute(n - 1))};
|
||||||
}
|
}
|
||||||
|
|
||||||
plot::Range ChannelBuffer::y_bounds() const {
|
plot::Range ChannelBuffer::y_bounds() const {
|
||||||
@@ -189,7 +189,7 @@ void ChannelBuffer::raw(u64 begin, u64 end, f32* out) const {
|
|||||||
for (u64 i = count; i < end - begin; ++i) out[i] = 0.0f;
|
for (u64 i = count; i < end - begin; ++i) out[i] = 0.0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ChannelBuffer::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
void ChannelBuffer::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||||
plot::data::GatherMode mode) const {
|
plot::data::GatherMode mode) const {
|
||||||
const u64 n = pyramid_.size();
|
const u64 n = pyramid_.size();
|
||||||
const u64 b = std::min(begin, n);
|
const u64 b = std::min(begin, n);
|
||||||
@@ -197,7 +197,7 @@ void ChannelBuffer::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_
|
|||||||
if (b >= e) return;
|
if (b >= e) return;
|
||||||
|
|
||||||
const u64 origin = pyramid_.window_base();
|
const u64 origin = pyramid_.window_base();
|
||||||
const f64 base = x0(); // x is emitted relative to this (see the header)
|
const f64 base = x_origin; // x is emitted relative to this (see the header)
|
||||||
|
|
||||||
const auto emit_raw = [&](u64 from, u64 to) {
|
const auto emit_raw = [&](u64 from, u64 to) {
|
||||||
for (u64 i = from; i < to; ++i) {
|
for (u64 i = from; i < to; ++i) {
|
||||||
@@ -302,13 +302,13 @@ u64 SlotSeries::snap_index(f64 x) const {
|
|||||||
return buffer_ != nullptr ? buffer_->snap_index(x) : 0;
|
return buffer_ != nullptr ? buffer_->snap_index(x) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
void SlotSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
void SlotSeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||||
plot::data::GatherMode mode) const {
|
plot::data::GatherMode mode) const {
|
||||||
if (event_ != nullptr) {
|
if (event_ != nullptr) {
|
||||||
event_->gather(level, begin, end, out_xy, mode);
|
event_->gather(level, begin, end, x_origin, out_xy, mode);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (buffer_ != nullptr) buffer_->gather(level, begin, end, out_xy, mode);
|
if (buffer_ != nullptr) buffer_->gather(level, begin, end, x_origin, out_xy, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
void SlotSeries::copy_raw(u64 begin, u64 end, f32* out) const {
|
void SlotSeries::copy_raw(u64 begin, u64 end, f32* out) const {
|
||||||
|
|||||||
+17
-12
@@ -34,8 +34,10 @@ using plot::u64;
|
|||||||
/// undecimated case and the common one.
|
/// undecimated case and the common one.
|
||||||
class ChannelBuffer final : public plot::data::Series {
|
class ChannelBuffer final : public plot::data::Series {
|
||||||
public:
|
public:
|
||||||
|
/// `values` is f32 and `times` is f64: see `BufferStore::time()` for why the clock
|
||||||
|
/// alone needs the wider type.
|
||||||
ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
|
ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
|
||||||
std::shared_ptr<const plot::data::RingBuffer<f32>> times, u64 stride, f64 epoch,
|
std::shared_ptr<const plot::data::RingBuffer<f64>> times, u64 stride, f64 epoch,
|
||||||
f64 nominal_dt);
|
f64 nominal_dt);
|
||||||
|
|
||||||
ChannelBuffer(const ChannelBuffer&) = delete;
|
ChannelBuffer(const ChannelBuffer&) = delete;
|
||||||
@@ -71,16 +73,18 @@ class ChannelBuffer final : public plot::data::Series {
|
|||||||
/// datagram leaves a gap, so index times must come from the time ring.
|
/// datagram leaves a gap, so index times must come from the time ring.
|
||||||
f64 x_at(u64 i) const override;
|
f64 x_at(u64 i) const override;
|
||||||
/// Bracketing samples by binary search over the time ring, which is monotonic,
|
/// Bracketing samples by binary search over the time ring, which is monotonic,
|
||||||
/// so a crosshair value is interpolated between the two samples actually either
|
/// so a crosshair reads whichever of the two samples actually either side of the
|
||||||
/// side of the pointer -- a grid walk would land on the wrong pair after a gap.
|
/// pointer is nearer -- a grid walk would land on the wrong pair after a gap.
|
||||||
bool neighbours(f64 x, u64& i0, u64& i1) const override;
|
bool neighbours(f64 x, u64& i0, u64& i1) const override;
|
||||||
u64 snap_index(f64 x) const override;
|
u64 snap_index(f64 x) const override;
|
||||||
plot::Range x_bounds() const override;
|
plot::Range x_bounds() const override;
|
||||||
plot::Range y_bounds() const override;
|
plot::Range y_bounds() const override;
|
||||||
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
|
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
|
||||||
/// x is emitted **relative to `x0()`** (the library's contract): staging an
|
/// x is emitted **relative to `x_origin`** (the library's contract): staging an
|
||||||
/// absolute epoch time as f32 would quantise it far coarser than a pixel.
|
/// absolute epoch time as f32 would quantise it far coarser than a pixel, and
|
||||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
/// staging it relative to the whole *retained window* quantises it coarser than a
|
||||||
|
/// pixel of a zoomed view -- see `plot::data::Series::gather()`.
|
||||||
|
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||||
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override;
|
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override;
|
||||||
|
|
||||||
/// `copy_raw`, published so the retargetable trace series can forward the
|
/// `copy_raw`, published so the retargetable trace series can forward the
|
||||||
@@ -94,10 +98,10 @@ class ChannelBuffer final : public plot::data::Series {
|
|||||||
/// Absolute (channel) index of retained-window index `i`.
|
/// Absolute (channel) index of retained-window index `i`.
|
||||||
u64 absolute(u64 i) const noexcept { return pyramid_.window_base() + i; }
|
u64 absolute(u64 i) const noexcept { return pyramid_.window_base() + i; }
|
||||||
/// Timestamp of channel sample `absolute_index`, in seconds since `epoch_`.
|
/// Timestamp of channel sample `absolute_index`, in seconds since `epoch_`.
|
||||||
f32 time_of(u64 absolute_index) const noexcept;
|
f64 time_of(u64 absolute_index) const noexcept;
|
||||||
|
|
||||||
std::shared_ptr<const plot::data::RingBuffer<f32>> values_;
|
std::shared_ptr<const plot::data::RingBuffer<f32>> values_;
|
||||||
std::shared_ptr<const plot::data::RingBuffer<f32>> times_;
|
std::shared_ptr<const plot::data::RingBuffer<f64>> times_;
|
||||||
u64 stride_ = 1;
|
u64 stride_ = 1;
|
||||||
f64 epoch_ = 0.0;
|
f64 epoch_ = 0.0;
|
||||||
f64 dt_ = 1.0;
|
f64 dt_ = 1.0;
|
||||||
@@ -107,14 +111,15 @@ class ChannelBuffer final : public plot::data::Series {
|
|||||||
/// slot cannot be overwritten under the read. Everything else on this object is read at
|
/// slot cannot be overwritten under the read. Everything else on this object is read at
|
||||||
/// *paint* time with no lock, and this one slot is the ring's oldest: the one the producer is
|
/// *paint* time with no lock, and this one slot is the ring's oldest: the one the producer is
|
||||||
/// about to overwrite. Reading it there returned a stamp a whole ring-span too new whenever
|
/// about to overwrite. Reading it there returned a stamp a whole ring-span too new whenever
|
||||||
/// the producer lapped it between the bounds check and the copy -- and `x0()` is the origin
|
/// the producer lapped it between the bounds check and the copy -- and `x0()` is what
|
||||||
/// the renderer reconstructs every trace point from (`gather[i] + series.x0()`), so one such
|
/// `neighbours()` tests the visible range against, as well as the origin the renderer
|
||||||
|
/// reconstructed every trace point from before the caller named one, so one such
|
||||||
/// read moved the entire polyline a ring-span off its axis (an invisible trace) or, caught
|
/// read moved the entire polyline a ring-span off its axis (an invisible trace) or, caught
|
||||||
/// inside the renderer's increasing-suffix filter, made it drop most of the points. That is
|
/// inside the renderer's increasing-suffix filter, made it drop most of the points. That is
|
||||||
/// the "live plot flickers as if there were no data" defect. The cached value is also the
|
/// the "live plot flickers as if there were no data" defect. The cached value is also the
|
||||||
/// *right* answer rather than merely a stable one: it is the stamp sample `window_base`
|
/// *right* answer rather than merely a stable one: it is the stamp sample `window_base`
|
||||||
/// actually carries, where the clamp answered with whatever slot happened to be oldest now.
|
/// actually carries, where the clamp answered with whatever slot happened to be oldest now.
|
||||||
f32 window_x0_ = 0.0f;
|
f64 window_x0_ = 0.0;
|
||||||
plot::data::MinMaxPyramid pyramid_;
|
plot::data::MinMaxPyramid pyramid_;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -161,7 +166,7 @@ class SlotSeries final : public plot::data::Series {
|
|||||||
plot::Range x_bounds() const override;
|
plot::Range x_bounds() const override;
|
||||||
plot::Range y_bounds() const override;
|
plot::Range y_bounds() const override;
|
||||||
const plot::data::MinMaxPyramid& pyramid() const override;
|
const plot::data::MinMaxPyramid& pyramid() const override;
|
||||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||||
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override;
|
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override;
|
||||||
bool streaming() const noexcept override { return event_ == nullptr && buffer_ != nullptr; }
|
bool streaming() const noexcept override { return event_ == nullptr && buffer_ != nullptr; }
|
||||||
|
|
||||||
|
|||||||
+29
-13
@@ -26,8 +26,24 @@ using plot::u64;
|
|||||||
/// live ring buffers switched by a pending flag, plus a display-epoch shift)
|
/// live ring buffers switched by a pending flag, plus a display-epoch shift)
|
||||||
/// could not stop.
|
/// could not stop.
|
||||||
struct EventSnapshot {
|
struct EventSnapshot {
|
||||||
std::vector<f32> stamps; ///< edge-relative seconds, monotonic
|
/// Seconds, monotonic, and **in the coordinates the axis reads**: a trigger event's
|
||||||
|
/// stamps are edge-relative and a pause view's are relative to the instant it froze,
|
||||||
|
/// which is what `x_offset` below records. **f64, like the live ring it is cut from**
|
||||||
|
/// (see `BufferStore::time()`), which also makes the cut in `publish_event()`/
|
||||||
|
/// `publish_pause_view()` a straight copy.
|
||||||
|
std::vector<f64> stamps;
|
||||||
std::vector<std::vector<f32>> values; ///< one array per channel, same length
|
std::vector<std::vector<f32>> values; ///< one array per channel, same length
|
||||||
|
/// The stream time of this snapshot's x = 0, so `x_offset + stamps[i]` is the clock the
|
||||||
|
/// data arrived on: the edge for a trigger event, the newest sample at the moment of the
|
||||||
|
/// freeze for a pause view.
|
||||||
|
///
|
||||||
|
/// It exists because a snapshot is the one thing on screen whose coordinates outlive the
|
||||||
|
/// `display_epoch()` that produced them, and `ScopeModel::stream_at_x0()` is what reads
|
||||||
|
/// it: an export has to name the stream's own clock whatever is on screen, and the pause
|
||||||
|
/// handler has to put a *display* window back on the ring to cut it. Before it, the
|
||||||
|
/// export added `trigger_time()` to a pause view's stamps -- an unrelated edge, or none
|
||||||
|
/// at all -- for want of anywhere to record which clock a snapshot was on.
|
||||||
|
double x_offset = 0.0;
|
||||||
double pre_s = 0.0; ///< seconds of pre-trigger data kept
|
double pre_s = 0.0; ///< seconds of pre-trigger data kept
|
||||||
double post_s = 0.0; ///< seconds of post-trigger data kept
|
double post_s = 0.0; ///< seconds of post-trigger data kept
|
||||||
std::vector<plot::Range> y; ///< per-channel value bounds, precomputed
|
std::vector<plot::Range> y; ///< per-channel value bounds, precomputed
|
||||||
@@ -102,7 +118,7 @@ class EventSeries final : public plot::data::Series {
|
|||||||
/// Indices need no rebasing: the pyramid is built from sample 0 and nothing is ever
|
/// Indices need no rebasing: the pyramid is built from sample 0 and nothing is ever
|
||||||
/// dropped from it, so `window_base()` is 0 and a window index *is* the absolute one
|
/// dropped from it, so `window_base()` is 0 and a window index *is* the absolute one
|
||||||
/// (unlike the ring, whose window slides).
|
/// (unlike the ring, whose window slides).
|
||||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||||
plot::data::GatherMode mode =
|
plot::data::GatherMode mode =
|
||||||
plot::data::GatherMode::Envelope) const override {
|
plot::data::GatherMode::Envelope) const override {
|
||||||
if (channel_ >= snap_->values.size()) return;
|
if (channel_ >= snap_->values.size()) return;
|
||||||
@@ -112,17 +128,17 @@ class EventSeries final : public plot::data::Series {
|
|||||||
const u64 b = std::min(begin, n);
|
const u64 b = std::min(begin, n);
|
||||||
const u64 e = std::min(std::max(end, b), n);
|
const u64 e = std::min(std::max(end, b), n);
|
||||||
if (b >= e) return;
|
if (b >= e) return;
|
||||||
// x **relative to x0()**, which is the library's gather contract: the renderer
|
// x **relative to `x_origin`**, which is the library's gather contract: the
|
||||||
// adds `x0()` back before mapping to the screen. Emitting the absolute stamp made
|
// renderer adds that same origin back before mapping to the screen. Emitting the
|
||||||
// it add x0 twice, so the whole capture was drawn one `x0()` -- a full
|
// absolute stamp made it add an origin twice, so the whole capture was drawn one
|
||||||
// pre-trigger window -- to the left of the axis it had just been measured
|
// `x0()` -- a full pre-trigger window -- to the left of the axis it had just been
|
||||||
// against. The event's own stamps are small (edge-relative, so [-pre, +post]),
|
// measured against. The event's own stamps are small (edge-relative, so
|
||||||
// which is why that shift looked like a plausible picture: with a 50/50 split the
|
// [-pre, +post]), which is why that shift looked like a plausible picture: with a
|
||||||
// right half of the plot was simply empty, zooming out showed the trace ending at
|
// 50/50 split the right half of the plot was simply empty, zooming out showed the
|
||||||
// the x = 0 marker, and zooming in past the shift showed nothing at all while the
|
// trace ending at the x = 0 marker, and zooming in past the shift showed nothing
|
||||||
// crosshair -- which reads `x_at()`, never `gather()` -- went on reporting the
|
// at all while the crosshair -- which reads `x_at()`, never `gather()` -- went on
|
||||||
// right values.
|
// reporting the right values.
|
||||||
const f64 base = x0();
|
const f64 base = x_origin;
|
||||||
const auto rel = [&](u64 i) {
|
const auto rel = [&](u64 i) {
|
||||||
return static_cast<f32>(stamps[static_cast<std::size_t>(i)] - base);
|
return static_cast<f32>(stamps[static_cast<std::size_t>(i)] - base);
|
||||||
};
|
};
|
||||||
|
|||||||
+28
-24
@@ -38,7 +38,7 @@ std::string sanitise(const std::string& name) {
|
|||||||
/// two time columns, with a `_2`, `_3` suffix. Two elements of one vector signal can
|
/// two time columns, with a `_2`, `_3` suffix. Two elements of one vector signal can
|
||||||
/// easily sanitise to the same string.
|
/// easily sanitise to the same string.
|
||||||
std::vector<std::string> column_labels(const ExportTable& table) {
|
std::vector<std::string> column_labels(const ExportTable& table) {
|
||||||
std::vector<std::string> taken{"time", "stream_time"};
|
std::vector<std::string> taken{"time", "abs_seconds"};
|
||||||
std::vector<std::string> out;
|
std::vector<std::string> out;
|
||||||
out.reserve(table.columns.size());
|
out.reserve(table.columns.size());
|
||||||
for (const ExportTable::Column& col : table.columns) {
|
for (const ExportTable::Column& col : table.columns) {
|
||||||
@@ -106,24 +106,28 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
|||||||
// What is on screen: a pause's frozen view, or the trigger's held event. Both are immutable
|
// What is on screen: a pause's frozen view, or the trigger's held event. Both are immutable
|
||||||
// snapshots, so the export is the picture the user is looking at either way.
|
// snapshots, so the export is the picture the user is looking at either way.
|
||||||
std::shared_ptr<const EventSnapshot> snap = model.display_snapshot();
|
std::shared_ptr<const EventSnapshot> snap = model.display_snapshot();
|
||||||
|
// The absolute clock of display x = 0, so `abs_base + time[i]` is the number the stream
|
||||||
|
// stamped sample i with -- **one expression, correct in all four display modes**, which is the
|
||||||
|
// whole reason `stream_at_x0()` exists. `epoch()` is the stream time of the first sample ever
|
||||||
|
// seen (the rings store stamps relative to it) and `stream_at_x0()` is the offset of the
|
||||||
|
// display's origin within that, whether it comes from a snapshot or from the live epoch. The
|
||||||
|
// snapshot branch used to add `trigger_time()` here, which named an unrelated edge on a
|
||||||
|
// *paused* export, or none at all before the first latch.
|
||||||
|
const f64 abs_base = model.epoch() + model.stream_at_x0();
|
||||||
if (snap != nullptr) {
|
if (snap != nullptr) {
|
||||||
// The snapshot is immutable and already index-aligned, so the visible window is two
|
// The snapshot is immutable and already index-aligned, so the visible window is two
|
||||||
// binary searches over its stamps and the same index range for every channel.
|
// binary searches over its stamps and the same index range for every channel.
|
||||||
const std::vector<f32>& st = snap->stamps;
|
const std::vector<f64>& st = snap->stamps;
|
||||||
const auto lo = std::lower_bound(st.begin(), st.end(), static_cast<f32>(view.min));
|
const auto lo = std::lower_bound(st.begin(), st.end(), view.min);
|
||||||
const auto hi = std::upper_bound(st.begin(), st.end(), static_cast<f32>(view.max));
|
const auto hi = std::upper_bound(st.begin(), st.end(), view.max);
|
||||||
if (hi <= lo) return table;
|
if (hi <= lo) return table;
|
||||||
const std::size_t first = static_cast<std::size_t>(lo - st.begin());
|
const std::size_t first = static_cast<std::size_t>(lo - st.begin());
|
||||||
const std::size_t n = static_cast<std::size_t>(hi - lo);
|
const std::size_t n = static_cast<std::size_t>(hi - lo);
|
||||||
// The event's x = 0 is the edge, so the stream clock has to be added back for the
|
|
||||||
// second column -- the one case where the visible x is not the stream's own.
|
|
||||||
const f64 edge = model.trigger_time().load(std::memory_order_relaxed);
|
|
||||||
|
|
||||||
table.time.resize(n);
|
table.time.resize(n);
|
||||||
table.stream_time.resize(n);
|
table.abs_seconds.resize(n);
|
||||||
for (std::size_t i = 0; i < n; ++i) {
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
table.time[i] = static_cast<f64>(st[first + i]);
|
table.time[i] = st[first + i];
|
||||||
table.stream_time[i] = edge + table.time[i];
|
table.abs_seconds[i] = abs_base + table.time[i];
|
||||||
}
|
}
|
||||||
for (const int c : chans) {
|
for (const int c : chans) {
|
||||||
const std::size_t ch = static_cast<std::size_t>(c);
|
const std::size_t ch = static_cast<std::size_t>(c);
|
||||||
@@ -141,23 +145,23 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
|||||||
// the same window hold the same samples. The lock is against a structural change
|
// the same window hold the same samples. The lock is against a structural change
|
||||||
// dropping the rings under the copy, which is what the UI thread has to fear here.
|
// dropping the rings under the copy, which is what the UI thread has to fear here.
|
||||||
std::lock_guard<std::mutex> lock(model.mutex());
|
std::lock_guard<std::mutex> lock(model.mutex());
|
||||||
// The axis reads `display_epoch + stamp`, so the window has to be put back on the
|
// The axis reads `stamp - stream_at_x0()`, so the window has to be put back on the stream's
|
||||||
// stream's clock before the ring is searched. It is 0 in live mode, but reading it is
|
// clock before the ring is searched -- and that offset is a whole window while streaming now,
|
||||||
// what keeps this correct rather than merely true today.
|
// not the 0 it once was: the live axis is `[-window, 0]` about the newest sample.
|
||||||
const f64 epoch = model.display_epoch();
|
const f64 s0 = model.stream_at_x0();
|
||||||
u64 first = 0, last = 0;
|
u64 first = 0, last = 0;
|
||||||
model.live_window_locked(view.min - epoch, view.max - epoch, first, last);
|
model.live_window_locked(view.min + s0, view.max + s0, first, last);
|
||||||
const u64 n = last - first;
|
const u64 n = last - first;
|
||||||
if (n == 0) return table;
|
if (n == 0) return table;
|
||||||
|
|
||||||
BufferStore& store = model.live();
|
BufferStore& store = model.live();
|
||||||
std::vector<f32> stamps(static_cast<std::size_t>(n));
|
std::vector<f64> stamps(static_cast<std::size_t>(n));
|
||||||
store.time().copy_from(first, n, stamps.data());
|
store.time().copy_from(first, n, stamps.data());
|
||||||
table.time.resize(static_cast<std::size_t>(n));
|
table.time.resize(static_cast<std::size_t>(n));
|
||||||
table.stream_time.resize(static_cast<std::size_t>(n));
|
table.abs_seconds.resize(static_cast<std::size_t>(n));
|
||||||
for (std::size_t i = 0; i < stamps.size(); ++i) {
|
for (std::size_t i = 0; i < stamps.size(); ++i) {
|
||||||
table.stream_time[i] = static_cast<f64>(stamps[i]);
|
table.time[i] = stamps[i] - s0;
|
||||||
table.time[i] = epoch + table.stream_time[i];
|
table.abs_seconds[i] = abs_base + table.time[i];
|
||||||
}
|
}
|
||||||
for (const int c : chans) {
|
for (const int c : chans) {
|
||||||
const std::size_t ch = static_cast<std::size_t>(c);
|
const std::size_t ch = static_cast<std::size_t>(c);
|
||||||
@@ -179,7 +183,7 @@ bool write_npy(const std::string& path, const ExportTable& table, std::string& e
|
|||||||
// `descr` as a list of (name, format) pairs is a *packed* structured dtype -- NumPy
|
// `descr` as a list of (name, format) pairs is a *packed* structured dtype -- NumPy
|
||||||
// only inserts alignment padding when asked to (`align=True`) -- so the records below
|
// only inserts alignment padding when asked to (`align=True`) -- so the records below
|
||||||
// are field widths back to back with no gaps.
|
// are field widths back to back with no gaps.
|
||||||
std::string descr = "[('time', '<f8'), ('stream_time', '<f8')";
|
std::string descr = "[('time', '<f8'), ('abs_seconds', '<f8')";
|
||||||
for (const std::string& label : labels) descr += ", ('" + label + "', '<f4')";
|
for (const std::string& label : labels) descr += ", ('" + label + "', '<f4')";
|
||||||
descr += "]";
|
descr += "]";
|
||||||
std::string header = "{'descr': " + descr + ", 'fortran_order': False, 'shape': (" +
|
std::string header = "{'descr': " + descr + ", 'fortran_order': False, 'shape': (" +
|
||||||
@@ -211,7 +215,7 @@ bool write_npy(const std::string& path, const ExportTable& table, std::string& e
|
|||||||
std::size_t at = 0;
|
std::size_t at = 0;
|
||||||
std::memcpy(record.data() + at, &table.time[i], sizeof(f64));
|
std::memcpy(record.data() + at, &table.time[i], sizeof(f64));
|
||||||
at += sizeof(f64);
|
at += sizeof(f64);
|
||||||
std::memcpy(record.data() + at, &table.stream_time[i], sizeof(f64));
|
std::memcpy(record.data() + at, &table.abs_seconds[i], sizeof(f64));
|
||||||
at += sizeof(f64);
|
at += sizeof(f64);
|
||||||
for (const ExportTable::Column& col : table.columns) {
|
for (const ExportTable::Column& col : table.columns) {
|
||||||
const f32 v = i < col.values.size() ? col.values[i] : 0.0f;
|
const f32 v = i < col.values.size() ? col.values[i] : 0.0f;
|
||||||
@@ -239,7 +243,7 @@ bool write_csv(const std::string& path, const ExportTable& table, std::string& e
|
|||||||
if (!opened(out, path, error)) return false;
|
if (!opened(out, path, error)) return false;
|
||||||
|
|
||||||
const std::vector<std::string> labels = column_labels(table);
|
const std::vector<std::string> labels = column_labels(table);
|
||||||
std::string line = "time,stream_time";
|
std::string line = "time,abs_seconds";
|
||||||
for (const std::string& label : labels) line += "," + label;
|
for (const std::string& label : labels) line += "," + label;
|
||||||
line += "\n";
|
line += "\n";
|
||||||
std::fwrite(line.data(), 1, line.size(), out.f);
|
std::fwrite(line.data(), 1, line.size(), out.f);
|
||||||
@@ -252,7 +256,7 @@ bool write_csv(const std::string& path, const ExportTable& table, std::string& e
|
|||||||
line.clear();
|
line.clear();
|
||||||
std::snprintf(buf, sizeof(buf), "%.17g", table.time[i]);
|
std::snprintf(buf, sizeof(buf), "%.17g", table.time[i]);
|
||||||
line += buf;
|
line += buf;
|
||||||
std::snprintf(buf, sizeof(buf), "%.17g", table.stream_time[i]);
|
std::snprintf(buf, sizeof(buf), "%.17g", table.abs_seconds[i]);
|
||||||
line += ",";
|
line += ",";
|
||||||
line += buf;
|
line += buf;
|
||||||
for (const ExportTable::Column& col : table.columns) {
|
for (const ExportTable::Column& col : table.columns) {
|
||||||
|
|||||||
+16
-7
@@ -24,13 +24,22 @@ struct ExportTable {
|
|||||||
std::vector<f32> values; ///< `rows()` long, the ring's own `f32`
|
std::vector<f32> values; ///< `rows()` long, the ring's own `f32`
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The x the plot shows and the crosshair reads: stream seconds in live mode,
|
/// The x the plot shows and the crosshair reads, which is **relative** in every mode: seconds
|
||||||
/// edge-relative seconds in `[-pre, +post]` while an event is held.
|
/// before now while streaming (so the newest sample is 0 and the window is `[-window, 0]`),
|
||||||
|
/// seconds from the edge in `[-pre, +post]` while an event is held, and seconds from the
|
||||||
|
/// instant of the freeze on a paused view.
|
||||||
std::vector<f64> time;
|
std::vector<f64> time;
|
||||||
/// The stream's own clock, so two exports taken in different modes line up. Equal to
|
/// The stream's own absolute clock -- the number the time signal carried, which is what the
|
||||||
/// `time` in live mode; `time + trigger_time` while an event is held, which is the one
|
/// source stamped the samples with -- so two exports taken in different modes, or from
|
||||||
/// case where the visible x is not the stream's.
|
/// different runs, line up on one timeline.
|
||||||
std::vector<f64> stream_time;
|
///
|
||||||
|
/// `f64 seconds` rather than the wire's integer, because the integer does not exist in this
|
||||||
|
/// process: `udps_frame_t::values` is a `const double*`, so the client has already widened
|
||||||
|
/// every value by the time acquisition sees it. At Unix-epoch magnitudes an `f64` resolves
|
||||||
|
/// about 240 ns, which is coarser than a 1 MS/s sample period -- so this column is the
|
||||||
|
/// *provenance* of a row and `time` is the column to difference. Its own `np.diff` is
|
||||||
|
/// legitimately not single-valued for that reason.
|
||||||
|
std::vector<f64> abs_seconds;
|
||||||
std::vector<Column> columns;
|
std::vector<Column> columns;
|
||||||
|
|
||||||
std::size_t rows() const noexcept { return time.size(); }
|
std::size_t rows() const noexcept { return time.size(); }
|
||||||
@@ -45,7 +54,7 @@ struct ExportTable {
|
|||||||
/// model access. In trigger mode nothing is locked: the `EventSnapshot` is immutable.
|
/// model access. In trigger mode nothing is locked: the `EventSnapshot` is immutable.
|
||||||
ExportTable collect_export(ScopeModel& model, RowSet& rows);
|
ExportTable collect_export(ScopeModel& model, RowSet& rows);
|
||||||
|
|
||||||
/// Writes a NumPy `.npy` file holding one structured array: `time` and `stream_time` as
|
/// Writes a NumPy `.npy` file holding one structured array: `time` and `abs_seconds` as
|
||||||
/// `<f8`, every signal as `<f4` -- the width the ring actually stores, so the file is
|
/// `<f8`, every signal as `<f4` -- the width the ring actually stores, so the file is
|
||||||
/// about half the size of an all-`f8` one and invents no precision.
|
/// about half the size of an all-`f8` one and invents no precision.
|
||||||
///
|
///
|
||||||
|
|||||||
+70
-18
@@ -1,26 +1,78 @@
|
|||||||
<b>What udpscope is</b>
|
<b>What udpscope is</b>
|
||||||
|
|
||||||
udpscope plots a live UDPStreamer source. One window holds up to 8 rows of plots, each
|
udpscope plots a live UDPStreamer source. One window holds up to 8 rows of plots, each with up
|
||||||
with up to 4 traces; every row shares one time axis, so the whole figure scrolls together.
|
to 4 traces, and every row shares one time axis, so the whole figure scrolls together.
|
||||||
Acquisition runs on its own thread and writes a ring buffer sized from --buffer-mb; the
|
Acquisition runs on its own thread and writes a ring buffer sized from <tt>--buffer-mb</tt>;
|
||||||
drawing thread reads whatever that ring holds when a frame starts.
|
the drawing thread reads whatever that ring holds when a frame starts, which is why a trace
|
||||||
|
never makes the window stutter.
|
||||||
|
|
||||||
|
The stream is the clock. Every interval you read on screen is measured between timestamps the
|
||||||
|
source sent, never your machine's, so a stalled source freezes the view instead of scrolling it
|
||||||
|
-- and a source whose clock steps still gets the window you asked for.
|
||||||
|
|
||||||
|
The time axis is <i>relative to now</i>. While streaming it is a fixed <tt>[-window .. 0]</tt>
|
||||||
|
with the newest sample at 0, so x reads as an age: <tt>-1.5s</tt> is a sample a second and a half
|
||||||
|
old. In trigger mode 0 is the event's edge instead, and a paused view keeps the ages it was
|
||||||
|
frozen with. The source's own clock is still there when you need it -- an export writes it as a
|
||||||
|
second column.
|
||||||
|
|
||||||
<b>How to reach it</b>
|
<b>How to reach it</b>
|
||||||
|
|
||||||
Start the app with a source (<tt>udpscope --server 127.0.0.1 --port 8000</tt>), or start it
|
Start the app with a source (<tt>udpscope --server 127.0.0.1 --port 8000</tt>), or start it with
|
||||||
with no <tt>--server</tt> and pick one from the chooser. The signals arrive disabled; turn
|
no <tt>--server</tt> and pick one from the chooser, which lists the sessions you have opened
|
||||||
one on in the side panel (<tt>s</tt>) and drag it onto a row.
|
before. A unicast source serves one client at a time: if a second viewer connects, they take
|
||||||
|
turns and the status bar says so. To watch one source from several instances, use
|
||||||
|
<tt>--multicast</tt> with the group and <tt>--interface</tt> with the local address.
|
||||||
|
|
||||||
|
The signals arrive <i>disabled</i>. Nothing is acquired until you turn one on in the signal
|
||||||
|
list, and enabling is what starts acquisition -- so a freshly opened scope with an idle side
|
||||||
|
panel is working correctly, not failing to connect.
|
||||||
|
|
||||||
|
<b>The window</b>
|
||||||
|
|
||||||
|
• <b>The header bar</b> -- left to right: the time window (<tt>2 s</tt>), the trigger and the
|
||||||
|
pause, then the pane switches -- signal list, cursors, measurements, math, side panel -- and
|
||||||
|
the menu, which holds Fit to data, the layout editor, Export, Keyboard shortcuts, Help and
|
||||||
|
About.
|
||||||
|
|
||||||
|
• <b>The side panel</b> -- the trigger, cursors, measurements and math panes at the top, then
|
||||||
|
the signal list: one row per signal with its colour, an enable switch, its name and its unit.
|
||||||
|
Signals are dragged from here onto a plot row.
|
||||||
|
|
||||||
|
• <b>The plot</b> -- up to 8 rows, each with its own y axis and up to 4 traces, all on one
|
||||||
|
shared time axis. Right-click a row for its own menu (fit, add a signal, remove a trace).
|
||||||
|
|
||||||
|
• <b>The status bar</b>, directly above the plot's bottom edge -- the state word, the stored
|
||||||
|
sample rate and the window, and the ⓘ beside it for packets, gaps and the buffer's own
|
||||||
|
figures. The second screenshot above is what a held capture looks like: <tt>event held</tt>,
|
||||||
|
the axis pinned to the event, and the trigger marker at the edge.
|
||||||
|
|
||||||
|
<b>The first five minutes</b>
|
||||||
|
|
||||||
|
1. <b>Connect.</b> With no <tt>--server</tt>, pick a stored session or type a host and port.
|
||||||
|
2. <b>Enable a signal</b> in the signal list. The plot stays empty for the first fraction of a
|
||||||
|
second while the first window fills -- the buffer holds only what has arrived.
|
||||||
|
3. <b>Drag it onto a row.</b> Drop a second signal on another row, or the same signal again for
|
||||||
|
a second view of it. The colour is per signal, so it is the same one wherever it appears.
|
||||||
|
4. <b>Set the window</b> with the control in the header bar: type a number, pick a unit, or step
|
||||||
|
a decade. The trigger's event window is a share of this span, so a window change moves it.
|
||||||
|
5. <b>Pause</b> (<tt>space</tt>) to look at a still picture; the producer keeps filling the
|
||||||
|
buffer behind it, and resuming shows what arrived meanwhile rather than a hole.
|
||||||
|
6. <b>Arm the trigger</b> (<tt>a</tt>) and wait for a latch: the view freezes on one event, and
|
||||||
|
you can zoom in on it. Disarming mid-capture keeps what the capture got.
|
||||||
|
7. <b>Measure it</b> -- the measurements pane computes a statistic of the visible window, and
|
||||||
|
<b>show on plot</b> marks it as a ruler or a pair of markers.
|
||||||
|
8. <b>Save the data</b> with Export visible range, which writes the plotted signals over the
|
||||||
|
window at the stored rate to <tt>.npy</tt> or <tt>.csv</tt>.
|
||||||
|
|
||||||
<b>Worth knowing</b>
|
<b>Worth knowing</b>
|
||||||
|
|
||||||
• Nothing is acquired until a signal is enabled. Enabling is what starts acquisition, so a
|
• A signal nobody enables costs nothing -- no buffer, no socket work, no measurements.
|
||||||
freshly opened scope with an idle side panel is working correctly.
|
• The window remembers itself. Rows, traces, the time window, calibration, which signals were
|
||||||
• The stream carries its own clock. Every time on screen is a stream timestamp, never the
|
enabled, the math channels with their graphs, and the measurements are written per host and
|
||||||
local wall clock, which is why a stalled source freezes the view instead of scrolling it.
|
port, and reloaded the next time you open that source.
|
||||||
• The window remembers itself. Rows, traces, the time window, calibration, math channels
|
• A very wide window on a fast stream is drawn from averaged groups of samples, so the rate in
|
||||||
and measurements are written to a session file per host and port, and reloaded next time.
|
the status bar falls: that is the anti-alias filter, not a fault -- striding would fold a fast
|
||||||
|
signal back as a slow one, which looks entirely plausible on screen.
|
||||||
<b>Example</b>
|
• Every instrument the app computed -- the trigger marker, the measurement marks -- is locked to
|
||||||
|
the pointer's reach. The cursors are yours to drag; those are not.
|
||||||
A 3-signal source at 10 kS/s with a 200 MB buffer holds 1677 s of history. Enable all three,
|
|
||||||
drop them on row 1, and the status bar reads <tt>● live · 10 kS/s · 2 s window</tt>.
|
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
<b>Rows, layout and drag & drop</b>
|
<b>Rows, layout and drag & drop</b>
|
||||||
|
|
||||||
The figure is a stack of rows, 1 to 8 of them, each holding up to 4 traces. Rows share one
|
The figure is a stack of rows, 1 to 8 of them, each holding up to 4 traces. Rows share one
|
||||||
time axis and one left gutter, so their y labels line up and their windows cannot drift apart.
|
time axis and one pair of side margins, so their y labels line up, their data areas start and
|
||||||
|
end on the same two columns, and their windows cannot drift apart. The left margin holds the y
|
||||||
|
labels; the right one is empty, and is there so the newest tick's label and the newest sample's
|
||||||
|
ink have somewhere to land instead of sitting on the frame.
|
||||||
|
|
||||||
<b>How to reach it</b>
|
<b>How to reach it</b>
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ unit, width-locked so a change of window cannot widen the header bar.
|
|||||||
value. Stamping at the end of a group would shift every trace half a stored sample late.
|
value. Stamping at the end of a group would shift every trace half a stored sample late.
|
||||||
• Moving the window across a decimation boundary rebuilds the buffer and drops the history.
|
• Moving the window across a decimation boundary rebuilds the buffer and drops the history.
|
||||||
Walking it inside one decade is free.
|
Walking it inside one decade is free.
|
||||||
|
• <b>This control is the only thing that changes the window.</b> A zoom, a pan or a fit moves the
|
||||||
|
view <i>inside</i> it and leaves it alone — which is the point of the previous bullet: a wheel
|
||||||
|
notch that redefined the window could re-plan the buffer and throw your samples away.
|
||||||
• The trigger sees the <i>stored</i> (filtered) signal, not the wire signal, because an edge
|
• The trigger sees the <i>stored</i> (filtered) signal, not the wire signal, because an edge
|
||||||
found on a sample that is not in the buffer could not be cut out of it.
|
found on a sample that is not in the buffer could not be cut out of it.
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ The full table is in the shortcut overview (<tt>Ctrl+/</tt>).
|
|||||||
• A <i>pinned</i> view is navigable within its own data but cannot leave it. While an event is
|
• A <i>pinned</i> view is navigable within its own data but cannot leave it. While an event is
|
||||||
held, a capture is filling, or the view is paused, a zoom <i>in</i> works normally — "not
|
held, a capture is filling, or the view is paused, a zoom <i>in</i> works normally — "not
|
||||||
outside the event" is not "not readable" — but a pan or a zoom out is clamped to the data.
|
outside the event" is not "not readable" — but a pan or a zoom out is clamped to the data.
|
||||||
|
• Nothing you do on the plot changes the <i>time window</i> — only the header control does. A
|
||||||
|
zoom or a pan moves the view inside the window; <b>f</b> (and Fit X axis) is the <i>undo</i> of a
|
||||||
|
zoom, so a 2 s window zoomed into reads 2 s again. The window is more than the axis: it is also
|
||||||
|
the trigger's event span and how the samples are stored, so a wheel notch that redefined it
|
||||||
|
would re-plan the buffer and drop your history.
|
||||||
|
• The axis does not scroll — the <i>data</i> does. A live axis is a fixed <tt>[-window .. 0]</tt>
|
||||||
|
with the newest sample at 0, so the labels stand still and the trace rolls under them. The
|
||||||
|
cursors stand still with the labels (they are positions on screen, so a cursor you placed stays
|
||||||
|
where you put it, over newer data each frame), while a measurement's marks travel with the
|
||||||
|
sample they measured and leave the view when it does.
|
||||||
• A live view is clamped to the window you asked for, not to what the ring happens to hold.
|
• A live view is clamped to the window you asked for, not to what the ring happens to hold.
|
||||||
That is why a buffer that has only just started filling leaves its left-hand side empty
|
That is why a buffer that has only just started filling leaves its left-hand side empty
|
||||||
instead of squeezing the window down to the first few samples.
|
instead of squeezing the window down to the first few samples.
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ live ring and puts the window back on the newest data.
|
|||||||
zoom out is clamped to the snapshot's data.
|
zoom out is clamped to the snapshot's data.
|
||||||
• Pausing is the easiest way to measure a layout or read a cursor precisely, because nothing
|
• Pausing is the easiest way to measure a layout or read a cursor precisely, because nothing
|
||||||
under the pointer moves between frames.
|
under the pointer moves between frames.
|
||||||
|
• The status bar says how long you have been paused — wall time, since the stream itself never
|
||||||
|
stopped. It is there so a frozen picture cannot be mistaken for a live one that went quiet.
|
||||||
|
|
||||||
<b>Example</b>
|
<b>Example</b>
|
||||||
|
|
||||||
|
|||||||
+18
-1
@@ -19,12 +19,29 @@ owns a <b>Reset</b> button. Drag a marker to move it. The mode dropdown offers f
|
|||||||
• <tt>mix</tt> is the reading mode: one line per <i>bound</i> trace, with the trace's own values
|
• <tt>mix</tt> is the reading mode: one line per <i>bound</i> trace, with the trace's own values
|
||||||
at each marker and their difference. A slot with a name but no channel behind it is not a
|
at each marker and their difference. A slot with a name but no channel behind it is not a
|
||||||
trace and gets no line.
|
trace and gets no line.
|
||||||
|
• A value is the <i>nearest stored sample's</i>, never a number between two samples. Zoom in past
|
||||||
|
the sample spacing and the line between two samples is something the plot drew, not data —
|
||||||
|
so a reading off it would be the drawing. The hover crosshair reads the same way.
|
||||||
|
• Every reading carries a <i>metric prefix</i> so the digits sit in <tt>1..999</tt>:
|
||||||
|
<tt>10.52 mV</tt>, not <tt>0.01 V</tt>. Two decimals is all an instrument landing on an
|
||||||
|
<tt>f32</tt> sample can claim, so without the prefix a small value would read <tt>0.00</tt>
|
||||||
|
and the reading would be gone. A stream that declares no unit gets the prefix anyway, written
|
||||||
|
tight against the number (<tt>585.24m</tt>) — the space separates the number from its unit,
|
||||||
|
and a spaced <tt>m</tt> with nothing after it would read as metres.
|
||||||
• A marker past the newest sample reads <tt>--</tt>. With only one marker over data the row
|
• A marker past the newest sample reads <tt>--</tt>. With only one marker over data the row
|
||||||
reads <tt>partial</tt>: a difference between a number and nothing is not a number.
|
reads <tt>partial</tt>: a difference between a number and nothing is not a number.
|
||||||
• In <tt>mix</tt> each marker also places a crossing annotation per trace, which is what lets
|
• In <tt>mix</tt> each marker also places a crossing annotation per trace, which is what lets
|
||||||
you see on the plot which sample a number came from.
|
you see on the plot which sample a number came from. It is <i>on that sample</i>, so when the
|
||||||
|
marker falls between two it sits a little off its own line: the line is where you put the
|
||||||
|
marker, the dot is the datum being quoted. The hover crosshair's mark snaps the same way, and
|
||||||
|
is drawn larger than a trace's sample dots so it does not read as one more sample.
|
||||||
• The time row exists only in the modes that show the vertical pair. <tt>horizontal</tt> has no
|
• The time row exists only in the modes that show the vertical pair. <tt>horizontal</tt> has no
|
||||||
time row, by design.
|
time row, by design.
|
||||||
|
• A cursor <i>stays where you put it</i> while a live view rolls under it, which is what a bench
|
||||||
|
scope does: its <tt>T0</tt> is an age, so a marker left at <tt>-1.5s</tt> keeps reading the
|
||||||
|
sample a second and a half old as the stream advances. A measurement's marks do the opposite —
|
||||||
|
they travel with the sample they measured — because a cursor is a question you asked and a mark
|
||||||
|
is an answer about one particular sample.
|
||||||
|
|
||||||
<b>Example</b>
|
<b>Example</b>
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ set, with <b>Save</b> and <b>Delete</b>.
|
|||||||
• A window with too few crossings reads <tt>invalid</tt> / <tt>--</tt> rather than a plausible
|
• A window with too few crossings reads <tt>invalid</tt> / <tt>--</tt> rather than a plausible
|
||||||
invented number. Frequency over a window holding one edge is the usual case.
|
invented number. Frequency over a window holding one edge is the usual case.
|
||||||
• <tt>frequency</tt> is reported in Hz and <tt>period</tt>/<tt>pulse duration</tt> in seconds;
|
• <tt>frequency</tt> is reported in Hz and <tt>period</tt>/<tt>pulse duration</tt> in seconds;
|
||||||
every other type carries the channel's own unit.
|
every other type carries the channel's own unit. A value is written with a <i>metric prefix</i>
|
||||||
|
so its digits sit in <tt>1..999</tt> — <tt>636.62 mV</tt>, not <tt>0.64 V</tt> — including when
|
||||||
|
the stream declares no unit at all, which is always the case for a math channel: there the
|
||||||
|
prefix goes tight against the number (<tt>636.62m</tt>), since two decimals of an unprefixed
|
||||||
|
small value is <tt>0.00</tt>.
|
||||||
• Cost is bounded by reading the summary pyramid the producer already maintains, not the raw
|
• Cost is bounded by reading the summary pyramid the producer already maintains, not the raw
|
||||||
window. The values are still exact: a bucket's sum is a sum.
|
window. The values are still exact: a bucket's sum is a sum.
|
||||||
|
|
||||||
|
|||||||
@@ -33,3 +33,19 @@ orange dot marks the output node.
|
|||||||
same source passes 50 Hz at 0.97 and 2 kHz at 0.002; a 1 kHz band pass with Q=2 passes 200 Hz at
|
same source passes 50 Hz at 0.97 and 2 kHz at 0.002; a 1 kHz band pass with Q=2 passes 200 Hz at
|
||||||
0.05, 1 kHz at 0.95 and 4 kHz at 0.05. Each derived channel must hold exactly as many samples as
|
0.05, 1 kHz at 0.95 and 4 kHz at 0.05. Each derived channel must hold exactly as many samples as
|
||||||
the shared clock has time slots.
|
the shared clock has time slots.
|
||||||
|
|
||||||
|
<b>Why an operation sometimes does nothing</b>. A graph's <i>output</i> is the node whose value
|
||||||
|
becomes the channel, and it is drawn as the orange dot. A new graph is built source, then operation,
|
||||||
|
and the last node added becomes the output -- but a graph whose output is left on the source is
|
||||||
|
valid and simply passes its input through, which is the usual reason a gain or a filter appears to
|
||||||
|
have no effect. <i>Set as output</i> moves the dot.
|
||||||
|
|
||||||
|
<b>Save refuses an incomplete graph</b>, and says why: an input that is not connected, no output
|
||||||
|
chosen, or a <i>channel</i> node naming a signal the scope is not acquiring. Only the channels being
|
||||||
|
acquired are offered, because a source that is not acquired reads as no data for ever -- and a graph
|
||||||
|
that reads NaN is harder to diagnose than one that would not save.
|
||||||
|
|
||||||
|
<b>The filters are real filters.</b> A math channel's samples are produced as the stream arrives,
|
||||||
|
once per stored slot, so a low pass carries the history a filter needs. A cutoff typed in hertz is
|
||||||
|
against the <i>stored</i> rate, which is the wire rate until a wide window makes the buffer decimate
|
||||||
|
-- the status line's rate is that number, and the filters are rebuilt whenever it changes.
|
||||||
@@ -17,15 +17,17 @@ the format follows the path's extension.
|
|||||||
• The stored rate is not always the wire rate. A window long enough to need decimation is stored
|
• The stored rate is not always the wire rate. A window long enough to need decimation is stored
|
||||||
averaged at a lower rate, and the export carries <i>that</i> — read the status bar's ⓘ popover
|
averaged at a lower rate, and the export carries <i>that</i> — read the status bar's ⓘ popover
|
||||||
for the figure before trusting a sample count.
|
for the figure before trusting a sample count.
|
||||||
• Two time columns. <tt>time</tt> is the display timeline (relative to the trigger edge while an
|
• Two time columns. <tt>time</tt> is the display timeline — negative ages ending at 0 while
|
||||||
event is held) and <tt>stream_time</tt> is the source's own clock. Both are strictly increasing
|
streaming, offsets from the trigger edge while an event is held — so it is the x you read a
|
||||||
and evenly spaced; duplicate or backwards stamps would mean the file held two clocks.
|
value at on screen. <tt>abs_seconds</tt> is the source's own clock for the same sample, which
|
||||||
|
is what lets two exports taken in different modes line up. Both are strictly increasing and
|
||||||
|
evenly spaced; duplicate or backwards stamps would mean the file held two clocks.
|
||||||
• An export taken <i>mid-capture</i> writes the partly filled window that is on screen, which is
|
• An export taken <i>mid-capture</i> writes the partly filled window that is on screen, which is
|
||||||
shorter than the event span. Export outside a capture if you want the whole event.
|
shorter than the event span. Export outside a capture if you want the whole event.
|
||||||
|
|
||||||
<b>Example</b>
|
<b>Example</b>
|
||||||
|
|
||||||
A 2 s window at 10 kS/s over 3 traces writes 20001 rows and 5 columns
|
A 2 s window at 10 kS/s over 3 traces writes 20001 rows and 5 columns
|
||||||
(<tt>time</tt>, <tt>stream_time</tt>, three signals). The <tt>.npy</tt> is
|
(<tt>time</tt>, <tt>abs_seconds</tt>, three signals). The <tt>.npy</tt> is
|
||||||
<tt>64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)</tt> bytes. Read it back with
|
<tt>64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)</tt> bytes. Read it back with
|
||||||
<tt>pd.DataFrame(np.load(path))</tt>.
|
<tt>pd.DataFrame(np.load(path))</tt>.
|
||||||
+18
-8
@@ -9,11 +9,19 @@ The bar is always visible under the plot. Click <b>ⓘ</b> for the detail popove
|
|||||||
|
|
||||||
<b>Worth knowing</b>
|
<b>Worth knowing</b>
|
||||||
|
|
||||||
• The badge is one glyph in three colours: lit and dim alternating means data is arriving, and a
|
• The badge is one glyph in four colours: green <i>alternating</i> lit and dim means data is
|
||||||
steady idle colour means it is not. A badge that never blinks is not a working badge.
|
arriving, and a steady colour means it is not. A badge that never blinks is not a working badge.
|
||||||
• The state field is one word from a fixed set: <tt>live</tt>, <tt>paused</tt>, <tt>armed</tt>,
|
Of the two steady colours, <i>grey</i> is no stream at all or a pipeline you stopped (the
|
||||||
<tt>capturing</tt>, <tt>event held</tt>, <tt>disarmed</tt>. In trigger mode the bar adds
|
tooltip says which), and <i>yellow</i> is a source that answered but has sent no configuration.
|
||||||
exactly one more field, how long ago the last event was.
|
• The state field is one word from a fixed set: <tt>connecting</tt>, <tt>live</tt>,
|
||||||
|
<tt>paused</tt>, <tt>armed</tt>, <tt>capturing</tt>, <tt>event held</tt>, <tt>disarmed</tt>. Two
|
||||||
|
states add one field each: in trigger mode, how long ago the last event was; while paused, how
|
||||||
|
long you have been paused.
|
||||||
|
• <tt>connecting</tt> means there is no <i>signal list</i> yet — either no session or a session
|
||||||
|
with no configuration behind it. Every other word claims a signal list, so until one arrives
|
||||||
|
there is nothing for them to describe: nothing can be enabled and no data is owed.
|
||||||
|
• The paused figure is <i>wall</i> time, not stream time — the producer never stops while paused,
|
||||||
|
so it answers "how long have I been looking at this picture".
|
||||||
• Nothing on the bar may change width with the stream, because the bar sits directly above the
|
• Nothing on the bar may change width with the stream, because the bar sits directly above the
|
||||||
plot and a wider label would relayout it. That is why the rate collapses to one figure or a
|
plot and a wider label would relayout it. That is why the rate collapses to one figure or a
|
||||||
range rather than listing per channel, and why an elapsed time steps to a coarser unit instead
|
range rather than listing per channel, and why an elapsed time steps to a coarser unit instead
|
||||||
@@ -22,9 +30,11 @@ The bar is always visible under the plot. Click <b>ⓘ</b> for the detail popove
|
|||||||
non-zero count means samples are absent from the buffer, not merely late.
|
non-zero count means samples are absent from the buffer, not merely late.
|
||||||
• <tt>reconnects</tt> counts sessions re-established with the source. Over unicast a climbing
|
• <tt>reconnects</tt> counts sessions re-established with the source. Over unicast a climbing
|
||||||
count usually means a second client is taking the source's single session.
|
count usually means a second client is taking the source's single session.
|
||||||
• The starvation notice — <i>connected, but no new frames in the last second</i> — is that same
|
• The starvation notice — <i>connected, but no data is arriving</i> — is that same situation seen
|
||||||
situation seen from the other side. Pointing the app at a port where nothing answers produces
|
from the other side: the signal list is here, so something took the stream away. A source that
|
||||||
it too.
|
has sent <i>no</i> configuration gets a different notice, <i>trying to connect</i>, because
|
||||||
|
nothing was taken away — pointing the app at a port where nothing answers is that case, and the
|
||||||
|
usual cause is a streamer that is not running or is serving another port.
|
||||||
• <tt>stored</tt> versus <tt>wire</tt> in the popover is the decimation. They differ exactly
|
• <tt>stored</tt> versus <tt>wire</tt> in the popover is the decimation. They differ exactly
|
||||||
when the window is too long to store at full rate.
|
when the window is too long to store at full rate.
|
||||||
|
|
||||||
|
|||||||
@@ -23,5 +23,12 @@
|
|||||||
<file>12-export.txt</file>
|
<file>12-export.txt</file>
|
||||||
<file>13-sessions.txt</file>
|
<file>13-sessions.txt</file>
|
||||||
<file>14-status.txt</file>
|
<file>14-status.txt</file>
|
||||||
|
|
||||||
|
<!-- The screenshots live with the user documentation (docs/); the resource aliases
|
||||||
|
them under `img/` so a help page can name one by resource path. The path is relative
|
||||||
|
to this manifest's directory. -->
|
||||||
|
<file alias="img/main-window.png">../../docs/img/main-window.png</file>
|
||||||
|
<file alias="img/trigger-capture.png">../../docs/img/trigger-capture.png</file>
|
||||||
|
<file alias="img/math-editor.png">../../docs/img/math-editor.png</file>
|
||||||
</gresource>
|
</gresource>
|
||||||
</gresources>
|
</gresources>
|
||||||
+93
-33
@@ -13,13 +13,14 @@
|
|||||||
namespace udpscope {
|
namespace udpscope {
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
/// Nulled by the `close-request` handler so a second open cannot present a dead widget.
|
/// Nulled by each window's own close handler, so a second open cannot present a dead widget.
|
||||||
/// One pointer per window, file-scope because there is one application.
|
/// One pointer per window, file-scope because there is one application.
|
||||||
GtkWidget* g_about = nullptr;
|
///
|
||||||
|
/// The help window is a `GtkWindow`. The other two are `AdwDialog`s, which are **not**
|
||||||
|
/// `GtkWindow`s: they have no `::close-request`, so they clear on `::closed` and close with
|
||||||
|
/// `adw_dialog_close()`.
|
||||||
GtkWidget* g_help = nullptr;
|
GtkWidget* g_help = nullptr;
|
||||||
|
AdwDialog* g_about = nullptr;
|
||||||
/// The shortcut overview is an `AdwDialog`, not a window, so it has its own pointer and
|
|
||||||
/// its own clearing signal (`::closed` rather than `::close-request`).
|
|
||||||
AdwDialog* g_shortcuts = nullptr;
|
AdwDialog* g_shortcuts = nullptr;
|
||||||
|
|
||||||
gboolean on_window_close(GtkWindow* window, gpointer slot) {
|
gboolean on_window_close(GtkWindow* window, gpointer slot) {
|
||||||
@@ -28,32 +29,49 @@ gboolean on_window_close(GtkWindow* window, gpointer slot) {
|
|||||||
return FALSE; // let the default handler destroy it
|
return FALSE; // let the default handler destroy it
|
||||||
}
|
}
|
||||||
|
|
||||||
void on_shortcuts_closed(AdwDialog* dialog, gpointer user) {
|
/// Shared by both `AdwDialog`s: the pointer to clear is the `user_data`, so there is one
|
||||||
|
/// handler rather than one per dialog.
|
||||||
|
void on_dialog_closed(AdwDialog* dialog, gpointer slot) {
|
||||||
(void)dialog;
|
(void)dialog;
|
||||||
(void)user;
|
*static_cast<AdwDialog**>(slot) = nullptr;
|
||||||
g_shortcuts = nullptr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct HelpPage {
|
struct HelpPage {
|
||||||
const char* section; ///< the group heading in the sidebar
|
const char* section; ///< the group heading in the sidebar
|
||||||
const char* title; ///< the row's label and the page's heading
|
const char* title; ///< the row's label and the page's heading
|
||||||
const char* resource; ///< a path under /org/udpscope/help
|
const char* resource; ///< a path under /org/udpscope/help
|
||||||
|
/// The screenshots shown above the text, as resource paths and a null terminator. A page
|
||||||
|
/// with none is the common case and the reason this is a list rather than one field: the
|
||||||
|
/// overview carries two, the window and a held capture.
|
||||||
|
const char* const* images = nullptr;
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Adding a page is one .txt file, one <file> line in the manifest and one row here.
|
/// Adding a page is one .txt file, one <file> line in the manifest and one row here.
|
||||||
/// The order here is the order the sidebar shows; `section` groups consecutive rows.
|
/// The order here is the order the sidebar shows; `section` groups consecutive rows.
|
||||||
|
/// How tall a page's screenshots are drawn. They are scaled to fit this *height* and centre
|
||||||
|
/// themselves in it, so a 1500x950 capture is readable without a horizontal scrollbar.
|
||||||
|
constexpr int kHelpShotHeight = 260;
|
||||||
|
|
||||||
|
/// The screenshots each page carries, in the order it shows them.
|
||||||
|
constexpr const char* kOverviewShots[] = {"/org/udpscope/help/img/main-window.png",
|
||||||
|
"/org/udpscope/help/img/trigger-capture.png",
|
||||||
|
nullptr};
|
||||||
|
constexpr const char* kTriggerShots[] = {"/org/udpscope/help/img/trigger-capture.png",
|
||||||
|
nullptr};
|
||||||
|
constexpr const char* kMathShots[] = {"/org/udpscope/help/img/math-editor.png", nullptr};
|
||||||
|
|
||||||
constexpr HelpPage kPages[] = {
|
constexpr HelpPage kPages[] = {
|
||||||
{"Getting started", "Overview", "/org/udpscope/help/01-overview.txt"},
|
{"Getting started", "Overview", "/org/udpscope/help/01-overview.txt", kOverviewShots},
|
||||||
{"Getting started", "Choosing a source", "/org/udpscope/help/02-source.txt"},
|
{"Getting started", "Choosing a source", "/org/udpscope/help/02-source.txt"},
|
||||||
{"Getting started", "Signals and calibration", "/org/udpscope/help/03-signals.txt"},
|
{"Getting started", "Signals and calibration", "/org/udpscope/help/03-signals.txt"},
|
||||||
{"Getting started", "Rows, layout and drag & drop", "/org/udpscope/help/04-layout.txt"},
|
{"Getting started", "Rows, layout and drag & drop", "/org/udpscope/help/04-layout.txt"},
|
||||||
{"Viewing", "Time window", "/org/udpscope/help/05-window.txt"},
|
{"Viewing", "Time window", "/org/udpscope/help/05-window.txt"},
|
||||||
{"Viewing", "Navigating the plot", "/org/udpscope/help/06-navigate.txt"},
|
{"Viewing", "Navigating the plot", "/org/udpscope/help/06-navigate.txt"},
|
||||||
{"Viewing", "Pause and play", "/org/udpscope/help/07-pause.txt"},
|
{"Viewing", "Pause and play", "/org/udpscope/help/07-pause.txt"},
|
||||||
{"Viewing", "Trigger", "/org/udpscope/help/08-trigger.txt"},
|
{"Viewing", "Trigger", "/org/udpscope/help/08-trigger.txt", kTriggerShots},
|
||||||
{"Analysis", "Cursors", "/org/udpscope/help/09-cursors.txt"},
|
{"Analysis", "Cursors", "/org/udpscope/help/09-cursors.txt"},
|
||||||
{"Analysis", "Measurements", "/org/udpscope/help/10-measurements.txt"},
|
{"Analysis", "Measurements", "/org/udpscope/help/10-measurements.txt"},
|
||||||
{"Analysis", "Math channels", "/org/udpscope/help/11-math.txt"},
|
{"Analysis", "Math channels", "/org/udpscope/help/11-math.txt", kMathShots},
|
||||||
{"Output and state", "Saving and export", "/org/udpscope/help/12-export.txt"},
|
{"Output and state", "Saving and export", "/org/udpscope/help/12-export.txt"},
|
||||||
{"Output and state", "Sessions and settings", "/org/udpscope/help/13-sessions.txt"},
|
{"Output and state", "Sessions and settings", "/org/udpscope/help/13-sessions.txt"},
|
||||||
{"Output and state", "Status bar and diagnostics", "/org/udpscope/help/14-status.txt"},
|
{"Output and state", "Status bar and diagnostics", "/org/udpscope/help/14-status.txt"},
|
||||||
@@ -92,37 +110,69 @@ void on_help_row_selected(GtkListBox*, GtkListBoxRow* row, gpointer body_label)
|
|||||||
bool ok = false;
|
bool ok = false;
|
||||||
const std::string body = load_page(kPages[idx], &ok);
|
const std::string body = load_page(kPages[idx], &ok);
|
||||||
gtk_label_set_markup(GTK_LABEL(body_label), body.c_str());
|
gtk_label_set_markup(GTK_LABEL(body_label), body.c_str());
|
||||||
|
// The screenshots above it, if this page has any. The column is the label's previous sibling
|
||||||
|
// inside the page's box, which is what lets this handler stay a matter of the label alone --
|
||||||
|
// and refilling it is a page *switch*, not a frame, so rebuilding it here is cheap and is
|
||||||
|
// also the only way to be sure the pictures are this page's and not the last one's.
|
||||||
|
GtkWidget* label = GTK_WIDGET(body_label);
|
||||||
|
GtkWidget* parent = gtk_widget_get_parent(label);
|
||||||
|
GtkWidget* shots = parent != nullptr ? gtk_widget_get_first_child(parent) : nullptr;
|
||||||
|
if (shots != nullptr && GTK_IS_BOX(shots)) {
|
||||||
|
while (GtkWidget* old = gtk_widget_get_first_child(shots)) {
|
||||||
|
gtk_box_remove(GTK_BOX(shots), old);
|
||||||
|
}
|
||||||
|
for (const char* const* img = kPages[idx].images; img != nullptr && *img != nullptr; ++img) {
|
||||||
|
GtkWidget* shot = gtk_picture_new_for_resource(*img);
|
||||||
|
gtk_picture_set_can_shrink(GTK_PICTURE(shot), TRUE);
|
||||||
|
// A height, and `CONTAIN` (the default) to fit the image inside it: a picture that may
|
||||||
|
// shrink asks for no height at all in a box, so without this it is allocated nothing and
|
||||||
|
// the page shows its text with a gap where the screenshots should be.
|
||||||
|
gtk_widget_set_size_request(shot, -1, kHelpShotHeight);
|
||||||
|
gtk_box_append(GTK_BOX(shots), shot);
|
||||||
|
}
|
||||||
|
gtk_widget_set_visible(shots, gtk_widget_get_first_child(shots) != nullptr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void show_about(GtkWindow* parent, const std::string& session_path) {
|
void show_about(GtkWindow* parent, const std::string& session_path) {
|
||||||
if (g_about != nullptr) {
|
if (g_about != nullptr) {
|
||||||
gtk_window_present(GTK_WINDOW(g_about));
|
adw_dialog_present(g_about, GTK_WIDGET(parent));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GtkAboutDialog is not deprecated in GTK 4 (unlike GtkShortcutsWindow), so the
|
// `AdwAboutDialog` rather than `GtkAboutDialog`: it shows the application icon as the page's
|
||||||
// stock dialog is used here and the overview is hand-rolled.
|
// own header, which is the whole reason the app has one, and `adw_about_window_new()` is
|
||||||
GtkWidget* dialog = gtk_about_dialog_new();
|
// deprecated in favour of it. It is an `AdwDialog`, so it is modal on what it is presented
|
||||||
|
// on and needs no transient-for or modal call of its own.
|
||||||
|
AdwDialog* dialog = adw_about_dialog_new();
|
||||||
g_about = dialog;
|
g_about = dialog;
|
||||||
GtkAboutDialog* about = GTK_ABOUT_DIALOG(dialog);
|
AdwAboutDialog* about = ADW_ABOUT_DIALOG(dialog);
|
||||||
gtk_about_dialog_set_program_name(about, "udpscope");
|
adw_about_dialog_set_application_name(about, "udpscope");
|
||||||
gtk_about_dialog_set_version(about, build_info::kVersion);
|
adw_about_dialog_set_application_icon(about, "org.udpscope.app");
|
||||||
gtk_about_dialog_set_comments(about, "An oscilloscope for UDPStreamer sources.");
|
adw_about_dialog_set_developer_name(about, "Martino Ferrari");
|
||||||
gtk_about_dialog_set_license_type(about, GTK_LICENSE_MIT_X11);
|
adw_about_dialog_set_version(about, build_info::kVersion);
|
||||||
|
adw_about_dialog_set_comments(about, "An oscilloscope for UDPStreamer sources.");
|
||||||
|
adw_about_dialog_set_license_type(about, GTK_LICENSE_MIT_X11);
|
||||||
|
adw_about_dialog_set_website(about, "https://git.ferrarinas.it/martino/udpscope");
|
||||||
|
|
||||||
char details[1024];
|
// `debug_info` rather than `system_information`: it renders as a Troubleshooting page with a
|
||||||
std::snprintf(details, sizeof(details),
|
// copy button, where the old field was inert text a user had to retype. A `std::string`
|
||||||
"Commit: %s\nBuilt: %s\nSettings: %s\nSession file: %s",
|
// rather than a fixed buffer, because a long settings path used to truncate it silently.
|
||||||
build_info::kCommit, build_info::kTimestamp, config_dir().c_str(),
|
const std::string debug = "Commit: " + std::string(build_info::kCommit) +
|
||||||
session_path.c_str());
|
"\nBuilt: " + build_info::kTimestamp +
|
||||||
gtk_about_dialog_set_system_information(about, details);
|
"\nSettings: " + config_dir() +
|
||||||
|
"\nSession file: " + session_path;
|
||||||
|
adw_about_dialog_set_debug_info(about, debug.c_str());
|
||||||
|
|
||||||
gtk_window_set_transient_for(GTK_WINDOW(dialog), parent);
|
g_signal_connect(dialog, "closed", G_CALLBACK(on_dialog_closed), &g_about);
|
||||||
gtk_window_set_modal(GTK_WINDOW(dialog), TRUE);
|
adw_dialog_present(dialog, GTK_WIDGET(parent));
|
||||||
g_signal_connect(dialog, "close-request", G_CALLBACK(on_window_close), &g_about);
|
}
|
||||||
gtk_window_present(GTK_WINDOW(dialog));
|
|
||||||
|
const char* about_icon_name() {
|
||||||
|
if (g_about == nullptr) return "";
|
||||||
|
return adw_about_dialog_get_application_icon(ADW_ABOUT_DIALOG(g_about));
|
||||||
}
|
}
|
||||||
|
|
||||||
HelpReport help_self_check() {
|
HelpReport help_self_check() {
|
||||||
@@ -168,7 +218,17 @@ void show_help(GtkWindow* parent) {
|
|||||||
GtkWidget* body_scroll = gtk_scrolled_window_new();
|
GtkWidget* body_scroll = gtk_scrolled_window_new();
|
||||||
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(body_scroll), GTK_POLICY_NEVER,
|
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(body_scroll), GTK_POLICY_NEVER,
|
||||||
GTK_POLICY_AUTOMATIC);
|
GTK_POLICY_AUTOMATIC);
|
||||||
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(body_scroll), body);
|
// The page's own column: its screenshots above its text. Built once and *refilled* by the page
|
||||||
|
// switch, which is why no picture is made here -- one `GtkPicture` per image of the page.
|
||||||
|
GtkWidget* shot_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
|
||||||
|
gtk_widget_set_margin_start(shot_box, 18);
|
||||||
|
gtk_widget_set_margin_end(shot_box, 18);
|
||||||
|
gtk_widget_set_margin_top(shot_box, 14);
|
||||||
|
gtk_widget_set_visible(shot_box, false);
|
||||||
|
GtkWidget* page_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||||
|
gtk_box_append(GTK_BOX(page_box), shot_box);
|
||||||
|
gtk_box_append(GTK_BOX(page_box), body);
|
||||||
|
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(body_scroll), page_box);
|
||||||
gtk_widget_set_hexpand(body_scroll, TRUE);
|
gtk_widget_set_hexpand(body_scroll, TRUE);
|
||||||
|
|
||||||
GtkWidget* list = gtk_list_box_new();
|
GtkWidget* list = gtk_list_box_new();
|
||||||
@@ -278,13 +338,13 @@ void show_shortcuts(GtkWindow* parent) {
|
|||||||
gestures[i].description);
|
gestures[i].description);
|
||||||
}
|
}
|
||||||
|
|
||||||
g_signal_connect(dialog, "closed", G_CALLBACK(on_shortcuts_closed), nullptr);
|
g_signal_connect(dialog, "closed", G_CALLBACK(on_dialog_closed), &g_shortcuts);
|
||||||
adw_dialog_present(dialog, GTK_WIDGET(parent));
|
adw_dialog_present(dialog, GTK_WIDGET(parent));
|
||||||
}
|
}
|
||||||
|
|
||||||
void close_help_windows() {
|
void close_help_windows() {
|
||||||
if (g_help != nullptr) gtk_window_close(GTK_WINDOW(g_help));
|
if (g_help != nullptr) gtk_window_close(GTK_WINDOW(g_help));
|
||||||
if (g_about != nullptr) gtk_window_close(GTK_WINDOW(g_about));
|
if (g_about != nullptr) adw_dialog_close(g_about);
|
||||||
if (g_shortcuts != nullptr) adw_dialog_close(g_shortcuts);
|
if (g_shortcuts != nullptr) adw_dialog_close(g_shortcuts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-6
@@ -8,10 +8,16 @@
|
|||||||
|
|
||||||
namespace udpscope {
|
namespace udpscope {
|
||||||
|
|
||||||
/// The About dialog: version, commit, build timestamp and where the session files live.
|
/// The About dialog: the app icon, version, commit, build timestamp and where the session
|
||||||
/// Modal on `parent`. `session_path` is this run's own session file.
|
/// files live. An `AdwAboutDialog`, so it is modal on `parent` by being presented on it, and
|
||||||
|
/// it is an `AdwDialog` rather than a `GtkWindow`. `session_path` is this run's own session
|
||||||
|
/// file; it goes on the Troubleshooting page, which has a copy button.
|
||||||
void show_about(GtkWindow* parent, const std::string& session_path);
|
void show_about(GtkWindow* parent, const std::string& session_path);
|
||||||
|
|
||||||
|
/// The icon name the open About dialog carries, or "" when it is not open. The probe reads it
|
||||||
|
/// so the dialog is asserted to *show* the app icon rather than merely to open.
|
||||||
|
const char* about_icon_name();
|
||||||
|
|
||||||
/// The paged help window. Modal on nothing — it is a reference, so it stays usable while
|
/// The paged help window. Modal on nothing — it is a reference, so it stays usable while
|
||||||
/// the scope runs. Already open: presented.
|
/// the scope runs. Already open: presented.
|
||||||
void show_help(GtkWindow* parent);
|
void show_help(GtkWindow* parent);
|
||||||
@@ -33,10 +39,11 @@ HelpReport help_self_check();
|
|||||||
/// deprecated as of GTK 4.18 and this project builds warning-free.
|
/// deprecated as of GTK 4.18 and this project builds warning-free.
|
||||||
void show_shortcuts(GtkWindow* parent);
|
void show_shortcuts(GtkWindow* parent);
|
||||||
|
|
||||||
/// Closes whichever of the three windows are open. The About dialog and the help window are
|
/// Closes whichever of the three windows are open. One of them is a GtkWindow -- the help
|
||||||
/// GtkWindows and close with gtk_window_close(), so their close-request handlers run; the
|
/// window, closed with gtk_window_close(), which emits close-request so its handler runs. The
|
||||||
/// shortcut overview is an AdwDialog and closes with adw_dialog_close(), which emits ::closed.
|
/// other two are AdwDialogs, which are not GtkWindows: they close with adw_dialog_close(),
|
||||||
/// Either way the file-scope pointer is nulled by the handler. Used by the probe.
|
/// which emits ::closed. Either way the file-scope pointer is nulled by the handler, which is
|
||||||
|
/// what prevents a second open presenting a dead widget. Used by the probe.
|
||||||
void close_help_windows();
|
void close_help_windows();
|
||||||
|
|
||||||
} // namespace udpscope
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
<!--
|
||||||
|
The symbolic form of the app icon: the same metaphor reduced to a screen frame with one
|
||||||
|
wave inside it, following the UI-icon rules rather than the app-icon ones.
|
||||||
|
|
||||||
|
Filled paths only, with no stroke anywhere. GTK's symbolic recolour wraps the file in a
|
||||||
|
stylesheet that reaches `fill` on `path`, `rect` and `circle` only, so a stroked line keeps
|
||||||
|
the colour written here and stays dark on a dark theme. The wave is therefore a closed
|
||||||
|
2px band: a sine along its top edge and the same sine 2px lower along its bottom, which is
|
||||||
|
how a stroke's look is had from a fill.
|
||||||
|
|
||||||
|
`fill-rule="evenodd"` over three subpaths. The frame is the outer rect minus the inner one,
|
||||||
|
so the screen's interior is two crossings deep and empty; the wave inside it is three, which
|
||||||
|
is odd, so it fills. Whole-pixel coordinates throughout, because this is drawn at 16px far
|
||||||
|
more often than not and a half-pixel feature is a grey smear at 1x.
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<path fill="#2e3436" fill-rule="evenodd"
|
||||||
|
d="M 1 2 H 15 V 14 H 1 Z
|
||||||
|
M 2 3 H 14 V 13 H 2 Z
|
||||||
|
M 3 7 C 5 3 6 3 8 7 C 10 11 11 11 13 7 L 13 9 C 11 13 10 13 8 9 C 6 5 5 5 3 9 Z"/>
|
||||||
|
</svg>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
<!--
|
||||||
|
The application icon: an oscilloscope's face, which is the clearest metaphor available and
|
||||||
|
the kind of physical object the HIG's top-and-front depth language was written for.
|
||||||
|
|
||||||
|
Drawn to <https://developer.gnome.org/hig/guidelines/app-icons.html>. The artwork spans
|
||||||
|
x 12..116 and y 22..106, so it does not fill the 128px canvas, and every feature sits on
|
||||||
|
the 2px grid. Depth is the 4px front profile along the bottom and nothing else: that is
|
||||||
|
the HIG's maximum of 2 detail units, and since every surface here is straight, the rest
|
||||||
|
is flat colour with no gradients and no shadows.
|
||||||
|
|
||||||
|
The profile is the bottom 4px of the chassis silhouette, clipped to it. A plain rect from
|
||||||
|
(12, 102) to (116, 106) would protrude past the radius-8 corners, which reads as a
|
||||||
|
misdrawn bump at 128px and as a dirty edge at 32px; the clip follows the corners exactly
|
||||||
|
and keeps every coordinate even, which hand-computing the arc would not.
|
||||||
|
|
||||||
|
Two deliberate omissions. There is no graticule: the grid lines a real scope face carries
|
||||||
|
cannot survive 32px, and the trace carries the metaphor without them. And the trace is
|
||||||
|
one cycle, a single crest and a single trough, because at 32px the screen is about
|
||||||
|
22x16 px and anything busier reads as a smudge.
|
||||||
|
|
||||||
|
Colours are the GNOME palette: Dark 3 chassis, Dark 2 profile, Dark 4 screen, Green 1 trace.
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="chassis-silhouette">
|
||||||
|
<rect x="12" y="22" width="104" height="84" rx="8"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<rect x="12" y="22" width="104" height="84" rx="8" fill="#3d3846"/>
|
||||||
|
<g clip-path="url(#chassis-silhouette)">
|
||||||
|
<rect x="12" y="102" width="104" height="4" fill="#5e5c64"/>
|
||||||
|
</g>
|
||||||
|
<rect x="20" y="30" width="88" height="64" rx="4" fill="#241f31"/>
|
||||||
|
<path d="M 24 62 C 37 38 51 38 64 62 C 77 86 91 86 104 62"
|
||||||
|
fill="none" stroke="#8ff0a4" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
@@ -2,16 +2,26 @@
|
|||||||
<!-- SPDX-License-Identifier: MIT -->
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
<!--
|
<!--
|
||||||
The four icons this app had to draw for itself, compiled into the binary so there is
|
The icons this app had to draw for itself, compiled into the binary so there is still
|
||||||
still exactly one file to run. `gtk_icon_theme_add_resource_path()` wants the tree an
|
exactly one file to run. `gtk_icon_theme_add_resource_path()` wants the tree an icon theme
|
||||||
icon theme has, so the prefix is followed by `<size>/<context>`; `scalable` is the one
|
has, so the prefix is followed by `<size>/<context>`; `scalable` is the one that means
|
||||||
that means "an SVG, use it at any size".
|
"an SVG, use it at any size", and `apps` is where an application icon belongs.
|
||||||
|
|
||||||
|
Every file carries `preprocess="xml-stripblanks"`, which makes glib-compile-resources pipe
|
||||||
|
it through xmllint. That is the only thing that catches a malformed SVG: librsvg refuses
|
||||||
|
such a file outright, but the icon *name* still resolves, so `gtk_icon_theme_has_icon()`
|
||||||
|
returns true and the icon simply draws nothing. No runtime GTK call can tell the two apart
|
||||||
|
(`lookup_icon()` never returns NULL and always reports the size it was asked for), so the
|
||||||
|
check belongs here, where a malformed SVG fails the build with a file and line number
|
||||||
|
(plus a caret under the offending character) instead.
|
||||||
-->
|
-->
|
||||||
<gresources>
|
<gresources>
|
||||||
<gresource prefix="/org/udpscope/icons">
|
<gresource prefix="/org/udpscope/icons">
|
||||||
<file>scalable/actions/udpscope-trigger-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/udpscope-trigger-symbolic.svg</file>
|
||||||
<file>scalable/actions/udpscope-cursors-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/udpscope-cursors-symbolic.svg</file>
|
||||||
<file>scalable/actions/udpscope-measure-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/udpscope-measure-symbolic.svg</file>
|
||||||
<file>scalable/actions/udpscope-math-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/udpscope-math-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app-symbolic.svg</file>
|
||||||
</gresource>
|
</gresource>
|
||||||
</gresources>
|
</gresources>
|
||||||
+37
-33
@@ -42,12 +42,6 @@ constexpr std::uint64_t kMathCostSample = 1024;
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
u64 Partition::total_samples() const noexcept {
|
|
||||||
u64 total = time_capacity;
|
|
||||||
for (u64 c : value_capacity) total += c;
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
|
|
||||||
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
|
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
|
||||||
double target_span_s) {
|
double target_span_s) {
|
||||||
Partition p;
|
Partition p;
|
||||||
@@ -76,11 +70,14 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
|||||||
for (std::size_t i = 0; i < n; ++i) p.stride[i] = 1;
|
for (std::size_t i = 0; i < n; ++i) p.stride[i] = 1;
|
||||||
|
|
||||||
const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes));
|
const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes));
|
||||||
const u64 budget = budget_bytes / sizeof(f32);
|
// Weighted in *bytes*, not in slots: the shared clock is f64 and the values are f32
|
||||||
double weight = 1.0; // the time slice itself
|
// (see `BufferStore::time()`), so a time slot costs twice a value sample and the two
|
||||||
for (u64 s : p.stride) weight += 1.0 / static_cast<double>(s);
|
// slices are not interchangeable. Counting both as one f32 is what would make
|
||||||
|
// `--buffer-mb` quietly understate the time ring by its own size.
|
||||||
|
double weight = static_cast<double>(sizeof(f64)); // the time slice itself
|
||||||
|
for (u64 s : p.stride) weight += static_cast<double>(sizeof(f32)) / static_cast<double>(s);
|
||||||
|
|
||||||
const u64 slots = budget / static_cast<u64>(std::llround(weight));
|
const u64 slots = static_cast<u64>(static_cast<double>(budget_bytes) / weight);
|
||||||
u64 time_capacity = pow2_floor(std::max<u64>(slots, 128));
|
u64 time_capacity = pow2_floor(std::max<u64>(slots, 128));
|
||||||
if (time_capacity == 0) {
|
if (time_capacity == 0) {
|
||||||
throw std::runtime_error("buffer too small for one time-base sample per channel");
|
throw std::runtime_error("buffer too small for one time-base sample per channel");
|
||||||
@@ -102,11 +99,13 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
|||||||
return std::max<u64>(pow2_floor(ticks / stride), 1);
|
return std::max<u64>(pow2_floor(ticks / stride), 1);
|
||||||
};
|
};
|
||||||
const auto bytes_for = [&](u64 ticks) {
|
const auto bytes_for = [&](u64 ticks) {
|
||||||
u64 total = ticks;
|
u64 total = ticks * sizeof(f64);
|
||||||
for (u64 s : p.stride) total += capacity_for(ticks, s);
|
for (u64 s : p.stride) total += capacity_for(ticks, s) * sizeof(f32);
|
||||||
return total * sizeof(f32);
|
return total;
|
||||||
};
|
};
|
||||||
while (time_capacity * 2 <= budget && bytes_for(time_capacity * 2) <= budget_bytes) {
|
// `bytes_for` bounds the doubling on its own: it is at least `2 * ticks * sizeof(f64)`,
|
||||||
|
// so a pass means `2 * ticks` is well inside the budget and cannot overflow.
|
||||||
|
while (bytes_for(time_capacity * 2) <= budget_bytes) {
|
||||||
time_capacity *= 2;
|
time_capacity *= 2;
|
||||||
}
|
}
|
||||||
p.time_capacity = time_capacity;
|
p.time_capacity = time_capacity;
|
||||||
@@ -138,7 +137,7 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
|||||||
|
|
||||||
BufferStore::BufferStore(const Partition& part, f64 span_s)
|
BufferStore::BufferStore(const Partition& part, f64 span_s)
|
||||||
: part_(part),
|
: part_(part),
|
||||||
time_(std::make_shared<plot::data::RingBuffer<f32>>(part.time_capacity)) {
|
time_(std::make_shared<plot::data::RingBuffer<f64>>(part.time_capacity)) {
|
||||||
(void)span_s;
|
(void)span_s;
|
||||||
const std::size_t n = part.value_capacity.size();
|
const std::size_t n = part.value_capacity.size();
|
||||||
values_.reserve(n);
|
values_.reserve(n);
|
||||||
@@ -452,7 +451,7 @@ BufferStore& ScopeModel::live() { return *store_; }
|
|||||||
|
|
||||||
void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& last) {
|
void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& last) {
|
||||||
BufferStore& src = live();
|
BufferStore& src = live();
|
||||||
plot::data::RingBuffer<f32>& tsrc = src.time();
|
plot::data::RingBuffer<f64>& tsrc = src.time();
|
||||||
|
|
||||||
// The slice is one absolute index range, found in the time ring and then shared by
|
// The slice is one absolute index range, found in the time ring and then shared by
|
||||||
// every value ring. Sharing it is what makes a cut's stamps and each of its channels
|
// every value ring. Sharing it is what makes a cut's stamps and each of its channels
|
||||||
@@ -460,16 +459,15 @@ void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& l
|
|||||||
// truncations that could disagree.
|
// truncations that could disagree.
|
||||||
const u64 tbase = tsrc.produced() - tsrc.size();
|
const u64 tbase = tsrc.produced() - tsrc.size();
|
||||||
const u64 tend = tsrc.produced();
|
const u64 tend = tsrc.produced();
|
||||||
// The bounds are rounded to the ring's own `f32` before they are compared against
|
// The bounds are compared in the ring's own type, which is the `f64` they arrive in,
|
||||||
// it. The window arrives in f64 and the stamps are stored as f32, so an f64
|
// so a bound that lands exactly on a sample includes it. This used to need rounding
|
||||||
// comparison decides a sample that sits exactly on a bound by which way its stamp
|
// *down* to the stored f32 first: an f64 comparison against an f32 stamp decided such
|
||||||
// happened to round -- the same nominal window then keeps or drops its first sample
|
// a sample by which way its stamp happened to round, and the same nominal window kept
|
||||||
// depending on how large the stream time is (`f32(38.001) < 38.001` but
|
// or dropped its first sample depending on the magnitude of the stream time
|
||||||
// `f32(18.001) > 18.001`). One sample is invisible at 10 kS/s and a third of the
|
// (`f32(38.001) < 38.001` but `f32(18.001) > 18.001`). The ring is f64 now and the
|
||||||
// capture at 1 S/s. Comparing in the stored type makes a bound that lands on a
|
// question does not arise.
|
||||||
// sample include it, always.
|
const f64 t_lo = lo_s;
|
||||||
const f32 t_lo = static_cast<f32>(lo_s);
|
const f64 t_hi = hi_s;
|
||||||
const f32 t_hi = static_cast<f32>(hi_s);
|
|
||||||
|
|
||||||
// Stream time is non-decreasing, so the bounds are a binary search. Searching the
|
// Stream time is non-decreasing, so the bounds are a binary search. Searching the
|
||||||
// stamps is the whole point: the window is in seconds and this ring is the only
|
// stamps is the whole point: the window is in seconds and this ring is the only
|
||||||
@@ -496,7 +494,7 @@ void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& l
|
|||||||
last = a;
|
last = a;
|
||||||
|
|
||||||
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||||
plot::data::RingBuffer<f32>& from = src.values(c);
|
plot::data::RingBuffer<f32>& from = src.values(c); // values stay f32
|
||||||
first = std::max(first, from.produced() - from.size());
|
first = std::max(first, from.produced() - from.size());
|
||||||
last = std::min(last, from.produced());
|
last = std::min(last, from.produced());
|
||||||
}
|
}
|
||||||
@@ -517,8 +515,9 @@ void ScopeModel::reset_live_history_locked() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScopeModel::publish_pause_view(double lo_s, double hi_s) {
|
void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) {
|
||||||
auto snap = std::make_shared<EventSnapshot>();
|
auto snap = std::make_shared<EventSnapshot>();
|
||||||
|
snap->x_offset = x_offset;
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
BufferStore& src = live();
|
BufferStore& src = live();
|
||||||
u64 first = 0;
|
u64 first = 0;
|
||||||
@@ -527,10 +526,14 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s) {
|
|||||||
const u64 n = last - first;
|
const u64 n = last - first;
|
||||||
snap->stamps.resize(static_cast<std::size_t>(n));
|
snap->stamps.resize(static_cast<std::size_t>(n));
|
||||||
if (n > 0) {
|
if (n > 0) {
|
||||||
// The stamps go in *as the ring holds them*: the stream's own seconds, which is the
|
// The stamps come out of the ring on the stream's own clock and are rebased onto
|
||||||
// coordinate the axis was already reading. Nothing is rebased, so a pause does not move the
|
// `x_offset`, which is the coordinate the axis was *already* reading: a live view is
|
||||||
// dates under the reader the way a trigger event's edge-relative axis deliberately does.
|
// newest-relative, so leaving them absolute would throw the frozen picture a whole uptime
|
||||||
|
// to the right of the axis at the instant the user pressed pause. Nothing is lost by the
|
||||||
|
// shift -- `x_offset` is exactly what makes the stream's time recoverable, which is what
|
||||||
|
// `stream_at_x0()` reads it for.
|
||||||
src.time().copy_from(first, n, snap->stamps.data());
|
src.time().copy_from(first, n, snap->stamps.data());
|
||||||
|
for (f64& t : snap->stamps) t -= x_offset;
|
||||||
}
|
}
|
||||||
snap->values.resize(channels_.size());
|
snap->values.resize(channels_.size());
|
||||||
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
|
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
|
||||||
@@ -553,8 +556,9 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s) {
|
|||||||
|
|
||||||
void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
||||||
auto snap = std::make_shared<EventSnapshot>();
|
auto snap = std::make_shared<EventSnapshot>();
|
||||||
|
snap->x_offset = edge; // the stream time x = 0 means, for everything that has to cross back
|
||||||
BufferStore& src = live();
|
BufferStore& src = live();
|
||||||
plot::data::RingBuffer<f32>& tsrc = src.time();
|
plot::data::RingBuffer<f64>& tsrc = src.time();
|
||||||
|
|
||||||
u64 first = 0, last = 0;
|
u64 first = 0, last = 0;
|
||||||
live_window_locked(edge - std::max(0.0, pre_s), edge + std::max(0.0, post_s), first, last);
|
live_window_locked(edge - std::max(0.0, pre_s), edge + std::max(0.0, post_s), first, last);
|
||||||
@@ -566,7 +570,7 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
|||||||
// Edge-relative from here on: the frozen event reads [-pre, +post] with the
|
// Edge-relative from here on: the frozen event reads [-pre, +post] with the
|
||||||
// edge at exactly 0, with no axis-level shift any other code path could
|
// edge at exactly 0, with no axis-level shift any other code path could
|
||||||
// disagree with.
|
// disagree with.
|
||||||
for (auto& t : snap->stamps) t -= static_cast<f32>(edge);
|
for (auto& t : snap->stamps) t -= edge;
|
||||||
}
|
}
|
||||||
|
|
||||||
snap->values.resize(channels_.size());
|
snap->values.resize(channels_.size());
|
||||||
|
|||||||
+41
-17
@@ -93,8 +93,6 @@ struct Partition {
|
|||||||
double base_dt = 1.0; ///< seconds per base tick
|
double base_dt = 1.0; ///< seconds per base tick
|
||||||
double span_s = 0.0; ///< stream seconds one buffer covers
|
double span_s = 0.0; ///< stream seconds one buffer covers
|
||||||
|
|
||||||
u64 total_samples() const noexcept;
|
|
||||||
u64 total_bytes() const noexcept { return total_samples() * sizeof(f32); }
|
|
||||||
/// Seconds between two time-base slots (the sample interval of every channel).
|
/// Seconds between two time-base slots (the sample interval of every channel).
|
||||||
double slot_dt() const noexcept { return base_dt * static_cast<double>(time_stride); }
|
double slot_dt() const noexcept { return base_dt * static_cast<double>(time_stride); }
|
||||||
/// The rate samples are actually stored at, in samples per second.
|
/// The rate samples are actually stored at, in samples per second.
|
||||||
@@ -125,8 +123,15 @@ class BufferStore {
|
|||||||
/// axis read seconds from the trigger event.
|
/// axis read seconds from the trigger event.
|
||||||
void set_epoch(f64 epoch) noexcept;
|
void set_epoch(f64 epoch) noexcept;
|
||||||
|
|
||||||
plot::data::RingBuffer<f32>& time() noexcept { return *time_; }
|
/// The shared clock, in **f64**. The values are f32 and the clock is not: a stamp is
|
||||||
const plot::data::RingBuffer<f32>& time() const noexcept { return *time_; }
|
/// `stream_time - epoch` and the epoch is fixed at the first sample ever seen, so the
|
||||||
|
/// magnitude grows without bound over a run's life while the interval that has to be
|
||||||
|
/// resolved stays the sample period. f32 has 24 bits of mantissa, so its ULP at 32 s is
|
||||||
|
/// 3.8 us -- four consecutive samples of a 1 MS/s stream then share one stamp, and at
|
||||||
|
/// 600 s uptime 61 of them do. A value's precision is a property of the signal and 24
|
||||||
|
/// bits is plenty; a *timestamp's* is a property of how long the scope has been running.
|
||||||
|
plot::data::RingBuffer<f64>& time() noexcept { return *time_; }
|
||||||
|
const plot::data::RingBuffer<f64>& time() const noexcept { return *time_; }
|
||||||
plot::data::RingBuffer<f32>& values(std::size_t channel) { return *values_[channel]; }
|
plot::data::RingBuffer<f32>& values(std::size_t channel) { return *values_[channel]; }
|
||||||
ChannelBuffer& channel(std::size_t channel) { return *channels_[channel]; }
|
ChannelBuffer& channel(std::size_t channel) { return *channels_[channel]; }
|
||||||
/// Shared handle to a channel view: the caller keeps the storage alive even if
|
/// Shared handle to a channel view: the caller keeps the storage alive even if
|
||||||
@@ -141,7 +146,7 @@ class BufferStore {
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
Partition part_;
|
Partition part_;
|
||||||
std::shared_ptr<plot::data::RingBuffer<f32>> time_;
|
std::shared_ptr<plot::data::RingBuffer<f64>> time_;
|
||||||
std::vector<std::shared_ptr<plot::data::RingBuffer<f32>>> values_;
|
std::vector<std::shared_ptr<plot::data::RingBuffer<f32>>> values_;
|
||||||
std::vector<std::shared_ptr<ChannelBuffer>> channels_;
|
std::vector<std::shared_ptr<ChannelBuffer>> channels_;
|
||||||
};
|
};
|
||||||
@@ -278,23 +283,24 @@ class ScopeModel {
|
|||||||
/// The live buffer's absolute index range covering `[lo_s, hi_s]` in stream seconds,
|
/// The live buffer's absolute index range covering `[lo_s, hi_s]` in stream seconds,
|
||||||
/// clamped to what the time ring and *every* value ring still hold. One range for the
|
/// clamped to what the time ring and *every* value ring still hold. One range for the
|
||||||
/// time ring and all the channels, which is what keeps a cut index-aligned by
|
/// time ring and all the channels, which is what keeps a cut index-aligned by
|
||||||
/// construction; the bounds are compared in the ring's own `f32`, so a bound that
|
/// construction; the bounds are compared in the ring's own type, which is now the
|
||||||
/// lands on a sample includes it whatever the magnitude of the stream time.
|
/// `f64` they arrive in, so a bound that lands on a sample includes it exactly.
|
||||||
///
|
///
|
||||||
/// Caller holds `mutex()` (the acquisition thread already does when it latches).
|
/// Caller holds `mutex()` (the acquisition thread already does when it latches).
|
||||||
void live_window_locked(double lo_s, double hi_s, u64& first, u64& last);
|
void live_window_locked(double lo_s, double hi_s, u64& first, u64& last);
|
||||||
/// The frozen view taken when the user paused, or null while running. A pause is a
|
/// The frozen view taken when the user paused, or null while running. A pause is a
|
||||||
/// snapshot of the *visible window* in exactly the sense a trigger event is -- an immutable
|
/// snapshot of the *visible window* in exactly the sense a trigger event is -- an immutable
|
||||||
/// series the plot binds to -- and the difference is the anchoring: an event's stamps are
|
/// series the plot binds to -- and both are anchored the same way: the stamps are the
|
||||||
/// edge-relative, a pause's are the stream's own, so the axis does not move when the pause
|
/// coordinates the axis was already reading, with `x_offset` recording which instant that
|
||||||
/// begins and the reader keeps the times they were already reading.
|
/// x = 0 was. So the axis does not move when the pause begins.
|
||||||
std::shared_ptr<const EventSnapshot> pause_view() const noexcept {
|
std::shared_ptr<const EventSnapshot> pause_view() const noexcept {
|
||||||
return pause_view_.load(std::memory_order_acquire);
|
return pause_view_.load(std::memory_order_acquire);
|
||||||
}
|
}
|
||||||
/// Snapshots the live ring over `[lo_s, hi_s]` (stream seconds, the display epoch already
|
/// Snapshots the live ring over `[lo_s, hi_s]` (stream seconds, i.e. the display window put
|
||||||
/// removed) as the pause view. The acquisition thread keeps writing, so this is a copy: what
|
/// back on the stream's clock) as the pause view, rebased onto `x_offset` so its stamps are
|
||||||
/// is frozen is the picture, not the data.
|
/// the x the axis is already showing. The acquisition thread keeps writing, so this is a
|
||||||
void publish_pause_view(double lo_s, double hi_s);
|
/// copy: what is frozen is the picture, not the data.
|
||||||
|
void publish_pause_view(double lo_s, double hi_s, double x_offset);
|
||||||
void clear_pause_view() noexcept { pause_view_.store(nullptr, std::memory_order_release); }
|
void clear_pause_view() noexcept { pause_view_.store(nullptr, std::memory_order_release); }
|
||||||
|
|
||||||
/// Whether the user paused acquisition. Lives here rather than on `Acquisition` because the
|
/// Whether the user paused acquisition. Lives here rather than on `Acquisition` because the
|
||||||
@@ -373,12 +379,30 @@ class ScopeModel {
|
|||||||
return event() != nullptr ? DisplayMode::Held : DisplayMode::Waiting;
|
return event() != nullptr ? DisplayMode::Held : DisplayMode::Waiting;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The x origin the plot uses: 0 for "seconds since the stream started", or minus
|
/// The offset the **live** channels report their x with: `display_x = stream_t + epoch`.
|
||||||
/// the trigger instant while in trigger mode, so the axis reads seconds from the
|
/// Minus the newest stored sample's stamp while streaming, so the axis reads
|
||||||
/// event. Applied to the live store's channels.
|
/// `[-window, 0]` and the newest data is at 0; minus the trigger instant while a capture
|
||||||
|
/// is filling, so the axis reads seconds from the event. It is 0 while a *snapshot* is on
|
||||||
|
/// screen (a held event, a paused view), because then the live channels are bound to
|
||||||
|
/// nothing and the snapshot's own `x_offset` is what says which clock x = 0 is --
|
||||||
|
/// `stream_at_x0()` is the accessor that does not have to know which of the two it is.
|
||||||
f64 display_epoch() const noexcept { return display_epoch_.load(std::memory_order_relaxed); }
|
f64 display_epoch() const noexcept { return display_epoch_.load(std::memory_order_relaxed); }
|
||||||
void set_display_epoch(f64 e) noexcept;
|
void set_display_epoch(f64 e) noexcept;
|
||||||
|
|
||||||
|
/// The stream time of the display's x = 0, so `stream_t = display_x + stream_at_x0()` and
|
||||||
|
/// `display_x = stream_t - stream_at_x0()` in **every** display mode.
|
||||||
|
///
|
||||||
|
/// One accessor for the two places the offset can live: a snapshot carries its own
|
||||||
|
/// (`EventSnapshot::x_offset`, frozen with the picture), and a live view carries it as
|
||||||
|
/// minus the display epoch. Everything that has to cross between the two clocks goes
|
||||||
|
/// through here -- the export's absolute column, and the pause handler putting a display
|
||||||
|
/// window back on the ring to cut it -- rather than picking the branch itself and getting
|
||||||
|
/// one of the four modes wrong.
|
||||||
|
f64 stream_at_x0() const noexcept {
|
||||||
|
if (std::shared_ptr<const EventSnapshot> snap = display_snapshot()) return snap->x_offset;
|
||||||
|
return -display_epoch();
|
||||||
|
}
|
||||||
|
|
||||||
// --- stream state ---------------------------------------------------------
|
// --- stream state ---------------------------------------------------------
|
||||||
StreamStats& stats() noexcept { return stats_; }
|
StreamStats& stats() noexcept { return stats_; }
|
||||||
const StreamStats& stats() const noexcept { return stats_; }
|
const StreamStats& stats() const noexcept { return stats_; }
|
||||||
|
|||||||
+134
-72
@@ -30,10 +30,23 @@ constexpr plot::u32 kRulerBColor = 0x8FA8C8u;
|
|||||||
/// The measurements' own colour, distinct from both cursor rulers so a readout mark cannot be
|
/// The measurements' own colour, distinct from both cursor rulers so a readout mark cannot be
|
||||||
/// mistaken for something the user placed.
|
/// mistaken for something the user placed.
|
||||||
constexpr plot::u32 kMeasureColor = 0x9BE8A0u;
|
constexpr plot::u32 kMeasureColor = 0x9BE8A0u;
|
||||||
/// The window-start caption's colour: quiet chrome, brighter than a grid line.
|
|
||||||
constexpr plot::u32 kTimeNoteColor = 0xC8C8C8u;
|
|
||||||
constexpr plot::u32 kTriggerColor = 0xFF5B5Bu;
|
constexpr plot::u32 kTriggerColor = 0xFF5B5Bu;
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// figures below a minute (of the SI-prefixed mantissa under a second, of the seconds above it),
|
||||||
|
/// whole seconds through `m:ss`/`h:mm:ss` up to a day, and three figures of days above. So the
|
||||||
|
/// resolution is a step function of the *magnitude* and not of the span -- which is the whole
|
||||||
|
/// reason a relative axis is ever needed, and the reason a span threshold alone gets it wrong in
|
||||||
|
/// both directions.
|
||||||
|
double time_label_resolution(double t) {
|
||||||
|
t = std::fabs(t);
|
||||||
|
if (!std::isfinite(t) || !(t > 0.0)) return 0.0;
|
||||||
|
if (t >= 86400.0) return 86400.0 * std::pow(10.0, std::floor(std::log10(t / 86400.0)) - 2.0);
|
||||||
|
if (t >= 60.0) return 1.0;
|
||||||
|
return std::pow(10.0, std::floor(std::log10(t)) - 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
constexpr double kParked = -1.0e12; ///< hides an instrument without removing it
|
constexpr double kParked = -1.0e12; ///< hides an instrument without removing it
|
||||||
/// The measurement marks' stroke: thinner than the chrome that frames the plot (a trace is 1.5,
|
/// The measurement marks' stroke: thinner than the chrome that frames the plot (a trace is 1.5,
|
||||||
/// the axis lines and the trigger marker `Style::line_width_px`), and broken, so a readout over
|
/// the axis lines and the trigger marker `Style::line_width_px`), and broken, so a readout over
|
||||||
@@ -112,9 +125,9 @@ bool RowSet::remove_row(int at) {
|
|||||||
void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
||||||
r.plot = &p;
|
r.plot = &p;
|
||||||
r.axis_x = r.plot->add_x_axis(
|
r.axis_x = r.plot->add_x_axis(
|
||||||
{"time", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
|
{"", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
|
||||||
r.axis_y = r.plot->add_y_axis(
|
r.axis_y = r.plot->add_y_axis(
|
||||||
{"value", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
|
{"", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
|
||||||
for (int s = 0; s < kSlotsPerRow; ++s) {
|
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||||
const std::size_t i = static_cast<std::size_t>(s);
|
const std::size_t i = static_cast<std::size_t>(s);
|
||||||
r.plot->add_trace(
|
r.plot->add_trace(
|
||||||
@@ -155,11 +168,6 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
|||||||
r.plot->set_cursor_stroke(r.measure_v[mi], kMeasureStrokeWidth,
|
r.plot->set_cursor_stroke(r.measure_v[mi], kMeasureStrokeWidth,
|
||||||
plot::Vec2{kMeasureDashOn, kMeasureDashOff});
|
plot::Vec2{kMeasureDashOn, kMeasureDashOff});
|
||||||
}
|
}
|
||||||
// The window-start caption for the relative x labels: an annotation, hidden until the
|
|
||||||
// labels are relative. Inside the axes on purpose -- its text changes with the window, and
|
|
||||||
// an axis title of a changing width would relayout the plot every frame.
|
|
||||||
r.time_origin_note = r.plot->add_annotation(
|
|
||||||
plot::Annotation{0.0, 0.0, "", plot::Color::hex(kTimeNoteColor), r.axis_x, r.axis_y, false});
|
|
||||||
r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor));
|
r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor));
|
||||||
// A marker, not an instrument: it must sit exactly on the edge's time, so its
|
// A marker, not an instrument: it must sit exactly on the edge's time, so its
|
||||||
// snap is Free and the controller never rounds it onto a sample.
|
// snap is Free and the controller never rounds it onto a sample.
|
||||||
@@ -191,7 +199,7 @@ void RowSet::rebuild_all() {
|
|||||||
}
|
}
|
||||||
figure_->link_x(static_cast<plot::u32>(i), x_group_);
|
figure_->link_x(static_cast<plot::u32>(i), x_group_);
|
||||||
}
|
}
|
||||||
if (y_gutter_px_ > 0.0) apply_y_gutter();
|
apply_gutters();
|
||||||
// A new figure starts with no window at all, so the one the rows were showing has to
|
// A new figure starts with no window at all, so the one the rows were showing has to
|
||||||
// be restated -- as the pin it was, if an event is on screen: assigning a trace or
|
// be restated -- as the pin it was, if an event is on screen: assigning a trace or
|
||||||
// changing the row count while a capture is displayed must not throw the view back
|
// changing the row count while a capture is displayed must not throw the view back
|
||||||
@@ -396,18 +404,30 @@ void RowSet::place_crossings() {
|
|||||||
if (ida >= r.plot->annotation_count() || idb >= r.plot->annotation_count()) continue;
|
if (ida >= r.plot->annotation_count() || idb >= r.plot->annotation_count()) continue;
|
||||||
plot::Annotation a = r.plot->annotation(ida);
|
plot::Annotation a = r.plot->annotation(ida);
|
||||||
plot::Annotation b = r.plot->annotation(idb);
|
plot::Annotation b = r.plot->annotation(idb);
|
||||||
|
double xa = 0.0;
|
||||||
double ya = 0.0;
|
double ya = 0.0;
|
||||||
|
double xb = 0.0;
|
||||||
double yb = 0.0;
|
double yb = 0.0;
|
||||||
const bool has_a = mix && r.plot->crosshair_value(static_cast<plot::u32>(s), cursor_a_x_, ya);
|
// `crosshair_point()`, not `crosshair_value()`: the number beside this dot is a *stored
|
||||||
const bool has_b = mix && r.plot->crosshair_value(static_cast<plot::u32>(s), cursor_b_x_, yb);
|
// sample's*, so the dot belongs on that sample and not on the marker's own column. The
|
||||||
|
// two differ by up to half a sample period, which is nothing at a whole-window view and
|
||||||
|
// the whole width of the panel once the view is zoomed to a handful of samples -- the
|
||||||
|
// state in which a reader is asking "which point is that?" in the first place. It leaves
|
||||||
|
// the dot a little off its vertical line by design: the line says *where the marker is*
|
||||||
|
// and the dot says *which datum is being quoted*, and when the marker is between two
|
||||||
|
// samples those are not the same place.
|
||||||
|
const bool has_a =
|
||||||
|
mix && r.plot->crosshair_point(static_cast<plot::u32>(s), cursor_a_x_, xa, ya);
|
||||||
|
const bool has_b =
|
||||||
|
mix && r.plot->crosshair_point(static_cast<plot::u32>(s), cursor_b_x_, xb, yb);
|
||||||
a.visible = has_a;
|
a.visible = has_a;
|
||||||
b.visible = has_b;
|
b.visible = has_b;
|
||||||
if (has_a) {
|
if (has_a) {
|
||||||
a.x = cursor_a_x_;
|
a.x = xa;
|
||||||
a.y = ya;
|
a.y = ya;
|
||||||
}
|
}
|
||||||
if (has_b) {
|
if (has_b) {
|
||||||
b.x = cursor_b_x_;
|
b.x = xb;
|
||||||
b.y = yb;
|
b.y = yb;
|
||||||
}
|
}
|
||||||
r.plot->set_annotation(ida, a);
|
r.plot->set_annotation(ida, a);
|
||||||
@@ -497,12 +517,48 @@ bool RowSet::update_time_labels() {
|
|||||||
const double span = view.span();
|
const double span = view.span();
|
||||||
if (!(span > 0.0) || !std::isfinite(span)) return false;
|
if (!(span > 0.0) || !std::isfinite(span)) return false;
|
||||||
|
|
||||||
// Sub-second windows read relative: `TickFormat::Time` writes three significant digits, so
|
// A relative axis is the answer to one problem only: a tick step the label form cannot write.
|
||||||
// `12.345671s` and `12.345672s` are the same string and a 1 ms step around a multi-second
|
// `TickFormat::Time` carries three significant digits, so a 1 ms step around a 12 s stamp
|
||||||
// time cannot be labelled at all. The band is the switch's hysteresis -- entering below a
|
// writes `12.3s` on every tick -- there the ticks have to be re-anchored and the anchor named.
|
||||||
// second, leaving only above 1.05 -- so a span sitting on the boundary cannot flicker
|
// The test is therefore the *step against the resolution at the axis' own magnitude*, never the
|
||||||
// between two forms while a zoom is nudged.
|
// span: the two part company in both directions, and both cases are real here. A 2 s window at
|
||||||
const bool want_relative = relative_x_ ? span < 1.05 : span < 1.0;
|
// twenty minutes of uptime is labelled `20:00` throughout and needs the anchor although its
|
||||||
|
// span is wide; a 50 ms trigger window spanning [-25ms, +25ms] is labelled `-20ms .. +20ms`
|
||||||
|
// perfectly well and needs nothing, although its span is narrow. That second case is what made
|
||||||
|
// a triggered sub-second view carry a `t0` caption *and* re-anchor its ticks away from the
|
||||||
|
// edge, which is the one position a triggered axis exists to show.
|
||||||
|
// The 1.05 band is the switch's hysteresis, so a step sitting on the boundary cannot flicker
|
||||||
|
// between two forms while a zoom is nudged, and the `1 - 1e-9` is a slack the *entry* needs for
|
||||||
|
// its own reason: a step read back from two tick values and a resolution from `pow(10, n)`
|
||||||
|
// differ in their last bits even when they are the same number, and `step == res` is a readable
|
||||||
|
// axis (`1.73s, 1.74s, 1.75s` -- one in the last digit is exactly enough), so a decision taken
|
||||||
|
// on those bits picked a different form from one frame to the next.
|
||||||
|
const double mag = std::max(std::fabs(view.min), std::fabs(view.max));
|
||||||
|
const double res = time_label_resolution(mag);
|
||||||
|
// The step the labels are actually written at: the majors the *last* layout produced. They are
|
||||||
|
// a function of the range and the axis' pixel width, not of the label form, so reading them
|
||||||
|
// back here is no feedback loop.
|
||||||
|
double step = 0.0;
|
||||||
|
{
|
||||||
|
const plot::Axis& ax = first.plot->x_axis(first.axis_x);
|
||||||
|
double prev = 0.0;
|
||||||
|
bool have = false;
|
||||||
|
for (const plot::Tick& t : ax.ticks) {
|
||||||
|
if (!t.major) continue;
|
||||||
|
if (have) {
|
||||||
|
step = t.value - prev;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
prev = t.value;
|
||||||
|
have = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No ticks yet -- the frames before the first layout. The form is a property of the labels, so
|
||||||
|
// with no labels there is nothing to decide, and keeping the current one is right where
|
||||||
|
// guessing a step from the span is not: a guess that entered the relative form would be
|
||||||
|
// *latched* there by the hysteresis above.
|
||||||
|
const bool want_relative =
|
||||||
|
step > 0.0 ? step < res * (relative_x_ ? 1.05 : 1.0 - 1.0e-9) : relative_x_;
|
||||||
const bool changed = want_relative != relative_x_;
|
const bool changed = want_relative != relative_x_;
|
||||||
relative_x_ = want_relative;
|
relative_x_ = want_relative;
|
||||||
|
|
||||||
@@ -518,17 +574,12 @@ bool RowSet::update_time_labels() {
|
|||||||
r.plot->set_x_axis_origin_from_range(r.axis_x, relative_x_);
|
r.plot->set_x_axis_origin_from_range(r.axis_x, relative_x_);
|
||||||
if (!relative_x_) r.plot->set_x_axis_origin(r.axis_x, 0.0);
|
if (!relative_x_) r.plot->set_x_axis_origin(r.axis_x, 0.0);
|
||||||
// The caption belongs with the labels, which a figure draws once, on the bottom row (the
|
// The caption belongs with the labels, which a figure draws once, on the bottom row (the
|
||||||
// one whose x axis is the figure's). Every row gets it parked.
|
// one whose x axis is the figure's). Every other row gets an empty one, which draws nothing.
|
||||||
const bool bottom = i == rows_ - 1;
|
const bool bottom = i == rows_ - 1;
|
||||||
if (r.time_origin_note >= r.plot->annotation_count()) continue;
|
if (!relative_x_ || !bottom) {
|
||||||
plot::Annotation note = r.plot->annotation(r.time_origin_note);
|
r.plot->set_x_note("");
|
||||||
note.visible = relative_x_ && bottom;
|
continue;
|
||||||
if (note.visible) {
|
}
|
||||||
// Inside the axes, just above the axis line, at their left edge: the reader adds this
|
|
||||||
// to a tick, so it sits beside the ticks.
|
|
||||||
const plot::Range yr = r.plot->y_range(r.axis_y);
|
|
||||||
note.x = view.min;
|
|
||||||
note.y = yr.min + 0.04 * (yr.span() > 0.0 ? yr.span() : 1.0);
|
|
||||||
char text[64];
|
char text[64];
|
||||||
// The anchor the *drawn* labels were built with: the axis' own origin, which the last
|
// The anchor the *drawn* labels were built with: the axis' own origin, which the last
|
||||||
// layout set from its range. Reading it back (rather than keeping our own copy) is what
|
// layout set from its range. Reading it back (rather than keeping our own copy) is what
|
||||||
@@ -536,9 +587,12 @@ bool RowSet::update_time_labels() {
|
|||||||
const double shown = r.plot->x_axis_origin(r.axis_x);
|
const double shown = r.plot->x_axis_origin(r.axis_x);
|
||||||
x_origin_ = shown;
|
x_origin_ = shown;
|
||||||
std::snprintf(text, sizeof(text), "t0 = %.6g s", shown);
|
std::snprintf(text, sizeof(text), "t0 = %.6g s", shown);
|
||||||
note.text = text;
|
// Chrome, not an annotation: an annotation is anchored to a *data* coordinate, so the caption
|
||||||
}
|
// crawled across the plot as the view panned and drifted as it zoomed -- and it came with the
|
||||||
r.plot->set_annotation(r.time_origin_note, note);
|
// annotation's own pin cross, which pointed at nothing. `set_x_note()` places it in the axes'
|
||||||
|
// bottom-left corner in pixels, on the legend's backplate, which is where the labels it
|
||||||
|
// qualifies are.
|
||||||
|
r.plot->set_x_note(text);
|
||||||
}
|
}
|
||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
@@ -646,20 +700,26 @@ void RowSet::unpin_pause_window() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void RowSet::fit_x() {
|
void RowSet::fit_x() {
|
||||||
Row& first = row(0);
|
if (!figure_) return;
|
||||||
if (!first.plot || !figure_) return;
|
if (!pinned_) {
|
||||||
first.plot->auto_fit_x();
|
// Live: the same path the window control and a resume take, with the window it already has.
|
||||||
const plot::Range fitted = first.plot->x_range(first.axis_x);
|
// That re-arms the follow and parks the newest `window_s_` at the live edge, which is the
|
||||||
const double span = fitted.span();
|
// whole of what a live view may show.
|
||||||
if (!std::isfinite(span) || !(span > 0.0)) return;
|
set_time_window(window_s_);
|
||||||
// The same two paths `fit_all()` takes: a held event owns its window and states it
|
return;
|
||||||
// through the link group, a live view re-arms the follow and frames the newest span.
|
|
||||||
if (pinned_) {
|
|
||||||
window_s_ = span;
|
|
||||||
figure_->set_x_link_range(x_group_, fitted);
|
|
||||||
} else {
|
|
||||||
set_time_window(span);
|
|
||||||
}
|
}
|
||||||
|
// Pinned (a held event or a paused view): the pin's full range, and the pin stays. The
|
||||||
|
// *members* have to be written as well as the group, because `hold_pinned_window()` prefers a
|
||||||
|
// member that has moved since our own last statement -- that is how it tells a gesture from its
|
||||||
|
// own echo -- so writing the group alone would be reverted to the zoomed-in member next frame.
|
||||||
|
const plot::Range pin{-pinned_pre_, pinned_post_};
|
||||||
|
if (!(pin.span() > 0.0) || !std::isfinite(pin.min) || !std::isfinite(pin.max)) return;
|
||||||
|
for (int i = 0; i < rows_; ++i) {
|
||||||
|
Row& r = row(i);
|
||||||
|
if (r.plot) r.plot->set_x_range(r.axis_x, pin);
|
||||||
|
}
|
||||||
|
pinned_stated_ = pin;
|
||||||
|
figure_->set_x_link_range(x_group_, pin);
|
||||||
}
|
}
|
||||||
|
|
||||||
void RowSet::sync_cursors(int drag_row) {
|
void RowSet::sync_cursors(int drag_row) {
|
||||||
@@ -778,30 +838,22 @@ void RowSet::fit_all() {
|
|||||||
Row& r = row(i);
|
Row& r = row(i);
|
||||||
if (r.plot) r.plot->auto_fit_y(r.axis_y);
|
if (r.plot) r.plot->auto_fit_y(r.axis_y);
|
||||||
}
|
}
|
||||||
Row& first = row(0);
|
// The x half is `fit_x()`'s exactly -- the zoom undone, the time window untouched -- and
|
||||||
if (!first.plot || !figure_) return;
|
// sharing it is what keeps the two fits from drifting apart.
|
||||||
// Row 0 fits its own x to the data it holds; that span becomes the shared window, and
|
fit_x();
|
||||||
// stating it through the group is what puts every row on it.
|
|
||||||
first.plot->auto_fit_x();
|
|
||||||
const plot::Range fitted = first.plot->x_range(first.axis_x);
|
|
||||||
const double span = fitted.span();
|
|
||||||
if (!std::isfinite(span) || !(span > 0.0)) return;
|
|
||||||
if (pinned_) {
|
|
||||||
// Fitting a captured event: the window is the snapshot's own `[-pre, +post]` span,
|
|
||||||
// and `set_time_window()` would re-arm the follow and re-frame on a live edge that
|
|
||||||
// trigger mode has nothing bound to.
|
|
||||||
window_s_ = span;
|
|
||||||
figure_->set_x_link_range(x_group_, fitted);
|
|
||||||
} else {
|
|
||||||
set_time_window(span);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void RowSet::note_y_gutter(double need_px) {
|
void RowSet::note_y_gutter(double need_px) {
|
||||||
double want = std::min(need_px + 6.0, 200.0);
|
// No slack added here, and that is load-bearing: `need_px` is the gutter the row was
|
||||||
|
// *laid out with*, which is already the maximum of its labels and the floor imposed last
|
||||||
|
// frame -- so a few pixels added to it are added again next frame. The `+ 6.0` this used
|
||||||
|
// to carry made the gutter a ratchet that climbed 6 px a frame until it hit the cap
|
||||||
|
// below, which is the whole reason the left margin was `200` of an 870 px viewport (and
|
||||||
|
// the `axes.w changes: 17` beside it). The label padding the reservation below adds is
|
||||||
|
// the slack, once.
|
||||||
|
double want = std::min(need_px, 200.0);
|
||||||
// Reserve room for the widest label the formatter can produce, once, with the
|
// Reserve room for the widest label the formatter can produce, once, with the
|
||||||
// font the labels are actually drawn in (the embedded font is monospaced, so a
|
// font the labels are actually drawn in.
|
||||||
// character count is an exact width).
|
|
||||||
if (!y_gutter_reserved_) {
|
if (!y_gutter_reserved_) {
|
||||||
for (int i = 0; i < rows_; ++i) {
|
for (int i = 0; i < rows_; ++i) {
|
||||||
Row& r = row(i);
|
Row& r = row(i);
|
||||||
@@ -809,10 +861,19 @@ void RowSet::note_y_gutter(double need_px) {
|
|||||||
const plot::render::TextBackend* text = r.plot->text_backend();
|
const plot::render::TextBackend* text = r.plot->text_backend();
|
||||||
if (text == nullptr) continue;
|
if (text == nullptr) continue;
|
||||||
const plot::render::Style& style = r.plot->style();
|
const plot::render::Style& style = r.plot->style();
|
||||||
const double advance = double(text->measure("0123456789")) / 10.0;
|
|
||||||
const double pad = double(style.device(style.grid_label_pad));
|
const double pad = double(style.device(style.grid_label_pad));
|
||||||
// "-1.23e-45" is the longest label `format_si()`'s fallback can print.
|
// A sign, three significant figures and a one-glyph SI prefix: the widest label
|
||||||
want = std::max(want, 9.0 * advance + 2.0 * pad + 2.0);
|
// `format_si()` writes for an axis with no unit, measured rather than counted in
|
||||||
|
// characters. Its `%.3g` fallback is wider (`-1.23e-45`) and was what this reserved,
|
||||||
|
// which cost a *third* of the gutter for a label no signal on a `Si` axis produces --
|
||||||
|
// it needs a value past 1e24, and `note_y_gutter()` is a monotone maximum over the
|
||||||
|
// measured need, so one that did appear widens the gutter for good anyway.
|
||||||
|
want = std::max(want, double(text->measure("-1.23m")) + 2.0 * pad + 2.0);
|
||||||
|
// The other side, from the same metrics. An x tick label is *centred* on its tick and
|
||||||
|
// the newest tick sits on the axes' right edge (a live axis is [-window, 0]), so half
|
||||||
|
// the widest time label is what the rightmost one needs to be drawn at all -- and that
|
||||||
|
// is also the band which keeps the newest sample's ink off the frame.
|
||||||
|
right_gutter_px_ = 0.5 * double(text->measure("-1.23ms")) + pad;
|
||||||
y_gutter_reserved_ = true;
|
y_gutter_reserved_ = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -820,9 +881,10 @@ void RowSet::note_y_gutter(double need_px) {
|
|||||||
if (want > y_gutter_px_) y_gutter_px_ = want;
|
if (want > y_gutter_px_) y_gutter_px_ = want;
|
||||||
}
|
}
|
||||||
|
|
||||||
void RowSet::apply_y_gutter() {
|
void RowSet::apply_gutters() {
|
||||||
if (!figure_ || !(y_gutter_px_ > 0.0)) return;
|
if (!figure_) return;
|
||||||
figure_->set_min_y_gutter(static_cast<f32>(y_gutter_px_));
|
if (y_gutter_px_ > 0.0) figure_->set_min_y_gutter(static_cast<f32>(y_gutter_px_));
|
||||||
|
figure_->set_right_gutter(static_cast<f32>(right_gutter_px_));
|
||||||
}
|
}
|
||||||
|
|
||||||
void RowSet::set_trigger_marker(double x, bool visible) {
|
void RowSet::set_trigger_marker(double x, bool visible) {
|
||||||
|
|||||||
+51
-24
@@ -106,11 +106,6 @@ class RowSet {
|
|||||||
/// it keeps its position whatever the cursor toggle says.
|
/// it keeps its position whatever the cursor toggle says.
|
||||||
plot::u32 trigger_marker = 0;
|
plot::u32 trigger_marker = 0;
|
||||||
|
|
||||||
/// The caption naming the window's start while the x labels are relative: an annotation
|
|
||||||
/// inside the axes, so its text can change every frame without moving the layout (an axis
|
|
||||||
/// *title* would, and it would be a different width each time).
|
|
||||||
plot::u32 time_origin_note = 0;
|
|
||||||
|
|
||||||
/// The measurements' readout marks: a pool created with the plot (a `Plot` cannot add or
|
/// The measurements' readout marks: a pool created with the plot (a `Plot` cannot add or
|
||||||
/// drop a cursor after the fact) and parked unless a measurement is using the slot.
|
/// drop a cursor after the fact) and parked unless a measurement is using the slot.
|
||||||
std::array<plot::u32, kMeasureMarks> measure_h{};
|
std::array<plot::u32, kMeasureMarks> measure_h{};
|
||||||
@@ -183,6 +178,14 @@ class RowSet {
|
|||||||
void set_time_window(double seconds);
|
void set_time_window(double seconds);
|
||||||
double time_window() const noexcept { return window_s_; }
|
double time_window() const noexcept { return window_s_; }
|
||||||
|
|
||||||
|
/// The newest x any row's live traces hold, which is what the link group follows -- and, since
|
||||||
|
/// it is reported in the display epoch currently in force, also what the frame tick subtracts
|
||||||
|
/// from that epoch to park the newest sample at x = 0. It must be read **after** `bind()` and
|
||||||
|
/// `acquire()`: it asks the bound series, so before them it answers for the previous frame's
|
||||||
|
/// window, and a frame's production at 1 MS/s over a 50 ms view is a third of the screen.
|
||||||
|
/// False when no row holds a streaming trace.
|
||||||
|
bool live_edge(double& newest) const;
|
||||||
|
|
||||||
void set_y_auto(int row, bool on);
|
void set_y_auto(int row, bool on);
|
||||||
void set_y_range(int row, double lo, double hi);
|
void set_y_range(int row, double lo, double hi);
|
||||||
|
|
||||||
@@ -196,7 +199,13 @@ class RowSet {
|
|||||||
void place_crossings();
|
void place_crossings();
|
||||||
CursorMode cursor_mode() const noexcept { return cursor_mode_; }
|
CursorMode cursor_mode() const noexcept { return cursor_mode_; }
|
||||||
|
|
||||||
/// The two vertical cursors' stream times; shared by every row.
|
/// 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
|
||||||
|
/// flows under it, which is what a bench scope's roll mode does and is the behaviour that was
|
||||||
|
/// asked for. They are read back out of the plots and written to them unchanged, so there is
|
||||||
|
/// nothing to convert; anything that needs the stream's own clock goes through
|
||||||
|
/// `ScopeModel::stream_at_x0()`. Shared by every row.
|
||||||
double cursor_a_x() const noexcept { return cursor_a_x_; }
|
double cursor_a_x() const noexcept { return cursor_a_x_; }
|
||||||
double cursor_b_x() const noexcept { return cursor_b_x_; }
|
double cursor_b_x() const noexcept { return cursor_b_x_; }
|
||||||
/// One row's ruler values, or 0 for a row that does not exist.
|
/// One row's ruler values, or 0 for a row that does not exist.
|
||||||
@@ -298,11 +307,25 @@ class RowSet {
|
|||||||
void set_data_pin(double lo, double hi);
|
void set_data_pin(double lo, double hi);
|
||||||
void unpin_pause_window();
|
void unpin_pause_window();
|
||||||
|
|
||||||
/// Frames the x window on the data row 0 holds, leaving every y axis alone. The x
|
/// Undoes an x zoom: puts the view back on the whole time window, leaving every y axis alone.
|
||||||
/// counterpart of `fit_all()`, for the one thing the y axes cannot say: how wide the
|
/// The x counterpart of `fit_all()`.
|
||||||
/// window should be.
|
///
|
||||||
|
/// **It does not change the time window** -- `window_s_` is read, never written. A fit is a
|
||||||
|
/// plot interaction, and the window is also the trigger's event span, the buffer partition and
|
||||||
|
/// the control's readout, so a fit that *framed the data* instead would redefine all of that
|
||||||
|
/// from a gesture (and re-plan the rings, dropping the history). What a user asks for here is
|
||||||
|
/// the zoom undone: a 2 s window zoomed into reads 2 s again.
|
||||||
|
///
|
||||||
|
/// Which 2 s depends on the state, and both cases restore the *whole* of what the view is
|
||||||
|
/// allowed to show: live, the newest window at the live edge; pinned (a held event or a paused
|
||||||
|
/// view), the pin's full range, with the pin kept -- `set_time_window()` would clear it and
|
||||||
|
/// re-frame on a live edge that a frozen view has nothing bound to.
|
||||||
void fit_x();
|
void fit_x();
|
||||||
|
|
||||||
|
/// Whether a window is pinned: a held event, a filling capture or a paused view. The condition
|
||||||
|
/// `fit_x()` branches on, which is why the fit probe reports it.
|
||||||
|
bool window_pinned() const noexcept { return pinned_; }
|
||||||
|
|
||||||
/// Adopts the instruments a gesture is moving, on `drag_row` (the row whose controller holds
|
/// Adopts the instruments a gesture is moving, on `drag_row` (the row whose controller holds
|
||||||
/// a cursor), and nothing otherwise.
|
/// a cursor), and nothing otherwise.
|
||||||
///
|
///
|
||||||
@@ -336,10 +359,9 @@ class RowSet {
|
|||||||
/// 0, so nothing outside the pre+post window is visible.
|
/// 0, so nothing outside the pre+post window is visible.
|
||||||
void pin_event_window(double pre_s, double post_s);
|
void pin_event_window(double pre_s, double post_s);
|
||||||
|
|
||||||
/// Fits every row's y axis to the data it holds, and re-frames the shared x window
|
/// Fits every row's y axis to the data it holds, and puts x back on the whole time window --
|
||||||
/// on what row 0 retains (the same thing the plot's own `f` key does). The window
|
/// `fit_x()`'s x half exactly, so the two cannot drift apart. Like it, **this does not change
|
||||||
/// the toolbar reports becomes the fitted span, so the dropdown can say what is
|
/// the time window**: the y axes are fitted to their data, x is merely un-zoomed.
|
||||||
/// actually on screen.
|
|
||||||
void fit_all();
|
void fit_all();
|
||||||
|
|
||||||
/// Reports the left gutter the rows currently need, and reserves at least the width
|
/// Reports the left gutter the rows currently need, and reserves at least the width
|
||||||
@@ -351,18 +373,22 @@ class RowSet {
|
|||||||
/// left and right by a few pixels every frame. Since `set_min_y_gutter()` is a
|
/// left and right by a few pixels every frame. Since `set_min_y_gutter()` is a
|
||||||
/// floor and the library still takes the larger of the two, a floor smaller than
|
/// floor and the library still takes the larger of the two, a floor smaller than
|
||||||
/// the labels cannot stop that; the reservation therefore covers the widest label
|
/// the labels cannot stop that; the reservation therefore covers the widest label
|
||||||
/// `format_si()` can produce (three significant digits, a sign and a prefix, or the
|
/// `format_si()` writes for an axis with no unit -- a sign, three significant figures
|
||||||
/// `%.3g` fallback) plus the label padding, measured once with the plot's own font.
|
/// and a one-glyph prefix -- plus the label padding, measured once with the plot's own
|
||||||
/// Both reservations only ever grow, so the gutter is constant in practice.
|
/// font. Both reservations only ever grow, so the gutter is constant in practice.
|
||||||
|
///
|
||||||
|
/// The same metrics fix the **right** gutter, which is a want and not a measurement:
|
||||||
|
/// see `apply_gutters()`.
|
||||||
void note_y_gutter(double need_px);
|
void note_y_gutter(double need_px);
|
||||||
|
|
||||||
/// Applies the stable gutter as the figure's floor. Call once per frame, before the
|
/// Applies the stable left gutter as the figure's floor, and the right gutter beside
|
||||||
/// frame is drawn.
|
/// it. Call once per frame, before the frame is drawn.
|
||||||
///
|
///
|
||||||
/// It has to be the *figure's* floor and not each row's: `Figure::layout()` clears
|
/// Both have to be the *figure's* and not each row's: `Figure::layout()` clears every
|
||||||
/// every row's `set_min_y_gutter()` to measure what its own labels need, so a floor
|
/// row's `set_min_y_gutter()` to measure what its own labels need, so a floor written
|
||||||
/// written onto the rows survives exactly until the next layout.
|
/// onto the rows survives exactly until the next layout, and the rows have to end on
|
||||||
void apply_y_gutter();
|
/// one column as well as start on one.
|
||||||
|
void apply_gutters();
|
||||||
|
|
||||||
/// The palette entry `index`, wrapped. Keyed by the *signal*, so that the colour a
|
/// The palette entry `index`, wrapped. Keyed by the *signal*, so that the colour a
|
||||||
/// trace is drawn in is the colour the signal list's chip shows, on every row the
|
/// trace is drawn in is the colour the signal list's chip shows, on every row the
|
||||||
@@ -391,8 +417,6 @@ class RowSet {
|
|||||||
/// at the end of the gesture (`Controller::end_drag()`), and a value read out of the plot
|
/// at the end of the gesture (`Controller::end_drag()`), and a value read out of the plot
|
||||||
/// mid-drag has already been through that act.
|
/// mid-drag has already been through that act.
|
||||||
void place_cursors(Row& r, plot::CursorPlacement placement = plot::CursorPlacement::Snapped);
|
void place_cursors(Row& r, plot::CursorPlacement placement = plot::CursorPlacement::Snapped);
|
||||||
/// The newest x any row's live traces hold, which is what the group follows.
|
|
||||||
bool live_edge(double& newest) const;
|
|
||||||
|
|
||||||
std::unique_ptr<plot::Figure> figure_;
|
std::unique_ptr<plot::Figure> figure_;
|
||||||
/// The one x link group every visible row belongs to. Groups are numbered from 1.
|
/// The one x link group every visible row belongs to. Groups are numbered from 1.
|
||||||
@@ -411,6 +435,9 @@ class RowSet {
|
|||||||
/// gesture to be honoured; one that matches it is just the clamp's own answer arriving back.
|
/// gesture to be honoured; one that matches it is just the clamp's own answer arriving back.
|
||||||
plot::Range pinned_stated_{0.0, 0.0};
|
plot::Range pinned_stated_{0.0, 0.0};
|
||||||
double y_gutter_px_ = 0.0;
|
double y_gutter_px_ = 0.0;
|
||||||
|
/// The empty band kept to the right of the data, device pixels. Nothing is drawn in it,
|
||||||
|
/// so it is reserved once from the font's metrics and never measured or grown.
|
||||||
|
double right_gutter_px_ = 0.0;
|
||||||
bool y_gutter_reserved_ = false;
|
bool y_gutter_reserved_ = false;
|
||||||
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
|
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
|
||||||
CursorMode cursor_mode_ = CursorMode::Vertical;
|
CursorMode cursor_mode_ = CursorMode::Vertical;
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ std::string format_glance(const StatusSnapshot& s) {
|
|||||||
out += " · ";
|
out += " · ";
|
||||||
out += s.window_text;
|
out += s.window_text;
|
||||||
out += " window";
|
out += " window";
|
||||||
|
if (s.paused) {
|
||||||
|
// Directly after the state word's own field rather than at the end, because it qualifies
|
||||||
|
// *that* word: "paused · 10 kS/s · 2 s window · for 12 s" reads as one statement.
|
||||||
|
out += " · for " + format_elapsed(s.paused_for_s);
|
||||||
|
}
|
||||||
if (s.trigger_on) {
|
if (s.trigger_on) {
|
||||||
out += " · ";
|
out += " · ";
|
||||||
if (s.have_event) {
|
if (s.have_event) {
|
||||||
@@ -74,6 +79,7 @@ std::string format_detail(const StatusSnapshot& s) {
|
|||||||
s.plot1_traces, s.plot1_samples, s.buffer_mb, s.span_s,
|
s.plot1_traces, s.plot1_samples, s.buffer_mb, s.span_s,
|
||||||
static_cast<unsigned long long>(s.time_stride));
|
static_cast<unsigned long long>(s.time_stride));
|
||||||
std::string out = buf;
|
std::string out = buf;
|
||||||
|
if (s.paused) out += " | paused for " + format_elapsed(s.paused_for_s);
|
||||||
if (s.trigger_on) {
|
if (s.trigger_on) {
|
||||||
out += s.have_event ? " | last event " + format_elapsed(s.since_event_s) + " ago"
|
out += s.have_event ? " | last event " + format_elapsed(s.since_event_s) + " ago"
|
||||||
: " | no event yet";
|
: " | no event yet";
|
||||||
|
|||||||
+18
-5
@@ -11,9 +11,12 @@ namespace udpscope {
|
|||||||
/// two cannot disagree. No GTK here on purpose: this is the one part of the status bar that
|
/// two cannot disagree. No GTK here on purpose: this is the one part of the status bar that
|
||||||
/// can be reasoned about as text.
|
/// can be reasoned about as text.
|
||||||
struct StatusSnapshot {
|
struct StatusSnapshot {
|
||||||
/// One word from the fixed set: "live", "paused", "armed", "capturing", "event held",
|
/// One word from the fixed set: "connecting", "live", "paused", "armed", "capturing",
|
||||||
/// "disarmed". It is a word from a *fixed* set because the bar sits directly above the plot
|
/// "event held", "disarmed". It is a word from a *fixed* set because the bar sits directly
|
||||||
/// and a field whose width tracked the stream would relayout it every frame.
|
/// above the plot and a field whose width tracked the stream would relayout it every frame --
|
||||||
|
/// so a word added here must be no wider than the widest already in it ("connecting" and
|
||||||
|
/// "event held" are both ten characters). "connecting" is the state before a CONFIG has
|
||||||
|
/// arrived, in which there is no signal list and so nothing any other word here can describe.
|
||||||
const char* state_word = "live";
|
const char* state_word = "live";
|
||||||
/// The long prose the old single-line bar carried, kept for the detail string and the dump.
|
/// The long prose the old single-line bar carried, kept for the detail string and the dump.
|
||||||
std::string state_detail;
|
std::string state_detail;
|
||||||
@@ -44,10 +47,20 @@ struct StatusSnapshot {
|
|||||||
bool have_event = false;
|
bool have_event = false;
|
||||||
double since_event_s = 0.0;
|
double since_event_s = 0.0;
|
||||||
int reconnects = 0;
|
int reconnects = 0;
|
||||||
|
|
||||||
|
/// How long the view has been frozen, in **wall** seconds, and whether it is. The producer
|
||||||
|
/// never stops while paused -- it keeps filling the live ring, which is what makes the resume
|
||||||
|
/// seamless -- so the stream's own clock says nothing about how long the user has been looking
|
||||||
|
/// at a still picture. Written through `format_elapsed()` like the event age, for the same
|
||||||
|
/// reason: it steps to a coarser unit instead of growing digits, and the bar is directly above
|
||||||
|
/// the plot.
|
||||||
|
bool paused = false;
|
||||||
|
double paused_for_s = 0.0;
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The bar: "live · 10 kS/s · 2 s window", plus " · event 4.2 s ago" in trigger mode and
|
/// The bar: "live · 10 kS/s · 2 s window", plus " · event 4.2 s ago" in trigger mode, " · for
|
||||||
/// nothing else ever. The badge glyph is a separate widget and is not part of this string.
|
/// 12 s" while paused, and nothing else ever. The badge glyph is a separate widget and is not
|
||||||
|
/// part of this string.
|
||||||
std::string format_glance(const StatusSnapshot& s);
|
std::string format_glance(const StatusSnapshot& s);
|
||||||
|
|
||||||
/// The popover's prose and the exit dump's line, field for field as the old single-line bar
|
/// The popover's prose and the exit dump's line, field for field as the old single-line bar
|
||||||
|
|||||||
+806
-240
File diff suppressed because it is too large.
Load diff
+31
-13
@@ -25,7 +25,7 @@ MAGIC = 0x53504455
|
|||||||
PKT_DATA, PKT_CONFIG, PKT_ACK, PKT_CONNECT, PKT_DISCONNECT = 0, 1, 2, 3, 4
|
PKT_DATA, PKT_CONFIG, PKT_ACK, PKT_CONNECT, PKT_DISCONNECT = 0, 1, 2, 3, 4
|
||||||
T_FLOAT64 = 9
|
T_FLOAT64 = 9
|
||||||
QUANT_NONE = 0
|
QUANT_NONE = 0
|
||||||
TIME_PACKET, TIME_FULL_ARRAY = 0, 1
|
TIME_PACKET, TIME_FULL_ARRAY, TIME_FIRST_SAMPLE = 0, 1, 2
|
||||||
PUBLISH_ACCUMULATE = 1
|
PUBLISH_ACCUMULATE = 1
|
||||||
NO_TIME_SIGNAL = 0xFFFFFFFF
|
NO_TIME_SIGNAL = 0xFFFFFFFF
|
||||||
|
|
||||||
@@ -50,28 +50,35 @@ def signal_desc(name, *, rate, time_mode, time_idx, unit, rows=1, cols=1, dims=0
|
|||||||
return bytes(d)
|
return bytes(d)
|
||||||
|
|
||||||
|
|
||||||
def build_config(rate):
|
def build_config(rate, time_mode=TIME_FULL_ARRAY, unit="V"):
|
||||||
sigs = [
|
sigs = [
|
||||||
# The stream's clock: referenced by every data signal, so udpscope treats
|
# The stream's clock: referenced by every data signal, so udpscope treats
|
||||||
# it as the time base rather than a plottable channel.
|
# it as the time base rather than a plottable channel. Its *own* time_mode
|
||||||
signal_desc("time", rate=0.0, time_mode=TIME_FULL_ARRAY,
|
# is what udpscope switches on, so `--time-mode first` has to land here.
|
||||||
|
signal_desc("time", rate=0.0, time_mode=time_mode,
|
||||||
time_idx=NO_TIME_SIGNAL, unit="s"),
|
time_idx=NO_TIME_SIGNAL, unit="s"),
|
||||||
signal_desc("sine", rate=rate, time_mode=TIME_FULL_ARRAY, time_idx=0, unit="V"),
|
signal_desc("sine", rate=rate, time_mode=time_mode, time_idx=0, unit=unit),
|
||||||
signal_desc("square", rate=rate, time_mode=TIME_FULL_ARRAY, time_idx=0, unit="V"),
|
signal_desc("square", rate=rate, time_mode=time_mode, time_idx=0, unit=unit),
|
||||||
signal_desc("ramp", rate=rate, time_mode=TIME_FULL_ARRAY, time_idx=0, unit="V"),
|
signal_desc("ramp", rate=rate, time_mode=time_mode, time_idx=0, unit=unit),
|
||||||
]
|
]
|
||||||
payload = struct.pack("<I", len(sigs)) + b"".join(sigs)
|
payload = struct.pack("<I", len(sigs)) + b"".join(sigs)
|
||||||
payload += bytes([PUBLISH_ACCUMULATE])
|
payload += bytes([PUBLISH_ACCUMULATE])
|
||||||
return header(PKT_CONFIG, 0, len(payload)) + payload
|
return header(PKT_CONFIG, 0, len(payload)) + payload
|
||||||
|
|
||||||
|
|
||||||
def build_data(counter, t0, dt, batch, freq):
|
def build_data(counter, t0, dt, batch, freq, time_mode=TIME_FULL_ARRAY):
|
||||||
stamps = [t0 + i * dt for i in range(batch)]
|
stamps = [t0 + i * dt for i in range(batch)]
|
||||||
sine = [math.sin(2.0 * math.pi * freq * t) for t in stamps]
|
sine = [math.sin(2.0 * math.pi * freq * t) for t in stamps]
|
||||||
square = [1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps]
|
square = [1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps]
|
||||||
ramp = [(freq * t) % 1.0 * 2.0 - 1.0 for t in stamps]
|
ramp = [(freq * t) % 1.0 * 2.0 - 1.0 for t in stamps]
|
||||||
|
# In FIRST_SAMPLE mode only element 0 of the time signal is meaningful: the producer
|
||||||
|
# stamps the first sample of the batch and says nothing about the rest. A scalar
|
||||||
|
# signal in Accumulate mode still occupies `batch` slots on the wire (the client
|
||||||
|
# reads `num_samples` values for any 1-element signal), so the anchor is repeated --
|
||||||
|
# which is exactly the shape that made udpscope derive a step of zero.
|
||||||
|
wire_time = [t0] * batch if time_mode == TIME_FIRST_SAMPLE else stamps
|
||||||
payload = struct.pack("<QI", int(t0 * 1e9), batch)
|
payload = struct.pack("<QI", int(t0 * 1e9), batch)
|
||||||
for arr in (stamps, sine, square, ramp):
|
for arr in (wire_time, sine, square, ramp):
|
||||||
payload += struct.pack("<%dd" % batch, *arr)
|
payload += struct.pack("<%dd" % batch, *arr)
|
||||||
return header(PKT_DATA, counter, len(payload)) + payload
|
return header(PKT_DATA, counter, len(payload)) + payload
|
||||||
|
|
||||||
@@ -88,6 +95,16 @@ def main():
|
|||||||
"clock advances ten times slower than the wire delivers samples")
|
"clock advances ten times slower than the wire delivers samples")
|
||||||
ap.add_argument("--declared-rate", type=float, default=-1.0,
|
ap.add_argument("--declared-rate", type=float, default=-1.0,
|
||||||
help="sampling_rate to put in CONFIG; -1 = the real rate, 0 = 'unknown'")
|
help="sampling_rate to put in CONFIG; -1 = the real rate, 0 = 'unknown'")
|
||||||
|
ap.add_argument("--time-mode", choices=("full", "first"), default="full",
|
||||||
|
help="how the stream carries its clock: 'full' stamps every sample "
|
||||||
|
"(UDPS_TIME_FULL_ARRAY), 'first' stamps only the batch's first "
|
||||||
|
"sample (UDPS_TIME_FIRST_SAMPLE) and leaves the viewer to space the "
|
||||||
|
"rest by the declared cadence -- the shape a real MARTe2-style "
|
||||||
|
"producer with a single u64 clock has")
|
||||||
|
ap.add_argument("--unit", default="V",
|
||||||
|
help="the unit the data signals declare in CONFIG; '' declares none, which "
|
||||||
|
"is an ordinary stream (and what every math channel looks like) and is "
|
||||||
|
"the only way a probe can see the unitless readout formatting")
|
||||||
ap.add_argument("--clock-jitter", type=float, default=0.0,
|
ap.add_argument("--clock-jitter", type=float, default=0.0,
|
||||||
help="per-packet stamp jitter, as a fraction of one packet's span: a device "
|
help="per-packet stamp jitter, as a fraction of one packet's span: a device "
|
||||||
"that stamps each batch from its own clock does not land on a perfect "
|
"that stamps each batch from its own clock does not land on a perfect "
|
||||||
@@ -98,14 +115,15 @@ def main():
|
|||||||
dt = args.stream_scale / args.rate # stream seconds per sample
|
dt = args.stream_scale / args.rate # stream seconds per sample
|
||||||
period = args.batch / args.rate # wall seconds per packet
|
period = args.batch / args.rate # wall seconds per packet
|
||||||
declared = args.rate if args.declared_rate < 0.0 else args.declared_rate
|
declared = args.rate if args.declared_rate < 0.0 else args.declared_rate
|
||||||
|
time_mode = TIME_FIRST_SAMPLE if args.time_mode == "first" else TIME_FULL_ARRAY
|
||||||
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
sock.bind(("0.0.0.0", args.port))
|
sock.bind(("0.0.0.0", args.port))
|
||||||
sock.settimeout(0.001)
|
sock.settimeout(0.001)
|
||||||
print("mock udpstreamer: waiting for CONNECT on :%d (%.0f S/s wire, %d per packet, "
|
print("mock udpstreamer: waiting for CONNECT on :%d (%.0f S/s wire, %d per packet, "
|
||||||
"stream clock x%.3g, declared %.0f S/s)"
|
"stream clock x%.3g, declared %.0f S/s, time-mode %s)"
|
||||||
% (args.port, args.rate, args.batch, args.stream_scale, declared), flush=True)
|
% (args.port, args.rate, args.batch, args.stream_scale, declared, args.time_mode), flush=True)
|
||||||
|
|
||||||
peer = None
|
peer = None
|
||||||
counter = 1
|
counter = 1
|
||||||
@@ -124,7 +142,7 @@ def main():
|
|||||||
t_stream = 0.0
|
t_stream = 0.0
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
next_send = started
|
next_send = started
|
||||||
sock.sendto(build_config(declared), peer)
|
sock.sendto(build_config(declared, time_mode, args.unit), peer)
|
||||||
print("mock udpstreamer: CONNECT from %s:%d, CONFIG sent" % peer, flush=True)
|
print("mock udpstreamer: CONNECT from %s:%d, CONFIG sent" % peer, flush=True)
|
||||||
elif magic == MAGIC and ptype == PKT_DISCONNECT:
|
elif magic == MAGIC and ptype == PKT_DISCONNECT:
|
||||||
print("mock udpstreamer: DISCONNECT", flush=True)
|
print("mock udpstreamer: DISCONNECT", flush=True)
|
||||||
@@ -148,7 +166,7 @@ def main():
|
|||||||
pdt = dt
|
pdt = dt
|
||||||
if args.clock_jitter > 0.0:
|
if args.clock_jitter > 0.0:
|
||||||
pdt = dt * (1.0 + random.uniform(-args.clock_jitter, args.clock_jitter))
|
pdt = dt * (1.0 + random.uniform(-args.clock_jitter, args.clock_jitter))
|
||||||
sock.sendto(build_data(counter, t_stream, pdt, args.batch, args.freq), peer)
|
sock.sendto(build_data(counter, t_stream, pdt, args.batch, args.freq, time_mode), peer)
|
||||||
counter += 1
|
counter += 1
|
||||||
t_stream += args.batch * pdt
|
t_stream += args.batch * pdt
|
||||||
next_send += period
|
next_send += period
|
||||||
|
|||||||
Reference in new issue
Block a user