24bdd6b9301a393462827a7aa8d920d93861c89e
91
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
16e6d7fa75 |
ui: sidebar tab selector uses text labels instead of icons
The two drawn tab glyphs were judged not understandable enough; the switcher goes to the wide policy with titles only, and the glyphs, their gresource entries and the icon-naming code are removed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
d955baf612 |
flatudp: the signal-row editor form with a per-row Vector as drop-down, its probe line, README and architecture notes
Multicast was verified on a loopback group (239.255.42.99 on 127.0.0.1); a real network was not exercised. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
e8db5602a2 |
e1608: cosmetic minors -- 2-digit frequency spin, smaller mock catch-up cap, docs wording
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
6245811699 |
e1608: do not fight a hardware trigger, stop the scan on a failed connect, daemon drop timer in the mock
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
3093568d11 |
docs: the E-1608 source kind, its probe readings and its unverified commands
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
4d859d6a37 |
docs: the source-plugin framework, the --source options and the source-kinds probe
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
f3f8f67a62 |
tools: a remote display script, Xvfb plus x11vnc over an SSH tunnel
Over SSH with X11 forwarding the app does not appear: no usable Vulkan device means GTK cannot create a swapchain, and `--renderer cairo` fixes that half only -- the plot is a GtkGLArea and asks for its own GL context, so indirect GLX is the second wall. A virtual X server on the machine that runs the app sidesteps both, because the GL context then lives next door to the samples. `tools/udpscope-remote.sh` starts Xvfb on a free display, runs the app there with `--renderer cairo`, and serves the screen through x11vnc bound to localhost only -- the SSH tunnel is the authentication, so `-nopw` is deliberate and `-localhost` is what keeps a mistake from being a remotely reachable screen. It prints the tunnel and viewer commands rather than assuming a client, mentions the websockify/noVNC route for a browser, and takes the X and VNC servers with it when the app exits. `--no-vnc` leaves just the virtual display, for another viewer; the preflight names the install command instead of a bare "command not found". Verified: the preflight refuses with exit 1 and the install line when x11vnc is absent; with `--no-vnc` the app runs against the virtual display and reads `buffer allocated: 4 channels` with no Vulkan warning and no critical, and no Xvfb is left behind when the timeout ends it. That run also exercises what the earlier log showed by omission: nothing is enabled, so a working app plots nothing. The README's Limitations gains the bullet a person actually needs -- the exact warning string, that both halves need the software preset, the script itself, and that a window which appears but stays empty usually means no signal is enabled. |
||
|
|
fcf268d249 |
Add --renderer for displays with no usable GPU; gate the header reading
GTK4 defaults to Vulkan, and an X11-forwarded display has no device to make a swapchain from; the plot is a GtkGLArea, so it then asks for a GL context that is not there either. --renderer cairo is the software preset for that display: it exports GSK_RENDERER=cairo and LIBGL_ALWAYS_SOFTWARE=1 in main() before gtk_init()/adw_init(), because both are read when the renderer and the context are created. --renderer gl is the faster choice when indirect GL works, and --renderer vulkan makes the default explicit. Nothing is exported, and GTK's own choice stands, when the option is absent. The once-a-second header line is now behind UDPSCOPE_PROBE_HEADER, following the [notrace] rule: its widths are still sampled for the exit dump, but the line is detail behind that reading rather than output every run prints. UDPSCOPE_PROBE_RENDERER prints the exported pair before GTK starts and the GskRenderer GTK actually built. README, the manual's overview and docs/claude record the two symptoms and the cure. |
||
|
|
ac054a30c9 |
fix: bound the density grid, and make [notrace] a reading instead of a flood
The plotter caps a density grid at kDensityCellCap now (deps/plotter), so a 2 s window with persistence on no longer scales the image with the window: the probe's four one-signal rows read 1 044 714 cells / 4 178 856 bytes where they read 2 226 150 / 8 904 600, and a moved frame uploads 1 044 714 bytes where it uploaded 2 226 150. The probe's own legs are unchanged (on peak=1.0000, step-acquisitions peak=1.5000, step-time ratio=0.367879). The paint probe no longer counts a frame the density is drawing as *traceless*. `Renderer::draw_traces()` returns while the ink is the image, so every frame of a persistence display stages no trace: the dump read `traceless paints: 653 (637 after the first bind)` for a run whose real figure is 4 (0), and the `[notrace]` line printed for each of those frames. It is now behind UDPSCOPE_PROBE_NOTRACE -- a per-paint line is a log flood and not a reading -- while the counters it details stay in every build, and the line stays reachable for the state it was written for (a bound row that draws nothing). Docs corrected where they stated the old rule: the README, the trigger invariants, the density probe's readings, the verification list (the new variable) and the manual page behind the panel's tooltip. |
||
|
|
da826f2cb5 |
fix: persistence is the trace's own phosphor, not a density behind a stroke
The plotter now deposits every acquisition -- the newest included -- into one grid per trace and
draws no solid trace over it, each grid in that trace's colour and with a one-sweep ceiling (its own
commit, **1b8b55d**). This side needed almost no change of its own, because the colour was already
the trace's and the trace's colour is `RowSet::signal_color()` -- the invariants' one route -- but the
figures it reads back are now per trace:
- **The ceiling is left at the library's default of one sweep**, no longer derived from the fade.
Deriving it from the steady state is the gain offset the user reported between the latest
acquisition and the persistent image; a signal crossing the same place every acquisition now settles
at the trace's colour, at full strength, and the newest sweep reads as the trace it is a picture of.
- **`cells`/`grid_bytes` are the sum over the row's traces** -- one grid each, a trace with no series
holding none -- and both the probe and the exit dump carry `traces=` beside `rows=`. That is what
makes the per-signal memory figure read: **556 537 cells and 2.2 MB per signal**, at 8904600 bytes
over the four one-signal rows of the probe's run.
The probes, re-read on this build (the `verification.md` command, `--rate 500000 --batch 1000`):
[density-probe] off enabled=0 ... rows=0 traces=0 cells=0 ink=0.0 peak=0.0000
[density-probe] on enabled=1 ... rows=4 traces=4 cells=2226150 ink=226786.0 peak=1.0000
[density-probe] step-acquisitions ... peak=1.5000
[density-probe] time-before ... peak=2.1359
[density-probe] step-time ... ratio=0.367879
[density-probe] frame cost: tick 0.62 -> 6.96 ms (184/23 frames), draw 2.02 -> 6.16 ms (177/18)
[udpscope] density: frames=748 rows=4 traces=4 cells=2226150 grid_bytes=8904600 ink=489400.2 peak=2.2 upload_bytes=2226150
`on` `peak=1.0000`, `step-acquisitions` `peak=1.5000` and `step-time` `ratio=0.367879` are the legs
that were expected, unchanged; the *draw* mean is now **below** the run before the split (2.02 -> 6.16
against 2.09 -> 20.91), because the solid traces are no longer drawn over the image.
Docs updated with it: the trigger manual page (`persistence` is the trace, one image per signal, the
newest included), the README's persistence section, `invariants-trigger-and-pinning.md` (five rules,
the ceiling and the no-stroke rule among them), `verification.md` and `probe-readings.md` (the lines
above), the trigger panel's comment and tooltips, and the plotter submodule.
`cmake --build build --clean-first -j4`: **0 errors, the documented three warnings** (two
`MathNode::source`, one `-Woverlength-strings` at 12352); `make -C build test-ui` **13 ok**;
`make -C build test-help` **green** (15 pages); the self-check **0 `Gtk-WARNING`/`Gtk-CRITICAL`** and
`gtk warnings: 0`, two `buffer allocated:` lines.
|
||
|
|
974d6aa2d9 |
trigger: a phosphor persistence, driven by the plotter's density image
The trigger pane gains the display the user asked for and the library now provides, and the
persistence it already had is renamed to what it is.
**Persistence is one image; the last-N-traces display is "multiple events".** Both live in a new
`Advanced` expander in the trigger panel, together with the settings that are set up once and left
alone. `persistence` (a switch) turns on the plotter's `Plot::set_density()` for every row:
every triggered acquisition is accumulated into a screen-sized intensity grid drawn behind the
traces, brightness being how many acquisitions covered a cell -- no depth to choose, no memory
that grows with the acquisitions (2.2 MB over four 573x412 rows in the probe's run). `fades by`
picks the fade the user asked for: **acquisitions** (each keeps a factor of what was before it,
0.5 by default -- the phosphor afterglow) or **time** (a time constant in stream seconds, so the
display fades while you watch whatever the trigger rate).
- **Trigger mode only, as asked.** `apply_density()` (the tick) states the display on for `Held`
and `Capturing` and off for `Live`, `Waiting`, `Paused` and a file, and turning it off drops the
grid at once rather than at an acquisition a quiet trigger never sends. It is unpickable and
reserves no layout space, exactly as the invalid-span bands are: a density can never be in the
way of a cursor or a drop, and it cannot move the framing.
- **One deposit per acquisition, one frame after the frame that frames it.** `Plot::accumulate_density()`
deposits what the plot holds through the transforms of its *last* layout -- the frame's own is
computed inside the paint that follows the tick -- so `density_pending` is set beside
`record_pending` and consumed by the next tick. In the same tick it was right on a Held-to-Held
transition and wrong on the first latch, whose axis was still the live ring's. Switching the
display on under an acquisition already on screen deposits it too, so the switch does not appear
to do nothing until the next latch (`single` mode may never send one).
- **The session carries it**: `trigger_density`, `trigger_density_decay` ("acquisitions"/"time",
the words the panel shows), `trigger_density_tau` and `trigger_density_factor`, written whether
or not it is on -- and `trigger_persistence` still means the multiple-event depth, so an older
file is unchanged.
- **The library changed twice under this**: the deposit now counts a cell **once per trace per
sweep** (counting the hits put one dense sweep at the ceiling of the brightness scale and hid the
sweeps behind it -- the probe's `on` leg read `peak=18` for a single sweep and reads `peak=1`
now), and a frame's upload is every row's rather than the last row's. Both are in the plotter's
own commit.
`UDPSCOPE_PROBE_DENSITY_MS` reads it all back off the plots -- the plotter's own `ink()`/`peak()` --
with the panel's own widgets driving the switch and the fade, in four legs: **off** (`rows=0
ink=0.0 peak=0.0000`), **on** (`rows=4`, `peak=1.0000`), **step-acquisitions** (`peak=1.0000 ->
1.5000`, the factor then the new sweep) and **step-time** (`ratio=0.367879`, `1/e` for `dt = tau`).
It also reads the frame cost as an A/B **inside one run** (the accumulators at the switch against
the frames after it): `tick 0.74 -> 9.99 ms`, `draw 2.09 -> 20.91 ms` at four rows and 2.2 M cells
on llvmpipe.
Acceptance: clean build 0 errors and exactly the three documented warnings (12352-byte GResource
blob, two `MathNode::source`), `test-ui` 13/13, `test-help` green, a plain run with **0
Gtk-CRITICAL / Gtk-WARNING** and `gtk warnings: 0`, and the panel's own frame reading
`boxes=16 controls_inside=15/15 advanced_widgets=5/5 advanced_inside=5/5` (the block's controls
counted from the expander's child, which is not in the widget-parent chain -- and the search
block's walk now names *its* expander instead of "the first one", which the second expander would
have stolen).
Docs: the trigger/pinning invariants carry the rule and its five consequences, `probe-readings.md`
the four legs and the dump line, `verification.md` the probe's sequence and assertions, and the
manual's trigger page explains both displays to a user.
|
||
|
|
e14e1ee069 |
trigger: a phosphor display keeps the last N acquisitions; and a Force button
Two changes in the trigger pane, landed together. **Persistence.** Trigger mode keeps the last N published acquisitions and draws them fading toward the background, N being a panel control (1..8, default 1) with N=1 meaning today's behaviour exactly, so the knob is the switch. The layers are the accumulate path's own -- one layer per (signal, age) in the row's free trace slots, named with the accumulate vocabulary so `layer_base_name()` strips the suffix and `signal_color()` resolves the base signal -- so a layer is not a signal: the signal list, the colours and the measurements see one row per signal. `ScopeModel` retains the outgoing snapshot in seven atomic `shared_ptr` slots (no lock, no copy; the snapshot already exists) and a layer's channel is resolved **by name inside the snapshot**, because the channel list is renumbered by any structural change since that acquisition was cut. `RowSet::sync_layers()` re-plans from the rows and the depth alone, so a latch costs no rebuild, and `event_data_window()` skips layer slots so an older acquisition can never re-pin the axis. The schedule is 0.5^age -- the acquisition on screen at full brightness and each older one half the previous, independent of N, so raising the depth adds dimmer acquisitions rather than rescaling the ones already on screen. Memory is bounded by the ring's own `--buffer-mb` rather than by the depth, which is the bound that cannot be driven into an OOM, and the panel says when either bound bites. This also wired up `set_trace_alpha()`, which the accumulate path was setting and nothing was reading -- so the documented 0.35 translucency of an accumulation was opaque in fact. **Force.** In the trigger pane beside Arm/Disarm, shown only while trigger mode is on: one acquisition now, with the configured window. It is a synthetic edge rather than a second publisher -- the engine opens one capture at the present moment and everything else is the ordinary latch, so a forced acquisition is cut, pinned, framed, recorded and cached exactly like a latched one, and the button owns no window and no publish. The pre-trigger gate is deliberately skipped (capture now is a request for the history that exists, and the snapshot reports its own shorter pre_s), a capture already filling is refused rather than restarted, and the state afterwards is the mode's: Normal stays armed, Single disarms. The request is one atomic flag written under the model mutex with `armed` -- the producer reads `armed` at the top of a packet and the request at the bottom, so a request written between the two would be dropped -- and consumed at the end of a packet, after the channel loop and before the pending publish. **A defect fixed in passing, in the function Force was modelled on:** `on_arm_clicked()` passed `TriggerConfig::channel` (an index into `channels()`) to `set_signal_enabled()` (an index into `signals()`). With one signal acquired, arming switched on the stream's clock signal and left the watched signal untouched -- and since a signal the panel was not showing is a change, it rebuilt the buffer under the click, dropping the very pre-trigger window the arming, and now the Force, was about to capture. Measured before: `0.0100 s of history behind it`; after: `2.5299 s`. `trigger_signal_index()` is now the one resolution, used by both buttons. |
||
|
|
fc4700d1c9 |
perf: a measurement pane is priced by its window, and its fold is inline
A pane is recomputed over the whole visible window on a cadence, and at a 15 s window (11.8 M samples, 184 k level-0 level buckets, `--buffer-mb 4000` so the stride stays 1) that is priced per *measured channel* and per *distinct share*. Measured with the new `[levels-probe]` sweep, per recompute: one window with one level 6.58 ms, four shares 10.28 ms, sixteen shares 24.55 ms, and four measurements one share each over four channels **35.69 ms** -- against a tick mean of 1.34 ms with an empty pane and 4.58 ms with that one. That is the reported "very slow with multiple measurements": the pass runs inside `tick()`, so it is a *frame* cost, and not an ingest one -- `gaps` and `packets` stay clean even though each `MeasureWindowReader` holds the model mutex for its whole life, because a socket buffer absorbs a few tens of milliseconds of producer. The largest single term inside a pass was a call. `measure_accumulate_bucket()` folds one bucket into one level, and it is the innermost loop of a walk whose iteration count is the window's buckets *times* the pane's levels; it lived out of line in `measure.cpp`, so every one of those iterations was a cross-translation-unit call. Moving it, and the crossing state it updates, into `measure.hpp` as `inline` takes sixteen shares on one channel from 24.55 to **17.25 ms** a recompute -- with every value in the dump's `measurements:` unchanged. Two levers remain and both are measured: the fetch's three out-of-line pyramid accessors plus a 48-byte copy per bucket (3.3 ms of an 11.8 M-sample window's 11.6 ms pass) needs a combined accessor upstream, and `raw` is the *signal's* share (4.6 % on the mock's noisy channel against 0.1 % on its sine), because a level inside a bucket's own `[lo, hi]` has to be walked sample by sample. Bisected with the pane populated (the earlier bisect had it empty, which cannot see this): the parent of the recent range and HEAD read **8.29** and **7.92 ms** a recompute for the same four-type pane at the same window, and neither of the two commits between them touches `ui_measure.cpp` or `measure.cpp` at all. So the cost is the scale and not a regression, and the fix makes a pass cheaper rather than removing one -- the plot's own per-frame cost was checked the same way (four commits, equal within 3 %, and unchanged by the window, the ring size and the visible sample count). `UDPSCOPE_PROBE_LEVELS_MS` with `UDPSCOPE_PROBE_LEVELS` and `UDPSCOPE_PROBE_LEVELS_CHANNELS` is the sweep; `docs/claude/invariants-measurements.md` records the curve and the inline rule, `verification.md` and `probe-readings.md` the probe and what its numbers must read, and the README the pane's cost at this scale. |
||
|
|
0a209becb1 |
fix: a still frame's measurements are the frame's, not the viewport's
A measurement is a statistic of the window the pane is *about*, and on a pinned view -- a held event, a paused stream, an opened file -- that is the frame, not the part of it on screen. The pane followed the viewport instead, so a wheel on a latched event moved `average` and `top`: the reported "when measuring a still frame, moving the zoom makes measurements such as average or top change". Nothing about the data had changed; the user was only looking closer. The window is `RowSet::pinned_range()` while the pin frames the view and the viewport otherwise, and the choice is made in `update_measures()` -- the call site -- rather than inside `MeasureWindowReader`, because it is *booked* as well as read: that window is what the "it moved" test compares against `App::measure_window`, so a gesture inside the pin spends no recompute either, and a reader that chose its own would leave a pinned view recomputing every gesture frame for a number that cannot change. `pinned_range()` is now the one expression of the pin's extent, shared with the framing (`hold_pinned_window()`, `fit_x()`), and the reader is handed the window instead of reading the plot. Live is unchanged: there the window really moves and the statistic follows it, and the accumulator's rule about what resets the history is about neither. `UDPSCOPE_PROBE_MEASUREPIN_MS` is the guard, three legs of before/after a gesture on the row's own axis. On a latched event zoomed to a quarter (`view=[-1..1]` -> `[-0.25..0.25]`, `pin` unchanged): `same=4/4 for_same=1 pass_delta=0`, every value bit-identical -- `average=0.000000 top=0.957975 peak to peak=2.000000 amplitude=1.915950` both sides. The same on a pause taken from it. Since the key `for=` follows `pin=` now, `[measure-trace]` gained the field, so a held run reads `for==pin` with `swin` free to sit inside it. On a live view a zoom to an eighth reads `same=0/4 for_same=0 pass_delta=3 span_after=0.125000 follows=1` with the values moving -- the case the rule must not swallow. Before the fix the same probe reads `same=1/4 for_same=0 pass_delta=1 span_after=0.500000` on the held event (`top` `0.957975` -> `0.957966`) and `same=0/4 span_after=0.125000` on the pause (`top` `0.957944` -> `0.358614`). Live reads the same either way. Also fixed, found by the pause leg: pausing while an event is *held* froze an **empty** picture. `on_pause_toggled()` read `stream_at_x0()` after setting the pause state, and that accessor follows the display -- `paused` with no pause view published yet answers `-display_epoch()`, which on a held event is 0, so the cut searched the ring at edge-relative x, found nothing, and the frame bound an empty snapshot: every measurement `--`, every ruler gone (`ev=650001` on the probe's line now, `ev=0` before). The offset and the window are read first, which is the offset the axis is actually displaying. The live case is unaffected, which is why the pause probe, which pauses a live view, never saw it. Docs corrected where they asserted the old half-truth: the invariants now separate the accumulator's rule (unchanged) from the window a value is computed over (this change), the trigger/pinning file names the pin as load-bearing for both the framing and the numbers and records the pause ordering rule, `verification.md`/`probe-readings.md` describe the new probe and `pin=`, and the README and the manual say what a still frame measures. Checked: `cmake --build build --clean-first -j4` 0 errors and the documented three warnings; `make -C build test-ui` 13 ok; `make -C build test-help` green; an exit-dump run 0 Gtk-WARNING/Gtk-CRITICAL and `gtk warnings: 0`; `[measurepin-probe]`, `[pan-probe]` (`held=1`, `allowed=1`, `limited=1`), `[pause-probe]` (`event_size` non-zero exactly while paused, `axis` inside `frozen_x`, `skips` 0 live / 30 paused), `[stats-probe]` (`dn=1` across the pause), `[zoom-probe]` (`gap_px=0.0` at every step) and `[measure-probe]` + the dump's `measures window:` (all-types run `marks_in_window=4/4`, `pin=off` live) on the fast mock. `measure cost:` is unchanged in kind: `raw=37298 (0.0%)` of `covered=121704537`, 1.04 ms a pass -- the runs that read higher are the probe's own zoomed-to-a-slice windows, where the window is legitimately shorter than a bucket. |
||
|
|
d91957a2fe |
fix: a recompute that renumbered its ring, and readouts frozen under a zoom
Three defects, all of them a window describing data that is not there. A math edit refilled the derived ring with `clear()`, which renumbers it from 0, leaving `produced()` short by the inputs' retained base. Since `live_window_locked()` clamps against every channel's `produced()`, that one lagging channel answered `n = 0` for the live view, every pause view and every trigger capture until a whole ring of fresh data had gone by -- the reported freeze, invisible until the ring first wraps, hence its intermittence. It now restarts with `clear_to(base)`, and `ChannelBuffer::republish_from(base)` puts the published window back: `acquire()` is incremental, so a rewrite in place published nothing and the pyramid went on describing the samples the edit replaced. The trigger's channel is an index, re-resolved by name after a renumbering -- but only in `rebuild_enabled_locked()`, so a math add or delete went through `set_math_channels()` with no restore, the index was clamped to -1, and `process()` returned at once while the panel still named the channel. The restore moves into `rebuild_channels()`, which *is* the renumbering, so no caller can forget it. And `update_measures()` suppressed its "the window moved" test while an event was held -- the one mode where a gesture is the only thing that can move it. Zooming a latched event left the pane describing the whole captured event. A pinned window needs no exemption: it lands on the range it already measured and fails the 2 % test by itself. The checks that missed all three: `UDPSCOPE_PROBE_MATHWRAP_MS` edits a graph whose ring has already wrapped, which `math_probe` structurally cannot reach; `[measure-trace]` now prints the mode, the window on screen and the window the numbers describe; and the dump's `marks_in_window` counts against the window on screen instead of against the one the marks were computed over, where it was a tautology. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
074cb611bc |
fix: a clock that stalled on a re-plan, and a dialog that could not close
**The clock.** Trigger mode showed a blank plot and never latched when the session defined a math
channel, because such a session gives the model a plan *before* the stream's own signals are in it:
one derived channel, declared rate 0, so `plan_partition()`'s 1 kHz fallback is the interval those
early frames are stamped at -- 1300x too fast for a 1.3 MS/s stream -- and they have nowhere to go,
since the value loop skips derived channels. Relative mode then stamped a *position* times the plan's
*current* interval, so when the CONFIG's channels landed and the plan took the real rate (769 ns) the
same positions produced times 1300x *smaller* than what was already in the ring, and the monotonic
clamp held the clock at the old value for the rest of the run: one repeated stamp, `history=0`, no
latch, and a window the renderer cannot draw. The same family as the restarted-client poisoning
fixed in
|
||
|
|
27c9b34277 |
fix: a claimed gap must be corroborated before it is believed, and its hole must fit
The user's real host reported "connected and live" with one missing-data band over the whole window
and no trace in it. The counter is the only thing that claims a packet existed, and a reservation
was booked on its word alone: a source whose DATA counter counts samples, or frames of an inner
layer, advances by more than 1 per datagram, the client cannot check that claim (it has no clock of
its own to compare against), so it holds each packet, saturates its reorder ring, delivers it in
order and reports the skipped counters as lost. Every frame therefore reported a phantom gap, and
`reserve_hole()` wrote `lost * batch_` NaN samples with no bound but the 256-PACKET cap -- the
sample form of that rule ("the number to weigh against the ring is 256 * batch_, not the 256") was
never implemented. With relative time on -- which that session carries -- each phantom gap is
materialised as NaN slots by construction: the window filled with reservations, there was never
anything missing to repair them, and the gaps also stretched the relative clock, which is why the
status bar read live at ten times the true rate.
`Acquisition::gap_plausible()` now gates every reservation on two independent tests: the arrival
clock must corroborate the claim (that many missing packets would take that many frame intervals;
deliberately permissive, because a false refusal costs a shading and never data), and the ring must
be able to hold the hole. The verdict is taken before the stamps, and relative mode's slot advance
is skipped with it, since the advance and the reservation are the same claim. Refusals are counted
in `StreamStats::gaps_refused` and said once per connection with the numbers, naming the wire shape
they describe.
tools/mock_udpstreamer.py gains `--counter-step N`: a counter that is not a packet counter on an
intact link, which is the one shape the mock could not produce and the shape the report came from.
Verified in both directions. A counter stepping 1000 per datagram reads `holes=0 hole_slots=0
backfilled=0` with the traces present (3 traces, 1.4 M samples) against 7.9 M claimed gaps, and the
warning above names the cause. Every other packet withheld -- a real loss -- still reads
`holes=3579`, so genuine gaps are still reserved: a fix that stopped believing real losses would
have traded one wrong picture for another. The user's control read as "the math channel is the
problem" and was confounded -- disabling it is a structural change, which rebuilds the buffer and
clears the accumulated NaN.
|
||
|
|
22e7a8efcc |
measure: the split's own means are back as above/below/between
`top`, `bottom` and `amplitude` became the *flat levels* in
|
||
|
|
e83ad86b8f |
measure: top/bottom/amplitude read the flat levels, not the extremes
A bench scope separates Vtop/Vbase from Vmax/Vmin because a band-limited edge overshoots and rings: the mock's `square` at 30 harmonics reaches ±1.179 where its plateau is ±1. On the rippled square the old readings were a property of the edges rather than of the waveform -- `top` 0.986496, `amplitude` 1.179350 -- and `amplitude` reported the overshoot. `top`, `bottom` and `amplitude` are now the flat levels and their delta. A flat level is a density question -- the plateau holds most of the samples and the ringing a few percent -- so `measure_scan_buckets()` folds a 64-bin histogram of the whole buckets' midpoints while it has the list in hand (one observation per bucket, each bin carrying its buckets' exact sum and finite count), and `measure_flat_levels()` takes, either side of the split, the densest bin holding at least half the densest one's buckets -- the outermost of those, which is what makes a waveform with no plateau read its peak -- and the exact mean of the samples of the buckets within 5 % of the window's peak-to-peak of it. 5 % is deliberately under the ~9 %-of-swing Gibbs overshoot. `amplitude` is `top - bottom` (a scope's Vamplitude), not the half-swing it was. Measured, fast mock, 2 s window, harmonics 0 -> 30: top 1.000000 -> 0.999983, bottom -1.000000 -> -0.999983, amplitude 2.000000 -> 1.999966, while `peak to peak` keeps the extremes (2.000000 -> 2.358701). On the 1 Hz sine top is 0.957988 (it was 0.636620, the half-wave mean; a sine has no plateau, so the densest band is at its peak) with sd 0.0014 over a scrolling window, and `peak to peak` is still exactly 2. No raw pass and no new fetch: the histogram is the same fetched bucket list read a second time, gated on a pane that holds one of the three types. `raw` is unchanged (0.1% clean, 2.1% with the --noise 0.1 interferer, against 0.1%/2.1% before) for 0.44 -> 0.50 ms and 0.52 -> 0.65 ms a pass. A window the pyramid cannot summarize -- an hf-filtered walk, or one shorter than a bucket -- keeps the old readings: the mean of each side of the split, and the window's peak to peak for amplitude. Docs: the flat-level invariant (and the two 2/pi assertions it replaces), the probe readings, the manual's measurements page and the README. |
||
|
|
d549223889 |
fix: the guided tour selects the page a stop's target lives on
A stop whose target is on another page of a stack was entered without switching to that page, so the spotlight aimed at a widget that was not on screen. A stop now declares the page it stands on (`TourStop::page`) and the tour selects it as the step is entered -- the window's trigger and signals stops put the sidebar on Signals, the chooser's tabs stop shows the Waveforms page and stepping off it brings Sources back. The stop filter moved into `tour_begin()` and now runs on `gtk_widget_get_mapped()`: GTK 4.24's `is_visible()` ignores `child_visible`, so it kept a stop on another page *and* one in a collapsed pane alike. A wrong page is kept and selected; a collapsed pane, whose stack is unmapped too, is still dropped. |
||
|
|
395c9caec3 |
help: send readers to the install targets, in the alert and the manual
The failure alert told people to run `cmake --install build --prefix ~/.local`; that still works, but `cmake --build build --target install-help-user` is the form that needs no prefix guessing and prints the path it wrote, so the alert names it and stays its former length. The manual gains a short "Installing this manual" section on the overview page naming both targets -- the no-root user one and the prefix-driven one for a system-wide or packaged install, which the caller wraps in sudo -- and README and the GTK invariants doc follow the alert's new command. |
||
|
|
669db6df9a |
build: install the manual alone, into the prefix or the user datadir
The manual is the only thing udpscope installs, but the only way to get it where `help:` resolves was a whole `cmake --install`. Two opt-in targets now install just the set, each printing where it wrote: - install-help goes through the install machinery into CMAKE_INSTALL_PREFIX (reachable via the new Help component), so a packager or a system-wide user points it at /usr and wraps it in sudo; - install-help-user resolves the user data directory the way the app does ($XDG_DATA_HOME, else $HOME/.local/share) and copies there without root. Neither is built by default and neither changes what `cmake --install` does; the install() rule keeps its destination. |
||
|
|
d8a79b96ba |
help: default the contextual layer off, and carry the help into the chooser
The contextual help was on by default, so a first run met a window with a "?" beside every surface it had not asked to have explained; `contextual_help` now defaults to false and the layer is asked for from the Help menu, from the chooser's new help menu, or with Ctrl+H. The source chooser runs before `App` exists, which makes it the window a user who does not know how to start is looking at, so it now carries the same three help affordances the window's Help view does: Documentation (the manual is another process and needs nothing of the app), Enable contextual help (a row whose check *is* the setting, written to the file the app reads a moment later), and Guided tour. The tour is drawn on the app's own window, so the chooser can only record the wish: it sets `Config::tour_pending` -- the value `main()` owns and hands to both `choose_source()` and `run_ui()` -- and `on_activate()` starts it once the window is up and laid out, through its own callback so the readings can tell a request from the first-run auto-start. The rows are the window's own `menu_button()`/`menu_toggle()`; `install_css()` therefore runs from `choose_source()` (it is idempotent now) so the popover gets the `.udpscope-menu` rules before it is shown. Opening the manual could also fail with a message that blamed the handler whatever was missing. `help_installed_path()` now resolves `help:<id>` the way the specification does and the failure alert says which of the two it is: with the set installed it names the viewer, without it it names the identifier, the expected installed path and the install command. `show_help()` gained an optional reporter, which `UDPSCOPE_PROBE_HELP_MS` uses for a new reading -- `[help] launch attempted= done= ok= error='' installed=` -- since a successful launch leaves no window of ours to observe. Tested: clean --clean-first build, three warnings as documented; test-ui 13/13 and test-help pass; no Gtk-CRITICAL in the help, helpui and chooser runs. |
||
|
|
1baa58b8fd |
feat: the help is a submenu, with a contextual-help layer and a guided tour
The hamburger menu's help rows were three flat entries and a window. They are a **`Help` submenu** now, which is where every GNOME menu keeps them, with the four things a first run cannot find: - Keyboard shortcuts Ctrl+/ (the existing overview, `show_shortcuts()`) - Documentation F1 (the existing manual, `show_help()`) - Guided tour Ctrl+T (new) - Enable contextual help (new, a stateful row: its check *is* the setting) The submenu *replaces* the menu's rows rather than floating beside them, the same shape and the same reason as the plot's right-click menu: a submenu torn out of the menu it belongs to is a second thing to dismiss, and the `GtkMenuButton` that owned one toggled itself out of sync the moment anything else closed its popover. It is the pages of a `GtkStack`, not a rebuild of the box and not a swap of the popover's child: the main view holds `app.layout_toggle`, which the tick's `sync_layout_editor()` and the `L` accel read, so rebuilding would leave that pointer on a destroyed widget, and a swapped child would leave the view that is not showing without an owner. **The contextual help is one table and a "?" per surface** (`src/ui_context_help.cpp`): the plot, the trigger, the cursors, the measurements, the math channels, the signals, the event history, the time window, the status line, and the math editor's identity row. Each row names the *heading box* its button joins -- a sibling of the title, never an overlay child on a corner, which is what would cover the switch or the [edit] button already there -- so adding a surface is a row and a widget pointer. Off means *gone*, not greyed: the buttons are removed, and they are found by a data key rather than a list of pointers because the math editor's heading is built afresh on every opening and a list would go stale one dialog later. `contextual_help` and `tour_seen` are keys of `settings.toml`, written through the same `Settings`/`load_settings()`/`save_settings()` the Preferences dialog uses, whose own rows start from `load_settings()` and so carry them through untouched. **The guided tour is five steps on a `GtkOverlay`** above the window's content: the plot, the side panel, the trigger, the signals and the status line. There is no popover in it and therefore no pointer grab to leak -- four dim rectangles around the hole are what make it modal -- and the probe asserts that as a reading (`controllers=` against the count taken before it opened) rather than as a claim. Everything is placed from `gtk_widget_compute_bounds()` re-read on every frame, because a widget's allocation is 0 on the frame it is first shown and a window resize moves it under a one-shot measurement; the hole is kept from the last frame that could measure the target, because a frame that lands between two layout passes would otherwise empty it and make the spotlight jump. `Skip`, `Finish` and `Escape` all hide every child and leave nothing behind. A first run gets the tour once, decided by `tour_seen` and not by "the settings file did not exist" (a user who changed the theme has a file and has still never seen it), and the flag is written when the tour *starts* rather than when it finishes: a user who skips it in the first second has still seen it. `UDPSCOPE_PROBE_HELPUI_MS` drives all of it -- the auto-start, the menu's seven rows and the submenu's five, the toggle's effect read as a count of affordances on either side of it, and the tour started, stepped forwards and back and dismissed -- and the exit dump gains `help layer:` and `help figures:`, the latter being the ten surfaces' window-coordinate rectangles that the manual's annotated screenshots crop their boxes from. Two of them are what the D-Bus-free headless run can still not decide, and both are limitations rather than defects: export carries no "?" (it is a menu action with no surface of its own, and a popover inside the menu's popover is the nested-popover trap the invariants already warn about), and the tour card cannot miss its target when the target is the whole content -- which is what the plot is at the window's 756 px minimum, where the split view overlays the side panel instead of putting it beside the plot. The probe prints both rectangles rather than asserting a placement, so that case reads as the geometry it is. Also fixed while it was found: `gtk_widget_measure()` includes the widget's own margins (measured: a 280 px box with `margin-start 453` reports 733), so a measure that is used to *place* the widget is a feedback loop -- that is what made the tour card walk between the corner and the target from frame to frame. |
||
|
|
c1460a6977 |
feat: the manual is a Mallard set opened in Yelp, not a GResource in the binary
The help was 14 Pango-markup `.txt` bodies in a second GResource, drawn into a paged `GtkWindow` built in `help_window.cpp`. That made F1 a viewer of ours with no search index, no cross-references and no translation, and it kept the manual in the *binary* when every other desktop application's is a document on disk. It is now a real Mallard set: help/C/org.udpscope.app/ index.page + 14 topic pages + figures/*.png and `show_help()` hands `help:org.udpscope.app` to whatever the desktop registered for the `help:` scheme -- Yelp on GNOME -- through a `GtkUriLauncher`, reporting a launch failure with a `GtkAlertDialog` rather than a click that silently does nothing. The paged window's builder, the page table and `help_self_check()`'s markup validation are gone with the resource. The document identifier is the desktop file's own basename (its desktop-file id), derived in CMakeLists from `data/org.udpscope.app.desktop.in`, so the same string is the `.desktop` name, the GApplication id and the install directory at once. The set is installed by a new `install()` rule to `<datadir>/help/C/org.udpscope.app/` -- where the freedesktop Help System Specification's `help:` URI resolves `help:<id>` -- so the manual is read from a datadir by another process even though the binary has no install step. `UDPSCOPE_HELP_NAME` and `UDPSCOPE_HELP_DIR` are compiled in from that one place. The 14 pages were migrated fact for fact, but restructured the Mallard way: a `<desc>` per page (what a search result shows), `<steps>` for procedures, `<terms>` for option lists, `<gui>`/`<key>`/`<keyseq>` for interface words and keys, `<note>` for the asides, and a topic graph via `<link type="guide" xref="index#main"/>`. The figures are annotated screenshots produced by `tools/make-help-shots.sh`, which drives the app headlessly against `tools/mock_udpstreamer.py` and draws the boxes with ImageMagick; the side panel's box is measured from the app's own `side=NNNx` dump reading, the header and status bands come from a documented table, and a crop whose standard deviation is 0 fails the script. `cmake --build build --target test-help` checks the set the way `test-ui` checks the `.ui` files: `yelp-check`'s offline subcommands (links, media, orphans, ids) and a `yelp-build html` render. `yelp-check validate` is deliberately absent -- it fetches the Mallard RelaxNG schema over the network and fails offline for that reason, not for a fault in the pages. The `[help]` probe reading changes with the resource it was written for. `parsed == pages` over in-binary markup cannot exist now, so `help_self_check()` scans `UDPSCOPE_HELP_DIR` and the line reads `topics=14 index=1 figures=3`; `topics=0 index=0` is the manual missing from the tree, which is the finding the old count named. Verified: clean build with the documented three warnings (the help bundle's `-Woverlength-strings` is gone, so the baseline moves from four to three); test-ui 14/14; test-help clean (the four subcommands and 15 pages rendered); a temp-prefix install lands 15 `.page` files and 3 figures at `share/help/C/org.udpscope.app/`; the headless run reads `[help] topics=14 index=1 figures=3 dir=...` with 0 Gtk-CRITICAL. Yelp's own window could not be exercised on this box -- it starts under Xvfb, activates its portals and never maps, the same WebKit/portal limitation that stops a grab -- so its rendering and its search index are unverified here; the content pipeline was (`yelp-build` renders the whole set). Docs: the manual's build/install/open story in `build-and-run.md` and README; the warning baseline in `verification.md`/CLAUDE.md; the help bullets in `invariants-gtk-and-chrome.md`; the `[help]` line in `probe-readings.md`. |
||
|
|
908286ee89 |
feat: the export writes an MDF 3.30 file, so a window loads in ScryLab
A third format beside `.npy` and `.csv`, written by hand in `src/export.cpp` -- MF4 is HDF5 and would mean a library, MF3 is a handful of fixed blocks. The layout is the asammdf 8.8.27 reader's own field list, byte-diffed against a known-good 3.30 file it wrote; the shape is the one ScryLab's data-source contract asks for: one channel group, record-interleaved, the master as the x axis, and the unit in the channel's conversion block. So in MDF the master is `abs_seconds`, named `time` -- the clock that is the same in every display mode -- and the plotted axis goes out as an ordinary channel, `time_relative`. `ExportTable::Column` gained the CONFIG's unit, which only this writer spells out; the `.npy`/`.csv` bytes are unchanged, and `write_export()` now picks the writer for both the dialog and the probe. |
||
|
|
8b2dae7655 |
fix: the overlaid sidebar steps aside for a drag, and the compact view follows the trigger
Two reports, both real, and neither was about the lock itself.
**A drag out of an overlaid sidebar had nowhere to land.** In a window too narrow to show both columns,
`AdwOverlaySplitView` puts the sidebar *on* the plot — that is what `collapsed` means — so the pointer
stays over the list and never over a drop target: a signal could be dragged but not dropped, which reads
as "dragging does not work". `row_drag_sidebar()` now hides the sidebar on `drag-begin` and restores it on
`drag-end`, and only when collapsed: side by side the plot is already visible, and hiding the list the drag
started in would be startling. The reading begins a drag through the *source* with the state forced
collapsed, the way the sidebar probe forces it:
pane drag: sidebar hidden for the drag=1 restored after it=1
**The compact view did not follow the trigger switch.** The locked line's first branch reads
`tc.enabled` — with trigger mode off it says `stream (trigger off)` and nothing else — and
`on_trigger_toggled()` never re-synced it, so the line kept saying whichever it had been. One call to
`sync_trigger_summary()` beside the switch sync. The dump's two `panes:` lines were already the test and
now pass it: with a session whose trigger is off, the line as found reads `stream (trigger off)` and the
live one reads the settings, because the exit probe turns the trigger on in between. Both reading `stream`
was the defect.
Verified: the two lines above; `panes: the same line as the probe found it: 'mode: stream (trigger off)'`
against the live `'signal: square | condition: window, rising | level: 0.3 .. 0.6 | mode: normal'`;
`make test-ui` 14 files; zero `Gtk-CRITICAL`. Docs: the `pane drag:` reading and the invariants bullet for
the overlay, and the `panes:` pair's second job — the refresh test — written down so the next reader knows
what both lines reading `stream` means.
|
||
|
|
a43d3830f0 |
test: the locked signal list keeps its drag, and the reading says so
Asked for: dragging a signal onto a plot must still work in the compact (view) list. It does — the drag
source is on the *row* and was never conditional on the lock — but nothing said so, and "the lock rebuilds
this list and drops three of its controls" is exactly the change that could take the drag with it. Now it
is a reading: **`drag_sources` must be `rows - 1`**, one per signal row, the `MATH` divider not being
draggable.
signals panel: … rows=3 signals=2 math=0 divider=1 drag_sources=2 (locked)
signals panel: … rows=5 signals=4 math=0 divider=1 drag_sources=4 (configuration mode)
Measured the direct way first, with a throwaway walk of the list's rows and
`gtk_widget_observe_controllers()`: `locked=1 rows=3 rows_with_a_drag_source=2`, the third row being the
divider. That is where the number in the reading comes from, and the walk is what found the shape of the
answer rather than assuming it.
One consequence worth recording, because it is an improvement rather than a cost: a locked row can be
dragged from *anywhere* on it, the colour chip included. In configuration mode the chip carries a click
gesture that claims the event sequence precisely so a drag cannot start there (it opens the colour
chooser); the locked list does not build that gesture, so the chip is inert and the row's drag source
sees the press.
What the lock does *not* change is which signals are listed: only the ones that are on. A signal nobody
acquires draws nothing on a plot, so a row for it would be a drag that ends in an empty slot.
Verified: both lines above, from sessions differing only in whether one exists; `make test-ui` 14 files;
zero `Gtk-CRITICAL`. Docs: the `drag_sources` field with what a `0` would mean, the invariants' lock
bullet, and the README's note.
|
||
|
|
796dda4ad5 |
fix: the trigger stays editable while it is off, and the locked pane says "stream"
Two things the lock got wrong, and they are the same mistake seen twice: trigger-off was treated as "the
pane is disabled".
**The configuration was unreachable exactly when it was wanted.** `on_trigger_toggled()` set the trigger
*body* insensitive with the mode — a rule carried over from the five-revealer sidebar, where a closed pane
had nothing to edit. A trigger is configured *before* it is switched on, so switching it off to change it,
or to read what it is set to, is the ordinary way to work. The body is now never disabled; only the
*cursors'* readout still is, which is the pane whose content has nothing to show with the instruments off.
**The locked pane described a trigger that was not running.** With trigger mode off the summary is one
line — `mode: stream (trigger off)` — because the four lines it used to show (signal, condition, level,
mode) describe a trigger that is not there. With the trigger on it is those four plus whatever is not at
its default (`noise band`, `HF reject`, `pre-trigger`, `hold-off`).
The reading changes with it: `trigger panel sensitive …=1` is now a **regression guard** rather than a
state — the trigger's body is always live, so a `0` there means the settings have been locked out again.
Verified in both states, from sessions whose only difference is `trigger_enabled`:
panes: trigger=locked signals=locked body_visible=0 summary='mode: stream (trigger off)'
panes: trigger=locked signals=locked body_visible=0 summary='signal: square | condition: window, rising | level: 0.3 .. 0.6 | mode: single | pre-trigger: 20 %'
trigger panel sensitive before the probe toggled it: 1
The locked trigger pane is 72 px with the trigger off and 192 px with it on. Docs: the invariants' rule
for which body is disabled and why the trigger's is not, the `summary`'s two forms with what each means,
the README's deviation note, and the second `panes:` line renamed to say what it is — the line as the
probe found it, because only a run whose *session* has the trigger off shows the stream form.
|
||
|
|
121abbefae |
feat: locked panes for a returning user, and the trigger is persisted
Two panes, two modes. **Locked** is what a returning user gets: the trigger pane shows one line saying
what it is set to, the signal list shows only the signals that are on, and neither offers a control that
changes the configuration — each heading has an `[edit]` button (the Adwaita `emblem-system-symbolic`,
swapping to `emblem-ok-symbolic` in configuration mode) that flips its pane. A **first run** — no session
file for that source — never reaches `apply_layout()` and comes up in configuration mode, because
configuring the host is what a first run is for. The mode is a mode and not a setting, so it is
deliberately not persisted.
The locked signal list drops the rows of every signal that is off, which is the whole of "show active
signals": the list already filters on nothing, so the lock is one `continue`. With the rows go the acquire
tick, the overflow menu (Information, Calibrate…, Delete, rename) and the `MATH` divider's `+` — and the
colour chip stops being clickable, because choosing a colour is configuration too. Dragging a signal onto
a plot stays: that is which view shows what, not what the scope is set up to do.
**The trigger is persisted, settings and state both** — the settings *even while it is off*, which is the
point: a trigger configured once and switched off is still the trigger you come back to. `LayoutState`
gains fifteen flat `trigger_*` keys; the channel is stored by *name* (every rebuild renumbers the
channels, the model's own rule) and the two enumerations as **the words the panel's dropdowns show**,
because an index ties the file to a declaration order — the lesson `math_op_at()` already carries. The
words are shared with the panel now (`trigger_type_text()` and friends build the dropdown arrays), so the
two cannot drift.
Two ordering traps, both found by running it:
- **The channel name resolves twice over.** `rebuild_trigger_signals()` picks up
`App::pending_trigger_channel` when the stream arrives *after* the load, and `apply_trigger_layout()`
resolves it itself when the channels are already there — without the second path the name stayed
pending past the one rebuild that would have used it, and the trigger silently sat on channel 0
(`summary='pulse width · sine …'` where the session said `square`).
- **The trigger's state is applied by pressing the control, not by setting a flag**: the toggle is set
*outside* the `updating_panels` window `apply_layout()` uses, because a toggle set inside it emits
`toggled` into a handler that returns at once and nothing would happen — no arming, no events button,
no sensitivity.
Verified, in both modes and both directions:
panes: trigger=locked signals=locked body_visible=0 summary='window · square · 0.3…0.6 · 20 % pre'
panes: trigger=config signals=config body_visible=1 summary=''
signals panel: labels=2 list=1 rows=7 signals=2 math=4 divider=1
The locked trigger pane is 42 px tall against 580 unlocked. A hand-written session round-trips: every
value it named came back in the panel's own controls, `trigger=1/1` (the state arrived and the pane's
switch followed it), and the file the app wrote back carried all fifteen keys with the untouched ones at
their defaults. `[side-tab-probe] … advanced=4` on its documented recipe; `make test-ui` 14 files; zero
`Gtk-CRITICAL` and no missing icon.
Docs: the `panes:` reading with what each field means, the corrected `labels=` note (the panel's frame is
counted above the list now, because the heading sits in a heading row), the invariants for both the
persistence and the lock, the README's deviation note, and the removal of its claim that the trigger has
never been persisted — it is now, and the locked view is built from exactly that.
|
||
|
|
cc8335c514 |
ui: each pane carries its own switch, the same one the header bar has
The trigger pane and the cursors pane now have a `GtkSwitch` in their heading, and it is *one control in
two places* with the header bar's button: both run the same action (`run_app_action()`), and
`sync_panel_switches()` re-reads each pair from the toolbar's state after any change, so clicking either
end moves both. A pane whose feature is off is disabled, which is exactly the state you need the switch
in, so it belongs *in* the pane rather than only in the toolbar.
Three things it needed:
- **The sensitive target moved to the pane's body.** A control inside an insensitive parent cannot be
clicked, so a switch that greyed out with its own pane could never turn it back on. `on_trigger_toggled`
and `on_cursors_toggled` now disable `trigger_body` / `cursors_body`, and so does the startup default;
the headings, with their switches, stay live.
- **The trigger's heading moved out of `trigger-panel.ui`** and is built in `build_trigger_panel()`,
which returns the heading row and the panel together. It has to be a sibling of the body for the switch
to stay usable, and keeping it in the file would have meant the file owning a widget the code must
arrange. The file's own panel is unchanged otherwise, so the `[trigger panel:]` counts still read.
- **A guard on the sync.** Setting a `GtkSwitch` emits `notify::active` for a programmatic write just as
it does for a click, so without `App::panel_switch_sync` the program's own write would come back as a
click and flip the feature again.
Readings, both directions in one line:
trigger panel sensitive before the probe toggled it: 0; … ; pane switches live while their panes are off=1
panel switches: trigger=1/1 cursors=1/1 drove_toolbar=1 (pane switch / header bar button)
`pane switches live …=1` is the design's point and is read before anything turns either pane on; `0` would
be a switch drawn, greyed and useless. The trigger pair is moved from the *toolbar* end and the cursors
pair from the *pane* end, so the line carries both halves of the sync, and `drove_toolbar=1` is the pane's
own switch having turned the cursors on with the button following. `trigger panel sensitive …=0` is the
body, not the panel.
Verified: the two lines above; `[side-tab-probe] … advanced=4`; `make test-ui` 14 files; zero
`Gtk-CRITICAL` and no missing icon. Docs: the new reading in `probe-readings.md` with what a `0` means,
the invariants bullet for the one-control-in-two-places rule and its two traps, the sidebar bullet's
sensitive target, and the README note on why the switch is in the pane.
|
||
|
|
d9287ea826 |
ui: the trigger's wave is a full period again
The bold pass had turned the trigger into half a period — a signal rising above its level — and that lost the thing the glyph is for: the crossings. A crest and a trough read as a wave; a hump reads as a hill, and a trigger that shows no crossing names the "above the level" half of an event. The full-period sine with the dashed level through it is restored, exactly as it was, and the Signals pill carries the same wave again so the pill and the toolbar button stay one picture. The Measure glyph keeps the bold redesign, which is the part that was better: two cursors (a cap over two 4px dashes) beside a ruler of two bands with three 2x4 marks and no end caps. The Signals pill also keeps its two bold channel rows. Verified: `toolbar icons: trigger=udpscope-trigger-symbolic(ok)`; `page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`; `switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36`; `[side-tab-probe] … advanced=4`; the Preferences dialog opened in the same run with no "not present"; `make test-ui` 14 files; zero `Gtk-CRITICAL`. The icons' blob is 12352 bytes, still over the C99 limit, so the clean-build warning count stays four. |
||
|
|
8c9c6ff7fd |
ui: bolder tab glyphs, and the trigger shares the Signals pill's picture
The previous pair were detailed miniatures — 1px ticks, 2px dashes, a 13-column wave — and at 32x16 that reads as *texture* rather than as a shape, which is what "not clear" looks like. All three glyphs are redrawn bold: one thick band, two thick dashes, few features. - **Measure** is two cursors beside the ruler they read against. Each cursor is a cap over two bold dashes (4px), where three 2px dashes read as a dotted column; the ruler is two bands with three 2x4 marks between them and **no end caps**, because a closed frame reads as a rectangle and costs a fifth of the ink on two lines that say nothing. This is the layout whose first attempt looked like two icons in a box — the difference is entirely the weight and the open ends. - **Signals** is a signal rising above its level beside two bold channel rows. Half a period instead of a full one: at this size a full period is a zigzag and a hump is unmistakably a wave, and the level's marks survive on both sides of it instead of being swallowed by the crest. - **The trigger's own icon** is now the same picture as the Signals pill, so the pill and the button that turns the trigger on are recognisably about the same thing. Each was rendered and read back as an ink map at 1:1 and at 2x before it was installed, and three candidates were compared for the Measure glyph (markers *on* a scale, cursors *over* a ruler, and this): the first two turned the marks and the shafts into blobs at the crossings. Ink is 18%, 30% and 29%, inside the range the icons above them set, and everything stays inset two pixels. Verified: `toolbar icons: trigger=udpscope-trigger-symbolic(ok)`; `page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`; `switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36`; `[side-tab-probe] … advanced=4`; the Preferences dialog opened in the same run with no "not present"; `make test-ui` 14 files; zero `Gtk-CRITICAL`. The icons' blob is 11774 bytes, still over the C99 limit, so the clean-build warning count stays four. |
||
|
|
75aa62cc3c |
ui: the trigger glyph is a sine crossing its level, and every glyph is inset
The trigger's toolbar icon was a rising edge, which names only one of the four shape triggers the panel offers. It is now a sine with a dashed level through it — the trigger's actual job, find the signal where it crosses this — and the Signals tab's pill carries the *same* picture beside the channel list, so the pill and the button that turns the trigger on read as the same idea. Both are one period of one-pixel stairs, the way the plot rasterises its own traces. Everything is inset two pixels now. The previous drafts ran their level and their bars flush to the cell edge, which is what makes art look shaved wherever a button's padding or a crop takes a pixel: nothing in either tab glyph, or in the trigger's, touches an edge. Each shape was drawn and then read back as an ink map rather than trusted — the first sine came out as a thin antialiased diagonal (a polygon through whole-pixel vertices has diagonal *edges*), which is why the band is now per-column stairs; the level-through-the-centre arrangement was chosen over level-above-the- centre after rendering both, since at these sizes a level nearer the crest merges with it into a blob. The four glyphs measure 18%, 22%, 25% and 25% ink, inside the range the icons above them set. Verified: `toolbar icons: trigger=udpscope-trigger-symbolic(ok)` and `page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`; `switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36`; `[side-tab-probe] … advanced=4`; `make test-ui` 14 files; zero `Gtk-CRITICAL` and no missing icon. The icons' GResource blob is 12408 bytes — the SVG sources are embedded, comments included — still over the C99 limit, so the clean-build warning count stays four. |
||
|
|
dbe6377415 |
ui: the tab glyphs are 32x16 pairs, drawn twice as wide
Each tab's icon is now two motifs side by side instead of one in a 16px square: the Measure pill is the two cursors beside the ruler, the Signals pill the trigger beside three channel rows. A 16px cell carries one motif — squeezing a pair into it is what turned both into a smear — and the pill has the width for a pair in a 300px column with two pills in it. Two measurements decided the size, both of which I had wrong from the outside: - **GDK paints an icon into a square box whatever the document's aspect.** `gtk_icon_theme_lookup_icon()` at 32 reports a `32x32` paintable for a 32x16 document, and librsvg fits it: rendered with aspect kept, the artwork comes out `32x16` inside a 32 request. So the pair is letterboxed, not stretched, and `pixel_size` 32 draws it at exactly the size it is written — 1:1, so the 2px bars stay crisp. A fractional scale (24 or 48) is what would blur them, which is why nothing here is drawn at one. - **The bar does not get taller for it.** `switcher: … icon=32x32 size=308x36`: 36px is what the switcher measured with 16px icons too, because the pill's fixed height takes a 32px box without growing. Both glyphs were drawn and then read back as ink maps rather than trusted: the first drafts came out at 39% and 44% ink (a blot beside the house 25–36%), and the tab bar is the last place a shape can be checked, since no screenshot works under this X setup. The finished two measure 23% and 33%. Verified: `switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36`; `page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`; `[side-tab-probe] … advanced=4`; the Preferences dialog opened in the same run with no "not present"; `make test-ui` 14 files; zero `Gtk-CRITICAL`. The icons' GResource blob is 11661 bytes, still over the C99 limit, so the clean-build warning count stays four. |
||
|
|
e615465366 |
ui: each tab's glyph is a pair, not one icon
The Measure tab is now `udpscope-tab-measure-symbolic` — the two cursors standing across a ruler — and the Signals tab is `udpscope-tab-signals-symbolic` — the trigger's rising edge over the channel list it acts on. Each is a 16px composite rather than one of the icons the toolbar carries: a glyph the toolbar already uses for one thing means two things the moment a tab shows it too, which is why the ruler alone and the trigger alone were both wrong here. The plain ruler stays exactly where it was. `udpscope-measure-symbolic` is the Preferences window's Plot page icon and is untouched; the tab's pair is a new file beside it, so nothing that already meant "the measurement ruler" changed meaning. Seven action icons bundled, nine with the two app icons. Both composites are at the limit of what 16px holds — four features each — so each was drawn and then *measured*: rasterized and read back as an ink map, since the shapes have to be checked as pixels and the house range is 25-36% ink (trigger 25, cursors 27, ruler 36). The Signals list is two rows and not three, so the edge and the list get half the cell each; its bars are all one length, because unequal ones read as a bar chart and a chart is not a list. The Measure ruler's ends are open on purpose: a closed frame puts four more pixels of ink at the left and right edges, which is what turns a ruler into a rectangle at this size. First drafts came out at 39% and 44% ink and were rebalanced to 32% and 36%. Verified: `page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`; `switcher: policy=narrow pills=2 labels=0 names=2`; `[side-tab-probe] … advanced=4` (switching by page name still works); the Preferences dialog opened in the same run with no "not present"; `make test-ui` 14 files; zero `Gtk-CRITICAL`. The icons' GResource blob is now 11309 bytes, still over C99's 4095-byte string-literal limit, so the clean-build warning count is still four. |
||
|
|
0b146f1d53 |
ui: the tabs are icons, and the pills keep their names
Each sidebar page gets its own glyph and no visible text. The Measure tab reuses the ruler
(`udpscope-measure-symbolic`, the icon its deleted toolbar toggle used and the Plot preferences page
still carries); the Signals tab gets a new `udpscope-signals-symbolic` — three rows of a colour chip
and a name, which is what that tab mostly is — rather than a second copy of the trigger glyph, since
one shape meaning two things in one window is worse than a new one. Its bars are all one length: a
set of unequal ones reads as a bar chart, and a chart is not a list.
`AdwViewSwitcherPolicy.narrow` alone does **not** give an icon-only bar. Measured on libadwaita 1.10
by walking the switcher's tree: with `narrow` and a title, each pill holds a `GtkImage` *and* a
`GtkLabel` — 48px of "Measure" inside a 54px pill. So the pages' `title` is **empty** (their `name` is
what identifies them to `select_side_tab()` and to the session file, and both are unchanged) and the
words are re-attached in code: `name_side_tabs()` runs on the first tick — the pills are
`AdwViewSwitcher`'s own and exist only once it is mapped — and gives each pill a **tooltip and an
accessible name** from the page's name, then hides the empty label that would otherwise leave the
icon off-centre. An icon-only pill is still a named control for a screen reader, and the page icon
that does not resolve leaves a *blank* pill, which is why it is read like the toolbar's icons are.
Two readings, because the claim is two claims:
switcher: policy=narrow pills=2 labels=0 names=2
page icons: measure=udpscope-measure-symbolic(ok) signals=udpscope-signals-symbolic(ok)
`labels` counts *mapped labels with text* — a plain mapped count reads 2 either way, because the
narrow pill keeps an empty label in its layout — and `pills=2` is the guard that makes it mean
something: with nothing mapped the rest of the line is void rather than a pass.
Docs: the icon census (six action icons, eight bundled, and what each of the two new users is), the
switcher bullet in `invariants-gtk-and-chrome.md` with the measurement above, the two new dump lines
in `probe-readings.md`, the re-measured GResource figures in `verification.md` (icons 8613 -> 9842
bytes, help 514660 after this session's help rewrite; the count stays four), and the README recording
that the design spec had the switcher showing titles and neither page an icon.
Verified: `make test-ui` 14 files; `[side-tab-probe] … advanced=4` (tab switching by name still
works with empty titles); `signals panel: … divider=1`; `[math-probe] 3 math channels`; zero
`Gtk-CRITICAL` and no missing icon.
|
||
|
|
6492bb910f |
test: a probe reads the hidden tab's accumulator, and the docs follow the tabs
`UDPSCOPE_PROBE_SIDE_TAB_MS` is the one reading for the design's §5 rule -- the statistics accumulator keeps running on the hidden page -- which nothing tested before. It switches to Measure, forces one pass so there is a baseline, switches away to Signals and reads again two seconds later: `advanced=4` is the assertion, and `advanced=0` is the defect where the accumulation is gated on the visible tab, which would leave a gap in min/max/avg for exactly as long as the trigger was on screen. Two things the probe has to do for itself, and getting either wrong makes `advanced=0` a lie about the tab: - it **enables its own signal**, because a signal starts disabled (enabling is what starts acquisition) and a probe that waited for one to be on would be reading another probe's side effects -- and `UDPSCOPE_ENABLE_ALL_MS` is not part of this run; - it **assigns that channel to a plot**, because a channel with no trace leaves the live window *still*, and a still window is precisely the state `update_measures()` is supposed to skip. Measured the hard way: without the assignment the probe read `advanced=0 min_n=0` with the view pinned and only four passes in twelve seconds, none of them the tab's doing. Docs: `probe-readings.md` gains the probe's entry and the signals panel's new shape (`rows == signals + 1 + math`, `divider=0` a list that lost its `+`), loses the whole `math panel:` entry, and follows `pane_sensitive=`, the gone `revealed=` field, the six toolbar controls and the tab-reading `m`/`g` key pairs. `verification.md` gains the probe's bullet and the re-measured warning figures. The invariants record the sidebar as a switcher above a stack with a scroller per page, the hidden-page trap that replaces the unrevealed-revealer one for it, that a feature's on/off is sensitivity rather than visibility, that a math channel is a row of the signal list, and that a cursors-off readout blanks to `--` with the panel left on screen. `shortcuts.ui` and the help page's window and sidebar bullets stop describing five revealers. The README records the deviation from `SPECS.md` that names the panes as separate collapsible sections. Verified: `[side-tab-probe] page=signals rows=4 accums=4 advanced=4 min_n=16`; `signals panel: labels=2 list=1 rows=9 signals=4 math=4 divider=1`; `panels: … (page_current=1) (sensitive=0) switcher=1`; `toolbar icons:` six controls with every name `(ok)`, with the Preferences dialog opened in the same run; zero `Gtk-CRITICAL` and no missing icon. |
||
|
|
345145c52b |
fix: a loaded recording is filtered into its buffer, not truncated
The loader pushed one slot per file row while the plan decimated by eight, so a 10 M-row file overran the ring and only its last 4.19 s survived -- a 20 s recording that read as a 4 s one. Each group of `time_stride` rows is now averaged into one slot at the group's mean stamp, as the packet path already does, and the load line reports rows and decimation so a filtered file cannot be mistaken for a truncated one. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
50a1ddca62 | added loading screen | ||
|
|
881b52748e | fixed event/live transition | ||
|
|
bb99be5889 | improved perfs and event management | ||
|
|
749b34a2cb |
docs: record the three noise-rejection controls and their invariants
README gains the three controls where each lives -- the trigger's `band`/`reject`, the measurement's `noise`/`hf`, and the math channel's `de-noise` node -- each with its unit, what "off" is, and what it costs, plus the two deliberate deviations from the design spec next to the features they belong to: the band applies to `edge` and `window` only (a pulse's width is measured from a crossing to its return, so a band there changes the measured width rather than rejecting noise; a dropout is an absence with no level to compare), and the band is verified through `BufferSearch` hit counts rather than `[condition-probe]` latch times, because probe-readings.md states that a `t0` is not an assertion. The invariant docs gain the rules a later change must not break: `EventCondition`'s two new fields and the once-per-packet `configure(dt)` that must not reset; the crossing rule that a change is between two *decided* sides; the measurement path's exception to "a readout quotes a stored sample", why the band moves the edge decision only, why `measure_accumulate` loops samples outer, and why a filtered window disables both bucket fast paths; that `MathOp` is append-only (the index *is* the serialisation), that the median window is pooled in the runtime rather than held per node, why the pole reuses `b0`/`z1`, and that median-then-pole is the design; the session's named `noise`/`hf` keys and its thirteen-field node string that still reads twelve; and the two GTK traps -- positionally brace-initialised dialogs, and a control that cannot apply being hidden rather than disabled. |
||
|
|
be71a1d4bb |
perf: a frozen frame is not re-rendered sixty times a second
The tick queued a GL render unconditionally. In `Held` and `Paused` the rows are bound to an immutable snapshot on a pinned axis, so a frame in which none of the picture's own terms moved is the same frame again. The gate keys on a coarse signature of what a frozen view can be re-framed by, opens for `kSettleRenders` frames on a change because the figure lays out per render, and has a floor that renders anyway every few ticks. The layout editor is excluded: its buttons are placed from the last frame's rects, so a skipped frame there is a stale widget rather than a stale picture. `skips=` in the probe dump is the reading. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
0e891ada04 |
docs: link the plotter by URL, and record the submodule with its .gitignore
Two directions of the same link: the plotter's README now names and links this project, and this README's link to the plotter was a relative path, which a forge renders as a 404. It points at the repository now. The submodule's pointer moves with its .gitignore fix, which is what made this repository read as having untracked content in `deps/plotter` on every status. |
||
|
|
4c56172005 |
feat: the preferences are a UI file, applying as they change
src/ui/preferences.ui is an AdwPreferencesDialog whose rows are libadwaita's own -- AdwEntryRow, AdwSpinRow, AdwSwitchRow, AdwComboRow, AdwButtonRow -- instead of a label and a bare GtkEntry in a GtkBox. There is no Apply button: a row applies as it changes, which is what those widgets are designed around, and [prefs-probe] reads the settings file back after every change. The folder chooser is cancellable now, so a picker still open when the dialog closes cannot write into a row that is gone; the cancellable is also how the probe takes the cancel path without a mouse. Two traps cost a run each and are recorded in docs/claude/invariants-gtk-and-chrome.md: an AdwDialog is not a GtkWindow (it cannot parent a GtkFileDialog, and in floating mode it adds a native surface to gtk_window_get_toplevels() that is neither a widget nor a window), and a .ui that is not listed in the GResource is a Gtk-ERROR at runtime rather than a build failure -- which is exactly what the probe opening every window is for. |
||
|
|
0e256764d5 |
udpscope: an oscilloscope view for a UDPStreamer source
GTK4 and libadwaita. It receives one UDP stream through the vendored udpsclient, buffers it in a large ring, and draws it with the vendored plotter: a trigger with a pre/post event window, a pause that freezes the window, cursors, measurements, math channels in a node editor, an event cache, named configurations, preferences, and an export of the visible window to .npy/.csv. Waveform files can be opened and analysed in place. The interface is two threads -- a producer that owns the socket and the GTK main thread that owns every widget and the GL context -- sharing one model under one mutex, with samples crossing through lock-free rings. deps/plotter is a submodule pinned to the commit this tree builds against; deps/udpsclient is vendored in place. docs/claude/ documents the rules that cost a defect to learn, and SPECS.md is the requirement document. |
||
|
|
8f54841c2c | working on todo.md features | ||
|
|
eacb3f5efc |
docs: the reseq probe's readings, and the invariants resequencing changes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
aa8ec1794a |
fix: a zero-width band fires on equality or not at all, and the text now says which
The earlier correction moved the claim rather than fixing it: four places still said the float-equality test "never fires on a real-valued signal", and a +/-1 square is a real-valued signal -- a standalone check against a band of [1, 1] fired 19 times, because `inside` is true at +1 and false at -1, so the signal changes side on every transition. All four now state the measured behaviour: an exact float-equality test against the level, which an ordinary signal never satisfies and a signal sitting at the level satisfies on every transition. Neither answer is a band, which is the reason the second spin is clamped an increment away rather than the detector special-cased -- the same conclusion, now resting on a true premise. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |