From fc4700d1c995cd42dc9e242b55c4c8af82907240 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Fri, 16 Oct 2026 08:00:54 +0200 Subject: [PATCH 001/127] 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. --- README.md | 29 ++++++++++++ docs/claude/invariants-measurements.md | 41 ++++++++++++++++ docs/claude/probe-readings.md | 27 +++++++++++ docs/claude/verification.md | 16 +++++++ src/measure.cpp | 61 ------------------------ src/measure.hpp | 60 ++++++++++++++++++++++++ src/ui.cpp | 3 ++ src/ui_app.hpp | 1 + src/ui_probe.cpp | 65 ++++++++++++++++++++++++++ 9 files changed, 242 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 1e3465d..2007139 100644 --- a/README.md +++ b/README.md @@ -1638,6 +1638,35 @@ Measured on the same stream and window: tick mean **1.39 ms** with the pane hold measurements against **0.73 ms** without (595 frames against 610), the pass itself 11 ms every 400 ms — and the 82 ms worst tick is the *control's* too, i.e. the publish stall, not this. +**...and the same pane costs what the *window* costs, which a big time scale buys.** The three +fixes above bound *how often* and *how much per sample*; what is left is the arithmetic of the +scale: a pass is priced per measured **channel** (one fetch of the window's level-0 buckets, one +statistics scan) and per **distinct share** in the pane (one level accumulator, walked over those +buckets), and the window's own sample count multiplies both. Measured with the new +`[levels-probe]` sweep on the mock at a **15 s** window and `--buffer-mb 4000` (so `time_stride` +stays 1 and the window holds 11.8 M samples, 184 k buckets), four signals, one row, on llvmpipe +— `measure cost:`'s `each` per recompute, and the tick's mean: + +| pane | `each` | `raw` | tick mean | +|---|---|---|---| +| empty | — | — | 1.34 ms | +| 1 measurement, 1 level, 1 channel | 6.58 ms | 0.1 % | 1.99 ms | +| 4 measurements, 4 shares, 1 channel | 10.28 ms | 0.4 % | 2.38 ms | +| 16 measurements, 16 shares, 1 channel | 24.55 ms | 1.2 % | 3.86 ms | +| 4 measurements, 1 share each, **4 channels** | 35.69 ms | 2.6 % | 4.58 ms | + +So a machine's ten measured signals at a 15 s window is tens of milliseconds a pass, several +times a second, on the **tick** — the pane is a *frame* cost and not an ingest one (`gaps` and +`packets` stay clean even though each reader holds the model mutex, which is what "the plot is +slow with measurements on" actually is). One of those terms was a pure call-overhead defect: +pass two folds each bucket into each level through `measure_accumulate_bucket()`, the innermost +loop of a walk whose iteration count is the window's buckets *times* the pane's levels, and as an +out-of-line function in its own translation unit it cost **30 % of the pass** — sixteen shares on +one channel read `24.55 ms` a recompute with every value in `measurements:` unchanged at +**17.25 ms** once it was moved into `measure.hpp` as `inline`. The fetch's three out-of-line +pyramid accessors per bucket (3.3 ms of an 11.8 M-sample window's 11.6 ms pass) are the next +lever, and `raw` is the last one and belongs to the signal rather than to the pane. + **"Show on plot" draws full-width rulers, and they must not blink.** A level measurement draws an `h_ruler` across the whole plot and a time one a full-height cursor, so a pane holding four measurements can put six lines over the data — that is the feature, and the lines move with the diff --git a/docs/claude/invariants-measurements.md b/docs/claude/invariants-measurements.md index 6dc1947..3e39764 100644 --- a/docs/claude/invariants-measurements.md +++ b/docs/claude/invariants-measurements.md @@ -118,6 +118,47 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear the pyramid for nothing. `measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say anything. +- **A pass costs the window's buckets times the pane's *measured channels* and its *distinct + levels*, and the window's own size multiplies all of it.** The cadence above bounds *how + often*; this is *how much*, and it is the term a big time scale buys. Measured on the mock at + the user's scale -- a **15 s** window, `--buffer-mb 4000` (so `time_stride` stays 1 and the + window is 11.8 M samples, 184 k level-0 buckets), four signals, one row -- with + `[levels-probe]` (see [verification.md](verification.md)), `measure cost:` reading + `recomputes`/`each`/`covered`/`buckets`/`raw`: + + | pane | `each` | `raw` | tick mean | + |---|---|---|---| + | empty | -- | -- | 1.34 ms | + | 1 measurement, 1 level | 6.58 ms | 0.1 % | 1.99 ms | + | 4 measurements, 4 shares on **one** channel | 10.28 ms | 0.4 % | 2.38 ms | + | 16 measurements, 16 shares on one channel | 24.55 ms | 1.2 % | 3.86 ms | + | 4 measurements, 1 share each on **four** channels | 35.69 ms | 2.6 % | 4.58 ms | + + So a pane is priced per measured **channel** (one `MeasureWindowReader`, one fetch, one scan) + and per **distinct share** (one accumulator), and the window's samples are the multiplier on + both: the same pane over a 2 M-sample window is the 1.2--1.6 ms a recompute this pane's + budget was fixed at, and a machine's ten noisy channels at a 15 s window is **tens of ms a + pass**, several times a second. That lands on the **tick** -- `update_measures()` runs inside + it -- so `frame cost:`'s tick mean is where a pane's cost shows (1.34 -> 4.58 ms above), and + it is *not* an ingest problem: `gaps=`/`packets=` stay exactly clean even though each + `MeasureWindowReader` holds `model->mutex()` for its whole life, because a socket buffer + absorbs a few tens of ms of producer. Read the tick mean and `each` before blaming the + producer. + Inside a pass the terms are, instrumented per window at that scale: the **fetch** 3.3 ms + (three out-of-line `MinMaxPyramid` accessors and a 48-byte `MeasureBucket` per bucket), + `measure_scan_buckets()` 0.8 ms, and **pass two** 7.5 ms -- dominated by the per-(bucket, + level) fold. That fold is why `measure_accumulate_bucket()` is `inline` in `measure.hpp`: it + is the innermost loop of a walk whose iteration count is the window's buckets *times* the + pane's levels, and as an out-of-line call in its own translation unit it cost **30 % of the + pass** (sixteen shares on one channel: `24.55 -> 17.25 ms` a recompute). A caller adding a + per-bucket type keeps it in the header, and a caller adding a bulk one may not let it grow a + call per bucket either. + The third term is `raw`, and it is the **signal's**: a level that falls inside a bucket's + `[lo, hi]` has to be walked sample by sample (the fold cannot split a bucket), so a signal + whose local range covers the level everywhere -- white noise, a noisy machine channel -- + degrades the pass to O(window) *per level*: measured `raw=4.6 %` on the mock's `noise` channel + against `0.1 %` on its `sine` at the same window. It is the field to read before blaming the + pane for a slow run, and no cheaper walk fixes it. - **`Top`/`Bottom`/`Amplitude` read a *flat* level, and the density that finds it is the buckets', not the samples'.** A band-limited edge overshoots and rings -- the mock's `square` at 30 harmonics reaches ±1.179 where its plateau is ±1 -- so a measurement that reads the extremes, or diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 79fef9b..e5998f2 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -714,6 +714,33 @@ Two lines of the self-check output are assertions in disguise: reported `gaps=73` with a pause against 0 without, which is what made the stall worth measuring; it has not reproduced and did not scale with the publish length — 79 gaps with the pyramid build removed against 73 with it — so it was incidental load on one run. Do not quote it as a cost.) +- `[levels-probe] N measurement(s) over K channel(s), shares='...' -> W window(s), L level + walk(s)` — then the exit dump's `measure cost:` and `frame cost:` beside it. This is how a + pane's cost is read, and the three fields are the three factors: **`W` is the pane's price + for the window**, one fetch and one scan per *measured channel*; **`L` is its price for the + pane's shares**, one accumulator per distinct one, walked over the window's buckets; and the + window's samples are the multiplier on both, so a sweep at one scale says nothing about + another. Measured at the user's scale (15 s window, `--buffer-mb 4000` -> `stride=1`, 11.8 M + samples in the window = 184 k buckets, four signals, mock at ~0.5 MS/s per signal, llvmpipe), + with `UDPSCOPE_PROBE_LEVELS_CHANNELS` set: + * `50`, 1 channel -> `1 window, 1 level`, `measure cost: 226 recomputes, 6.58 ms each`, `raw=0.1%`, + `tick 1.99 ms` against an empty pane's **1.34**; + * `5,10,15,20`, 1 channel -> `4 levels`, **10.28 ms** each, `raw=0.4%`, `tick 2.38 ms`; + * the sixteen shares `5..80`, 1 channel -> `16 levels`, **24.55 ms** each, `raw=1.2%`, `tick 3.86 ms`; + * `5,10,15,20` over **4 channels** -> 4 windows and 4 levels, **35.69 ms** each, `raw=2.6%`, + `tick 4.58 ms`, and still `gaps=0 packets=30580` — the pane is a *tick* cost and not an + ingest one, which is the thing to say before blaming the producer. + `measure cost:`'s `each` is the pass and `covered`/`recomputes` is the mean window it ran + over (11.8 M samples here), so `each` is only comparable between runs at the same window and + the same pane; `raw` is the *signal's* share (the mock's `noise` channel reads `4.6%` against + its `sine`'s `0.1%` at the same window, because a level inside a bucket's own range has to be + walked sample by sample). A pass that reads **tens of ms** on a 15 s window with a handful of + measurements is the curve in + [invariants-measurements.md](invariants-measurements.md) and not a defect, and the same pane + at a 2 s window reads the 1.2--1.6 ms the pane's budget was fixed at. The guard for the walk + itself is the exit dump's `frame cost:`: sixteen shares on one channel read `24.55 ms` a pass + and `3.86 ms` of tick mean before `measure_accumulate_bucket()` was made `inline`, and + **17.25 ms** a pass after it, with every value in `measurements:` unchanged. - `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 mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=.. diff --git a/docs/claude/verification.md b/docs/claude/verification.md index c179a81..1823012 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -76,6 +76,22 @@ precedent, not an exception. with, so a synchronous sequence would read every position one action stale. It clicks by emitting `::clicked`, never `gtk_widget_activate()`, which holds a `GtkButton` for its 250 ms `ACTIVATE_TIMEOUT` and then emits into a button the rebuild has already destroyed. +- `[levels-probe] N measurement(s) over K channel(s), shares='...' -> W window(s), L level + walk(s)` (stderr, `UDPSCOPE_PROBE_LEVELS_MS`) — **prices a measurement pane**, which is what a + report of "it is slow with many measurements" has to become before it is a number. It adds one + `above` per share in `UDPSCOPE_PROBE_LEVELS` (comma separated, default `50`), spread in turn + over the first `UDPSCOPE_PROBE_LEVELS_CHANNELS` channels (default 1), and reports the two + things a count does not name: how many **windows** (one `MeasureWindowReader`, one fetch, one + scan per measured channel) and how many **level walks** (one accumulator per *distinct* share) + the pass then had to take. `50,50,50,50` on one channel is four pane rows and **one** walk; + `5,10,15,20` is four rows and **four**; the same four shares over four channels is four + windows *and* four walks. Read the `measure cost:` line it leaves in the exit dump and the + sweep in [invariants-measurements.md](invariants-measurements.md) — and read it against a run + with `UDPSCOPE_PROBE_LEVELS_MS` unset, because the pane's cost is a *difference* between two + tick means. **Run it at the scale being asked about**: the cost is the window's, so a 2 s + window prices a 15 s one at nothing (four measurements over four channels read `35.69 ms` a + recompute and a 4.58 ms tick mean at a 15 s window against `6.58 ms`/1.34 ms for one window at + the same window). - `[measure-probe] N measurements on 'X'` (stderr, `UDPSCOPE_PROBE_MEASURE_MS`) — adds one measurement of each statistical type (average, top, peak to peak, amplitude) on the first *channel* and forces a recompute. It must name a **channel**, not `signals().front()`: the diff --git a/src/measure.cpp b/src/measure.cpp index 9ef7f4e..0d9bfae 100644 --- a/src/measure.cpp +++ b/src/measure.cpp @@ -14,20 +14,6 @@ namespace { /// in. Shared by the raw walk and the bucket walk so the two cannot disagree about what an edge /// records -- an interval's width is the distance from the edge that entered it to the edge that /// left it, which is the pairing the pulse duration averages. -void enter_side(MeasureLevel& acc, std::size_t e, double t) { - const std::size_t other = 1 - e; - if (acc.enters[e] == 0) acc.first_enter[e] = t; - acc.last_enter[e] = t; - ++acc.enters[e]; - if (acc.open[other]) { - acc.width_sum[other] += t - acc.open_at[other]; - ++acc.widths[other]; - acc.open[other] = false; - } - acc.open_at[e] = t; - acc.open[e] = true; -} - // The hysteresis band and its rationale moved to `MeasureOptions::noise_pct`: it is a per-measurement // setting now, and a second copy of the number here would be a second thing to keep in step. constexpr double kPi = 3.14159265358979323846; @@ -240,51 +226,6 @@ void measure_accumulate(const MeasureChunk& chunk, } } -bool measure_accumulate_bucket(const MeasureBucket& k, - const std::function& time_of, - MeasureLevel& acc) { - if (k.count == 0) return true; - // A non-finite sample in the bucket would make the edge's position a guess: the raw walk takes - // the first *finite* sample of the new side, and an aggregate cannot say which that is. - if (k.count != k.span) return false; - // The *sums* need only the bare level: strictly on one side of it, so every sample decides the - // same bin and none is *at* it, which is what makes the fold exact rather than an approximation. - const bool above = k.lo > acc.level; - const bool below = k.hi < acc.level; - if (!above && !below) return false; - const std::size_t e = above ? 1u : 0u; - // The band is deliberately *not* folded into the test above. Tightening the whole condition to - // `level +/- h` refuses every bucket lying inside the band -- a tenth of the amplitude either side - // of the mid level is where a waveform moves fastest, so those are exactly the buckets a crossing - // passes through -- which hands the raw walk a share of the window rather than a bucket or two per - // crossing, and breaks the "`raw` stays at a fraction of a percent of `covered`" invariant. - // Instead: a bucket on one side of the level can always be *binned*, and only a *side change* - // whose position is inside the bucket is refused. - const double top = acc.level + acc.hysteresis; - const double bot = acc.level - acc.hysteresis; - // Does the bucket reach past the band at all? One wholly inside it decides no side, exactly as a - // raw sample inside it decides none, so the walk state is left alone. - const bool reaches = above ? (k.hi > top) : (k.lo < bot); - const bool change = reaches && acc.have_side && e != (acc.side ? 1u : 0u); - // A bucket that *enters* a side while straddling its own threshold holds the crossing somewhere - // inside itself, and only the raw walk can say where. One wholly past the threshold holds it at - // the bucket's first sample, exactly, which is where the raw walk would have found it. - const bool fully_outside = above ? (k.lo > top) : (k.hi < bot); - // Nothing may be folded before this point: a refusal hands the bucket to `measure_accumulate()`, - // which adds the same samples again. Sums added above the refusal double-counted every opened - // bucket -- which read as `top` 0.614304 on a unit sine against the 2/pi a mean over a half then - // gave, and which is now the sums `Top`/`Bottom` fall back to on a window with no histogram, so - // a wrong one is a wrong answer waiting for that window. - if (change && !fully_outside) return false; - acc.sum[e] += k.sum; - acc.count[e] += k.count; - if (change) enter_side(acc, e, time_of(k.offset)); - if (reaches) { - acc.side = e != 0; - acc.have_side = true; - } - return true; -} MeasureFlatLevels measure_flat_levels(const MeasureStats& b, double pct) { MeasureFlatLevels out; @@ -375,7 +316,6 @@ int measure_level_pcts(MeasureType type, const MeasureOptions& options, double p } } - const char* measure_type_name(MeasureType type) { switch (type) { case MeasureType::Average: return "average"; @@ -598,5 +538,4 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* const l return out; } - } // namespace udpscope diff --git a/src/measure.hpp b/src/measure.hpp index ec72944..d31642a 100644 --- a/src/measure.hpp +++ b/src/measure.hpp @@ -349,6 +349,66 @@ bool measure_accumulate_bucket(const MeasureBucket& bucket, const std::function& time_of, MeasureLevel& level); +inline void enter_side(MeasureLevel& acc, std::size_t e, double t) { + const std::size_t other = 1 - e; + if (acc.enters[e] == 0) acc.first_enter[e] = t; + acc.last_enter[e] = t; + ++acc.enters[e]; + if (acc.open[other]) { + acc.width_sum[other] += t - acc.open_at[other]; + ++acc.widths[other]; + acc.open[other] = false; + } + acc.open_at[e] = t; + acc.open[e] = true; +} + +inline bool measure_accumulate_bucket(const MeasureBucket& k, + const std::function& time_of, + MeasureLevel& acc) { + if (k.count == 0) return true; + // A non-finite sample in the bucket would make the edge's position a guess: the raw walk takes + // the first *finite* sample of the new side, and an aggregate cannot say which that is. + if (k.count != k.span) return false; + // The *sums* need only the bare level: strictly on one side of it, so every sample decides the + // same bin and none is *at* it, which is what makes the fold exact rather than an approximation. + const bool above = k.lo > acc.level; + const bool below = k.hi < acc.level; + if (!above && !below) return false; + const std::size_t e = above ? 1u : 0u; + // The band is deliberately *not* folded into the test above. Tightening the whole condition to + // `level +/- h` refuses every bucket lying inside the band -- a tenth of the amplitude either side + // of the mid level is where a waveform moves fastest, so those are exactly the buckets a crossing + // passes through -- which hands the raw walk a share of the window rather than a bucket or two per + // crossing, and breaks the "`raw` stays at a fraction of a percent of `covered`" invariant. + // Instead: a bucket on one side of the level can always be *binned*, and only a *side change* + // whose position is inside the bucket is refused. + const double top = acc.level + acc.hysteresis; + const double bot = acc.level - acc.hysteresis; + // Does the bucket reach past the band at all? One wholly inside it decides no side, exactly as a + // raw sample inside it decides none, so the walk state is left alone. + const bool reaches = above ? (k.hi > top) : (k.lo < bot); + const bool change = reaches && acc.have_side && e != (acc.side ? 1u : 0u); + // A bucket that *enters* a side while straddling its own threshold holds the crossing somewhere + // inside itself, and only the raw walk can say where. One wholly past the threshold holds it at + // the bucket's first sample, exactly, which is where the raw walk would have found it. + const bool fully_outside = above ? (k.lo > top) : (k.hi < bot); + // Nothing may be folded before this point: a refusal hands the bucket to `measure_accumulate()`, + // which adds the same samples again. Sums added above the refusal double-counted every opened + // bucket -- which read as `top` 0.614304 on a unit sine against the 2/pi a mean over a half then + // gave, and which is now the sums `Top`/`Bottom` fall back to on a window with no histogram, so + // a wrong one is a wrong answer waiting for that window. + if (change && !fully_outside) return false; + acc.sum[e] += k.sum; + acc.count[e] += k.count; + if (change) enter_side(acc, e, time_of(k.offset)); + if (reaches) { + acc.side = e != 0; + acc.have_side = true; + } + return true; +} + /// The levels a type reads, as shares of the window's own min..max, **lower first**, or 0 when it /// reads none (the pure statistics, answered from `MeasureStats` alone). `pct` must hold two: a /// level type fills `pct[0]` and answers 1, `Between` fills both and answers 2. diff --git a/src/ui.cpp b/src/ui.cpp index c20847c..1e27c1b 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -5831,6 +5831,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURE_MS")) { g_timeout_add(static_cast(std::atoi(env)), measure_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_LEVELS_MS")) { + g_timeout_add(static_cast(std::atoi(env)), levels_probe, &app); + } if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASUREPIN_MS")) { g_timeout_add(static_cast(std::atoi(env)), measurepin_probe, &app); } diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 55c898e..3cff1b9 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -1730,6 +1730,7 @@ gboolean mathwrap_probe(gpointer user); gboolean mathwrap_probe_wait(gpointer user); gboolean mathwrap_probe_report(gpointer user); gboolean measure_probe(gpointer user); +gboolean levels_probe(gpointer user); gboolean measurepin_probe(gpointer user); gboolean measurepin_held_wait(gpointer user); gboolean measurepin_pause_wait(gpointer user); diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index a2e6a41..276321d 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -1326,6 +1326,71 @@ gboolean measure_probe(gpointer user) { return G_SOURCE_REMOVE; } +/// `UDPSCOPE_PROBE_LEVELS_MS`: puts a pane of **N** measurements on screen -- one `above` per +/// share in `UDPSCOPE_PROBE_LEVELS` (comma separated, default `50`) -- spread in turn over the +/// first `UDPSCOPE_PROBE_LEVELS_CHANNELS` channels (default 1), and says how many *windows* and +/// *levels* the pass then had to walk. +/// +/// The pane's cost has three factors and a count alone names none of them: the window's samples +/// (one fetch and one scan per **measured channel**, whatever the pane holds), the number of +/// *distinct levels* in it (measurements that share a share share the walk), and the rows that +/// are only labels over the two. `50,50,50,50` is four rows and one walk; `5,10,15,20` is four +/// rows and four; and the same four shares on four signals is four windows *and* four walks. +/// This probe is the only way to vary the three apart, which is what a report of "it is slow +/// with many measurements" needs before it is a number. +gboolean levels_probe(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + std::vector names; + { + std::lock_guard lock(a->model->mutex()); + for (const ChannelInfo& c : a->model->channels()) names.push_back(c.name); + } + if (names.empty()) return G_SOURCE_REMOVE; + const char* chan_env = g_getenv("UDPSCOPE_PROBE_LEVELS_CHANNELS"); + const long want_channels = chan_env != nullptr ? std::atol(chan_env) : 1; + const std::size_t channels = + want_channels > 0 ? std::min(static_cast(want_channels), + names.size()) + : 1u; + const char* list = g_getenv("UDPSCOPE_PROBE_LEVELS"); + const std::string shares = list != nullptr && list[0] != '\0' ? list : "50"; + std::size_t rows = 0; + for (std::size_t at = 0; at <= shares.size();) { + const std::size_t comma = shares.find(',', at); + const std::string field = + shares.substr(at, comma == std::string::npos ? std::string::npos : comma - at); + at = comma == std::string::npos ? shares.size() + 1u : comma + 1u; + char* end = nullptr; + const double pct = std::strtod(field.c_str(), &end); + if (end == field.c_str() || !(pct >= 0.0)) continue; + Measure m; + m.signal = names[rows % channels]; + m.type = MeasureType::Above; + m.options.threshold_pct = pct; + a->measures.push_back(std::move(m)); + ++rows; + } + rebuild_measures_pane(*a); + update_measures(*a, /*force=*/true, /*accumulate=*/true); + // What that pass walked: the level accumulators the pane deduplicated to, and the windows they + // hang off. Read without the model lock -- `measure_samples` is the *tick*'s own state, not the + // producer's. + std::size_t levels = 0; + std::size_t windows = 0; + for (const App::MeasureWindow& w : a->measure_samples) { + ++windows; + levels += w.pct.size(); + } + std::fprintf(stderr, + "[levels-probe] %zu measurement(s) over %zu channel(s), shares='%s' -> %zu " + "window(s), %zu level walk(s)\n", + rows, channels, shares.c_str(), windows, levels); + std::fflush(stderr); + g_timeout_add(250, measure_trace, a); + return G_SOURCE_REMOVE; +} + /// `UDPSCOPE_PROBE_MEASUREPIN_MS`: on a **still frame** the pane describes the frame and not the /// viewport, so a pan or a zoom changes no number -- and on a live view the numbers still follow /// their window. From e14e1ee069c4818f0f9d906988d07d7847ba32df Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Fri, 16 Oct 2026 16:44:15 +0200 Subject: [PATCH 002/127] 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. --- README.md | 48 ++ docs/claude/invariants-trigger-and-pinning.md | 136 +++++- docs/claude/probe-readings.md | 148 ++++++- docs/claude/verification.md | 57 +++ help/C/org.udpscope.app/trigger.page | 52 ++- src/acquisition.cpp | 32 ++ src/acquisition.hpp | 17 + src/event_series.hpp | 23 + src/layout_store.cpp | 5 + src/layout_store.hpp | 4 + src/model.cpp | 62 +++ src/model.hpp | 64 +++ src/plot_host.cpp | 213 ++++++++- src/plot_host.hpp | 46 ++ src/trigger.cpp | 14 + src/trigger.hpp | 30 ++ src/ui.cpp | 73 +++- src/ui/trigger-panel.ui | 39 +- src/ui_app.hpp | 58 +++ src/ui_layout.cpp | 13 +- src/ui_probe.cpp | 410 ++++++++++++++++++ src/ui_trigger.cpp | 134 +++++- 22 files changed, 1652 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 2007139..f466146 100644 --- a/README.md +++ b/README.md @@ -1214,6 +1214,30 @@ back to the *previous* event, or to blank if this was the first. No event fired, latch counter does not move; a separate `ScopeModel::event_seq()` counts published snapshots, and that is what the frame tick keys its framing on. +**Force takes one acquisition now.** The trigger panel's **Force** button, beside +Arm/Disarm, is the scope's "capture this instant": for the condition that is not coming, +it opens a capture at the present moment with the configured pre/post window rather than +waiting for an edge. It is deliberately *not* a second publisher — `TriggerEngine::force()` +only puts the engine into the pending state an edge leaves it in, with the newest stored +stamp as the event's own time, and the ordinary latch does everything else: the cut, the +publish, the latch count, the marker at x = 0 and the framing. A forced acquisition is +therefore framed, pinned and recorded exactly like a latched one, down to the axis being +pinned from the snapshot's own stamps rather than from what was asked for (a request the +ring cannot reach back to — a 100 s window at 99 % pre — comes out seconds wide, which is +what the probe's second leg reads). + +Two choices are worth naming. The engine's pre-window gate is **skipped**: the history +behind "now" is whatever the ring holds, and refusing to capture it because the ring is +young would refuse the one thing the button is for. And a capture already filling is **not** +restarted — it is the acquisition being asked for, and restarting it would throw away the +post window it has collected; the click is ignored (and said so on stderr) in that state +only. What the trigger *is* afterwards is still the mode's decision, made where it always +was: Normal leaves it armed for the next edge, Single disarms, because one acquisition is +what the mode means whether an edge produced it or the button. The button lives in the body +of the pane rather than in the locked pane's heading beside Arm/Disarm: the heading's one +control is the runtime *state* a locked pane exists to change, while a force is an action +whose subject — the condition, the level, the pre/post split and the mode — is in the body. + The frame that **frames** a snapshot is the one that consumes its sequence number, not merely the first frame to see it. The tick reads `display_mode()` before it takes the model's mutex, while the producer publishes the snapshot inside that mutex and only lowers @@ -1233,6 +1257,30 @@ snapshot's own stamps. That re-basing machinery already existed (it was written frozen view and then never needed, since snapshots carry edge-relative stamps); this is what finally uses it. +**Persistence: the last N acquisitions, dimmer with age.** Trigger mode can keep the last +`N` acquisitions on screen at once, each older one drawn at half the brightness of the one before +it (1, 0.5, 0.25, 0.125, …), which is what a phosphor display does with successive sweeps: they +are aligned on their own trigger edge, so the shape of the event repeats and whatever varies +between acquisitions — jitter, ringing, an occasional glitch — reads as a faint ghost behind the +current trace. Persistence in the trigger panel is the knob; `1`, the default, is +exactly the display without it, and the range stops at 8, where the eighth layer would be 0.8 % +ink that nothing can see. The older acquisitions are drawn *behind* the current one and are never +pinned: the axis, the framing and every measurement still describe the event on screen. +`N = 1` retains nothing at all — no copy, no layer — and lowering the depth frees the retained +snapshots there and then, because a trigger that has stopped firing never reaches another publish. + +**And it says what it costs.** A retained acquisition is a whole window copied out of the ring, so +the retention is bounded by two things: the depth, and **`--buffer-mb`** — persistence may hold at +most as much again as the buffer does, which is the bound a long window on a fast stream reaches +long before the eighth layer. The other bound is the plot's own: a layer takes a *free trace slot +in the row that plots its signal*, layer by layer, so a row of four signals — the layout the app +starts with — has room for one older acquisition of each, while a row per signal has room for all +seven. A depth that the room or the budget cuts short is counted and said in a notice, rather than +quietly drawing fewer layers than were asked for. The layers are *not* signals: they hold no +channel, so the signal list, the colour chips, the calibration table, the layout file and every +measurement see one signal where the plot draws several traces of it — the same rule the event +*pane*'s accumulate view follows. + **The whole trigger path measures the window in stream seconds, and never in samples.** `TriggerEngine` is fed each sample's timestamp and latches once that clock has advanced past the post window; `publish_event()` then finds the slice covering diff --git a/docs/claude/invariants-trigger-and-pinning.md b/docs/claude/invariants-trigger-and-pinning.md index 8a22ec4..885236c 100644 --- a/docs/claude/invariants-trigger-and-pinning.md +++ b/docs/claude/invariants-trigger-and-pinning.md @@ -27,6 +27,16 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear already says "stream (trigger off)" and there is no engine to arm, and with the pane open the body's button is the one on screen — **two arm buttons at once would be two controls for one state**, which is why the probe reads the body's allocation as `-1` while the compact one is up. + **The same function states the Force button beside the arm one, and the Force button is in the body + only.** Its sensitivity is the same `enabled` (a trigger that is off has no engine to force) and it + is stated in `sync_arm_buttons()` for the same reason the pair above is stated there — one place, so + nothing can drift — but it is *not* armed-gated: in Single mode the previous acquisition leaves the + engine disarmed, which is exactly when a user reaches for Force again, so a button that greyed out + with the engine's armed state would be unavailable for the one thing it is for. It stays in the body + because the heading's single control is the runtime *state* a locked pane exists to change, while a + force is an action whose subject — the condition, the level, the pre/post split and the mode that + decides whether one acquisition is all of it — is in the body; a second Force in the heading would + be a second control for one action whose settings are off screen. - **A widget's allocation is not readable in the tick it is first shown.** The compact arm control is created hidden and shown for the first time inside a signal handler, so `gtk_widget_get_width()` in that same tick reads `0` while `gtk_widget_is_visible()` already says `1` — and its sibling's stale @@ -36,7 +46,15 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear has a `compact_arm_step()` of its own; and the control to compare against must be measured with `is_visible()` or not at all. - **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. **And the signal is resolved + *through* the channel, never taken as one.** `TriggerConfig::channel` indexes `channels()` (the + acquired ones) while `ScopeModel::set_signal_enabled()` indexes the stream's `signals()`, and the + two are not the same index: with one signal acquired, channel 0 *is* that signal while signal 0 is + the stream's own clock. Passing one for the other switched on the time base, left the signal the + trigger watches exactly as it was — and, because a signal the panel was not showing *is* a change, + rebuilt the buffer under the click and dropped its history, which is precisely the pre-trigger + window an arming, and now a force, was about to capture. `trigger_signal_index()` is the one + resolution and both buttons go through it. - **The trigger's event window is separate from the display window**, and `pretrigger_pct` is a share of the *event* span, not of the ring. - **The event window is counted in stream seconds end to end; nothing converts it to @@ -201,6 +219,42 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear export.** Both need the same absolute index range over the time ring and every value ring, or an export and a capture of the same window would hold different samples. Call it under `mutex()`. +- **Force is a synthetic edge, and the latch is still the only publisher.** The trigger panel's + Force button opens one capture at the *present* moment through `TriggerEngine::force()`: the + newest stored stamp becomes the event's own time and the engine enters the same pending state an + edge leaves it in, so the ordinary latch cuts, publishes, counts and frames the acquisition. A + forced acquisition therefore obeys every rule a latched one does — the axis pinned from the + snapshot's own stamps, a `pre_s` shorter than the request when the ring does not reach back that + far, and the frame that frames it being the one that consumes its `event_seq()` — and the button + owns no window, no publish and no framing of its own: **the event it publishes is cut by the same + code as a latched one.** Two things it deliberately does *not* do, both of them choices: + - **It skips the pre-window gate.** `force()` does not test `event_t - oldest_s_ >= pre_seconds_`: + "capture now" is a request for the history that *exists*, and refusing it because the ring is + young would refuse the one thing the button is for. The snapshot reports its own `pre_s`, and the + pin follows it — which is also how the pin gets *read*: with a request the ring cannot satisfy (a + 100 s window at 99 % pre) the axis must come out a few seconds wide, where a pin stated from the + request would come out a hundred. + - **It does not restart a capture already filling.** That capture *is* the acquisition being asked + for: the display is already showing it fill (`live(capturing)`), and restarting it would throw + away the post window it has collected and move the marker to the press. The click is refused, and + named on stderr, rather than silently reinterpreted. + What the trigger *is* afterwards is still the mode's decision, and it is made where it always was — + in the latch: Normal leaves the engine armed with the latch's hold-off, Single disarms it. One + acquisition, whether an edge produced it or the button. +- **The Force request is written under the model lock and consumed at the end of a packet.** It is + one atomic flag on `Acquisition` (`request_force()`), read with `exchange` after the channel loop + and before the `trigger_pending()`/`capture_edge()` publish — after the loop because the cut + `publish_event()` is about to make clamps to every value ring's `produced()`, so the newest sample + of every channel has to be in by then, and before the publish so the capture the click opened is + reported by the same packet that opened it. The *write* belongs under `ScopeModel::mutex()` and in + the same critical section as the `armed` flag the click also sets: the producer holds that lock for + a whole packet, reading the panel's `armed` at the top of one and the request at the bottom, so a + request written in between would be read by a packet whose engine was still disarmed and then + dropped — and dropped is right, because a momentary request must not be replayed later on a trigger + the user has stopped watching. With **no channel resolved** (no signal acquired) the click is + refused in the handler and not left to the producer: `on_data()` returns at once for a model with + no *streamed* channel, so a request parked behind that return would fire on the first signal + switched on afterwards. - **The trigger engine is not fed a reserved hole's samples.** A NaN comparison is false anyway, so the engine would not latch; but the real reason the samples are withheld is the other one: feeding them would advance the hold-off clock across time nothing was sampled in. The probe @@ -242,6 +296,86 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear moved under the stream; a frame accepted into a model with no streamed channel) and for why the relative clock is accumulated in seconds. +- **Persistence is the older acquisitions drawn *over* the current one, and "a layer is not a + signal" is what keeps them from becoming one.** Trigger mode may keep the last `N` + acquisitions -- `TriggerConfig::persist_depth`, the panel's *persistence* spin, 1 = the event + on screen alone -- and draw the older ones dimmer, which is the whole feature. The machinery is + the layer machinery the accumulate path already has: one **layer** per (signal, age), in the + same trace slot the accumulate path puts its own extra events in, named with the same suffix + vocabulary (` (A)`, ` (B)`, ` #4`), so `layer_base_name()` gives the base signal back and the + signal list, the colour resolver, the measurements and the layout writer all keep seeing **one** + signal where the plot draws several traces of it. Four consequences, each of which is a rule: + * **A layer slot holds no channel.** `RowSet::bind()` resolves a layer's *base* signal (it must + still be acquired -- switching a signal off takes its traces off every plot, layers included) + and then finds the channel **by name inside the snapshot** (`EventSnapshot::channel_names`), + because the channel list is renumbered by any structural change since that acquisition was + cut -- the rule `TriggerEngine` already follows, for the same reason. It leaves + `Row::channel[]` at `-1`, which is what makes every reader that asks "is this slot a trace of + a signal" skip it: the trace counts, the cursors pane's `mix` mask, the measurement marks, the + dump's per-slot lines, `collect_layout()`. A persistence layer is therefore invisible to the + signal list, the calibration table and the measurements by construction and not by a check; + the *measurements* go on describing the current frame because they resolve a channel from the + signal name the user configured, and `sine (A)` is not a signal anybody can configure. + * **Its brightness is a property of the slot, and it is applied where the trace is created.** + `Row::slot_alpha` is `RowSet::persist_alpha(age)` -- **half per acquisition**, so the schedule + is 1, 0.5, 0.25, 0.125, ... and the seventh layer is 0.8 %, i.e. gone; it does *not* depend on + the depth, so raising N adds dimmer acquisitions instead of restating the ones already on + screen. `install()` mixes `slot_alpha * trace_alpha_` into the trace's colour, because a + `Plot` fixes a trace's colour **at `add_trace()`** and a brightness nobody can set later is a + brightness that has to be a rebuild. Two things follow: a per-layer alpha is impossible + without a per-slot one, and **`set_trace_alpha()` is finally read** -- it was set by the + accumulate path and read by nothing, so an accumulation's "translucent, so agreement is + brightness" was opaque in fact while the docs claimed otherwise. + * **The dimmest trace is created first.** A `Plot` draws its traces in the order they were + created and cannot reorder them, so `install()` creates them sorted by brightness and the + *newest* acquisition is the ink on top. A layer drawn over the event would take its colour out + of the event's own trace wherever the two agree -- the opposite of what a persistence display + shows. + * **Every slot the plan does not fill is freed, and the plan does not depend on the data.** + `RowSet::sync_layers()` (called from the tick, before the frame's `bind()`) re-plans one layer + per (signal, age) into the row's free slots, *layer by layer* -- age 1 for every signal first, + then age 2, as the accumulate path does -- so a row that runs out of room draws the sharper + layer for each of its signals and stops. The plan is a function of the rows, the depth and + nothing else, so a **latch costs no rebuild**: the slot names and ages stay put and only the + snapshot behind them changes. +- **An older acquisition is *drawn*, and it may not move anything.** The current event is the one + the axis is pinned to, and persistence must not disturb that, so `RowSet::event_data_window()` + -- the union the pin is re-stated from every frame while an event is held -- **skips layer + slots**: a wider older acquisition would otherwise re-pin the axis, and with it the window a + pinned measurement reads, which is a *measurement* moving because a display option was turned + up. The layers are bound only in `Held` and `Capturing` (a live view has no current event to be + the newest of them, a `Waiting` display is blank by design, and a pause view's stamps are on the + live window's clock, not on an edge): in `Waiting` and `Live` the layer slots are planned but + bound to nothing, and a trace with no series is not drawn and not listed in the legend. The one + thing they do share with the signal is the **row's y axis**, so the y auto-fit sees them -- a + y axis that ignored the traces it draws would clip the excursion the display exists to show. +- **A retained acquisition is a whole window copy, so the depth is capped by bytes and by what a + row can draw.** `publish_event()` moves the outgoing `event()` into the retention before it + publishes the new one: `ScopeModel::persist_event(age)` is the event `age` latches back, one + atomic `shared_ptr` slot per age, read by the tick without a lock exactly as `event()` is. + `snapshot_bytes()` is the honest cost of one -- both stamp arrays, every channel's `f32` samples + and the LOD pyramids built for them, i.e. ~4.5 bytes per sample per channel plus 8 per sample of + clock -- and `prune_persist_locked()` cuts the set to **both** bounds: the depth, and + `persist_budget_bytes()`, which is the ring's own `--buffer-mb`, because persistence is a copy + *of* what the rings hold and holding as much again is the honest ceiling for a display of it. + `kMaxPersistDepth` is 8 for the different reason the panel needs one: the layer there is already + invisible (0.8 %) and seven is all the free slots a one-signal row has. The depth is not the only + thing that limits what is *drawn* -- a row of `S` signals with 8 slots shows + `floor((8 - S) / S)` ages of each, so the default four-signal row shows one older acquisition + and a row per signal shows all seven. The two shortfalls are **separate readings**, and a caller + that conflated them would report a shrunken display as complete: a *slot* shortfall is + `placed < want` (what `RowSet::sync_layers()` returns against what the depth asked for), while a + *budget* shortfall is a `kept` below the depth plus the count `persist_dropped()` carries -- and in + that case the layer slots **exist and draw nothing at all**, so `layers=`/`placed=` alone say + nothing is wrong. The panel's notice therefore names both numbers -- `D of W layers fit, K of A + older acquisitions retained` -- because a user who asked for eight layers and sees two has no other + way to tell "the display is deliberately shallower" from "the feature is broken". +- **Depth 1 is not a mode, it is the *absence* of one.** `persist_depth = 1` retains nothing at + all -- no copy, no layer, no rebuild -- and `set_persist_depth()` prunes the set **at once** + rather than at the next latch, because lowering the depth is a user asking to stop paying, and a + trigger that has stopped firing never reaches another publish. A depth change is therefore also + how the memory comes back. + ## A latch is also a file The pane that lists the cache is **on demand**: the toolbar's event-history button and the pane are diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index e5998f2..1992e1a 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -94,6 +94,50 @@ Two lines of the self-check output are assertions in disguise: `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 the disarm` line in the dump meaning what it says. +- `[force-probe] button off: sensitive=0 visible=1 w=X | on: sensitive=1 w=X | arm w=Y | + label_found=1` (stderr, `UDPSCOPE_PROBE_FORCE_MS`) — the Force button's stated state, read on both + sides of the trigger toggle. **`sensitive=0` with the trigger off and `1` with it on** is the whole + rule (`sync_arm_buttons()` states it from the toggle, and a trigger that is off has no engine to + force); `visible=1` in both says it is a *body* control and not a compact-heading one, which is why + the off reading must not be `visible=0`; `w` is its allocation a frame after the state it belongs + to, and `arm w=Y` the button beside it, so a `w=0` next to a non-zero `Y` is the "width read in the + tick the control was stated" trap rather than a collapsed button. `label_found=1` is the button the + window shows under the word **Force** *being* the panel's own widget. +- `[force-probe] leg A|B want=[-a..b] during=[-c..d] snap=[-e..f] n=N axis=[..] latches=P->Q + display=.. matches_request=D pin_is_snapshot=P` (stderr, `UDPSCOPE_PROBE_FORCE_MS`) — one line per + leg, taken after the latch the press produced. **`latches` must move by exactly one and `display` + must read `event(frozen)`**: the probe sets the condition to a level the mock never reaches (9 V + against ±1 V signals), so the engine cannot latch on its own and a forced acquisition is the only + thing that *can* move the count — that is also why this probe must run alone, since any other probe + that arms a reachable condition would add latches of its own. `want` is the pre/post split the + panel was set to at the press, taken from the producer's own `post_span_s()`; `snap` is what the + snapshot holds (`[-pre_s..+post_s]`); `during` is the axis while the forced capture was *filling*, + which is the *requested* window — a capture has no snapshot to frame from yet. The two legs differ + in which of those can agree: + - **Leg A (2 s window at 50 % pre, on a ring that has filled)**: `matches_request=1`, i.e. + `snap ≈ want` to within a stored interval, and `n` the span times the stream's real rate plus + one (`20001` at 10 kS/s for `[-1..+1]`, exactly as the latch line demands). + - **Leg B (100 s window at 99 % pre, which no run this short can reach back to)**: the request is + 99 s of history and the ring holds seconds, so `matches_request=0` **on purpose** and + `snap ≈ [-history..+1]`. That is the leg that makes **`pin_is_snapshot=1`** a reading rather than + a coincidence: `axis` must equal `[-snap.pre..+snap.post]` (a few seconds wide) while `during` + still reads the requested `[-99..+1]`. A pin stated from the *request* — the + `hold_pinned_window()`/`pin_event_window()` mistake this probe exists for — reads + `axis=[-99.0000..1.0000]` here, i.e. `pin_is_snapshot=0` with the frozen picture a fraction of + its own axis. + A forced acquisition is an ordinary latch in every other respect, so the `trigger: edge at X ... + want [-a..b] got [-c..d]` line appears beside each leg with the *force's* edge as its `X`, and that + line's own rule (`got == want`, `n` exact) applies to it unchanged. The line + `[udpscope] trigger: forced acquisition, edge at E -- want [-a..b] s, H s of history behind it, + mode=M` is the producer accepting the request: `H` is the ring's retained span at the press, which + is what caps the pre side in leg B, and `mode=single (disarms at the latch)` says the one + acquisition the mode means, so an armed state that reads `0` afterwards is right and not a lost + click. `force ignored (enabled=.. armed=.. ch=.. pending=..)` is the refusal — a trigger that is + off, a channel that is still `-1`, or **a capture already filling**, which is deliberately *not* + restarted (it is the acquisition being asked for); in the `ch=-1` case the click is refused in the + handler instead (`force refused -- no signal is being acquired to trigger on`), because a request + left to the producer would sit behind `on_data()`'s no-streamed-channel early return and fire on + the first signal switched on afterwards. - `[trigrenum-probe] window=W event_span=S pre_pct=P post=O holdoff=H level=L edge=E mode=M enabled=1 armed=1 ch=C channels=N`, then `capture open: t0=T ch=C -- enabling signal 1 now`, `after enable: trigger ch=C' channels=N'`, and `latched t0=T' opened on T t0_held=D` (stderr, @@ -598,7 +642,12 @@ Two lines of the self-check output are assertions in disguise: must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the 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 - 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). A **persistence + layer** would appear in that line as a suffixed name (`sine (A)`, `sine #4`) *while trigger mode + is on* — it is not the user's arrangement and is never saved (`collect_layout()` writes a layer + slot as empty) — and it is absent from the dump, which is always taken after the exit probe's + go-live has cleared the layers. `[persist-probe]` is therefore the only run-in-progress reading of + them. - `[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) — "connected but nothing is arriving after a reconnect", which over unicast is the second @@ -1323,7 +1372,7 @@ popover row reads `failed: ` — one stderr line, no retry storm again ## `[defaults]` and `[configs]` ``` -[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01 +[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01 0 0 1 [defaults] calibrated: 2.5 -1.25 model=2.5 -1.25 [configs] before=default active=default [configs] save-as=1 names=probe,default active=probe @@ -1339,7 +1388,7 @@ sets them to **7.5 and 250** before saving, so a key that never reached the file | --- | --- | --- | | `[defaults] calibrate:` | `1 0 model=1 0` | an unseeded spin button: `gtk_spin_button_new_with_range()` leaves the value at the range minimum, so the offset opened at -1e12 and Apply wrote it | | `[defaults] calibrated:` | `2.5 -1.25 model=2.5 -1.25` — the dialog and the model agreeing after a calibration was set | a dialog that opens at the identity while the trace is drawn through a gain: pressing Apply "confirms" the calibration away. The two halves are printed so the assertion is that they *match*, not that either is a particular number | -| `[defaults] trigger:` | `0.001 0.002 0 0.001 0 50 0.01` | the same, per trigger field; a level of `-1e12` is a number nobody chose | +| `[defaults] trigger:` | `0.001 0.002 0 0.001 0 50 0.01 0 0 1` | the same, per trigger field — the ten spins in tree order: width, width2, level2, timeout, level, pre-trigger, hold-off, noise band, HF reject, persistence; a level of `-1e12` is a number nobody chose, and the trailing `1` is the persistence depth, whose default *is* the feature off | | `[configs] names` | gains `probe`, and loses it after `forget` | a save that writes somewhere the listing does not read, or a delete that leaves the file | | `[configs] active` | `probe` after the save-as | the `active` file not written, so the next start opens the wrong configuration | | `[configs] round trip` | `read=1`, the note verbatim, `buffer_mb` non-zero | the note or the startup settings not persisted — a configuration that carries a layout but not the buffer size it was tuned with | @@ -1850,7 +1899,7 @@ through one row type, for the reason in `invariants-gtk-and-chrome.md`. The form ## `trigger panel:` ``` -[udpscope] trigger panel: boxes=15 controls_inside=15/15 search_widgets=4/4 search_inside=4/4 +[udpscope] trigger panel: boxes=17 controls_inside=16/16 search_widgets=4/4 search_inside=4/4 ``` The trigger panel's frame, read from the window: **the `.ui`'s own panel** (`trigger_body`), not the box @@ -1858,18 +1907,101 @@ this app wraps it in — the heading row and the locked summary are the code's a *wrapper* made this line report `boxes=2 … search_inside=0/4` the moment the heading moved out of the file. That is the failure this line is for, so it is worth knowing it can happen to itself. `boxes` is the row boxes the `.ui` holds, `controls_inside` -counts the fifteen controls the code built and then appended into those rows -- the last two being the -noise band and the HF reject, whose rows arrived with the noise-rejection work, `search_widgets` is the four +counts the sixteen controls the code built and then appended into those rows -- the last three being the +noise band and the HF reject, whose rows arrived with the noise-rejection work, and the persistence depth +whose row arrived with the persistence display (the Arm/Force pair is a row of its own too, so `boxes` counts +it while `controls_inside` does not: the pair is a *button* row, not a value) -- `search_widgets` is the four search buttons having resolved by id, and `search_inside` is those four being inside the block the expander opens onto. A control built and never placed -- an id that drifted between the file and the code -- reads as -`controls_inside` below 15 while every other line still looks right, which is the panel that has a field +`controls_inside` below 16 while every other line still looks right, which is the panel that has a field missing. The search walk starts at `gtk_expander_get_child()`, not at the panel: an expander's child is not in the widget-parent chain at all, so walking up from a search button reaches no ancestor of the panel (see -`invariants-gtk-and-chrome.md`). `[defaults] trigger:` prints the same panel's seven spins in tree order, so +`invariants-gtk-and-chrome.md`). `[defaults] trigger:` prints the same panel's ten spins in tree order, so the two lines together read what the file gave and what the code put in it. +## `[persist-probe]` and `persistence:` + +``` +[persist-probe] held depth=4 kept=3 bytes=20998152 budget=209715200 dropped=0 signals=5 channels=4 layers=12 want=12 placed=12 alphas=[0.1250,0.2500,0.5000] ages=[3,2,1] row0=[1:'sine (A)'@0.5000 2:'sine (B)'@0.2500 3:'sine #4'@0.1250] drawn=12 +[persist-probe] held axis=[-0.2500..0.2500] pin=[-0.2500..0.2500] snap=[-0.2500..0.2500] n=250001 ev=[-0.2500..0.2500] ev_is_snap=1 pin_is_snap=1 +[udpscope] persistence: depth=4 kept=3 bytes=20998152 budget=209715200 dropped=0 layers=0 want=0 placed=0 schedule=[0.1250,0.2500,0.5000] alphas=[] +``` + +The persistence display: the last `depth` triggered acquisitions drawn behind the current one, each older one +at half the previous one's brightness. Run with one signal per row and a 0.5 s window against the mock at +500 kS/s, which is what the two lines above are. + +**`alphas=` is the assertion, oldest first**: `0.5000,0.2500,0.1250` — halving, with `ages=[3,2,1]` matching +one for one. A flat, ascending, or age-gapped list is the decay not being applied or applied backwards (the +rule is in [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)). `row0=` is the same +schedule read per slot, with each layer's name — `sine (A)` one acquisition back, `sine (B)` two, `sine #4` +three, the accumulate path's own suffix vocabulary. `layers=` is the layer slots that exist, +`want=` the depth's whole demand across the rows, `placed=` what fitted: **the three agreeing is a display +that shows every age it was asked for**, and `placed < want` is a layout with no free trace slot left (a row +of `S` signals shows `floor((8 - S) / S)` ages of each, so the default four-signal row shows one). + +**`kept`/`bytes`/`dropped` are the *retention*, and they bound what those slots can draw.** `kept=3` of the +3 asked at `bytes=20998152` (21 MB: three acquisitions of a 0.5 s window at 500 kS/s over four channels, +~7 MB each) against `budget=209715200`, the ring's own `--buffer-mb`. `drawn=` is how many layer slots are +actually *bound* to a retained acquisition, and it is a different number from `placed=` exactly when the +budget bit — which is the run to take next, on the fast mock with a long window: + +``` +[persist-probe] settled: kept=1 of 7 after 270 polls +[persist-probe] held depth=8 kept=1 bytes=72798888 budget=209715200 dropped=0 signals=5 channels=4 layers=28 want=28 placed=28 alphas=[0.0078,0.0156,0.0312,0.0625,0.1250,0.2500,0.5000] ages=[7,6,5,4,3,2,1] row0=[1:'sine (A)'@0.5000 … 7:'sine #8'@0.0078] drawn=4 +[persist-probe] held axis=[-1.0000..1.0000] pin=[-1.0000..1.0000] snap=[-1.0000..1.0000] n=2600000 ev=[-1.0000..1.0000] ev_is_snap=1 pin_is_snap=1 +[udpscope] persistence: depth=8 kept=2 bytes=145598128 budget=209715200 dropped=2 layers=0 want=0 placed=0 schedule=[0.0078,0.0156,0.0312,0.0625,0.1250,0.2500,0.5000] alphas=[] +``` + +`UDPSCOPE_PROBE_PERSIST_DEPTH=8 UDPSCOPE_PROBE_PERSIST_WINDOW=2` against the mock at 1.3 MS/s: one retained +acquisition is 72.8 MB, so the 200 MB budget keeps two and drops two — `kept` below the depth, `dropped` +non-zero, and **`drawn=4` against `placed=28`**: the layer slots exist and the oldest seven ages per row draw +nothing at all. That is the memory bound as a reading, and `settled:` is the wait saying so (the retention +can never reach the depth, so the probe reports what stopped growing instead of waiting out its cap — its +`kept` can therefore be mid-fill, which is why the *dump's* line below is the settled one). The +in-doubling of `alphas=` there is the same rule at full depth: 1/128 at the eighth, which is why the depth +stops at 8. Do not read this run's `frame cost:` as persistence's: a 2 s window at 1.3 MS/s costs ~90 ms a +latch in `publish cost:` on its own, with or without persistence. + +**`ev_is_snap` and `pin_is_snap` must be 1**, and that is the rule about the past: the pin the held event's +axis is re-stated from (`event_data_window()`) is the *snapshot's* own window, so the older acquisitions — +which are bound event series on the same rows — moved neither the axis nor the framing; both runs above read +`axis = pin = snap = [-0.2500..0.2500]` with `n=250001`. They are read on a frame that is *bound* (the probe +waits for one), because a latch landing mid-frame leaves the mode `Held` while the rows still hold the live +ring's bindings, and a reading taken there says nothing about either. + +**And `N = 1` is the display without the feature**, which is the same command at +`UDPSCOPE_PROBE_PERSIST_DEPTH=1`: + +``` +[persist-probe] held depth=1 kept=0 bytes=0 budget=209715200 dropped=0 signals=5 channels=4 layers=0 want=0 placed=0 alphas=[] ages=[] row0=[] drawn=0 +[persist-probe] held axis=[-0.2500..0.2500] pin=[-0.2500..0.2500] snap=[-0.2500..0.2500] n=250001 ev=[-0.2500..0.2500] ev_is_snap=1 pin_is_snap=1 +``` + +Nothing retained, nothing planned, nothing bound, no bytes — and the framing line identical field for field, +which is what "the same display as before the feature existed" means as a reading. **That run is also the +baseline for the exit dump's `frame cost:` line**, because the tick mean is a mean over the whole run and the +depth is raised at `UDPSCOPE_PROBE_PERSIST_MS` (2.5 s of 16 s), so the difference between the pair is a +*lower bound* on what the layers cost. At the scale above (0.5 s window, 500 kS/s, four rows, one signal +each): + +| run | tick mean | draw mean | paint mean | frames | +|---|---|---|---|---| +| `..._PERSIST_DEPTH=1` | `0.82 ms` | `11.10 ms` | `15.96 ms` | 936 | +| `..._PERSIST_DEPTH=4` | `1.18 ms` | `28.92 ms` | `34.60 ms` | 470 | + +Thirteen extra traces across the four rows (four signals plus nine layers, the twelfth layer's signal having +no free slot in its row) for **+17.8 ms of draw a frame**, i.e. ~2.6× the ink of the same plot without +persistence; `tick` moves with it by a few tenths, mostly because there are half as many frames to average. + +`[udpscope] persistence:` is the same state at the end of the run, and the one line the *standard* self-check +prints without a probe: `depth`, the retention (`kept`/`bytes`/`budget`/`dropped`), the plan +(`layers`/`want`/`placed`, 0 here because the exit probe's go-live has already cleared the layers — +`layers=0` in that line is correct, not a defect), `schedule=` the alphas the depth asks for oldest first +(the rule; the probe's `alphas=` is what the rows were built with), and `alphas=` for row 0. + ## `signals panel:` ``` diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 1823012..5c10705 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -57,6 +57,63 @@ precedent, not an exception. `UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_PROBE_DISARM_MS` only *starts* its sequence: it switches the trigger on and then polls for a capture to open, so the Disarm lands ~300 ms into one rather than wherever a fixed delay happened to fall. +- `UDPSCOPE_PROBE_FORCE_MS` — the trigger panel's **Force** button: one acquisition at the present + moment, with the pre/post window the panel is set to, without waiting for the condition. It is a + chain, not an instant — `MS` is when it *starts*: it forces trigger mode **off** through the toggle + (a session file remembers the trigger, so the off state is stated rather than assumed) and reads the + button's stated state 300 ms later; it then sets a level no mock signal reaches (9 V), a 2 s window + at 50 % pre, acquires the mock's `sine` and turns the trigger on; it presses Force 2.5 s after that + (the window write re-plans the buffer and clears the history, so the ring needs the time to fill + past the 1 s leg A asks for); it presses a second time on a 100 s window at 99 % pre — a request no + run this short can satisfy — 400 ms after the first latch. **Run it alone, against the plain mock**, + with time for both legs: + + ```sh + python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & + sleep 1.5 + xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=14000 UDPSCOPE_PROBE_FORCE_MS=3000 \ + ./build/udpscope --server 127.0.0.1 --port 8114 2>&1 | \ + grep -E 'force-probe|trigger:' + ``` + + Being alone is load-bearing twice: the level is chosen so the engine *cannot* latch on its own, so + a latch count that moves is the force's and nothing else's — any other probe that arms a reachable + condition adds latches of its own — and `UDPSCOPE_ENABLE_ALL_MS` before it would acquire four + signals, which changes the level's default range and the plan. Two lines per pass are in + [probe-readings.md](probe-readings.md); **`latches=P->Q` with `Q == P + 1`, `display=event(frozen)`, + `matches_request=1` in leg A, `pin_is_snapshot=1` in both** are the assertions, and leg B's + `matches_request=0` is *expected* — a ring that cannot reach back 99 s is the only state where + "pinned to the snapshot, not to the request" is a reading rather than a coincidence. +- `UDPSCOPE_PROBE_PERSIST_MS` (+ `UDPSCOPE_PROBE_PERSIST_DEPTH`, default 4, 1..8) — the **persistence + display**: trigger mode keeps the last N acquisitions on screen, each older one drawn at half the + previous one's brightness. It enables the signals itself (one call, as a restored session does) and + arranges one signal per row — the arrangement the app gives an opened file, and the one with room + for every age — sets the depth **through the panel's own spin**, arms the trigger on the mock's + `square` and polls until the retention is full: an event on screen and as many older ones kept as + the depth asks for. The depth is *left* where it put it, so the run's own tail is the display at + that depth: + + ```sh + python3 tools/mock_udpstreamer.py --port 9541 --rate 500000 --batch 1000 & + PY=$!; sleep 0.5 + xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=16000 \ + UDPSCOPE_PROBE_PERSIST_MS=2500 UDPSCOPE_PROBE_PERSIST_DEPTH=4 \ + ./build/udpscope --server 127.0.0.1 --port 9541 2>&1 | grep -E 'persist-probe|persistence:|layout:' + kill $PY + ``` + + **Not with `UDPSCOPE_ENABLE_ALL_MS`**: that probe ends by switching a signal off two seconds later, + which takes the row that plotted it — and its layers with it — out of the middle of the measurement. + + **Read it as a pair of runs**: the same command at `..._PERSIST_DEPTH=1` is the display *without* + the feature, so its `kept=0`, `layers=0`, `bytes=0` and its `layout:` line holding only the signals + is the "N = 1 is exactly the old behaviour" reading, and it is also the run the exit dump's + `frame cost:` line is compared against — the tick/draw means are means over the whole run, and the + depth is raised at `MS`, so the difference is a *lower bound* on what the layers cost. **`ev_is_snap` + and `pin_is_snap` must be 1 in both runs**: the older acquisitions are drawn and never pinned, so + the axis, the framing and the measurements still describe the current event. `alphas=` is the decay + schedule in use, oldest first — halves, and `1/2^age` at that. Both lines and the two exit-dump + lines are in [probe-readings.md](probe-readings.md). - `UDPSCOPE_PROBE_TRIGRENUM_MS` — the trigger's capture must survive a **renumbering of its own channel**. It sets the reported geometry through the real widgets (1 s window, 10 % pre share, hold-off from `--holdoff`) on the mock's *square* alone, polls for a capture to open, and then diff --git a/help/C/org.udpscope.app/trigger.page b/help/C/org.udpscope.app/trigger.page index 1bcf3cd..c50903d 100644 --- a/help/C/org.udpscope.app/trigger.page +++ b/help/C/org.udpscope.app/trigger.page @@ -5,7 +5,7 @@ - The trigger's four shapes, arming and hold-off, the event cache with its replay, + The trigger's four shapes, arming, forcing an acquisition and hold-off, the event cache with its replay, compare and accumulate, and searching the buffer for a condition. Martino Ferrari @@ -35,6 +35,17 @@ offered only while trigger mode is on, because a trigger that is off has no engine to arm.

+ +

Force takes one acquisition now, with the event window above, + without waiting for the condition to hold — for when the event you are waiting for is not + coming and you want to see what the signal is doing this instant. The capture runs out its + post-trigger share and then freezes, exactly like a latch: the marker sits at the instant + you pressed. In Normal mode the trigger goes back to waiting afterwards; in + Single mode it disarms, because one acquisition is the whole of what that mode + means either way. It needs a signal to be acquired — the trigger cannot watch one that is + switched off — and pressing it while a capture is already filling leaves that capture + alone, since it is the acquisition you asked for.

+

Search the buffer is at the foot of the same panel.

@@ -82,6 +93,40 @@ offers a pulse width to a window trigger.

+
+ Persistence: keeping the last few acquisitions +

Persistence decides how many of the most recent triggered acquisitions stay on + screen at once. It is 1 by default, which is the event alone — the display you get + when you ask for no persistence — and it can be raised to 8. Each older acquisition is drawn + at half the brightness of the one before it (1, 0.5, 0.25, 0.125, …), and they are all aligned + on their own trigger edge, so the pre-trigger part of the event lines up from acquisition to + acquisition and whatever changes between them — jitter, ringing, the occasional glitch — shows + as a faint ghost behind the current trace. That is what a phosphor display does with + successive sweeps.

+ +

The older acquisitions are drawn behind the current one and nothing else changes + because of them: the axis still frames the event on screen, the trigger marker still marks + its edge, and the measurements still describe the current frame. Lowering + Persistence back to 1 remembers nothing and frees the retained acquisitions + immediately.

+ +

Two things bound how many are drawn, and the panel tells you when one of them bites.

+ +

Room in the plot. Each older acquisition is an extra trace in the row that + plots its signal, so it needs a free trace slot there. A row holding four signals — the + layout the app starts with — has room for one older acquisition of each of them; a row per + signal has room for all seven.

+

Memory. A retained acquisition is a whole window copied out of the ring, + so persistence may hold at most as much again as the buffer (--buffer-mb) does. + On a long window at a high sample rate that bound is reached before the eighth layer.

+
+ +

If a depth cannot be shown in full, a notice says how many layers fit, and the panel's + tooltip carries the same rule. Persistence is a display of what has already been captured: it + changes neither what is acquired nor what is measured, and it does nothing while the trigger + is off.

+
+
The event cache, replay and compare

Every triggered acquisition is kept. Latches are written to the event cache @@ -178,7 +223,10 @@ by live data.

The first edge after arming is only accepted once the history reaches back a full pre-trigger window, so a capture may legitimately be shorter than you asked for. - The axis is pinned to what the capture actually got.

+ The axis is pinned to what the capture actually got. Force is the one + acquisition that skips that gate — it captures the history that exists, because "now" + is the whole of what you asked for — so on a ring that has just started filling its + left side is short even though the axis still shows only what it got.

Disarming during a capture publishes what the capture got to, so the picture on screen at that moment stays on screen. No event fired, so the event count does not move.

diff --git a/src/acquisition.cpp b/src/acquisition.cpp index 51c21a1..a00dcb8 100644 --- a/src/acquisition.cpp +++ b/src/acquisition.cpp @@ -1051,6 +1051,38 @@ void Acquisition::on_data(const udps_frame_t* frame) { } last_stamp_ = last_stamp_used; + // A Force pressed since the last packet: one acquisition, at the present moment, without waiting + // for the condition to hold. Read *here* -- the end of the packet, past the channel loop -- for + // the reason the latch itself is deferred out of that loop: `last_stamp_` is the newest stored + // sample by now, so the capture opens on the moment the user pressed, and every channel has + // pushed that sample, so the cut the latch goes on to make cannot clamp it away + // (`publish_event()` clamps to each value ring's `produced()`). + // + // It opens the capture and nothing else. The latch below -- reached through `engine.process()` + // by an edge, through `engine.force()` here -- is what cuts the window, bumps the latch count, + // sets the trigger time and applies the mode's re-arm rule, so a forced acquisition is framed, + // pinned and recorded exactly like one the condition produced. A capture already in flight is + // not cancelled: it *is* the acquisition being asked for, and the engine refuses the request + // rather than restarting it. + if (force_want_.exchange(false, std::memory_order_acq_rel)) { + if (engine.force(last_stamp_)) { + std::fprintf(stderr, + "[udpscope] trigger: forced acquisition, edge at %.3f s -- want " + "[-%.4f..%.4f] s, %.4f s of history behind it, mode=%s\n", + last_stamp_, event_span - post_want, post_want, last_stamp_ - oldest_s, + tc.mode == TriggerMode::Single ? "single (disarms at the latch)" : "normal"); + std::fflush(stderr); + } else { + // Nothing to force. Every cause is a state the user can see from the panel -- a trigger that + // is off, a signal that is not acquired, the engine disarmed, a capture already filling -- + // so it is named on stderr rather than swallowed. + std::fprintf(stderr, + "[udpscope] trigger: force ignored (enabled=%d armed=%d ch=%d pending=%d)\n", + int(tc.enabled), int(engine.armed()), trig_channel, int(engine.pending())); + std::fflush(stderr); + } + } + model_.trigger_armed().store(engine.armed(), std::memory_order_relaxed); // The edge goes out before the flag that makes the UI read it (release/acquire on // `trigger_pending`), so a tick that sees a capture open sees which edge it is for. A diff --git a/src/acquisition.hpp b/src/acquisition.hpp index a008bb6..c2fa94e 100644 --- a/src/acquisition.hpp +++ b/src/acquisition.hpp @@ -48,6 +48,20 @@ class Acquisition { bool capture_want() const noexcept { return capture_want_.load(std::memory_order_acquire); } + + /// Asks for one **forced trigger acquisition** -- the trigger panel's Force button. The producer + /// consumes the request on its next packet and opens a capture at that packet's newest stamp, + /// which the engine's ordinary latch then publishes with the configured pre/post window. The + /// request is dropped rather than retried when there is nothing to force: trigger mode off, no + /// channel resolved, the engine disarmed, or a capture already filling (a capture in flight *is* + /// the acquisition being asked for). + /// + /// **Write it under `ScopeModel::mutex()`, together with the `armed` flag the click also sets.** + /// The producer holds that lock for a whole packet -- it reads the panel's `armed` at the top of + /// one and consumes this request at the bottom -- so a request written between the two would be + /// read by a packet whose engine was still disarmed, and then dropped. + void request_force() noexcept { force_want_.store(true, std::memory_order_release); } + /// The capture, for the popover's row and the probe. Counters and errors are read /// cross-thread through PacketCapture's own published fields. const PacketCapture& capture() const noexcept { return capture_; } @@ -110,6 +124,9 @@ class Acquisition { std::atomic paused_{false}; PacketCapture capture_; std::atomic capture_want_{false}; + /// The pending Force request (see `request_force()`): a flag and not a queue, because two clicks + /// inside one packet are one acquisition, which is what a momentary button means. + std::atomic force_want_{false}; std::uint32_t capture_slots_ = 1000; /// Written once, by the GTK thread, before `capture_want_` is ever published true; /// the producer reads it only after the flag, so no lock is needed. diff --git a/src/event_series.hpp b/src/event_series.hpp index cf2b707..009514a 100644 --- a/src/event_series.hpp +++ b/src/event_series.hpp @@ -7,6 +7,7 @@ #include #include +#include #include namespace udpscope { @@ -63,8 +64,30 @@ struct EventSnapshot { /// already O(n), and the same channel on two rows shares it instead of building and /// storing it twice. std::vector pyramids; + /// The channel *names* `values` is indexed by, so a snapshot can be re-bound after the channel + /// list was rebuilt -- and renumbered -- under it: a persistence layer names its signal and + /// resolves that name here, exactly as the trigger engine names its channel and for the same + /// reason (an index is a position in a list that any enable, disable or CONFIG renumbers). + /// Empty on a snapshot nobody re-binds later (a pause view). + std::vector channel_names; }; +/// Bytes one snapshot holds: both stamp arrays, every channel's samples, the pyramids built for +/// them and the names above. The number the persistence retention is measured in -- a retained +/// snapshot is a whole window copied out of the ring, so what bounds it is bytes, not a count. +inline std::size_t snapshot_bytes(const EventSnapshot& s) { + std::size_t bytes = s.stamps.size() * sizeof(f64) + s.abs_stamps.size() * sizeof(f64) + + s.y.size() * sizeof(plot::Range) + + s.channel_names.size() * sizeof(std::string); + for (const std::vector& v : s.values) bytes += v.size() * sizeof(f32); + // A pyramid entry is 32 bytes (`MinMaxPyramid::Entry`), one per level-`l` bucket of + // `kBucket << l` samples -- the whole index is about one byte per sample. + for (const plot::data::MinMaxPyramid& p : s.pyramids) { + for (u32 l = 0; l < p.level_count(); ++l) bytes += p.level_size(l) * 32u; + } + return bytes; +} + /// A plot::data::Series view of one channel of an EventSnapshot. The stamps are /// near-uniform (the arrival clock), so dx is the mean spacing and x_at returns /// the exact stamp; gather decimates by 2^level like the ring series does. diff --git a/src/layout_store.cpp b/src/layout_store.cpp index 7a77fcf..ef8d9be 100644 --- a/src/layout_store.cpp +++ b/src/layout_store.cpp @@ -516,6 +516,10 @@ bool load_layout(const std::string& path, LayoutState& out) { num(t.noise_band); } else if (k == "hf") { num(t.hf_fc); + } else if (k == "persistence") { + double n = 0.0; + num(n); + t.persistence = static_cast(n); } }; if (section == Section::Top) { @@ -718,6 +722,7 @@ bool save_layout(const std::string& path, const LayoutState& state) { o << "trigger_holdoff = " << state.trigger.holdoff_s << "\n"; o << "trigger_band = " << state.trigger.noise_band << "\n"; o << "trigger_hf = " << state.trigger.hf_fc << "\n"; + o << "trigger_persistence = " << state.trigger.persistence << "\n"; if (!state.comment.empty()) o << "comment = " << toml_escape(state.comment) << "\n"; if (state.buffer_mb > 0.0) o << "buffer_mb = " << state.buffer_mb << "\n"; o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n"; diff --git a/src/layout_store.hpp b/src/layout_store.hpp index a89f7d5..37809c8 100644 --- a/src/layout_store.hpp +++ b/src/layout_store.hpp @@ -97,6 +97,10 @@ struct LayoutState { double holdoff_s = 0.010; double noise_band = 0.0; double hf_fc = 0.0; + /// How many of the most recent acquisitions the trigger mode keeps on screen (1 = the event + /// alone). Part of the trigger's own setup, like the window it captures and the shape it waits + /// for: it is what the display does with the acquisitions, and there is no second switch. + int persistence = 1; /// Whether the file said anything about the level. A session that did keeps it; one that did not /// leaves the panel's own "follow the channel's range" default alone. bool level_given = false; diff --git a/src/model.cpp b/src/model.cpp index 60a987b..79fe9f1 100644 --- a/src/model.cpp +++ b/src/model.cpp @@ -891,9 +891,11 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) { } snap->values.resize(channels_.size()); + snap->channel_names.resize(channels_.size()); snap->y.resize(channels_.size(), plot::Range{0.0, 0.0}); snap->pyramids.resize(channels_.size()); for (std::size_t c = 0; c < channels_.size(); ++c) { + snap->channel_names[c] = channels_[c].name; auto& vals = snap->values[c]; vals.resize(static_cast(n)); if (n > 0) src.values(c).copy_from(first, n, vals.data()); @@ -918,6 +920,24 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) { // capture at the first sample past `post_s` rather than exactly on it. snap->pre_s = snap->stamps.empty() ? 0.0 : -static_cast(snap->stamps.front()); snap->post_s = snap->stamps.empty() ? 0.0 : static_cast(snap->stamps.back()); + // The persistence retention: what was on screen until now becomes the *newest of the older* + // acquisitions, and the set is cut back to the depth the panel asks for and to the retention + // budget. Nothing is retained at depth 1, so the default costs no memory and no copy at all. + if (const int depth = persist_depth_locked(); depth > 1) { + std::shared_ptr prev = event_.load(std::memory_order_relaxed); + if (prev != nullptr) { + // Only the slots the depth wants are shifted: the ones past it are the prune's business, and + // moving an acquisition into a slot nothing can ever read is work for a snapshot about to be + // dropped. + const std::size_t top = std::min(static_cast(depth - 1), persist_.size()); + for (std::size_t i = top; i-- > 1;) { + persist_[i].store(persist_[i - 1].load(std::memory_order_relaxed), + std::memory_order_relaxed); + } + persist_[0].store(std::move(prev), std::memory_order_relaxed); + } + } + prune_persist_locked(static_cast(persist_depth_locked() - 1)); event_.store(std::move(snap), std::memory_order_release); // Bumped after the snapshot is in place, so a consumer that sees the new sequence // number sees the snapshot it counts. @@ -963,6 +983,48 @@ void ScopeModel::set_trigger_enabled(bool on) { trigger_enabled_.store(on, std::memory_order_relaxed); } +void ScopeModel::prune_persist_locked(std::size_t keep) { + const std::size_t limit = std::min(keep, persist_.size()); + const std::size_t budget = persist_budget_bytes(); + std::size_t bytes = 0; + std::size_t kept = 0; + std::uint64_t dropped = 0; + bool full = false; + for (std::size_t i = 0; i < persist_.size(); ++i) { + const std::shared_ptr snap = persist_[i].load(std::memory_order_relaxed); + if (snap == nullptr) continue; + if (i < limit) { + // Either bound can cut the set: the depth the panel asks for, and the budget, which a long + // window on a fast stream reaches long before the seventh layer. + const std::size_t b = snapshot_bytes(*snap); + if (!full && bytes + b <= budget) { + bytes += b; + ++kept; + continue; + } + full = true; // does not fit, and neither does anything older + // Only what the *budget* refused is counted: a slot past the depth is not a drop, it is the + // depth -- and counting it would make the reading say the memory was the reason when the + // panel's own number was. + ++dropped; + } + persist_[i].store(nullptr, std::memory_order_relaxed); + } + persist_count_.store(kept, std::memory_order_relaxed); + persist_bytes_.store(bytes, std::memory_order_relaxed); + persist_dropped_.store(persist_dropped_.load(std::memory_order_relaxed) + dropped, + std::memory_order_relaxed); +} + +void ScopeModel::set_persist_depth(int depth) { + const int n = std::clamp(depth, 1, kMaxPersistDepth); + std::lock_guard lock(mutex_); + trigger_.persist_depth = n; + // Pruned here and not only at the next latch: lowering the depth is the user asking to stop + // paying for the layers, and a trigger that has stopped firing never reaches another publish. + prune_persist_locked(static_cast(n - 1)); +} + void ScopeModel::set_epoch_if_unset(f64 e) noexcept { if (epoch_ == 0.0) epoch_ = e; } diff --git a/src/model.hpp b/src/model.hpp index e155fe7..338caef 100644 --- a/src/model.hpp +++ b/src/model.hpp @@ -10,6 +10,8 @@ #include +#include +#include #include #include #include @@ -511,6 +513,55 @@ class ScopeModel { /// while the latch count keeps answering "how many events fired". std::uint64_t event_seq() const noexcept { return event_seq_.load(std::memory_order_acquire); } + // --- persistence ---------------------------------------------------------- + /// The acquisitions retained for the persistence display, newest first: `persist_event(1)` is + /// the event one latch back, `(2)` the one before that. Null for an age nobody kept. + /// + /// Read **without a lock**, like `event()`: the producer publishes these in `publish_event()` + /// with the same atomic-`shared_ptr` discipline, and a snapshot is immutable and shared, so a + /// reader holds it alive for as long as it draws it. What the *ring* would have given is not + /// needed afterwards -- a retained snapshot is a whole window copied out of it. + std::shared_ptr persist_event(std::size_t age) const noexcept { + if (age < 1 || age > persist_.size()) return nullptr; + return persist_[age - 1].load(std::memory_order_acquire); + } + /// How many acquisitions are retained right now: what the depth asks for, or fewer -- never more + /// -- when the retention budget cut the set short. A reading, so it is counted as the slots that + /// hold a snapshot rather than derived from the depth. + std::size_t persist_count() const noexcept { + return persist_count_.load(std::memory_order_relaxed); + } + /// What those snapshots hold in bytes, and how many acquisitions the *budget* -- as against the + /// depth -- has dropped. Both are producer-written and read as readings. + std::size_t persist_bytes() const noexcept { + return persist_bytes_.load(std::memory_order_relaxed); + } + std::uint64_t persist_dropped() const noexcept { + return persist_dropped_.load(std::memory_order_relaxed); + } + /// The bytes persistence may retain, which is the ring's own byte budget (`--buffer-mb`): the + /// snapshots are copies *of* what the rings hold, so holding as much again as the buffer does is + /// the honest ceiling for a display of it -- and it is the bound that cannot be driven into an + /// OOM by a long window on a fast stream, whatever depth the panel asks for. + std::size_t persist_budget_bytes() const noexcept { + const int mb = cfg_.buffer_mb > 0 ? cfg_.buffer_mb : 0; + return static_cast(mb) * 1024u * 1024u; + } + + /// Sets how many acquisitions the persistence display keeps, clamped to `kMaxPersistDepth`, and + /// prunes the retained set to it **at once**: lowering the depth is a user asking to stop paying + /// for the layers, and the next latch -- when the retention would otherwise catch up -- may never + /// come on a trigger that has stopped firing. + /// + /// Takes `mutex()` itself; do not call it holding the lock. + void set_persist_depth(int depth); + + /// How many acquisitions the persistence display keeps: the depth, clamped to the cap. Reading + /// `trigger()` needs the lock, which every caller of this already holds. + int persist_depth_locked() const noexcept { + return std::clamp(trigger_.persist_depth, 1, kMaxPersistDepth); + } + /// Publishes the live buffer's new samples to its `ChannelBuffer`s. void acquire_all(); @@ -742,6 +793,19 @@ class ScopeModel { std::atomic capture_edge_{0.0}; std::atomic> event_; ///< the event on screen std::atomic> pause_view_; ///< the view frozen at pause + /// The retention behind `persist_event()`: one slot per *previous* acquisition, newest first, so + /// slot 0 is the event one latch back. `kMaxPersistDepth - 1` of them, because the acquisition on + /// screen is `event_` itself and is not retained a second time. Written by the producer in + /// `publish_event()` and by `set_persist_depth()`, both under `mutex()`; read without it. + std::array>, kMaxPersistDepth - 1> persist_{}; + /// The readings `persist_count()`/`persist_bytes()`/`persist_dropped()` answer with, maintained + /// where the slots are written. + std::atomic persist_count_{0}; + std::atomic persist_bytes_{0}; + std::atomic persist_dropped_{0}; + /// Drops the retained snapshots past `keep` (0 empties the set) and recounts the readings. + /// Caller holds `mutex()`. + void prune_persist_locked(std::size_t keep); std::atomic paused_{false}; /// Per-signal-name calibration (see `set_calibration`). std::unordered_map> calibration_; diff --git a/src/plot_host.cpp b/src/plot_host.cpp index efef5e5..770a70b 100644 --- a/src/plot_host.cpp +++ b/src/plot_host.cpp @@ -68,8 +68,28 @@ constexpr float kMeasureDashOff = 4.0f; /// stacked frames read as one tall plot with a line through it. constexpr plot::f32 kRowGap = 6.0f; +/// The suffix that names a persistence layer, in the accumulate path's own vocabulary -- +/// ` (A)`, ` (B)`, ` #4` -- so that `layer_base_name()` takes it back off, and the signal list, the +/// colour resolver, the measurements and the layout writer all keep seeing one signal where the +/// plot draws several traces of it. +std::string layer_suffix(int age) { + if (age == 1) return " (A)"; + if (age == 2) return " (B)"; + return " #" + std::to_string(age + 1); +} + } // namespace +float RowSet::persist_alpha(int age) noexcept { + // Half per acquisition, so the seventh is 1/128 -- and halves, not a slope, because that is what + // "exponential decay" means and because it is the one schedule that can be read off a line of + // numbers: 1, 0.5, 0.25, 0.125. It does not depend on the depth, so raising N adds dimmer + // acquisitions instead of restating the ones already on screen. + float a = 1.0f; + for (int i = 0; i < age; ++i) a *= 0.5f; + return a; +} + std::string layer_base_name(const std::string& name) { const std::size_t a = name.rfind(" ("); if (a != std::string::npos && a + 3 < name.size() && name[a + 3] == ')' && @@ -146,6 +166,11 @@ void RowSet::carry_row(Row& dst, const Row& src) { dst.name = src.name; dst.channel = src.channel; dst.color_index = src.color_index; + // A layer travels with the slot that draws it: it *is* part of what that slot holds. The tick's + // `sync_layers()` re-plans them all next frame in any case, which is what heals a row that has + // moved under a plan made for the old one. + dst.layer = src.layer; + dst.slot_alpha = src.slot_alpha; dst.ruler_y = src.ruler_y; dst.ruler_b_y = src.ruler_b_y; @@ -161,6 +186,8 @@ void RowSet::clear_row(Row& r) { r.name = {}; r.channel = {-1, -1, -1, -1}; r.color_index = {0, 1, 2, 3}; + r.layer = {0, 0, 0, 0}; + r.slot_alpha = {1.0f, 1.0f, 1.0f, 1.0f}; r.ruler_y = 0.0; for (SlotSeries& s : r.slots) s.bind(nullptr); } @@ -190,10 +217,24 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) { {"", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time}); r.axis_y = r.plot->add_y_axis( {"", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si}); - for (int s = 0; s < kSlotsPerRow; ++s) { + // Traces are drawn in the order they were created, and a `Plot` cannot reorder them: the + // *dimmest* traces are created first and a signal's own trace last, so the newest acquisition is + // the ink on top. A layer drawn over the event would take its colour out of the event's own trace + // wherever the two agree -- the opposite of what a persistence display shows. + std::array order{}; + for (int s = 0; s < kSlotsPerRow; ++s) order[static_cast(s)] = s; + std::stable_sort(order.begin(), order.end(), [&r](int a, int b) { + return r.slot_alpha[static_cast(a)] < r.slot_alpha[static_cast(b)]; + }); + for (const int s : order) { const std::size_t i = static_cast(s); - r.plot->add_trace({&r.slots[i], signal_color(r.signal_name[i], r.color_index[i]), 1.2f, - r.axis_x, r.axis_y, r.name[i]}); + plot::Color color = signal_color(r.signal_name[i], r.color_index[i]); + // The slot's own brightness times the figure-wide one the group views set: a persistence + // layer's decay and an accumulation's translucency are the same knob, and `set_trace_alpha()` + // is what reads it here. It was set and never read before, which is why an accumulation's + // traces were opaque. + color.a = r.slot_alpha[i] * trace_alpha_; + r.plot->add_trace({&r.slots[i], color, 1.2f, r.axis_x, r.axis_y, r.name[i]}); } r.cursor_a = r.plot->add_cursor(0.0, plot::Color::hex(kCursorAColor)); r.cursor_b = r.plot->add_cursor(0.0, plot::Color::hex(kCursorBColor)); @@ -320,9 +361,132 @@ void RowSet::assign(int row_index, int slot, std::string signal_name, r.channel[s] = -1; // re-resolved by the next bind() r.name[s] = std::move(display_name); r.color_index[s] = color_index; + // Whatever the slot held, it is a *signal's* trace now: a drop onto a slot a persistence layer + // was using is the user's arrangement, and the tick's plan moves the layers around it next frame. + r.layer[s] = 0; + r.slot_alpha[s] = 1.0f; row_dirty_[static_cast(row_index)] = true; } +void RowSet::assign_layer(int row_index, int slot, std::string signal_name, + std::string display_name, int color_index, int age, float alpha) { + assign(row_index, slot, std::move(signal_name), std::move(display_name), color_index); + if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return; + Row& r = row(row_index); + const std::size_t s = static_cast(slot); + r.layer[s] = age; + r.slot_alpha[s] = alpha; + row_dirty_[static_cast(row_index)] = true; +} + +void RowSet::clear_layers() { + for (int i = 0; i < kMaxRows; ++i) { + Row& r = row(i); + for (int s = 0; s < kSlotsPerRow; ++s) { + const std::size_t si = static_cast(s); + if (r.layer[si] == 0) continue; + assign(i, s, "", "", r.color_index[si]); + } + } +} + +int RowSet::sync_layers(int depth, int* want) { + const int ages = std::clamp(depth, 1, kMaxPersistDepth) - 1; + // What each slot should hold: 0 a signal's own trace (kept as it is), -1 free, >0 a layer age. + std::array, kMaxRows> plan{}; + std::array, kMaxRows> names{}; + int wanted = 0; + for (int r = 0; r < rows_; ++r) { + Row& row_state = row(r); + for (int s = 0; s < kSlotsPerRow; ++s) { + const std::size_t si = static_cast(s); + // A layer slot is free for re-planning; a signal's own trace is not. + plan[static_cast(r)][si] = + row_state.layer[si] > 0 ? -1 : (row_state.signal_name[si].empty() ? -1 : 0); + } + } + // Layer by layer, as the accumulate path fills its own: age 1 for every signal first, then age 2, + // so a row with room for two layers draws the *sharper* one for each of its signals rather than + // four ages of the first one. + for (int age = 1; age <= ages; ++age) { + for (int r = 0; r < rows_; ++r) { + const std::size_t ri = static_cast(r); + Row& row_state = row(r); + for (int s = 0; s < kSlotsPerRow; ++s) { + const std::size_t si = static_cast(s); + if (plan[ri][si] != 0) continue; + ++wanted; + int target = -1; + for (int t = 0; t < kSlotsPerRow; ++t) { + if (plan[ri][static_cast(t)] == -1) { + target = t; + break; + } + } + if (target < 0) continue; // this row is full: it persists as far as it can + const std::size_t ti = static_cast(target); + plan[ri][ti] = age; + names[ri][ti] = row_state.signal_name[si] + layer_suffix(age); + } + } + } + int placed = 0; + for (int r = 0; r < rows_; ++r) { + const std::size_t ri = static_cast(r); + Row& row_state = row(r); + for (int s = 0; s < kSlotsPerRow; ++s) { + const std::size_t si = static_cast(s); + const int now = plan[ri][si]; + const int had = row_state.layer[si]; + if (now > 0) { + ++placed; + if (had == now && row_state.signal_name[si] == names[ri][si]) continue; // already right + assign_layer(r, s, names[ri][si], names[ri][si], row_state.color_index[si], now, + persist_alpha(now)); + } else if (had > 0) { + assign(r, s, "", "", row_state.color_index[si]); // nothing wants this slot any more + } + } + } + if (want != nullptr) *want = wanted; + return placed; +} + +int RowSet::layer_of(int row_index, int slot) const { + if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return 0; + return row(row_index).layer[static_cast(slot)]; +} + +float RowSet::slot_alpha_of(int row_index, int slot) const { + if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return 1.0f; + return row(row_index).slot_alpha[static_cast(slot)]; +} + +int RowSet::layer_slots() const { + int n = 0; + for (int i = 0; i < kMaxRows; ++i) { + const Row& r = row(i); + for (int s = 0; s < kSlotsPerRow; ++s) { + if (r.layer[static_cast(s)] > 0) ++n; + } + } + return n; +} + +std::vector> RowSet::row_layers(int row_index) const { + std::vector> out; + if (row_index < 0 || row_index >= kMaxRows) return out; + const Row& r = row(row_index); + for (int s = 0; s < kSlotsPerRow; ++s) { + const std::size_t si = static_cast(s); + if (r.layer[si] > 0) out.push_back({r.layer[si], r.slot_alpha[si]}); + } + // Oldest first: the order the decay has to read in. + std::sort(out.begin(), out.end(), + [](const auto& a, const auto& b) { return a.first > b.first; }); + return out; +} + bool RowSet::slot_bound(int row_index, int slot) const { if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return false; return !row(row_index).signal_name[static_cast(slot)].empty(); @@ -348,9 +512,15 @@ void RowSet::bind(ScopeModel& model, DisplayMode mode) { // Resolve the stable identity every frame: the channel list is rebuilt (and // renumbered) whenever a signal is enabled, disabled or re-described, and a // stored index would then point at a different signal's channel. + // + // A persistence layer resolves the name *under its suffix*: `sine (A)` is `sine`, one + // acquisition back, and it is the same signal's trace -- so it is acquired (or not) with its + // signal, drawn in its colour, and counted by nothing that counts signals. + const std::string base_name = + r.layer[si] > 0 ? layer_base_name(r.signal_name[si]) : r.signal_name[si]; int signal_index = -1; for (std::size_t k = 0; k < signals.size(); ++k) { - if (signals[k].name == r.signal_name[si]) { + if (signals[k].name == base_name) { signal_index = static_cast(k); break; } @@ -364,6 +534,36 @@ void RowSet::bind(ScopeModel& model, DisplayMode mode) { } } } + if (r.layer[si] > 0) { + // A layer holds no channel: every reader that asks "is this slot a trace of a signal" + // -- the trace counts, the cursors pane's `mix` mode, the measurement marks, the layout + // writer -- tests `channel >= 0`, so a layer is invisible to all of them by construction. + r.channel[si] = -1; + // It draws only while a *snapshot* is on screen, and only if its signal is still acquired + // (switching a signal off takes its traces off every plot, layers included), and it + // resolves its channel **by name inside the snapshot**: the channel list may have been + // rebuilt and renumbered many times since that acquisition was cut, which is exactly why + // the trigger engine names its channel too. + std::shared_ptr snap; + if (channel >= 0 && (mode == DisplayMode::Held || mode == DisplayMode::Capturing)) { + snap = model.persist_event(static_cast(r.layer[si])); + } + std::size_t layer_channel = snap != nullptr ? snap->channel_names.size() : 0; + if (snap != nullptr) { + for (std::size_t c = 0; c < snap->channel_names.size(); ++c) { + if (snap->channel_names[c] == base_name) { + layer_channel = c; + break; + } + } + } + if (snap != nullptr && layer_channel < snap->values.size()) { + r.slots[si].bind_event(std::move(snap), layer_channel); + } else { + r.slots[si].bind(nullptr); + } + continue; + } r.channel[si] = channel; if (channel < 0) { // Its signal is present but not acquired: keep the identity (so enabling it @@ -818,6 +1018,11 @@ bool RowSet::event_data_window(double& lo, double& hi) const { const Row& r = row(i); if (!r.plot) continue; for (int s = 0; s < kSlotsPerRow; ++s) { + // A persistence layer is bound to an event series too, and it is the *past*: letting one + // widen this window would move the pin -- and with it the framing and the numbers a pinned + // measurement reads -- every time an older acquisition happened to be wider than the one on + // screen. What is pinned is the current event's window, and only that. + if (r.layer[static_cast(s)] > 0) continue; const EventSeries* ev = r.slots[static_cast(s)].bound_event(); if (ev == nullptr || ev->size() < 2) continue; const plot::Range b = ev->x_bounds(); diff --git a/src/plot_host.hpp b/src/plot_host.hpp index e4b99f5..e809cf0 100644 --- a/src/plot_host.hpp +++ b/src/plot_host.hpp @@ -112,6 +112,16 @@ class RowSet { /// `add_trace()`, which is why this is stored rather than resolved per frame -- /// changing it needs a rebuild, and `assign()` already forces one. std::array color_index = {0, 1, 2, 3}; + /// The *persistence layer* this slot draws: 0 for a signal's own trace (whatever the frame's + /// display mode binds it to), `a` > 0 for the acquisition `a` latches back, drawn at + /// `slot_alpha`. A layer slot holds a suffixed name (`sine (A)`) and no channel at all -- it is + /// not a signal, and `channel[]` stays -1 for it (see `assign_layer()`). + std::array layer = {0, 0, 0, 0}; + /// How bright the slot's trace is built: 1 for a signal's own trace, `persist_alpha(layer)` for + /// a persistence layer, multiplied into `trace_alpha_`. It lives on the slot because a `Plot` + /// fixes a trace's colour -- alpha included -- at `add_trace()`, so a per-layer brightness is a + /// property of the trace's *creation*, which is what a rebuild is for. + std::array slot_alpha = {1.0f, 1.0f, 1.0f, 1.0f}; plot::u32 axis_x = 0; plot::u32 axis_y = 0; plot::u32 cursor_a = 0; @@ -200,6 +210,42 @@ class RowSet { /// The channel index a row/slot holds, or -1. int channel_of(int row, int slot) const; + // --- persistence layers --------------------------------------------------- + /// The brightness a persistence layer is drawn at, `a` acquisitions back: the acquisition on + /// screen is the full-bright one and each older one is **half** the previous, so the schedule is + /// exponential in age -- 1, 0.5, 0.25, 0.125, ... and by the seventh (0.008, `kMaxPersistDepth`) + /// the layer is gone. Halving is per acquisition and not per *depth*, so changing N re-cuts how + /// many are kept without rescaling the ones already on screen. + static float persist_alpha(int age) noexcept; + + /// Binds a row's slot to a persistence *layer*: the acquisition `age` latches back, at `alpha` + /// (see `Row::layer`). The name carries the layer's suffix (`sine (A)`, `sine #4`), which is what + /// keeps a layer out of the signal list and out of the measurements -- it is not a signal -- while + /// `signal_color()` still resolves it to the base signal's own colour. + void assign_layer(int row, int slot, std::string signal_name, std::string display_name, + int color_index, int age, float alpha); + /// Frees every slot bound to a layer. What turning persistence off -- or the trigger -- is. + void clear_layers(); + /// Lays the persistence layers out over the rows' free slots, one per (signal, age): the signal's + /// own trace stays where it is and its layers take the free slots of the *same* row, layer by + /// layer (age 1 for every signal first, then age 2, as the accumulate path does), so a row that + /// runs out of room draws as many layers as it has room for and no more. Every slot the plan does + /// not fill is freed, which is what makes the display follow a layout change, a depth change and a + /// signal's removal without any caller having to reset anything. + /// + /// Returns the number of layers placed; `want` is how many the depth asked for, so a caller can + /// say when a layout could not show them all. + int sync_layers(int depth, int* want); + /// The layer a slot is drawing, 0 for a signal's own trace. + int layer_of(int row, int slot) const; + /// The slot's own brightness (1 unless it is a layer). + float slot_alpha_of(int row, int slot) const; + /// How many slots are layers, over every row: the reading that says the layers exist. + int layer_slots() const; + /// The layers of one row, oldest first, as `(age, alpha)` pairs -- the ordering a reader has to + /// see to know the decay is the right way round. + std::vector> row_layers(int row) const; + /// Rebinds every slot to what `mode` says it should show this frame. Called every /// frame, because the channel list is renumbered by any structural change. /// diff --git a/src/trigger.cpp b/src/trigger.cpp index ca7f8cd..90163f8 100644 --- a/src/trigger.cpp +++ b/src/trigger.cpp @@ -39,6 +39,20 @@ void TriggerEngine::arm(double now_s) noexcept { detector_.reset(); } +bool TriggerEngine::force(double now_s) noexcept { + if (!cfg_.enabled || !armed_ || cfg_.channel < 0 || pending_) return false; + // Straight into the pending state `process()` maintains, with the present moment as the event's + // own time: the post window is counted from here by the same accumulation an edge's capture uses, + // and the latch that ends it is the same latch. `last_trigger_s_` is the edge, so the marker, the + // cut's centre and the snapshot's `x_offset` all land on the instant of the press. + last_t_s_ = now_s; + last_trigger_s_ = now_s; + pending_ = true; + pending_start_s_ = now_s; + post_accum_count_ = 0; + return true; +} + bool TriggerEngine::process(double value, double now_s) noexcept { if (!cfg_.enabled || !armed_ || cfg_.channel < 0) return false; diff --git a/src/trigger.hpp b/src/trigger.hpp index 63cbafc..04fe2f0 100644 --- a/src/trigger.hpp +++ b/src/trigger.hpp @@ -30,12 +30,25 @@ struct TriggerConfig { double pretrigger_pct = 50.0; ///< share of the buffer shown before the event. double holdoff_s = 0.010; ///< default from the spec. TriggerMode mode = TriggerMode::Normal; + /// How many of the most recent acquisitions the plot keeps on screen, the older ones drawn + /// dimmer (see `RowSet::persist_alpha()`): 1 -- the default -- keeps nothing but the event on + /// screen, which is the display without persistence, and there is deliberately no second switch + /// for it. + int persist_depth = 1; /// Seconds of post-trigger data to collect before latching the event, given /// the buffer's span. double post_span_s(double buffer_span_s) const; }; +/// The deepest persistence the trigger panel offers. Seven retained acquisitions is what fits: the +/// layer in that position is drawn at half the seventh power (0.8 %, i.e. already gone, see +/// `RowSet::persist_alpha()`), and seven is also the number of free slots a row has beside a single +/// signal, so a deeper request could not be drawn on the simplest layout there is. It is **not** +/// the bound on what persistence may cost in memory -- `ScopeModel::persist_budget_bytes()` is, and +/// a window of millions of samples reaches it long before the seventh layer. +inline constexpr int kMaxPersistDepth = 8; + /// The producer-side trigger state machine. Fed one sample at a time in stream /// order; it says when the event buffer should be latched. class TriggerEngine { @@ -75,6 +88,23 @@ class TriggerEngine { void disarm() noexcept { armed_ = false; pending_ = false; } void arm(double now_s) noexcept; + /// Opens a capture **now**, as if the condition had just fired: `now_s` becomes the event's own + /// time, so the engine collects the post window from the samples that follow it and the ordinary + /// latch publishes `[-pre, +post]` around that instant. This is the Force button -- the one + /// acquisition a scope takes when the edge is not coming -- and it is deliberately not a + /// publish of its own: the capture, the cut, the marker, the count and the mode's re-arm rule are + /// the edge path's, which is what keeps a forced acquisition framed and pinned exactly like a + /// latched one. + /// + /// The pre-trigger gate is the one thing *not* applied: the history behind the present moment is + /// whatever the ring holds, and a caller who asks to capture now is asking for that. A snapshot + /// shorter than the request reports its own `pre_s`, and the view is pinned to it. + /// + /// Returns false when there is no engine to force -- trigger mode off, no channel resolved, not + /// armed -- or when a capture is already filling: a capture in flight *is* the acquisition being + /// asked for, and restarting it would discard the post window it has already collected. + bool force(double now_s) noexcept; + /// Feeds one sample of the trigger channel. `now_s` is the sample's timestamp /// in stream seconds. Returns true when the event has just been latched. bool process(double value, double now_s) noexcept; diff --git a/src/ui.cpp b/src/ui.cpp index 1e27c1b..73288fb 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -3514,11 +3514,27 @@ gboolean tick(gpointer user) { app->capturing = display == DisplayMode::Capturing; if (new_capture) app->capture_edge = cap_edge; + // The persistence plan this frame: what the depth asked for and what the rows had room to draw. + // Declared outside the lock block because the notice below is a widget write, and the tick's + // widget work happens outside the model's lock. + const bool live_display = display == DisplayMode::Live || !app->waveform_name.empty(); + int persist_depth = 1; + int persist_want = 0; + { // Serialise against the acquisition thread's CONFIG handling and against the // UI's own structural changes: the bind takes shared handles into the live // store, and acquire() reads it. std::lock_guard lock(model.mutex()); + // Persistence first, so the bind below is what *resolves* the layers: the last N acquisitions + // are planned into the rows' free slots, and a depth of 1 -- or the trigger off, or a *file* on + // screen, whose data is not a stream of acquisitions at all -- frees every layer slot, which is + // the display without the feature. The plan is a function of the rows and the depth and not of + // the data, so a latch costs no rebuild and the names and ages on screen stay put. + persist_depth = live_display ? 1 : model.persist_depth_locked(); + const int placed = app->rows->sync_layers(persist_depth, &persist_want); + app->persist_want = persist_want; + app->persist_placed = placed; app->rows->bind(model, display); app->rows->acquire(model); if (app->comparing && app->rows_b != nullptr) { @@ -3564,6 +3580,28 @@ gboolean tick(gpointer user) { // acquisition thread mutates, so it belongs under this lock and not after it. app->rows->set_invalid_spans(model, display); } + // A depth the room or the retention budget could not show in full says so, once per *plan* rather + // than once per frame: a user who asked for eight layers and sees two has no other way to tell + // "the display is deliberately shallower" from "the feature is broken". Both bounds are named, + // because they are different numbers and only one of them is the user's own: a *slot* shortfall is + // `placed < want` (the rows had no free trace slot), and a *budget* shortfall is fewer retained + // acquisitions than the depth asks for, in which case the slots exist and draw nothing at all. + const int want_ages = persist_depth > 1 ? persist_depth - 1 : 0; + const int kept_ages = static_cast(std::min( + model.persist_count(), static_cast(std::max(0, want_ages)))); + if (persist_depth > 1 && + (app->persist_placed < persist_want || kept_ages < want_ages) && + (app->persist_noted_depth != persist_depth || app->persist_noted_want != persist_want || + app->persist_noted_placed != app->persist_placed)) { + app->persist_noted_depth = persist_depth; + app->persist_noted_want = persist_want; + app->persist_noted_placed = app->persist_placed; + set_notice(*app, + "persistence: " + std::to_string(app->persist_placed) + " of " + + std::to_string(persist_want) + " layers fit, " + std::to_string(kept_ages) + + " of " + std::to_string(want_ages) + " older acquisitions retained", + 5); + } // Outside the lock: it reads and writes the two plots' own windows, not the model. sync_compare_axes(*app); sync_events_divider(*app); @@ -5777,6 +5815,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_PROBE_DISARM_MS")) { g_timeout_add(static_cast(std::atoi(env)), disarm_probe_step1, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_FORCE_MS")) { + g_timeout_add(static_cast(std::atoi(env)), force_probe, &app); + } if (const char* env = g_getenv("UDPSCOPE_ARM_TRIGGER_MS")) { g_timeout_add(static_cast(std::atoi(env)), arm_probe, &app); } @@ -5909,6 +5950,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_PROBE_RESEQ_MS")) { g_timeout_add(static_cast(std::atoi(env)), reseq_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_PERSIST_MS")) { + g_timeout_add(static_cast(std::atoi(env)), persist_probe, &app); + } // Our own options were consumed by parse_args(); GTK would reject them, so only // the program name is handed to g_application_run(). @@ -6340,7 +6384,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit (app.search_run_button != nullptr) + (app.search_cancel_button != nullptr) + (app.search_prev_button != nullptr) + (app.search_next_button != nullptr); std::printf( - "[udpscope] trigger panel: boxes=%d controls_inside=%d/15 search_widgets=%d/4 " + "[udpscope] trigger panel: boxes=%d controls_inside=%d/16 search_widgets=%d/4 " "search_inside=%d/4\n", boxes, inside(app.trigger_signal_drop, app.trigger_body) + @@ -6357,6 +6401,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit inside(app.trigger_holdoff_spin, app.trigger_body) + inside(app.trigger_band_spin, app.trigger_body) + inside(app.trigger_hf_spin, app.trigger_body) + + inside(app.trigger_persist_spin, app.trigger_body) + inside(app.events_record_switch, app.trigger_body), search_present, inside(app.search_run_button, search_root) + inside(app.search_cancel_button, search_root) + @@ -6600,6 +6645,32 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit static_cast(buf != nullptr ? buf->size() : 0)); } } + { + // Persistence: what is retained, what the rows had room to draw, and the decay schedule in + // use (oldest first, so the half-per-acquisition fall-off reads straight off the line). The + // layer slots are named in the `layout:` line above, as `sine (A)`, and are deliberately + // absent from the per-slot channel lines below it: a layer is not a channel. + std::lock_guard lock(model.mutex()); + const int depth = model.persist_depth_locked(); + std::printf("[udpscope] persistence: depth=%d kept=%zu bytes=%zu budget=%zu dropped=%llu " + "layers=%d want=%d placed=%d schedule=[", + depth, model.persist_count(), model.persist_bytes(), + model.persist_budget_bytes(), + static_cast(model.persist_dropped()), rows.layer_slots(), + app.persist_want, app.persist_placed); + // The decay schedule the depth asks for, oldest first: half per acquisition, so this line + // reads the rule and the probe's `alphas=` reads what the rows were actually built with. + for (int age = std::min(depth - 1, kMaxPersistDepth - 1); age >= 1; --age) { + std::printf("%s%.4f", age != depth - 1 ? "," : "", + static_cast(RowSet::persist_alpha(age))); + } + std::printf("] alphas=["); + const std::vector> layers = rows.row_layers(0); + for (std::size_t i = 0; i < layers.size(); ++i) { + std::printf("%s%.4f", i != 0 ? "," : "", static_cast(layers[i].second)); + } + std::printf("]\n"); + } } return result; } diff --git a/src/ui/trigger-panel.ui b/src/ui/trigger-panel.ui index 6e814fb..da9c566 100644 --- a/src/ui/trigger-panel.ui +++ b/src/ui/trigger-panel.ui @@ -2,10 +2,11 @@ + + what the trigger mode does with the acquisitions it takes: the last few kept as dimmer traces, and a persistence image every acquisition is accumulated into + + + + horizontal + 6 + + + Advanced + start + + + + + vertical + 6 + 4 + + + horizontal + 6 + + + multiple events + start + + + + + + + horizontal + 6 + + + persistence + start + 1 + + + + + + + horizontal + 6 + + + fades by + start + + + + + + + horizontal + 6 + + + time constant s + start + + + + + + + horizontal + 6 + + + per acquisition + start + + + + + + diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 022d36a..d71678e 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -256,6 +256,66 @@ struct App { std::size_t dbg_persist_last_kept = 0; int dbg_persist_stable_polls = 0; bool dbg_persist_settled = false; + /// The phosphor density's controls -- the trigger pane's *Advanced* block: the switch, the + /// dropdown that chooses what the image fades by, and the two parameter spins, only one of which + /// is on screen at a time (`sync_trigger_rows()` states that, as it does for the condition rows). + /// The rows and the expander are held too, so the probe can read what the `.ui` holds and the dump + /// can walk into the block -- an expander's child is *not* in the widget-parent chain. + GtkWidget* trigger_density_switch = nullptr; + GtkWidget* trigger_density_decay_drop = nullptr; + GtkWidget* trigger_density_tau_spin = nullptr; + GtkWidget* trigger_density_factor_spin = nullptr; + GtkWidget* trigger_density_row = nullptr; + GtkWidget* trigger_density_decay_row = nullptr; + GtkWidget* trigger_density_tau_row = nullptr; + GtkWidget* trigger_density_factor_row = nullptr; + GtkWidget* trigger_advanced = nullptr; + /// A new acquisition has reached the screen and has not been deposited into the density grid yet. + /// Set where a snapshot is *framed* -- the held-event branch of the tick -- and when the display + /// is switched on under an acquisition already on screen, and consumed by the next tick's + /// `apply_density()`: the deposit needs the plot laid out for the acquisition it deposits, and + /// that layout is the frame's, one frame after the bind. + bool density_pending = false; + /// The previous tick's clock (`g_get_monotonic_time()`), for the density's per-frame fade; 0 + /// before the first frame, which is what makes the first frame's `dt` zero rather than since-boot. + gint64 density_last_us = 0; + /// Whether the density was on the last time the tick looked, so switching it on under an + /// acquisition already on screen deposits that one at once instead of waiting for a latch that + /// `single` mode may never send. + bool density_was_on = false; + /// What the last tick left on screen, for the dump: the rows the density was on, the total ink + /// and the peak over them, and the bytes the last *frame* uploaded. Captured on the tick because + /// the dump runs after the window is gone. + /// The **last tick the density was on**, captured there and not read back from the plots at + /// exit: the dump runs after the window is gone and after the exit probe has switched the trigger + /// off, which turns the display off with it -- a dump that asked the plots would say `rows=0` + /// about a run whose whole tail had the image up. + int dbg_density_rows = 0; + unsigned long long dbg_density_cells = 0; + unsigned long long dbg_density_grid_bytes = 0; + double dbg_density_ink = 0.0; + double dbg_density_peak = 0.0; + /// The most any one frame **uploaded** of the grid: one byte a cell, so this is the resolution + /// read back as the frame's own cost. A maximum rather than the last value, because the exit + /// probe changes the display before the dump runs and the last painted frame is then not a frame + /// of this display. + long long dbg_density_upload_bytes = 0; + /// How many ticks it was on for: 0 is "no image was ever drawn", which is what separates a real + /// reading from a default. + int dbg_density_frames = 0; + /// The frame-cost accumulators as of the tick the probe switched the display on, so the cost can + /// be read **before and after** it inside one run -- the same stream, the same layout, the same + /// mock. Two runs would differ in everything but the density. + double dbg_density_tick_sum0 = 0.0; + int dbg_density_tick_n0 = 0; + double dbg_density_draw_sum0 = 0.0; + int dbg_density_draw_n0 = 0; + /// The density probe's own state: the time constant its second half uses, and how many polls it + /// has spent waiting for an event to be on screen. + double dbg_density_tau = 1.0; + int dbg_density_polls = 0; + /// The density probe's step, so its sequence is a state machine rather than nested timers. + int dbg_density_stage = 0; GtkWidget* signal_list = nullptr; GtkWidget* pause_button = nullptr; /// When the pause began, on the **monotonic wall clock**, or 0 while running. The elapsed time @@ -1817,6 +1877,26 @@ void persist_probe_report(App& a, const char* what); void persist_probe_set_depth(App& a, int depth); gboolean persist_probe_wait(gpointer user); gboolean persist_probe(gpointer user); + +/// The phosphor density's one step for a frame: state every row's `plot::Plot` with the +/// configuration the trigger holds, advance its fade by the frame's own `dt`, and deposit the +/// acquisition on screen if one has arrived since the last step. `display` and `live_display` are +/// passed in rather than re-read, so the step, the bind and the status line all describe one frame. +/// +/// The density is on while an acquisition is on screen -- `Held`, and `Capturing` as it fills -- +/// and off on a live view, a paused one, or a file: the display the user asked for is a *trigger* +/// display. Off is not "paused": switching it off (and leaving trigger mode, and opening a file) +/// drops the image at once, so a fresh presentation starts empty rather than showing a stale image +/// at another scale. +void apply_density(App& a, DisplayMode display, bool live_display); + +/// The density probe (`UDPSCOPE_PROBE_DENSITY_MS`): the display's own numbers read back off the +/// plots -- total ink and peak with it off, on, and after one decay step of each kind. +void density_probe_report(App& a, const char* what); +/// States the switch and/or the fade through the panel's own widgets, the way a user would. +void density_probe_set(App& a, bool on, bool by_time); +gboolean density_probe(gpointer user); +gboolean density_probe_step(gpointer user); void force_probe_report(App& a, const char* leg); gboolean force_probe_press(gpointer user); gboolean force_probe_poll(gpointer user); diff --git a/src/ui_layout.cpp b/src/ui_layout.cpp index 1a31e70..d6c059f 100644 --- a/src/ui_layout.cpp +++ b/src/ui_layout.cpp @@ -88,6 +88,10 @@ LayoutState collect_layout(App& app) { st.trigger.noise_band = tc.condition.noise_band; st.trigger.hf_fc = tc.condition.hf_fc; st.trigger.persistence = tc.persist_depth; + st.trigger.density = tc.density; + st.trigger.density_decay = tc.density_decay == PersistDecay::Time ? "time" : "acquisitions"; + st.trigger.density_tau_s = tc.density_tau_s; + st.trigger.density_factor = tc.density_factor; st.trigger.level_given = true; // written means given, so a load keeps it } // The connection is the session's too: the file's name carries only host and port. @@ -341,6 +345,14 @@ void apply_trigger_layout(App& app, const LayoutState::Trigger& t) { tc.condition.hf_fc = t.hf_fc; tc.pretrigger_pct = t.pretrigger_pct; tc.holdoff_s = t.holdoff_s; + // The phosphor density: a plain field write, unlike the multiple-event depth below -- there is + // nothing to prune, because the image is not retained data. Its own clamp is the plotter's + // (`full_scale`/`gamma` are the plot's), and the two numbers a file may carry anywhere are + // already inside the floats the plot takes. + tc.density = t.density; + tc.density_decay = t.density_decay == "time" ? PersistDecay::Time : PersistDecay::Acquisitions; + tc.density_tau_s = t.density_tau_s; + tc.density_factor = t.density_factor; } // The persistence depth is not a field write like the rest: it *prunes* the retained // acquisitions to what the file asks for, so it goes through the one setter (which takes the lock diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index d8cf6c8..e2274b7 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -6061,5 +6061,278 @@ gboolean persist_probe_wait(gpointer user) { return G_SOURCE_CONTINUE; } + +// --- phosphor density (UDPSCOPE_PROBE_DENSITY_MS) ----------------------------- +// +// The density is the *plotter's* image, so every number below is read off the plots -- the same +// `DensityGrid::ink()`/`peak()` the library's own tests read -- summed and maxed over the rows the +// display is on. The probe drives the panel's own widgets for the switch and the fade (the way a +// click publishes them) and calls the plot's own `accumulate_density()`/`decay_density()` for the +// two decay steps, so a step's ratio is arithmetic rather than a race against the frame clock: both +// reads and the step happen inside one `g_timeout` callback, with no tick in between. +// +// The legs, in order: +// * **off** -- the switch as a session that never turned it on leaves it, which is also the run +// the exit dump's `frame cost:` line is read against (a plain run, no probe: the density is off +// by default and nothing draws it); +// * **on** -- one deposit, the acquisition already on screen (switching the display on under an +// event deposits it at once rather than waiting for a latch `single` mode may never send); +// * **step, acquisitions** -- one more accumulation: every cell keeps the factor of what it held +// and the trace on screen adds its own pass. With the same frame in both reads the relation is +// exact (`ink' = f*ink + ink`, the second `ink` being one pass), which is what the *library's* +// own case asserts; **in the app the acquisition may move between the two reads**, so what the +// line's ratio says is "the old image halved and one more pass landed on top" rather than a +// number to expect -- and `ink` growing while the ratio stays above `f` is that reading. 0.5 is +// the panel's default factor; +// * **step, time** -- `exp(-dt/tau)` off the ink, with `dt = tau` stated outright, so the ratio +// the line prints must be `1/e`. +// +// It is left **on**, with the fade it last set, so the tail of the run is the display with the +// density: that is what makes the exit dump's `frame cost:` a mean over frames that uploaded an +// image, to be compared with the same command without the probe. +// +// `UDPSCOPE_PROBE_DENSITY_TAU` states the time constant the time leg uses (default 1.0 s, clamped to +// the panel's own range): `dt = tau` is one step whatever it is, so the ratio does not depend on it +// -- it is there so a run can fade faster than a second and still be read. + +/// The time constant the probe's time leg sets, from `UDPSCOPE_PROBE_DENSITY_TAU` (default 1.0 s). +double density_probe_tau() { + const char* env = g_getenv("UDPSCOPE_PROBE_DENSITY_TAU"); + const double s = env != nullptr ? std::atof(env) : 1.0; + return s > 0.0 ? std::min(s, 3600.0) : 1.0; +} + +/// What the density holds over every row it is on: the rows, the cells, and the two numbers the +/// plot reports. A row with the display off contributes nothing -- it has no grid at all. +struct DensityReading { + int rows = 0; + unsigned long long cells = 0; + double ink = 0.0; + double peak = 0.0; +}; + +DensityReading density_read(const App& a) { + DensityReading r; + for (int i = 0; i < a.rows->row_count(); ++i) { + const RowSet::Row& row = a.rows->row(i); + if (row.plot == nullptr || !row.plot->density().enabled) continue; + const plot::DensityGrid& g = row.plot->density_grid(); + ++r.rows; + r.cells += static_cast(g.cell_count()); + r.ink += g.ink(); + r.peak = std::max(r.peak, static_cast(g.peak())); + } + return r; +} + +/// One decay step of each kind, on every row the display is on. The acquisitions step is the +/// plotter's own accumulation (its per-acquisition fade happens inside it, before the deposit), and +/// the time step is `decay_density(tau)` -- the same call the frame tick makes with a frame's `dt`. +double density_step(App& a, bool by_time) { + double before = 0.0; + for (int i = 0; i < a.rows->row_count(); ++i) { + const RowSet::Row& row = a.rows->row(i); + if (row.plot == nullptr || !row.plot->density().enabled) continue; + before += row.plot->density_grid().ink(); + } + for (int i = 0; i < a.rows->row_count(); ++i) { + plot::Plot* p = a.rows->row(i).plot; + if (p == nullptr || !p->density().enabled) continue; + if (by_time) { + p->decay_density(a.dbg_density_tau); + } else { + p->accumulate_density(); + } + } + double after = 0.0; + for (int i = 0; i < a.rows->row_count(); ++i) { + const RowSet::Row& row = a.rows->row(i); + if (row.plot == nullptr || !row.plot->density().enabled) continue; + after += row.plot->density_grid().ink(); + } + return before > 0.0 ? after / before : 0.0; +} + +/// States the display through the panel's own widgets and publishes it: the *widget* is the path a +/// user takes, so what is measured is the app's wiring rather than a second way in. +void density_probe_set(App& a, bool on, bool by_time) { + if (a.trigger_density_switch != nullptr) { + gtk_switch_set_active(GTK_SWITCH(a.trigger_density_switch), on); + } + if (a.trigger_density_decay_drop != nullptr) { + gtk_drop_down_set_selected(GTK_DROP_DOWN(a.trigger_density_decay_drop), by_time ? 1u : 0u); + } + publish_trigger_fields(&a); +} + +/// One leg's line: the numbers, the fade in force, and the ratio a decay step produced (1.0 where no +/// step was taken). +void density_probe_report(App& a, const char* what, double ratio) { + const DensityReading r = density_read(a); + // The configuration is read from the **model**, not off the widgets: the switch and the dropdown + // are what the probe writes, so reading them back would only say what it just set. The model is + // what the tick reads, so `fade=`/`tau=` beside `rows=` is the wiring's own answer -- and a + // spread between the two is the defect the line exists for (a switch that published nothing, a + // tau the plot never saw). + TriggerConfig tc; + { + std::lock_guard lock(a.model->mutex()); + tc = a.model->trigger(); + } + std::fprintf(stderr, + "[density-probe] %s enabled=%d fade=%s tau=%.3f factor=%.3f rows=%d cells=%llu " + "ink=%.1f peak=%.4f ratio=%.6f\n", + what, tc.density ? 1 : 0, + tc.density_decay == PersistDecay::Time ? "time" : "acquisitions", tc.density_tau_s, + tc.density_factor, r.rows, r.cells, r.ink, r.peak, ratio); + std::fflush(stderr); +} + +/// Step 1: the display **off** through the panel's switch (a session file remembers the trigger, so +/// the off state is stated rather than assumed), then the trigger armed on the mock's `square` the +/// way the persistence probe arms its own -- several crossings a second, so an acquisition lands +/// within a couple of seconds. +gboolean density_probe(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + density_probe_set(*a, /*on=*/false, /*by_time=*/false); + { + const std::vector& sigs = a->model->signals(); + std::vector all(sigs.size()); + for (std::size_t i = 0; i < all.size(); ++i) all[i] = i; + if (!all.empty()) a->model->set_signals_enabled(all, true); + } + // One signal per row, the arrangement the app gives an opened file and the persistence probe + // uses -- and, for this probe, the one that puts an image in *every* row (a reading that summed + // over rows would be one row's if only one row plotted anything). + const std::vector& setup_channels = a->model->channels(); + const std::vector& setup_signals = a->model->signals(); + const int setup_rows = + std::min(RowSet::kMaxRows, static_cast(setup_channels.size())); + if (setup_rows > 0) { + a->rows->set_row_count(setup_rows); + for (int r = 0; r < setup_rows; ++r) { + for (int s = 0; s < RowSet::kSlotsPerRow; ++s) a->rows->assign(r, s, "", "", 0); + const std::size_t ci = static_cast(r); + a->rows->assign(r, 0, setup_channels[ci].name, setup_channels[ci].name, + signal_color_index(setup_signals, setup_channels[ci].signal)); + } + a->relayout_pending = true; + } + // The time constant the time leg will state, through the panel's own spin -- so the model's tau, + // the plot's tau and the step's `dt` are one number rather than three that must agree. + a->dbg_density_tau = density_probe_tau(); + if (a->trigger_density_tau_spin != nullptr) { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_density_tau_spin), a->dbg_density_tau); + } + if (a->trigger_density_factor_spin != nullptr) { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_density_factor_spin), 0.5); + } + int sel = -1; + const std::vector& channels = a->model->channels(); + for (std::size_t i = 0; i < channels.size(); ++i) { + if (channels[i].name == "square") sel = static_cast(i); + } + if (sel >= 0) { + gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_signal_drop), static_cast(sel)); + } + gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_type_drop), 0); + gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_edge_drop), 0); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level_spin), 0.0); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_holdoff_spin), 0.0); + apply_time_window(*a, 0.5); + publish_trigger_fields(a); + std::fprintf(stderr, "[density-probe] arming on '%s', window 0.5 s, display off\n", + sel >= 0 ? channels[static_cast(sel)].name.c_str() : "(none)"); + std::fflush(stderr); + if (a->trigger_toggle != nullptr) { + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE); + } + a->dbg_density_stage = 1; + a->dbg_density_polls = 0; + g_timeout_add(200, density_probe_step, a); + return G_SOURCE_REMOVE; +} + +/// The sequence. Each step is a separate `g_timeout`, so whole frames -- ticks, binds, paints -- +/// happen between two of them: a reading taken in the same callback that set a widget would describe +/// the frame *before* the change, which is the trap the compact-arm probe records. +gboolean density_probe_step(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + ++a->dbg_density_polls; + const bool held = a->model->display_mode() == DisplayMode::Held; + double lo = 0.0; + double hi = 0.0; + switch (a->dbg_density_stage) { + case 1: + // Wait for an acquisition to be on screen. The trigger's cadence is the stream's, so a fixed + // delay would report whatever happened to have latched; the cap reports anyway (and says so + // through `rows=0`). + if (!held || !a->rows->event_data_window(lo, hi)) { + if (a->dbg_density_polls > 100) { + density_probe_report(*a, "off", 1.0); + std::fprintf(stderr, "[density-probe] no event appeared; stopping\n"); + std::fflush(stderr); + return G_SOURCE_REMOVE; + } + return G_SOURCE_CONTINUE; + } + density_probe_report(*a, "off", 1.0); + // The cost *before*: the accumulators as of this tick, so the "after" mean is over the frames + // that follow and the two are read off one run -- same stream, same layout, same mock. Two + // runs would differ in everything but the density, which is exactly what a frame-cost A/B + // cannot afford. + a->dbg_density_tick_sum0 = a->dbg_tick_ms_sum; + a->dbg_density_tick_n0 = a->dbg_tick_n; + a->dbg_density_draw_sum0 = a->dbg_draw_ms_sum; + a->dbg_density_draw_n0 = a->dbg_paint_n; + density_probe_set(*a, /*on=*/true, /*by_time=*/false); + a->dbg_density_stage = 2; + return G_SOURCE_CONTINUE; + case 2: { + // The frame that switched the display on deposited the acquisition already on screen; the + // numbers are the one-pass ones. + density_probe_report(*a, "on", 1.0); + const double ratio = density_step(*a, /*by_time=*/false); + density_probe_report(*a, "step-acquisitions", ratio); + density_probe_set(*a, /*on=*/true, /*by_time=*/true); + a->dbg_density_stage = 3; + return G_SOURCE_CONTINUE; + } + case 3: { + density_probe_report(*a, "time-before", 1.0); + // One step of `dt = tau`, which is 1/e by the plot's own rule -- and the one reading that says + // the time fade is wired at all: the panel's constant reaches the grid through the tick. + const double ratio = density_step(*a, /*by_time=*/true); + density_probe_report(*a, "step-time", ratio); + // The cost *after*: the same two means over the frames since the switch, which is the + // difference this display costs -- the per-frame upload and deposit against the frame without + // them. `upload_bytes` in the exit dump beside it is the one frame's share of it. + const int tick_n = a->dbg_tick_n - a->dbg_density_tick_n0; + const int draw_n = a->dbg_paint_n - a->dbg_density_draw_n0; + std::fprintf(stderr, + "[density-probe] frame cost: tick before=%.2f ms (%d frames) after=%.2f ms " + "(%d frames) draw before=%.2f ms (%d) after=%.2f ms (%d)\n", + a->dbg_density_tick_n0 > 0 ? (a->dbg_density_tick_sum0 / a->dbg_density_tick_n0) + : 0.0, + a->dbg_density_tick_n0, + tick_n > 0 ? (a->dbg_tick_ms_sum - a->dbg_density_tick_sum0) / tick_n : 0.0, + tick_n, + a->dbg_density_draw_n0 > 0 ? (a->dbg_density_draw_sum0 / a->dbg_density_draw_n0) + : 0.0, + a->dbg_density_draw_n0, + draw_n > 0 ? (a->dbg_draw_ms_sum - a->dbg_density_draw_sum0) / draw_n : 0.0, + draw_n); + std::fprintf(stderr, "[density-probe] done: the display is left on, fading by time\n"); + std::fflush(stderr); + a->dbg_density_stage = 0; + return G_SOURCE_REMOVE; + } + default: + return G_SOURCE_REMOVE; + } +} + } // namespace ui_impl } // namespace udpscope diff --git a/src/ui_trigger.cpp b/src/ui_trigger.cpp index d03495f..6064ccd 100644 --- a/src/ui_trigger.cpp +++ b/src/ui_trigger.cpp @@ -248,6 +248,20 @@ void write_trigger_widgets(App& app) { static_cast( std::clamp(tc.persist_depth, 1, kMaxPersistDepth))); } + if (app.trigger_density_switch != nullptr) { + gtk_switch_set_active(GTK_SWITCH(app.trigger_density_switch), tc.density); + } + if (app.trigger_density_decay_drop != nullptr) { + gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_density_decay_drop), + tc.density_decay == PersistDecay::Time ? 1u : 0u); + } + if (app.trigger_density_tau_spin != nullptr) { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_density_tau_spin), tc.density_tau_s); + } + if (app.trigger_density_factor_spin != nullptr) { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_density_factor_spin), + tc.density_factor); + } gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_type_drop), dropdown_index_of(app.trigger_type_drop, trigger_type_text(tc.condition.type))); @@ -393,6 +407,23 @@ void publish_trigger_fields(App* app) { const double hf = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_hf_spin)); const int persist = static_cast(std::lround(gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_persist_spin)))); + // The density's four controls. Guarded on null, unlike the ones above: they are built by this + // panel and a `.ui` that lost one leaves the field at its default rather than crashing the + // publish -- and the switch's own state is what a session file and the tick then agree on. + const bool density = app->trigger_density_switch != nullptr && + gtk_switch_get_active(GTK_SWITCH(app->trigger_density_switch)) != FALSE; + const guint density_decay = + app->trigger_density_decay_drop != nullptr + ? gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_density_decay_drop)) + : 0; + const double density_tau = + app->trigger_density_tau_spin != nullptr + ? gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_density_tau_spin)) + : 1.0; + const double density_factor = + app->trigger_density_factor_spin != nullptr + ? gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_density_factor_spin)) + : 0.5; { std::lock_guard lock(app->model->mutex()); TriggerConfig& tc = app->model->trigger(); @@ -411,6 +442,10 @@ void publish_trigger_fields(App* app) { tc.condition.timeout_s = timeout; tc.condition.noise_band = band; tc.condition.hf_fc = hf; + tc.density = density; + tc.density_decay = density_decay == 1u ? PersistDecay::Time : PersistDecay::Acquisitions; + tc.density_tau_s = density_tau; + tc.density_factor = density_factor; } // The persistence depth is applied through the one setter rather than written into the config // here: it is clamped to the cap *and* it prunes the retained acquisitions at once, so lowering @@ -460,6 +495,37 @@ void sync_trigger_rows(App& app) { gtk_spin_button_set_value(l2, other < level ? level - step : level + step); } } + // The density's own rows follow the *display*, not the condition: the two fades take different + // numbers, so only the one in use is on screen -- a greyed spin for the other is a field the user + // cannot tell from a broken one -- and the whole choice is hidden while the display is off, since + // a decay kind for an image that is not being drawn has no subject. The switch itself stays: it is + // the one control that turns the display on. + const bool density_on = app.trigger_density_switch != nullptr && + gtk_switch_get_active(GTK_SWITCH(app.trigger_density_switch)) != FALSE; + const bool density_by_time = + app.trigger_density_decay_drop != nullptr && + gtk_drop_down_get_selected(GTK_DROP_DOWN(app.trigger_density_decay_drop)) == 1u; + show(app.trigger_density_decay_row, density_on); + show(app.trigger_density_tau_row, density_on && density_by_time); + show(app.trigger_density_factor_row, density_on && !density_by_time); +} + +/// The **density switch**. It publishes the field and nothing else: whether the image is on is +/// read every frame from the model, so there is no widget state to keep in step, and the tick +/// notices the transition itself -- which is what deposits the acquisition already on screen +/// instead of waiting for the next latch (in `single` mode there may never be one). +void on_density_toggled(GObject* o, GParamSpec*, gpointer user) { + (void)o; + publish_trigger_fields(static_cast(user)); +} + +/// The **fade** dropdown. Publishes, then re-states the block: which of the two parameter rows is +/// on screen is this dropdown's business and `sync_trigger_rows()` is the one place that reads it. +void on_density_decay_changed(GObject* o, GParamSpec*, gpointer user) { + (void)o; + App* app = static_cast(user); + publish_trigger_fields(app); + sync_trigger_rows(*app); } void on_trigger_field_changed(GtkWidget*, gpointer user) { @@ -684,12 +750,65 @@ GtkWidget* build_trigger_panel(App& app) { static_cast(persist_seed)); gtk_widget_set_tooltip_text( app.trigger_persist_spin, - "how many of the most recent triggered acquisitions stay on screen, each older one at half " - "the previous one's brightness; 1 is the event alone. A layer needs a free trace slot in the " - "row that plots its signal, and the retained acquisitions are also bounded by the buffer's " - "own size"); + "multiple events: how many of the most recent triggered acquisitions stay on screen as " + "separate traces, each older one at half the previous one's brightness; 1 is the event " + "alone. A layer needs a free trace slot in the row that plots its signal, and the retained " + "acquisitions are also bounded by the buffer's own size"); app.trigger_persist_row = row_for(ui, "persistence_row", app.trigger_persist_spin); + // The phosphor *density*: one screen-sized image every acquisition is accumulated into, drawn + // behind the traces and fading by a time constant or by a per-acquisition factor -- the other + // display of the same acquisitions, and nothing like the multiple-event one above (N traces + // against one image, and no count to choose). It applies in trigger mode only, which the body + // already is: the whole block goes insensitive with the trigger off, the panel's own rule. + app.trigger_density_switch = gtk_switch_new(); + gtk_widget_set_valign(app.trigger_density_switch, GTK_ALIGN_CENTER); + gtk_widget_set_halign(app.trigger_density_switch, GTK_ALIGN_END); + { + std::lock_guard lock(app.model->mutex()); + gtk_switch_set_active(GTK_SWITCH(app.trigger_density_switch), + app.model->trigger().density); + } + gtk_widget_set_tooltip_text( + app.trigger_density_switch, + "accumulate every triggered acquisition into one screen-sized image, drawn behind the " + "traces: brightness is how often a trace passed, so an image of a thousand acquisitions " + "costs what one costs. Trigger mode only, and cleared whenever the display leaves it"); + g_signal_connect(app.trigger_density_switch, "notify::active", + G_CALLBACK(on_density_toggled), &app); + // Not `row_for()`: it expands its child, and an expanded switch is a wide pill that reads as a + // slider rather than as a switch -- the record row's rule, and the label carries the `hexpand`. + if (GtkWidget* density_row = GTK_WIDGET(gtk_builder_get_object(ui, "density_row"))) { + gtk_box_append(GTK_BOX(density_row), app.trigger_density_switch); + app.trigger_density_row = density_row; + } + + const char* density_decays[] = {"acquisitions", "time", nullptr}; + app.trigger_density_decay_drop = gtk_drop_down_new_from_strings(density_decays); + gtk_widget_set_tooltip_text( + app.trigger_density_decay_drop, + "'acquisitions': every acquisition multiplies the image by the factor below, so the N-th " + "acquisition back is the factor to the N -- it scales with the trigger rate. 'time': the " + "image fades by exp(-dt / the time constant) as the display runs, whatever the rate"); + app.trigger_density_decay_row = + row_for(ui, "density_decay_row", app.trigger_density_decay_drop); + g_signal_connect(app.trigger_density_decay_drop, "notify::selected", + G_CALLBACK(on_density_decay_changed), &app); + + app.trigger_density_tau_spin = spin(0.01, 3600.0, 0.1, 3, 1.0); + gtk_widget_set_tooltip_text(app.trigger_density_tau_spin, + "seconds for the image to fall to 1/e of its ink: the trace is still " + "visible a second afterwards, faintly, which is what a phosphor does"); + app.trigger_density_tau_row = row_for(ui, "density_tau_row", app.trigger_density_tau_spin); + + app.trigger_density_factor_spin = spin(0.0, 1.0, 0.05, 2, 0.5); + gtk_widget_set_tooltip_text(app.trigger_density_factor_spin, + "what each acquisition keeps of the ones before it: 0.5 is the " + "schedule a phosphor's afterglow has -- 1, 0.5, 0.25, ... -- and 0 " + "shows only the newest acquisition"); + app.trigger_density_factor_row = + row_for(ui, "density_factor_row", app.trigger_density_factor_spin); + app.arm_button = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_arm")); if (app.arm_button != nullptr) { g_signal_connect(app.arm_button, "clicked", G_CALLBACK(on_arm_clicked), &app); @@ -708,9 +827,14 @@ GtkWidget* build_trigger_panel(App& app) { for (GtkWidget* w : {app.trigger_level_spin, app.trigger_pre_spin, app.trigger_holdoff_spin, app.trigger_width_spin, app.trigger_width2_spin, app.trigger_level2_spin, app.trigger_timeout_spin, app.trigger_band_spin, - app.trigger_hf_spin, app.trigger_persist_spin}) { + app.trigger_hf_spin, app.trigger_persist_spin, + app.trigger_density_tau_spin, app.trigger_density_factor_spin}) { g_signal_connect(w, "value-changed", G_CALLBACK(on_trigger_field_changed), &app); } + // The advanced block itself, so the probe can read it back and the dump can walk into it: an + // expander's child is *not* in the widget-parent chain (see docs/claude/invariants-gtk-and-chrome.md), + // so "is this control in the panel" has to start from the child the expander returns. + app.trigger_advanced = GTK_WIDGET(gtk_builder_get_object(ui, "advanced_exp")); g_signal_connect(app.trigger_edge_drop, "notify::selected", G_CALLBACK(on_trigger_drop_changed), &app); g_signal_connect(app.trigger_mode_drop, "notify::selected", From 5e321995978a93ad6927ba51eced410a1e11ad85 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Fri, 16 Oct 2026 17:38:06 +0200 Subject: [PATCH 004/127] fix: the density's ceiling comes from the fade, so the ramp is visible The library's `full_scale` now defaults to the default fade's steady state and `gamma` to 1.0 (its own commit); this side states the ceiling for the fade *it* configured, because that is what the ceiling means: the ink the display settles at, so the newest acquisition is a fraction of full ink and white is reached only where acquisitions agree. - **acquisitions**: `1/(1 - factor)` -- the geometric sum, 2.0 at the panel's 0.5, and 1.0 at 0 (only the newest sweep, at full ink). - **time**: the library's default of two sweeps. A time fade has no fixed steady state -- it settles where the trigger's rate and the constant meet, which is the *point* of it -- so a fast trigger reads brighter than a slow one the way a phosphor's does. - `gamma` is left at the library's 1.0: a cell is a count of sweeps, so half as many must read as half the ink. With the ceiling at one sweep and gamma at 0.45 (the library's first defaults) one sweep was already full white and every display looked the same -- which is exactly what the user reported seeing, and why the assertion for it is a framebuffer one in the library's case (`empty=21 full=83 quarter=54`) rather than anything `ink`/`peak` can say. The probe's own figures are unchanged in kind (they are the grid's): `on` `peak=1.0000`, `step-acquisitions` `peak=1.5000`, `step-time` `ratio=0.367879`, `off` all zeros -- re-read on this build, with the frame cost `tick 0.53 -> 7.49 ms`, `draw 1.89 -> 16.78 ms` and `gtk warnings: 0` in the same run. The trigger/pinning invariants carry the ceiling rule and why the eye found it. --- deps/plotter | 2 +- docs/claude/invariants-trigger-and-pinning.md | 11 +++++++++++ src/ui.cpp | 12 ++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/deps/plotter b/deps/plotter index 6a6915e..7382052 160000 --- a/deps/plotter +++ b/deps/plotter @@ -1 +1 @@ -Subproject commit 6a6915e64e9a9bd5e7b8c93cfa600334bf192b10 +Subproject commit 738205293eca6e77d6876dbd0ad2df1a42f26791 diff --git a/docs/claude/invariants-trigger-and-pinning.md b/docs/claude/invariants-trigger-and-pinning.md index 4723572..1c8d7bb 100644 --- a/docs/claude/invariants-trigger-and-pinning.md +++ b/docs/claude/invariants-trigger-and-pinning.md @@ -403,6 +403,17 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear * **The switch deposits what is already on screen.** `on && !density_was_on` sets the same pending flag, so a user who turns persistence on while an event is held sees it instead of waiting for a latch that `single` mode may never send. + * **The brightness ceiling is the fade's steady state, and the ramp is linear in sweeps.** The app + states `DensityConfig::full_scale` from the fade it configured -- `1/(1 - factor)` for the + acquisition fade, the library's default two sweeps for the time one, which has no fixed steady + state (it settles where the rate and the constant meet) -- and leaves `gamma` at 1.0. A ceiling + at *one* sweep is the defect a user reported as "the colour fading is not applied, it looks + white": a single sweep was already full ink, everything above it clamped to the same pixel, and + a display of one sweep and a display of five were the same white. It is also the one half that + the *grid's* own numbers cannot see -- `ink`/`peak` were right while the picture was flat -- so + the assertion is a **framebuffer** one: `a_phosphor_density_is_drawn_under_the_traces` measures a + cell at a quarter of the scale against one at the ceiling and against the background + (`empty=21 full=83 quarter=54`), which is the reading the eye asked for. * **It is the plot's, so a figure rebuild resets it** -- the grid lives on each `Plot`, and `rebuild_all()` builds new ones. That is the one state a structural change (a row added, a signal switched off, a trace recoloured) does not carry: it costs the accumulated image and nothing else, diff --git a/src/ui.cpp b/src/ui.cpp index bc77641..0f85ce4 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -3396,6 +3396,18 @@ void apply_density(App& app, DisplayMode display, bool live_display) { : plot::DensityDecay::PerAcquisition; cfg.time_constant_s = static_cast(tc.density_tau_s); cfg.per_acquisition = static_cast(tc.density_factor); + // The brightness ceiling: the ink this fade settles at, so the newest acquisition is a + // *fraction* of full ink and the display reaches white only where the acquisitions agree. For a + // per-acquisition fade that is the geometric sum `1/(1-f)` exactly. A time fade has no fixed + // one -- it settles where the trigger's rate and the constant meet, which is the point of it -- + // so it keeps the library's default of two sweeps, and a fast trigger then reads brighter than + // a slow one the way a phosphor's does. The gamma is left at the library's 1.0: the cell is a + // count of sweeps, so half as many sweeps must read as half the ink. (With the ceiling at one + // sweep -- the library's first default -- one sweep was already full white and the fade was + // invisible, which is exactly what a user reported seeing.) + cfg.full_scale = tc.density_decay == PersistDecay::Time + ? 2.0f + : static_cast(1.0 / std::max(1.0 - tc.density_factor, 0.05)); // The plot drops what it held when this turns the display off, so the step below only ever // speaks about an image that is on screen. p->set_density(cfg); From da826f2cb57e5015c162112175fd5b87fc0e0690 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Fri, 16 Oct 2026 20:21:57 +0200 Subject: [PATCH 005/127] 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. --- README.md | 26 +++++--- deps/plotter | 2 +- docs/claude/invariants-trigger-and-pinning.md | 56 ++++++++++------- docs/claude/probe-readings.md | 62 +++++++++++-------- docs/claude/verification.md | 10 +-- help/C/org.udpscope.app/trigger.page | 19 +++--- src/ui.cpp | 58 +++++++++-------- src/ui/trigger-panel.ui | 8 +-- src/ui_app.hpp | 1 + src/ui_probe.cpp | 51 ++++++++------- src/ui_trigger.cpp | 18 +++--- 11 files changed, 181 insertions(+), 130 deletions(-) diff --git a/README.md b/README.md index fea3c6e..418ceff 100644 --- a/README.md +++ b/README.md @@ -1269,14 +1269,21 @@ pinned: the axis, the framing and every measurement still describe the event on `N = 1` retains nothing at all — no copy, no layer — and lowering the depth frees the retained snapshots there and then, because a trigger that has stopped firing never reaches another publish. -**Persistence: one image, every acquisition.** The trigger panel's `Advanced` block carries the -*other* display of the same acquisitions, and it is not a count at all: `persistence` turns on a -**phosphor density** — the plotter's screen-sized intensity image (`Plot::set_density()`, one `f32` -cell per device pixel of the axes rect) into which every triggered acquisition is deposited and -which is drawn behind the traces. A cell's brightness is how many acquisitions passed through it, -so the sweep on screen is the brightest, the older ones add on top **with no depth to choose and no -memory that grows with them** — the image is 2.2 MB over four 573x412 rows in the probe's run, and -a thousand acquisitions cost the same. `fades by` picks how it decays: `acquisitions` (each +**Persistence: one image per signal, every acquisition.** The trigger panel's `Advanced` block +carries the *other* display of the same acquisitions, and it is not a count at all: `persistence` +turns on a **phosphor density** — the plotter's screen-sized intensity image (`Plot::set_density()`, +one `f32` cell per device pixel of the axes rect **per trace, in that trace's own signal colour**) +into which every triggered acquisition is deposited, the newest included, and which is drawn *in +place of* the traces. The current acquisition is therefore a sweep *inside* the image and fades with +the rest; no solid polyline is drawn over it, which is what a report of "the persistent image plus +the last acquisition, and the last one never fades in time mode" turned out to be. A cell's +brightness is how many acquisitions passed through it — one sweep's ink is the trace's own colour, +and where several agree it clamps there rather than going white — so the display brightens where the +signal is steady and dims where it only passed, **with no depth to choose and no memory that grows +with the acquisitions**: the cost is the screen's times the traces on it (2.2 MB over the probe's +four one-signal rows; the default four signals in one 800x480 row are 6 MB) and a thousand +acquisitions cost the same. Each signal is a separate image, so two signals agreeing in a cell read +as two colours and not one blended grey. `fades by` picks how it decays: `acquisitions` (each acquisition keeps the factor of what was before it, 0.5 by default — the phosphor afterglow schedule) or `time` (a time constant in stream seconds, so the display fades while you watch whatever the trigger rate). It applies in **trigger mode only** — while a captured acquisition is @@ -1285,7 +1292,8 @@ is an image *of* a stream of acquisitions. Like the invalid-span bands it reserv and nothing on it can be picked. The `[density-probe]` run that measured it is in `docs/claude/probe-readings.md`; what it costs is in the same place (`[density-probe] frame cost:`: tick 0.74 → 9.99 ms and draw 2.09 → 20.91 ms over the same run, at four rows and 2.2 M cells a -frame, on llvmpipe). +frame, on llvmpipe — measured before the per-trace split, when the one image was still re-filled +per row). **And it says what it costs.** A retained acquisition is a whole window copied out of the ring, so the retention is bounded by two things: the depth, and **`--buffer-mb`** — persistence may hold at diff --git a/deps/plotter b/deps/plotter index 7382052..1b8b55d 160000 --- a/deps/plotter +++ b/deps/plotter @@ -1 +1 @@ -Subproject commit 738205293eca6e77d6876dbd0ad2df1a42f26791 +Subproject commit 1b8b55d3ee37d13a9d8533784a343ac1755b4153 diff --git a/docs/claude/invariants-trigger-and-pinning.md b/docs/claude/invariants-trigger-and-pinning.md index 1c8d7bb..e96c784 100644 --- a/docs/claude/invariants-trigger-and-pinning.md +++ b/docs/claude/invariants-trigger-and-pinning.md @@ -376,21 +376,26 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear trigger that has stopped firing never reaches another publish. A depth change is therefore also how the memory comes back. -- **The phosphor density is one *image*, and that is a different display from the layers above, not a +- **The phosphor density is the *trace*, and that is a different display from the layers above, not a mode of them.** The trigger panel's `Advanced` block offers both, and the words matter: *multiple events* is `persist_depth` (N retained acquisitions, N traces, dimmer with age) while *persistence* is `TriggerConfig::density` -- the plotter's `Plot::set_density()`, a screen-sized intensity grid - every acquisition is deposited into and which fades either by a time constant or by a - per-acquisition factor. There is **no depth** on the density and no per-acquisition memory: the - image is `columns * rows * 4` bytes, one cell per device pixel of the row's axes (2.2 MB over the - four rows of the probe's run), so a thousand acquisitions cost what one costs. A cell's value is a - number of *sweeps*: the library deposits a cell once per trace per sweep however many of that - trace's own samples crossed it (`[density-probe] on` reads `peak=1.0000` for one sweep; a per-hit - deposit read 18) -- dwell is `Style::intensity_grading`'s question, not this one's. - Four things hold it together, each of which is a rule: + **per trace, drawn in that trace's own colour**, every acquisition deposited into it, which fades + either by a time constant or by a per-acquisition factor. There is **no depth** on the density and + no per-acquisition memory: the image is `traces * columns * rows * 4` bytes, one cell per device + pixel of the row's axes for every trace the row draws (the app's default four signals in one row + are four grids -- 6 MB at 800x480; the probe's one-signal-per-row run is 2.2 MB over four rows), so + a thousand acquisitions cost what one costs. A cell's value is a number of *sweeps*: the library + deposits a cell once per trace per sweep however many of that trace's own samples crossed it + (`[density-probe] on` reads `peak=1.0000` for one sweep; a per-hit deposit read 18) -- dwell is + `Style::intensity_grading`'s question, not this one's. The colour is the trace's own + (`RowSet::signal_color()`, the same source the trace was built with and never a second one), so a + saturated cell is that signal's colour at full strength and two signals agreeing in a cell are two + quads of two colours rather than a blend. + Five things hold it together, each of which is a rule: * **It is a trigger-mode display.** `apply_density()` (the tick) states `Held` and `Capturing` as the states it is on in; `Live`, `Waiting`, `Paused` and a file are not, and turning it off drops - the grid **at once** rather than at an acquisition a quiet trigger never sends (the rule + the grids **at once** rather than at an acquisition a quiet trigger never sends (the rule `set_persist_depth()` follows). So it goes away with the trigger, and a re-enable starts from an empty image rather than a stale one at another scale. * **One deposit per acquisition, one frame *after* the frame that frames it.** `Plot::accumulate_density()` @@ -403,21 +408,26 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear * **The switch deposits what is already on screen.** `on && !density_was_on` sets the same pending flag, so a user who turns persistence on while an event is held sees it instead of waiting for a latch that `single` mode may never send. - * **The brightness ceiling is the fade's steady state, and the ramp is linear in sweeps.** The app - states `DensityConfig::full_scale` from the fade it configured -- `1/(1 - factor)` for the - acquisition fade, the library's default two sweeps for the time one, which has no fixed steady - state (it settles where the rate and the constant meet) -- and leaves `gamma` at 1.0. A ceiling - at *one* sweep is the defect a user reported as "the colour fading is not applied, it looks - white": a single sweep was already full ink, everything above it clamped to the same pixel, and - a display of one sweep and a display of five were the same white. It is also the one half that - the *grid's* own numbers cannot see -- `ink`/`peak` were right while the picture was flat -- so - the assertion is a **framebuffer** one: `a_phosphor_density_is_drawn_under_the_traces` measures a - cell at a quarter of the scale against one at the ceiling and against the background - (`empty=21 full=83 quarter=54`), which is the reading the eye asked for. - * **It is the plot's, so a figure rebuild resets it** -- the grid lives on each `Plot`, and + * **No trace is drawn over it, and the newest acquisition is inside it.** `Renderer::draw_traces()` + returns at once while a density is on: the trace ink *is* the image, so the acquisition just + deposited is a sweep in it and fades with the rest. Drawing the polyline too put a solid, + unfading stroke over its own faint ghost -- the reported "persistent image + last acquisition", + where the last acquisition never faded in time mode -- and the assertion is a **framebuffer** one: + `a_phosphor_density_is_the_trace_in_its_own_colour` reads the same pixel before and after one + time constant and requires it dimmer (`full=224 -> faded=115`, the trace's own `0xE0` green), + which a stroke over the density could not do. + * **The brightness ceiling is the trace's own brightness, and the ramp is linear in sweeps.** The + app leaves `DensityConfig::full_scale` at the library's default of **one sweep** and `gamma` at + 1.0, so the acquisition just deposited reads as the trace it is a picture of and a signal crossing + the same place every acquisition settles *at* the trace's colour (the ceiling clamps), rather than + a fraction of a brighter steady state. The old ceiling -- the fade's steady state, `1/(1-factor)` + for the acquisition fade -- is the gain offset a user reported between the latest acquisition and + the persistent image, and the "dimmer by design" headroom that made the ramp visible is reversed + here. + * **It is the plot's, so a figure rebuild resets it** -- the grids live on each `Plot`, and `rebuild_all()` builds new ones. That is the one state a structural change (a row added, a signal switched off, a trace recoloured) does not carry: it costs the accumulated image and nothing else, - and the alternative -- keeping the grid in the `RowSet` -- is what would make it not a plot + and the alternative -- keeping the grids in the `RowSet` -- is what would make it not a plot feature. The layout is unaffected either way: the density reserves no space and nothing on it can be picked, exactly as `set_invalid_spans()` does. * **The session carries it.** `trigger_density`, `trigger_density_decay`, `trigger_density_tau` and diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 1733be8..1c0dc45 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -1980,53 +1980,61 @@ ring's bindings, and a reading taken there says nothing about either. ``` [density-probe] arming on 'square', window 0.5 s, display off -[density-probe] off enabled=0 fade=acquisitions tau=1.000 factor=0.500 rows=0 cells=0 ink=0.0 peak=0.0000 ratio=1.000000 -[density-probe] on enabled=1 fade=acquisitions tau=1.000 factor=0.500 rows=4 cells=2226150 ink=226596.0 peak=1.0000 ratio=1.000000 -[density-probe] step-acquisitions enabled=1 fade=acquisitions tau=1.000 factor=0.500 rows=4 cells=2226150 ink=262722.0 peak=1.5000 ratio=1.159429 -[density-probe] time-before enabled=1 fade=time tau=1.000 factor=0.500 rows=4 cells=2226150 ink=213362.2 peak=1.2182 ratio=1.000000 -[density-probe] step-time enabled=1 fade=time tau=1.000 factor=0.500 rows=4 cells=2226150 ink=78491.6 peak=0.4481 ratio=0.367879 -[density-probe] frame cost: tick before=0.74 ms (163 frames) after=9.99 ms (12 frames) draw before=2.09 ms (157) after=20.91 ms (7) -[udpscope] density: frames=504 rows=4 cells=2226150 grid_bytes=8904600 ink=632845.4 peak=2.8 upload_bytes=1081350 +[density-probe] off enabled=0 fade=acquisitions tau=1.000 factor=0.500 rows=0 traces=0 cells=0 ink=0.0 peak=0.0000 ratio=1.000000 +[density-probe] on enabled=1 fade=acquisitions tau=1.000 factor=0.500 rows=4 traces=4 cells=2226150 ink=226786.0 peak=1.0000 ratio=1.000000 +[density-probe] step-acquisitions enabled=1 fade=acquisitions tau=1.000 factor=0.500 rows=4 traces=4 cells=2226150 ink=313971.0 peak=1.5000 ratio=1.384437 +[density-probe] time-before enabled=1 fade=time tau=1.000 factor=0.500 rows=4 traces=4 cells=2226150 ink=458610.1 peak=2.1359 ratio=1.000000 +[density-probe] step-time enabled=1 fade=time tau=1.000 factor=0.500 rows=4 traces=4 cells=2226150 ink=168713.2 peak=0.7858 ratio=0.367879 +[density-probe] frame cost: tick before=0.62 ms (184 frames) after=6.96 ms (23 frames) draw before=2.02 ms (177) after=6.16 ms (18) +[udpscope] density: frames=748 rows=4 traces=4 cells=2226150 grid_bytes=8904600 ink=489400.2 peak=2.2 upload_bytes=2226150 ``` The **phosphor density** (`Plot::set_density()`, the trigger pane's `persistence` switch): one -screen-sized intensity image per row, every acquisition accumulated into it, drawn behind the traces and -fading by a count or by the clock. The numbers are the plotter's own -- `DensityGrid::ink()`/`peak()` summed -and maxed over the rows the display is on, which is the same source the library's tests read -- and the +screen-sized intensity image **per trace**, in that trace's own signal colour, every acquisition +accumulated into it (the newest included), drawn *in place of* the traces and fading by a count or by +the clock. The numbers are the plotter's own -- `DensityGrid::ink()`/`peak()` summed and maxed over the +traces the display is on, which is the same source the library's tests read -- and the probe drives the panel's **own widgets** for the switch and the fade, so what it measures is this app's wiring. -- **`off` is the display without it**: `rows=0`, so there is no grid at all and both numbers are zero. - That is also the run the frame cost is read against, and it is a plain run -- the density is off by - default and nothing draws it. -- **`on` is one sweep**: `peak=1.0000` exactly, and `ink=226596` is the cells that sweep covered over - four rows. A cell takes **1.0 once per trace per sweep** however many of the trace's own samples crossed - it, which is what makes a sweep's peak *one* rather than the eighteen a per-hit deposit read on the same - run -- and what makes the sweeps behind it visible at all. +- **`off` is the display without it**: `rows=0 traces=0`, so there is no grid at all and both numbers + are zero. That is also the run the frame cost is read against, and it is a plain run -- the density + is off by default and nothing draws it. +- **`on` is one sweep**: `traces=4` -- one grid per signal, four rows of one signal each -- and + `peak=1.0000` exactly, with `ink=226786` the cells that sweep covered. A cell takes **1.0 once per + trace per sweep** however many of the trace's own samples crossed it, which is what makes a sweep's + peak *one* rather than the eighteen a per-hit deposit read on the same run -- and what makes the + sweeps behind it visible at all. - **`step-acquisitions`** deposits one more sweep: `peak=1.0000 -> 1.5000`, which is `1 + 0.5 x 1` -- the - panel's factor 0.5 applied to what was there, then the new sweep's own pass on top. `ink` rises from - `226596` to `262722` for the same reason: the old image halved and one sweep landed on it. The ratio is + panel's factor 0.5 applied to what was there, then the new sweep's own pass on top. The ratio is **not** a constant to expect -- the acquisition on screen can move between the two reads, so what it says is "the old image halved and a pass landed on it"; the exact relation is the *library's* case (`a_density_halves_per_acquisition_and_keeps_the_newest_brightest`), where the same frame is deposited twice. - **`step-time` is `1/e` by construction**: one step of `dt = tau` through the plot's own `decay_density()`, so `ratio=0.367879` is `exp(-1)`. The leg before it (`time-before`) reads the same image under the time - fade, and the pair is the reading that says the *panel's* constant reaches the grid. + fade (`peak=2.1359`), and the pair is the reading that says the *panel's* constant reaches the grid. - **`frame cost:` is the A/B inside one run**: the accumulators as of the tick the display was switched on against the frames after it, so both means are the same stream, the same layout and the same mock. At four - rows of 573x412 cells it reads **tick 0.74 -> 9.99 ms** and **draw 2.09 -> 20.91 ms**: the per-frame + rows of 573x412 cells it reads **tick 0.62 -> 6.96 ms** and **draw 2.02 -> 6.16 ms**: the per-frame conversion, upload and deposit. It is llvmpipe, and the arithmetic is the grid's -- 2.2 M cells, one byte - each, every frame the time fade moves it. + each, every frame the time fade moves it. The *draw* mean is now below the run before the per-trace + split (2.02 -> 6.16 against 2.09 -> 20.91): the solid traces are no longer drawn over the image, so the + frame draws four quads where it used to draw four quads *and* every trace. +- **`cells`/`grid_bytes` are the per-signal memory figure**: 556 537 cells and 2.2 MB *per signal*, times + the four signals here (2 226 150 cells, 8 904 600 bytes). It is the axes rect's device pixels, so it is + the screen and the trace count and nothing the trigger has done -- and the app's default layout, four + signals in one row, is four grids for that one row. - **The dump's `density:` line** is captured on the last tick the display was **on**, not read at exit: the exit probe switches the trigger off before it dumps, which takes the display with it, so a dump that asked the plots would say `rows=0` about a run whose tail had the image up. `upload_bytes` is the **most** any - one frame uploaded -- one byte a cell, over the rows that moved -- and not the last frame's, for the same - reason. + one frame uploaded -- one byte a cell **per trace**, over the grids that moved -- and not the last frame's, + for the same reason. -**Read `peak` as the display's ceiling**: with the default `full_scale` of 1.0 a cell at 1.0 is full ink, so -a steady display under the acquisition fade sits between 1 and 2, and a `peak` well above that (the -per-hit deposit read 189 after two sweeps) is the defect this probe's `on` leg now catches. +**Read `peak` as the display's ceiling**: with the default `full_scale` of 1.0 a cell at 1.0 is full ink +(the trace's own colour), so a steady display under the acquisition fade sits between 1 and 2, and a `peak` +well above that (the per-hit deposit read 189 after two sweeps) is the defect this probe's `on` leg now +catches. **And `N = 1` is the display without the feature**, which is the same command at `UDPSCOPE_PROBE_PERSIST_DEPTH=1`: diff --git a/docs/claude/verification.md b/docs/claude/verification.md index b7d4ec4..823b23b 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -115,8 +115,9 @@ precedent, not an exception. schedule in use, oldest first — halves, and `1/2^age` at that. Both lines and the two exit-dump lines are in [probe-readings.md](probe-readings.md). - `UDPSCOPE_PROBE_DENSITY_MS` (+ `UDPSCOPE_PROBE_DENSITY_TAU`, default 1.0 s) — the **phosphor - density**: one screen-sized intensity image every triggered acquisition is accumulated into, drawn - behind the traces, fading by a per-acquisition factor or by a time constant. It states the display + density**: one screen-sized intensity image **per trace**, in that trace's own colour, every + triggered acquisition accumulated into it (the newest included), drawn *in place of* the traces, + fading by a per-acquisition factor or by a time constant. It states the display **off** through the panel's own switch first, so the `off` leg is the run without it, then arms the trigger on the mock's `square` with one signal per row — the arrangement the persistence probe uses — and only then switches it on, so the acquisition already on screen is the first image. Four legs, @@ -131,8 +132,9 @@ precedent, not an exception. kill $PY ``` - **The assertions, in order:** `off` reads `rows=0 cells=0 ink=0.0 peak=0.0000` (there is no grid at - all); `on` reads `rows=4` — every row has its own image — and **`peak=1.0000`**, one sweep's ink + **The assertions, in order:** `off` reads `rows=0 traces=0 cells=0 ink=0.0 peak=0.0000` (there is no + grid at all); `on` reads `rows=4` — every row has its own image, one per trace in its own colour, + so `traces=4` with one signal a row — and **`peak=1.0000`**, one sweep's ink being one per cell it covers; `step-acquisitions` reads **`peak=1.5000`**, which is the panel's 0.5 applied to the old image plus the new sweep's own pass; and `step-time` reads **`ratio=0.367879`**, one step of `dt = tau` through the plot's own `decay_density()`. A `peak` in diff --git a/help/C/org.udpscope.app/trigger.page b/help/C/org.udpscope.app/trigger.page index 5f85d2b..7d2cd1a 100644 --- a/help/C/org.udpscope.app/trigger.page +++ b/help/C/org.udpscope.app/trigger.page @@ -123,16 +123,19 @@
Persistence: how often the trace has been here

persistence — the switch in the trigger panel's Advanced block — keeps - one image of the last acquisitions instead of their traces. Every acquisition is - accumulated into it, so a cell's brightness means how many of them passed through it: the - sweep on screen is the brightest, the ones behind it add on top, and where several agree the - ink is brighter still. It is off by default.

+ an image of the last acquisitions instead of their traces. Every acquisition is + accumulated into it, the one on screen included, so a cell's brightness means how many of them + passed through it: the sweep on screen is the brightest, the ones behind it add on top, and + where several agree the ink is brighter still. There is no stroke drawn over the image — the + acquisition on screen is its newest ink — so it fades with the rest in both modes. Each + signal is kept as its own image in that signal's own colour, so where two signals cross the same + place you see both, not one blended line. It is off by default.

Two things make it different from multiple events beside it, and the panel offers - both because they answer different questions. It never runs out of room: the image is - the size of the plot on screen, so a display of a thousand acquisitions costs what a display of - one costs, and there is no count to choose. And it fades, in one of two ways — - fades by picks them:

+ both because they answer different questions. It never runs out of room: each signal's + image is the size of the plot on screen, so a display of a thousand acquisitions costs what a + display of one costs — one image per signal, whatever the plot's trace slots — and there is no + count to choose. And it fades, in one of two ways — fades by picks them:

acquisitions: every acquisition keeps per acquisition of what was there before it (0.5, the default, is the schedule a diff --git a/src/ui.cpp b/src/ui.cpp index 0f85ce4..efd928e 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -3384,6 +3384,7 @@ void apply_density(App& app, DisplayMode display, bool live_display) { app.density_was_on = on; int rows_on = 0; + int traces_on = 0; unsigned long long cells = 0; double ink = 0.0; double peak = 0.0; @@ -3396,18 +3397,14 @@ void apply_density(App& app, DisplayMode display, bool live_display) { : plot::DensityDecay::PerAcquisition; cfg.time_constant_s = static_cast(tc.density_tau_s); cfg.per_acquisition = static_cast(tc.density_factor); - // The brightness ceiling: the ink this fade settles at, so the newest acquisition is a - // *fraction* of full ink and the display reaches white only where the acquisitions agree. For a - // per-acquisition fade that is the geometric sum `1/(1-f)` exactly. A time fade has no fixed - // one -- it settles where the trigger's rate and the constant meet, which is the point of it -- - // so it keeps the library's default of two sweeps, and a fast trigger then reads brighter than - // a slow one the way a phosphor's does. The gamma is left at the library's 1.0: the cell is a - // count of sweeps, so half as many sweeps must read as half the ink. (With the ceiling at one - // sweep -- the library's first default -- one sweep was already full white and the fade was - // invisible, which is exactly what a user reported seeing.) - cfg.full_scale = tc.density_decay == PersistDecay::Time - ? 2.0f - : static_cast(1.0 / std::max(1.0 - tc.density_factor, 0.05)); + // The brightness ceiling is left at the library's default of **one sweep** -- the trace's own + // brightness -- so the acquisition just deposited reads as the trace it is a picture of and a + // signal crossing the same place every acquisition settles at the trace's colour rather than + // brighter. The fade's steady state is deliberately *not* the ceiling: it put a gain offset + // between the newest sweep and the image behind it (the newest at a fraction of the settled + // brightness), which is what a user reported as a gain offset between the latest acquisition + // and the persistent image, and the "dimmer by design" headroom that made the ramp visible is + // the wrong call at this brightness scale -- reversed here. // The plot drops what it held when this turns the display off, so the step below only ever // speaks about an image that is on screen. p->set_density(cfg); @@ -3415,16 +3412,26 @@ void apply_density(App& app, DisplayMode display, bool live_display) { ++rows_on; // Time first, then the deposit: under `PerAcquisition` the decay is the accumulate path's own, // so this is a no-op there; under `TimeConstant` it is this frame's fade and the deposit lands - // on top of it at full ink. + // on top of it at full ink. Either way the *newest* acquisition is inside the image, so it + // fades with the rest -- the renderer draws no solid trace over the density. p->decay_density(dt); if (deposit) p->accumulate_density(); - cells += static_cast(p->density_grid().cell_count()); - ink += p->density_grid().ink(); - peak = std::max(peak, static_cast(p->density_grid().peak())); + // One grid per trace, each in its own signal's colour, so the row's figures are the **sum over + // its traces** and `cells` scales with the signal count as well as the row count. `peak` is the + // brightest of them, which is the reading that says whether any one trace is saturating. + for (plot::u32 t = 0; t < p->trace_count(); ++t) { + const plot::DensityGrid& g = p->density_grid(t); + if (g.cell_count() == 0u) continue; // an unbound slot holds no grid + ++traces_on; + cells += static_cast(g.cell_count()); + ink += g.ink(); + peak = std::max(peak, static_cast(g.peak())); + } } app.density_pending = false; if (rows_on == 0) return; // nothing was on this frame: the dump's figures stay the last real ones app.dbg_density_rows = rows_on; + app.dbg_density_traces = traces_on; app.dbg_density_cells = cells; app.dbg_density_grid_bytes = cells * sizeof(plot::f32); app.dbg_density_ink = ink; @@ -6804,15 +6811,16 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit { // The phosphor density, as of the last tick it was **on** (the exit probe switches the // trigger off before this runs, so the plots hold nothing by now -- see `App::dbg_density_*`). - // `frames` is how many ticks drew an image, `cells`/`grid_bytes` its resolution and memory - // over the rows it was on, `ink`/`peak` the numbers the plot's own readers report, and - // `upload_bytes` the most any one frame uploaded -- one byte a cell, on the frames whose grid - // moved; a frame whose grid did not move uploads nothing at all. - std::printf("[udpscope] density: frames=%d rows=%d cells=%llu grid_bytes=%llu ink=%.1f " - "peak=%.1f upload_bytes=%lld\n", - app.dbg_density_frames, app.dbg_density_rows, app.dbg_density_cells, - app.dbg_density_grid_bytes, app.dbg_density_ink, app.dbg_density_peak, - app.dbg_density_upload_bytes); + // `frames` is how many ticks drew an image; `rows`/`traces` how many plots and how many of + // their traces held a grid (one per trace, in the trace's own colour); `cells`/`grid_bytes` + // its resolution and memory over them, `ink`/`peak` the numbers the plot's own readers + // report, and `upload_bytes` the most any one frame uploaded -- one byte a cell, on the frames + // whose grids moved; a frame whose grids did not move uploads nothing at all. + std::printf("[udpscope] density: frames=%d rows=%d traces=%d cells=%llu grid_bytes=%llu " + "ink=%.1f peak=%.1f upload_bytes=%lld\n", + app.dbg_density_frames, app.dbg_density_rows, app.dbg_density_traces, + app.dbg_density_cells, app.dbg_density_grid_bytes, app.dbg_density_ink, + app.dbg_density_peak, app.dbg_density_upload_bytes); } } return result; diff --git a/src/ui/trigger-panel.ui b/src/ui/trigger-panel.ui index 22ec6a9..a850a16 100644 --- a/src/ui/trigger-panel.ui +++ b/src/ui/trigger-panel.ui @@ -217,11 +217,11 @@ Both displays live here and they are two different things: *multiple events* keeps the last N acquisitions as N traces, dimmer with age, while *persistence* accumulates every - acquisition into one screen-sized image (the plotter's `Plot::set_density()`). The - first is a count and the second is a decay, which is why neither is a mode of the - other. --> + acquisition into an image (one screen-sized grid per trace, in that trace's own colour, + the plotter's `Plot::set_density()`), drawn in place of the traces. The first is a + count and the second is a decay, which is why neither is a mode of the other. --> - what the trigger mode does with the acquisitions it takes: the last few kept as dimmer traces, and a persistence image every acquisition is accumulated into + what the trigger mode does with the acquisitions it takes: the last few kept as dimmer traces, and a persistence image (one per signal, in its own colour) every acquisition is accumulated into diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 76e2b8b..20cdc1c 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -40,26 +40,46 @@ int on_render_probe(GtkGLArea*, GdkGLContext*, gpointer user) { if (s.graded_traces > 0) ++app->dbg_graded_frames; if (s.raw_traces > 0 || s.lod_traces > 0) check_frame_ink(*app); if (s.raw_traces == 0 && s.lod_traces == 0) { - ++app->dbg_notrace_frames; - app->dbg_notrace_last = app->dbg_paint_n; - if (app->dbg_ever_bound) { - ++app->dbg_notrace_after_bound; - // What the frame was asked to draw, beside what there was to draw it from: an axis - // that left the data and a trace that had nothing bound are the same picture and - // different bugs, so both are named here rather than inferred from the count. - if (app->dbg_notrace_after_bound <= 40) { - const RowSet::Row& r = app->rows->row(0); - const plot::Range ax = r.plot != nullptr ? r.plot->x_range(r.axis_x) : plot::Range{}; - std::fprintf(stderr, "[notrace] paint %d mode=%s axis=[%.6f..%.6f] segments=%u", - app->dbg_paint_n, display_mode_name(app->dbg_display), ax.min, ax.max, - s.staged_segments); - for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) { - if (r.channel[static_cast(sl)] < 0) continue; - const plot::Range b = r.slots[static_cast(sl)].x_bounds(); - std::fprintf(stderr, " slot%d n=%zu x=[%.6f..%.6f]", sl, - r.slots[static_cast(sl)].size(), b.min, b.max); + // **A frame the density is drawing is not a traceless one.** `Renderer::draw_traces()` + // returns while a plot's phosphor density is on -- the trace ink *is* the image -- so every + // frame of a persistence display stages no trace and would be counted here, which inflates + // the dump's reading and (with `UDPSCOPE_PROBE_NOTRACE` set below) prints a line per frame. + // That is the display working, not "what the frame was asked to draw" with nothing to draw + // it from. Read off the plots, because the frame's own stats can only say no trace was + // drawn and not why. + bool density_on = false; + for (int i = 0; i < app->rows->row_count() && !density_on; ++i) { + const plot::Plot* p = app->rows->row(i).plot; + density_on = p != nullptr && p->density().enabled; + } + if (!density_on) { + ++app->dbg_notrace_frames; + app->dbg_notrace_last = app->dbg_paint_n; + if (app->dbg_ever_bound) { + ++app->dbg_notrace_after_bound; + // What the frame was asked to draw, beside what there was to draw it from: an axis + // that left the data and a trace that had nothing bound are the same picture and + // different bugs, so both are named here rather than inferred from the count. + // + // **A per-paint line, so it is behind a variable** (`UDPSCOPE_PROBE_NOTRACE`), unlike + // the counters beside it: a state that draws nothing lasts as long as it lasts -- a + // `Waiting` tail, a bound row with no data -- and the line would be one per frame the + // whole time. The count in the dump is the reading; this is the detail behind it. + static const bool notrace_print = g_getenv("UDPSCOPE_PROBE_NOTRACE") != nullptr; + if (notrace_print && app->dbg_notrace_after_bound <= 40) { + const RowSet::Row& r = app->rows->row(0); + const plot::Range ax = r.plot != nullptr ? r.plot->x_range(r.axis_x) : plot::Range{}; + std::fprintf(stderr, "[notrace] paint %d mode=%s axis=[%.6f..%.6f] segments=%u", + app->dbg_paint_n, display_mode_name(app->dbg_display), ax.min, ax.max, + s.staged_segments); + for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) { + if (r.channel[static_cast(sl)] < 0) continue; + const plot::Range b = r.slots[static_cast(sl)].x_bounds(); + std::fprintf(stderr, " slot%d n=%zu x=[%.6f..%.6f]", sl, + r.slots[static_cast(sl)].size(), b.min, b.max); + } + std::fprintf(stderr, "\n"); } - std::fprintf(stderr, "\n"); } } } diff --git a/src/ui_trigger.cpp b/src/ui_trigger.cpp index 0d7c8a6..67217de 100644 --- a/src/ui_trigger.cpp +++ b/src/ui_trigger.cpp @@ -756,7 +756,7 @@ GtkWidget* build_trigger_panel(App& app) { "acquisitions are also bounded by the buffer's own size"); app.trigger_persist_row = row_for(ui, "persistence_row", app.trigger_persist_spin); - // The phosphor *density*: one screen-sized image per trace, in that trace's own colour, every + // The phosphor *density*: one bounded image per trace, in that trace's own colour, every // acquisition accumulated into it, drawn *in place of* the traces and fading by a time constant // or by a per-acquisition factor -- the other display of the same acquisitions, and nothing like // the multiple-event one above (an image per signal against N retained traces, and no count to @@ -772,7 +772,7 @@ GtkWidget* build_trigger_panel(App& app) { } gtk_widget_set_tooltip_text( app.trigger_density_switch, - "accumulate every triggered acquisition into a screen-sized image per signal, in that " + "accumulate every triggered acquisition into an image per signal, in that " "signal's own colour, drawn in place of the traces: brightness is how often a trace passed, " "so an image of a thousand acquisitions costs what one costs. Trigger mode only, and cleared " "whenever the display leaves it"); From fcf268d249e5e97008f321fd6afc724defbf75b2 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Wed, 28 Oct 2026 16:03:05 +0100 Subject: [PATCH 007/127] 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. --- README.md | 28 +++++++++++++++++++++++++- docs/claude/build-and-run.md | 6 ++++++ docs/claude/probe-readings.md | 18 +++++++++++++++-- docs/claude/verification.md | 11 ++++++++++ help/C/org.udpscope.app/overview.page | 15 +++++++++++++- src/config.cpp | 15 ++++++++++++++ src/config.hpp | 9 +++++++++ src/main.cpp | 27 +++++++++++++++++++++++++ src/ui.cpp | 29 ++++++++++++++++++++++++--- 9 files changed, 151 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 50c2e46..25a2a25 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,10 @@ Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` window the toolbar offers, default 600), `--holdoff` (default trigger hold-off), `--recv-buffer`, `--plots` (the number of stacked plots to start with, 1..8; rows are added and removed from the **Layout editor** afterwards), `--relative-time` (see below), -`--capture-packets N` (see "Packet capture" below). +`--capture-packets N` (see "Packet capture" below), `--renderer NAME` (GTK's renderer: +`cairo`, `gl` or `vulkan`; `cairo` is the software preset that exports both +`GSK_RENDERER` and `LIBGL_ALWAYS_SOFTWARE` before GTK initialises — see the SSH +limitation below). **What a large `--buffer-mb` costs at startup, and what the window says while it happens.** The buffer's rings are value-initialised, so allocating one touches every byte of `--buffer-mb` --- about @@ -2922,6 +2925,29 @@ silence timeout and then resumes, which is the EMI case end to end. inner popover takes the grab, the menu closes under the click, and the bubble is left parented to a row in a hidden popover. Export is described in the Documentation instead. +- **Over SSH with X11 forwarding the window may never paint or allocate, and the cure is + `--renderer cairo`.** There are two walls, in order. + GTK4 defaults to Vulkan and a forwarded display has no usable Vulkan device, so the + log shows `vkCreateSwapchainKHR(): Initialization of an object could not be completed + ... (VK_ERROR_INITIALIZATION_FAILED)` and nothing is drawn. `GSK_RENDERER` alone gets + past that, but the plot is a `GtkGLArea` and asks GDK for a GL context of its own + whatever GSK draws with; over forwarding there is no hardware one, and the window then + stays unallocated — `header: window_button_w=0` with a zero `bar_w`, and no + `[width-probe]` line (it prints only once the plot has a viewport wider than 100 px) — + while the stream side is fine. Absence of `buffer allocated:` is *not* part of that + reading: signals arrive disabled, so a start with nothing enabled plans no buffer and + prints no line even on a healthy display, and the plot stays empty until a signal's + switch is turned on. + `--renderer cairo` is the preset for exactly this display: it exports + `GSK_RENDERER=cairo` **and** `LIBGL_ALWAYS_SOFTWARE=1` (llvmpipe answers the GL area), + the same pair `tools/make-help-shots.sh` uses. `--renderer gl` is the faster + alternative when indirect GL works. The variables are exported in `main()` **before** + `gtk_init()`/`adw_init()`, which is the whole trap: they are read when the renderer and + the GL context are created, so setting them later is a silent no-op. Without the option + nothing is set and GTK's own choice stands. No `GDK_BACKEND` companion exists on + purpose: an X11-forwarded session carries `DISPLAY` and no `WAYLAND_DISPLAY`, so GDK + already selects X11; forcing it would override a working local Wayland session for no + gain (if both are set and Wayland is broken, export `GDK_BACKEND=x11` yourself). - **The tour card can cover its own target, and in the documented headless run it does.** The card is placed under, over or beside the hole whenever one of those has room; when the target covers the whole content -- which is what the plot does at the diff --git a/docs/claude/build-and-run.md b/docs/claude/build-and-run.md index f52482d..5e23f4a 100644 --- a/docs/claude/build-and-run.md +++ b/docs/claude/build-and-run.md @@ -103,6 +103,12 @@ count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwar the value is required, and the popover button uses 1000 when the flag was absent). The flag is **not** persisted in the session file: persistence of the enable state belongs with `todo.md` item 3, alongside the relative-time toggle. +`--renderer cairo|gl|vulkan` exports `GSK_RENDERER` **at the top of `main()`** — +before `choose_source()`'s `adw_init()` and `run_ui()`'s, because GSK reads the variable when +it creates its renderer and setting it later is a silent no-op. `cairo` is the software preset +and also exports `LIBGL_ALWAYS_SOFTWARE=1`: the plot is a `GtkGLArea`, so a display with no +usable GPU fails twice, and this is the pair `tools/make-help-shots.sh` already sets. Nothing +is exported when the option is absent, so GTK's own choice stands. `tools/mock_udpstreamer.py` has waveform-injection options used by the probes: diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 777032d..0f20a67 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -450,11 +450,25 @@ Two lines of the self-check output are assertions in disguise: floor are the two refusals, and because the probe emits `::clicked` straight at the widget it reaches the handler even when the button is insensitive, so `add above 0` there must leave `rows=` and `traces=` untouched rather than growing a ninth row. -- `header: window_button_w=W bar_w=B` (stderr, once a second) — **`W` must be +- `[udpscope] header: window_button_w=W bar_w=B` (stderr, once a second, **only when + `UDPSCOPE_PROBE_HEADER` is set**) — **`W` must be a small constant and must not track `B`**: the window control is a header-bar child, and an expanding one is measured into the title's centre box, so it stretched from the label to the title (516 px at a 1270 px bar before the fix, 121 px after). A `W` that - grows with the window width is that bug back. + grows with the window width is that bug back. The widths are still sampled every second + in every run, because the exit dump's `header:` line reads them; only the line is behind + the variable, since a reading printed on every run is a log flood (the `[notrace]` rule). + An unallocated window reads `0` for both, which is the reading for "the toplevel never + got a size", not for a layout drift. +- `[renderer] option=NAME GSK_RENDERER=V LIBGL_ALWAYS_SOFTWARE=V` (stderr, at startup, + before GTK initialises; only when `UDPSCOPE_PROBE_RENDERER` is set) — what `--renderer` + exported, printed from `main()` so the reading is of the environment a pre-init GTK will + see. `option=(unset)` with both variables `(unset)` is the default and the assertion that + the flag changes nothing unless asked. +- `[renderer] realized=TYPE` (stderr, once, from the first tick that finds a window renderer; + same probe) — the class GTK actually built (`GskCairoRenderer` for `--renderer cairo`), + which is the half of the claim the export alone cannot make. It is null until the window is + realized, so a tick that finds none tries again rather than printing a false reading. - `toolbar icons: trigger=NAME(ok) events=... pause=... cursors=... sidebar=... menu=...` (in the exit dump, beside `panel controls:`) — the icon name every toolbar button carries, with `gtk_icon_theme_has_icon()`'s verdict. **Every entry must read `(ok)`.** Three diff --git a/docs/claude/verification.md b/docs/claude/verification.md index e91556e..87525f4 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -155,6 +155,17 @@ precedent, not an exception. (the frame's ink is the image) and would otherwise be counted as well. The counter itself is in every build; see [probe-readings.md](probe-readings.md). +- `UDPSCOPE_PROBE_HEADER` (no value; **not** a scheduled probe) — the once-a-second + `[udpscope] header: window_button_w=… bar_w=…` line. The widths are sampled either way (the + exit dump reads them); the line is behind this variable because it is detail behind that + reading, not a line every run should print. An unallocated window reads `0` for both. +- `UDPSCOPE_PROBE_RENDERER` (no value; **not** a scheduled probe) — the renderer pair + `--renderer` should have exported, printed from `main()` before GTK initialises + (`[renderer] option=… GSK_RENDERER=… LIBGL_ALWAYS_SOFTWARE=…`), then the renderer GTK + built (`[renderer] realized=…`) once the window has one. Run it with `--renderer cairo` to + check the option reaches the environment before `gtk_init()`/`adw_init()`; run it without + the option to check the default is untouched. + - `UDPSCOPE_PROBE_TRIGRENUM_MS` — the trigger's capture must survive a **renumbering of its own channel**. It sets the reported geometry through the real widgets (1 s window, 10 % pre share, hold-off from `--holdoff`) on the mock's *square* alone, polls for a capture to open, and then diff --git a/help/C/org.udpscope.app/overview.page b/help/C/org.udpscope.app/overview.page index 6ea5685..43c2d4d 100644 --- a/help/C/org.udpscope.app/overview.page +++ b/help/C/org.udpscope.app/overview.page @@ -55,7 +55,20 @@

The signals arrive disabled. 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.

+ idle side panel is working correctly, not failing to connect. For the same reason a start + that has enabled nothing prints no buffer allocated: line on stderr.

+ + +

Over SSH with X11 forwarding the window may never appear, and the cure is + udpscope --renderer cairo. Two symptoms tell the two halves apart: a + vkCreateSwapchainKHR() ... VK_ERROR_INITIALIZATION_FAILED warning is + GTK's default Vulkan renderer failing to make a swapchain and is what the option's + GSK_RENDERER=cairo answers; a window whose allocation stays zero + (window_button_w=0) means the plot's GL area has no context either, and + that is what its LIBGL_ALWAYS_SOFTWARE=1 answers. Both variables are + exported before GTK initialises, because they are read when the renderer and the GL + context are created. --renderer gl is faster when indirect GL works.

+

See Choosing a source for the chooser and the multicast diff --git a/src/config.cpp b/src/config.cpp index 66c1a72..1b71402 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -62,6 +62,12 @@ void print_usage(std::FILE* out, const char* argv0) { " time is then kept for export only\n" " --open FILE open an exported waveform (.npy) instead of a live source:\n" " signals from its columns, no socket, no acquisition\n" + " --renderer NAME render with cairo (software), gl or vulkan. cairo also\n" + " exports LIBGL_ALWAYS_SOFTWARE=1, because the plot is a\n" + " GtkGLArea and still needs a GL context. Over SSH/X11\n" + " forwarding use cairo: Vulkan cannot make a swapchain there\n" + " and the GL area has no hardware context, so the window\n" + " never paints or allocates\n" " -h, --help this text\n", argv0); } @@ -113,6 +119,15 @@ Config parse_args(int argc, char** argv) { c.capture_packets = static_cast(n); } else if (a == "--open" || a.rfind("--open=", 0) == 0) { c.open_waveform = value_of(argc, argv, i, "--open"); + } else if (a == "--renderer" || a.rfind("--renderer=", 0) == 0) { + const std::string v = value_of(argc, argv, i, "--renderer"); + // Only the three a source or a forwarded display actually chooses between. Anything + // GTK does not know would fall back with a warning and leave the user reading a + // renderer failure that is really a typo, so it is refused here instead. + if (v != "cairo" && v != "gl" && v != "vulkan") { + fail("--renderer must be cairo, gl or vulkan: " + v); + } + c.renderer = v; } else if (a == "--relative-time") { c.relative_time = true; c.relative_time_specified = true; diff --git a/src/config.hpp b/src/config.hpp index 640ae16..3304834 100644 --- a/src/config.hpp +++ b/src/config.hpp @@ -60,6 +60,15 @@ struct Config { /// and the chooser's Waveforms page sets this rather than the connection fields. std::string open_waveform; + /// `--renderer NAME`: the GSK renderer to force, by exporting `GSK_RENDERER` **before any GTK + /// call**. Empty leaves GTK's own choice alone. `cairo` is the software preset over SSH/X11 + /// forwarding: it also exports `LIBGL_ALWAYS_SOFTWARE=1`, because the plot's `GtkGLArea` asks + /// for a GL context whatever GSK draws with. `gl` is the faster alternative when indirect GL + /// works. The variables are read when the renderer/context is created, so setting them anywhere + /// after `gtk_init()`/`adw_init()` is a no-op -- which is why this is a `Config` field applied + /// at the top of `main()` and not a preference. + std::string renderer; + /// The source chooser's "Guided tour" entry. The tour is drawn on the application's own window, /// which does not exist while the chooser runs, so the request rides on the one value `main()` /// owns and hands to both the chooser and the front end (`open_waveform` and the preferences' diff --git a/src/main.cpp b/src/main.cpp index 9672625..b4e439c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -10,6 +10,8 @@ #include "source_dialog.hpp" #include "ui.hpp" +#include + #include #include @@ -26,6 +28,31 @@ int main(int argc, char** argv) { return 2; } + // `--renderer` is exported here and nowhere else, because GSK reads `GSK_RENDERER` when it + // *creates* its renderer: inside the first `adw_init()`/`gtk_init()`, which `choose_source()` + // below may call. Setting it after that point is a silent no-op, so the one place it can be + // honoured is before anything touches GTK. Overwrite: a flag on the command line is a + // statement about this run, and the environment is only a default it may override. + // + // `cairo` is the whole software preset and not just GSK's half: the plot is a `GtkGLArea`, + // which asks GDK for a GL context of its own whatever GSK draws with. Over X11 forwarding + // that request is what leaves the window unallocated (no `buffer allocated:` line, zero + // width) once the Vulkan swapchain failure is out of the way, so `LIBGL_ALWAYS_SOFTWARE` + // makes Mesa answer it with llvmpipe. This is the pair `tools/make-help-shots.sh` sets. + if (!cfg.renderer.empty()) g_setenv("GSK_RENDERER", cfg.renderer.c_str(), TRUE); + if (cfg.renderer == "cairo") g_setenv("LIBGL_ALWAYS_SOFTWARE", "1", TRUE); + // The startup half of the renderer probe, printed before GTK exists: the request and the + // variables this process actually exported. The window's `[renderer] realized=` line is the + // other half and says which renderer GTK built. + if (g_getenv("UDPSCOPE_PROBE_RENDERER") != nullptr) { + const char* gsk = g_getenv("GSK_RENDERER"); + const char* gl = g_getenv("LIBGL_ALWAYS_SOFTWARE"); + std::fprintf(stderr, "[renderer] option=%s GSK_RENDERER=%s LIBGL_ALWAYS_SOFTWARE=%s\n", + cfg.renderer.empty() ? "(unset)" : cfg.renderer.c_str(), + gsk != nullptr ? gsk : "(unset)", gl != nullptr ? gl : "(unset)"); + std::fflush(stderr); + } + // No `--server` means the user has not said which source to open, so ask. Before the model: // declining must cost no buffer and no socket, and nothing below has to cope with "no source". // `--open` is the other answer to that question, and the chooser knows it: its Waveforms page diff --git a/src/ui.cpp b/src/ui.cpp index efd928e..19d4f8e 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -3465,6 +3465,22 @@ gboolean tick(gpointer user) { ScopeModel& model = *app->model; const gint64 tick_start_us = g_get_monotonic_time(); + // The renderer GTK actually built, printed once and only under the probe: `--renderer` exports + // `GSK_RENDERER` before any GTK call, and this is the other half of that claim -- the class GTK + // chose, not the name it was asked for. The renderer is null until the window is realized, so a + // tick that finds none simply tries again. + { + static const bool renderer_probe = g_getenv("UDPSCOPE_PROBE_RENDERER") != nullptr; + static bool renderer_printed = false; + if (renderer_probe && !renderer_printed) { + if (GskRenderer* r = gtk_native_get_renderer(GTK_NATIVE(app->window))) { + std::fprintf(stderr, "[renderer] realized=%s\n", G_OBJECT_TYPE_NAME(r)); + std::fflush(stderr); + renderer_printed = true; + } + } + } + // The paint probe's clock, claimed on the first tick that finds one: a widget has no // frame clock until it is realized, and the area is realized on its first draw. if (app->dbg_clock == nullptr && app->area_widget != nullptr) { @@ -3500,14 +3516,21 @@ gboolean tick(gpointer user) { // the title's centre box, i.e. window_button_w tracked the window width). if (app->window_button != nullptr) { static gint64 next_header_log = 0; + // The widths stay sampled on this one-second cadence whatever the environment, because the + // exit dump's `header:` line reads them; only the *line* is behind a variable. A reading + // printed on every run is a log flood and not a reading -- the same rule the `[notrace]` + // per-paint line follows. + static const bool header_print = g_getenv("UDPSCOPE_PROBE_HEADER") != nullptr; const gint64 now = g_get_monotonic_time(); if (now >= next_header_log) { next_header_log = now + G_TIME_SPAN_SECOND; app->dbg_win_btn_w = gtk_widget_get_width(app->window_button); app->dbg_bar_w = gtk_widget_get_width(app->window); - std::fprintf(stderr, "[udpscope] header: window_button_w=%d bar_w=%d\n", - app->dbg_win_btn_w, app->dbg_bar_w); - std::fflush(stderr); + if (header_print) { + std::fprintf(stderr, "[udpscope] header: window_button_w=%d bar_w=%d\n", + app->dbg_win_btn_w, app->dbg_bar_w); + std::fflush(stderr); + } } } // The help layer's own readings and the bounds the help's annotated figures crop, on the same From f3f8f67a62eccb7942dacb7c6cb32afa76a3c8af Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Wed, 28 Oct 2026 16:16:32 +0100 Subject: [PATCH 008/127] 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. --- README.md | 10 +++ tools/udpscope-remote.sh | 135 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100755 tools/udpscope-remote.sh diff --git a/README.md b/README.md index 25a2a25..64bbd6b 100644 --- a/README.md +++ b/README.md @@ -2893,6 +2893,16 @@ silence timeout and then resumes, which is the EMI case end to end. ## Limitations +- **A remote display needs the software renderer, and the plot is empty until a signal is + enabled.** Over SSH with X11 forwarding there is no usable Vulkan device: GTK cannot create a + swapchain (`vkCreateSwapchainKHR … VK_ERROR_INITIALIZATION_FAILED`) and the window never appears + even though the stream connects and its CONFIG parses. `--renderer cairo` selects the software + preset for **both** halves — GTK's renderer and the plot's own GL context, which is a + `GtkGLArea` and asks for one regardless of what GSK draws with — and + `tools/udpscope-remote.sh` runs the app against a virtual X server with a VNC view carried over + an SSH tunnel, avoiding X11 forwarding altogether. If a window does appear but stays empty, check + that a **signal is enabled**: signals start disabled, so a session with none switched on plots + nothing and prints no `buffer allocated:` line, by design rather than as a fault. - **Multi-element (vector/matrix) signals are acquired with a zero-order hold.** `udps_client` decodes exactly one *sample* of `num_rows x num_cols` values per DATA packet for such a signal, regardless of the publish mode, so it cannot be diff --git a/tools/udpscope-remote.sh b/tools/udpscope-remote.sh new file mode 100755 index 0000000..a9857c5 --- /dev/null +++ b/tools/udpscope-remote.sh @@ -0,0 +1,135 @@ +#!/bin/sh +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 Martino Ferrari +# +# Run udpscope on a headless machine and look at it over an SSH tunnel. +# +# Why this exists: over SSH with X11 forwarding there is no usable Vulkan device, so GTK cannot +# create a swapchain and the window never appears; `--renderer cairo` fixes GTK's half, but the +# plot is a GtkGLArea and indirect GLX is the second wall. A virtual X server on the machine that +# actually runs the app sidesteps both -- the GL context lives next door to the samples -- and VNC +# carries the picture back through a tunnel. `ssh -X` is not needed and not used. +# +# The picture is rendered by Xvfb's software path, so the app must be told to use its software +# preset too (`--renderer cairo`), otherwise GTK reaches for Vulkan inside Xvfb and warns exactly +# as it does over a forwarded display. +# +# x11vnc binds to localhost only and runs without a password *by design*: the SSH tunnel is the +# authentication. Never replace that with `-listen 0.0.0.0`, and never open the VNC port on the +# host's firewall. `-localhost` exists here so that a mistake is not a remotely reachable screen. +# +# Usage: +# tools/udpscope-remote.sh [--size WxH] [--port N] [--app PATH] [--no-vnc] [-- udpscope args...] +# +# --size WxH virtual screen (default 1600x1000) +# --port N VNC port on localhost (default 5900) +# --app PATH the binary to run (default build/udpscope) +# --no-vnc start the X server and the app only; for another viewer, or to test the server +# -- everything after this is passed to udpscope unchanged +# +# What it prints when it is up: +# * the ssh command to run on *your* machine, which forwards the VNC port, +# * the viewer command (any VNC client), and +# * optionally how to get a browser instead (websockify + noVNC, if installed). +# +# Exits when udpscope exits, or on Ctrl-C, taking the X server and the VNC server with it. +set -eu + +here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) +root=$(dirname -- "$here") + +size=1600x1000 +port=5900 +app=${UDPSCOPE_REMOTE_APP:-$root/build/udpscope} +vnc=1 +app_args= + +while [ $# -gt 0 ]; do + case $1 in + --size) size=$2; shift 2 ;; + --port) port=$2; shift 2 ;; + --app) app=$2; shift 2 ;; + --no-vnc) vnc=0; shift ;; + --help|-h) sed -n '7,37p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + --) shift; app_args=$*; break ;; + *) echo "udpscope-remote: unknown option $1 (use -- to pass options to udpscope)" >&2; exit 2 ;; + esac +done + +# Preflight, with the fix in the message rather than a bare "command not found". +need() { + command -v "$1" >/dev/null 2>&1 || { + echo "udpscope-remote: $1 is required. $2" >&2 + exit 1 + } +} +need Xvfb "Install Xvfb (Arch: pacman -S xorg-server-xvfb; Debian: apt install xvfb)." +if [ "$vnc" = 1 ]; then + need x11vnc "Install x11vnc (Arch: pacman -S x11vnc; Debian: apt install x11vnc), or pass --no-vnc." +fi +[ -x "$app" ] || { echo "udpscope-remote: $app is not executable; build it first or pass --app" >&2; exit 1; } + +# A free display: probe the ones a desktop is unlikely to be using and take the first that answers +# no. `xdpyinfo` failing is the test -- a stale lock file does not matter, only a live server does. +display= +n=90 +while [ "$n" -le 120 ]; do + if ! xdpyinfo -display ":$n" >/dev/null 2>&1; then display=":$n"; break; fi + n=$((n + 1)) +done +[ -n "$display" ] || { echo "udpscope-remote: no free display in :90..:120" >&2; exit 1; } + +xvfb_pid= +app_pid= +vnc_pid= +cleanup() { + [ -n "${vnc_pid:-}" ] && kill "$vnc_pid" 2>/dev/null || true + [ -n "${app_pid:-}" ] && kill "$app_pid" 2>/dev/null || true + [ -n "${xvfb_pid:-}" ] && kill "$xvfb_pid" 2>/dev/null || true + wait 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +# `-nolisten tcp` too: the virtual server is for this machine's sockets only. +Xvfb "$display" -screen 0 "${size}x24" -nolisten tcp >/dev/null 2>&1 & +xvfb_pid=$! +for _ in 1 2 3 4 5 6 7 8 9 10; do + xdpyinfo -display "$display" >/dev/null 2>&1 && break + sleep 0.2 +done +xdpyinfo -display "$display" >/dev/null 2>&1 || { echo "udpscope-remote: Xvfb did not come up on $display" >&2; exit 1; } + +export DISPLAY="$display" +# The software preset, as one option: GTK's renderer *and* the GL-area path (see --renderer's help). +[ "$vnc" = 1 ] && echo "udpscope-remote: screen $display ${size}x24, vnc on localhost:$port" + +# shellcheck disable=SC2086 +"$app" --renderer cairo $app_args & +app_pid=$! + +if [ "$vnc" = 1 ]; then + x11vnc -display "$display" -localhost -nopw -forever -shared -quiet -rfbport "$port" & + vnc_pid=$! + sleep 1 + cat < + +then, in another terminal on your machine: + + vncviewer localhost:$port # or any VNC client + +A browser instead of a VNC client, if websockify and noVNC are installed here: + + websockify 6080 localhost:$port & + # ...and open http://localhost:6080/vnc.html through a second tunnel: + # ssh -N -L 6080:localhost:6080 $(id -un)@ + +Ctrl-C here, or quitting udpscope, closes the app and both servers. +EOF +fi + +# The app owns the run: when it exits, so does everything else (the trap). +wait "$app_pid" || true From 1a3393229aca775fb96181cdf8f634896adb0602 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sat, 31 Oct 2026 19:25:21 +0100 Subject: [PATCH 009/127] docs: design for the data-source plugin framework (sub-project 1 of 4) Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-10-31-source-plugins-design.md | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-31-source-plugins-design.md diff --git a/docs/superpowers/specs/2026-10-31-source-plugins-design.md b/docs/superpowers/specs/2026-10-31-source-plugins-design.md new file mode 100644 index 0000000..802cebb --- /dev/null +++ b/docs/superpowers/specs/2026-10-31-source-plugins-design.md @@ -0,0 +1,116 @@ +# Source plugins: framework and UDPS port (sub-project 1 of 4) + +Date: 2026-10-31 +Branch: `feature/source-plugins` + +## Context + +udpscope reads exactly one kind of source, a UDPStreamer stream through `deps/udpsclient`. +The goal is to accept several kinds of data source as plugins, each with its own config +dialog and, where the hardware offers it, its own UI (signal generator, digital I/O, +multiple timebases). + +The work is split into four sub-projects, each with its own spec, plan and implementation: + +1. **Plugin framework + UDPS client ported onto it** (this document). +2. Flat UDP plugin (standard MARTe2 UDPWriter stream; the wire format must be supplied or + located before that spec is written -- none was found in `~/workspace` or `~/Projects`). +3. MCC E-1608 plugin. The protocol code in `MCC_E1608_DataSource` is bound to MARTe2 + (`BasicTCPSocket`, `StreamString`, `SingleThreadService`); it is ported to plain C++, not + linked against MARTe2. AI streaming, DIO and DAC/generator panels. +4. ABScope plugin. Needs a multi-timebase data path and a wire format: the ABScope design + only sketches the UDP header and `streamerd` is unwritten. + +Sub-project 1 changes no UDPS behaviour. It is a refactor plus the hosting machinery the +later plugins need. + +## Decisions + +| Question | Decision | +| --- | --- | +| What "plugin" means | Compiled-in registry; the interface is kept clean enough for a `dlopen` loader to be added later (not built now). | +| Threading | `Acquisition` keeps the single producer thread and calls `source->poll()`. UI actions reach the source through a thread-safe command queue drained inside `poll()`. CLAUDE.md's two-thread rule holds. (A per-source I/O thread was considered and deferred.) | +| Source UI | Widget factory: a source returns `GtkWidget*` for its config form and each capability panel. | +| Timebases in the frame | The neutral frame is single-timebase. Sub-project 4 extends interface and data path together; this may break the interface once, accepted. | +| CLI / sessions | `--server/--port/...` keep meaning UDPS; `--source NAME` and `--source-opt k=v` added. Sessions keyed by kind + identity; legacy `host_port.toml` read as `udps`. | + +## Design + +### 1. The source interface (new `src/source/`) + +- **`SourceConfig`**: ordered string key/value map with typed getters. It is the stored form + (a TOML table), the `--source-opt` form, and what a config widget reads and writes. +- **`Frame`**: source-neutral, mirrors `udps_frame_t`: `counter`, `lost`, `late`, + `recv_time`, `publish_mode`, `num_samples`, `signals[]` (the existing `SignalInfo`) and + `values[]` (doubles). The hole, backfill and time-base logic needs `counter/lost/late`; a + source with no counter supplies a running counter and `lost = 0`. `Frame::value(sig, k, + col)` replaces `udps_frame_value`. +- **`DataSource`**: the per-connection object. + - `open()`, `poll(timeout_ms)`, `close()`. + - `stats()` returns the counters now in `StreamStats`; `connected()` reports link state. + - `post(Command)`: thread-safe queue, drained inside `poll()`. + - `set_datagram_tap(fn)`: optional; lets UDP-based sources feed the raw-packet capture. + - Output goes to a `SourceSink` (`on_config`, `on_data`, `on_event`) called inside + `poll()`, as `udps_client` does today. +- **`SourceKind`**: factory and UI description of one plugin type: `id`, `display_name`, + `identity(cfg)`, `make(cfg)`, `make_config_form(cfg)` (a `GtkWidget*` plus a `read()` + closure) and `make_panels(source)` (each `{id, title, GtkWidget*}`). +- **`SourceRegistry`**: filled by an explicit `register_builtin_sources()` call. No + static-init self-registration (linkers can drop it). A future `dlopen` loader would only + call `register_kind()`. + +### 2. Acquisition and the UDPS plugin + +- `Acquisition` takes a `std::unique_ptr` instead of building a `udps_client_t` + and implements `SourceSink`; the four trampolines go away. +- `on_config` / `on_data` change only in the type they read. Time base, holes, backfill, + decimation, trigger and math code are untouched. +- `run()` calls `source_->poll(50)` and copies `source_->stats()` into + `ScopeModel::stats()`. `PacketCapture` stays in `Acquisition`, fed through the datagram + tap. Sources without datagrams (E-1608) leave the capture UI disabled, not hidden. +- `src/source/udps_source.cpp` wraps `deps/udpsclient` unchanged: it maps `udps_signal_t` + to `SignalInfo` and `udps_frame_t` to `Frame`, and carries the existing knobs (server, + port, multicast, interface, recv buffer). Identity is `host:port`. +- The `udps_*` types are used only in `acquisition.cpp` today, so no other file changes for + this step beyond the `Frame` type. + +### 3. UI hosting, config and persistence + +- **Connect dialog** (`source_dialog.cpp`): gains a "Source type" dropdown; choosing a kind + swaps in that kind's `make_config_form()`. The UDPS form (server, port, multicast, + interface) moves into the UDPS plugin. The dialog keeps the known-sources list, buffer + size, relative time and capture ring size. +- **Capability panels**: after the source is created the app calls `make_panels()` and adds + each panel as a page of a "Source" stack in the side area. A panel is insensitive when + disconnected (disabled, not hidden; the tab switcher owns visibility). A source with no + panels adds no tab; UDPS has none. Panels call `source->post(Command)`, never the source + directly, so cross-thread safety lives in one place. A helper kit for common panels (DIO + bank, generator form) lives in `src/source/widgets/` but is only added when a plugin uses + it, so it arrives with sub-project 3. +- **Config / CLI**: `Config` gains `source_kind` (default `udps`) and a `SourceConfig`. + `--server/--port/--multicast/--interface` populate the UDPS entries. `--source NAME` and + repeatable `--source-opt k=v` work for any kind. `--help` lists the registered kinds. +- **Sessions**: file `_.toml` with the source's config in a `[source]` + table. Legacy `host_port.toml` is read as `udps`; nothing is migrated destructively. +- **Mocks**: `tools/mock_udpstreamer.py` remains the UDPS mock. Each later plugin ships its + own mock under `tools/` so the headless self-check keeps working. + +## Verification + +- This is a pure refactor for UDPS: the existing headless self-check must read exactly as on + `main` -- exactly three build warnings, no `Gtk-CRITICAL` (see `docs/claude/verification.md`). +- New probe `UDPSCOPE_PROBE_SOURCES`, extending the probe/dump in `src/ui.cpp` rather than + adding a harness. It checks that the registry lists its kinds, that a config form + round-trips its `SourceConfig`, and -- with a stub test-only kind carrying one panel -- + that the panel's tab is built, insensitive while disconnected, and that a posted command is + drained by `poll()`. +- Document the new probe lines in `docs/claude/probe-readings.md`; update `architecture.md` + (a `DataSource` replaces the client in the thread description), `build-and-run.md` + (new CLI options) and `README.md`. + +## Out of scope + +- Runtime `.so` loading, and a per-source I/O thread. +- Multi-timebase data path (sub-project 4). +- The flat UDP, E-1608 and ABScope plugins (sub-projects 2-4). +- The DIO / generator helper widgets (arrive with sub-project 3). From 5bc14b1163f6c39de65d492c04e5f4e060203f99 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sat, 31 Oct 2026 19:52:21 +0100 Subject: [PATCH 010/127] docs: implementation plan for the source-plugin framework --- .../plans/2026-10-31-source-plugins.md | 2277 +++++++++++++++++ 1 file changed, 2277 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-31-source-plugins.md diff --git a/docs/superpowers/plans/2026-10-31-source-plugins.md b/docs/superpowers/plans/2026-10-31-source-plugins.md new file mode 100644 index 0000000..6d28036 --- /dev/null +++ b/docs/superpowers/plans/2026-10-31-source-plugins.md @@ -0,0 +1,2277 @@ +# Source Plugins: Framework and UDPS Port Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let udpscope read from pluggable data sources behind one `DataSource` interface, with the existing UDPStreamer client ported onto it and no change in UDPS behaviour. + +**Architecture:** A compiled-in `SourceRegistry` of `SourceKind` factories. `Acquisition` owns one `DataSource` and still runs the single producer thread, calling `source->poll(50)`; the GTK thread reaches the source only through `post(Command)`, a mutex-guarded queue drained inside `poll()`. A source-neutral `Frame` replaces `udps_frame_t`; each kind supplies a config form (`ConfigForm`) for the chooser and optional capability panels that become pages of the existing `side_stack`. + +**Tech Stack:** C++17, GTK4 + libadwaita, CMake, `deps/udpsclient` (wrapped, unchanged), `deps/plotter`. Python mock `tools/mock_udpstreamer.py`. + +**Spec:** `docs/superpowers/specs/2026-10-31-source-plugins-design.md` (Task 0 amends it with the implementation details settled here). + +## Global Constraints + +- There is **no unit test suite.** The only automated check is the headless self-check under `xvfb-run` against `tools/mock_udpstreamer.py`; extend the probes in `src/ui_probe.cpp` rather than adding a harness. Read `docs/claude/verification.md` and `docs/claude/probe-readings.md` before running one. +- A clean full build has **exactly three warnings**; an incremental build shows one. The run must show `gtk warnings: 0` and no `Gtk-CRITICAL`. +- Every new source file starts with `// SPDX-License-Identifier: MIT` and `// SPDX-FileCopyrightText: 2026 Martino Ferrari`. +- Exactly two threads: the producer (owns the source) and the GTK main thread. UI code never touches a `DataSource` directly except `post()`. +- UDPS behaviour must not change: `--server/--port/--multicast/--interface` keep meaning UDPS; legacy `host_port.toml` session files are read as `udps`; nothing is migrated destructively. +- Disable, don't hide: a panel for a disconnected source is insensitive, not hidden. +- GTK traps (see `docs/claude/invariants-gtk-and-chrome.md`): `notify::` handlers take `(GObject*, GParamSpec*, gpointer)`; probes click with `g_signal_emit_by_name(w, "clicked")`. +- Verification runs are short and serial; kill the mock afterwards; never point a run at the real `~/.config` (use `XDG_CONFIG_HOME=$(mktemp -d)`). +- Mock: `python3 tools/mock_udpstreamer.py --port 8000 &`. Self-check: `xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 ./build/udpscope --server 127.0.0.1 --port 8000` (dump on stdout, probes on stderr). +- Work happens on branch `feature/source-plugins`. Commit from the repo root. + +## File Structure + +New (all under `src/source/` unless noted): + +| File | Responsibility | +| --- | --- | +| `source_config.hpp/.cpp` | `SourceConfig`: ordered string key/value map with typed getters. | +| `frame.hpp` | Source-neutral `Frame` and `SignalValues` (header-only). | +| `data_source.hpp/.cpp` | `Command`, `SourceStats`, `SourceEvent`, `SourceSink`, `DatagramTap`, `DataSource` (command queue). | +| `source_kind.hpp` | `ConfigForm`, `SourcePanel`, `SourceKind` (includes GTK). | +| `registry.hpp/.cpp` | Registry functions, `register_builtin_sources()`, session-key helpers. | +| `udps_source.hpp/.cpp` | `UdpsSource`: wraps `deps/udpsclient`. | +| `udps_kind.cpp` | `UdpsKind`: id, identity, config form. | +| `probe_source.hpp/.cpp` | Test-only `probe` kind with one panel (registered only when the probe env var is set). | +| `src/ui_source.cpp` | Builds and syncs capability panels in the side stack. | + +Modified: `src/model.hpp`, `src/model.cpp`, `src/acquisition.hpp/.cpp`, `src/config.hpp/.cpp`, `src/main.cpp`, `src/layout_store.hpp/.cpp`, `src/source_dialog.cpp`, `src/ui/source-form.ui`, `src/ui.cpp`, `src/ui_app.hpp`, `src/ui_layout.cpp`, `src/ui_probe.cpp`, `CMakeLists.txt`, and the docs. + +--- + +## Task 0: Baseline and spec amendment + +**Files:** +- Modify: `docs/superpowers/specs/2026-10-31-source-plugins-design.md` +- Create (outside repo): `/tmp/udps-baseline.out`, `/tmp/udps-baseline.err` + +**Interfaces:** Produces the baseline files every later UDPS check is compared against. + +- [ ] **Step 1: Build main's behaviour (the current branch has no code changes yet)** + +Run: `cmake -S . -B build -DCMAKE_BUILD_TYPE=Release >/dev/null && cmake --build build -j 2>&1 | grep -c "warning:"` +Expected: a number (3 on a clean build, 1 if incremental). Note it. + +- [ ] **Step 2: Capture the baseline self-check** + +```bash +python3 tools/mock_udpstreamer.py --port 8000 & +MOCK=$! +sleep 1 +xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --server 127.0.0.1 --port 8000 >/tmp/udps-baseline.out 2>/tmp/udps-baseline.err +kill $MOCK +grep -c "Gtk-CRITICAL" /tmp/udps-baseline.err; grep "gtk warnings" /tmp/udps-baseline.err +``` +Expected: `0`, then `gtk warnings: 0`. + +- [ ] **Step 2b: Capture the chooser baseline** + +The chooser's form probes (see `docs/claude/probe-readings.md`, "The chooser's form") are the reference for Task 5. Same fresh config dir for all three, mock running: + +```bash +python3 tools/mock_udpstreamer.py --port 8000 & +MOCK=$! +sleep 1 +CFGB=$(mktemp -d) +echo "$CFGB" >/tmp/chooser-cfg-dir +xvfb-run -a env XDG_CONFIG_HOME=$CFGB UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_PICK_MS=900 \ + UDPSCOPE_PROBE_NEWHOST=127.0.0.1:8000 ./build/udpscope 2>/tmp/chooser-baseline-new.err >/dev/null +xvfb-run -a env XDG_CONFIG_HOME=$CFGB UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_PICK_MS=900 \ + UDPSCOPE_PROBE_EDITHOST=127.0.0.1:8000 ./build/udpscope 2>/tmp/chooser-baseline-edit.err >/dev/null +xvfb-run -a env XDG_CONFIG_HOME=$CFGB UDPSCOPE_PROBE_SOURCES=1 ./build/udpscope 2>/tmp/chooser-baseline-list.err >/dev/null +kill $MOCK +grep -h "^\[sources\]" /tmp/chooser-baseline-new.err /tmp/chooser-baseline-edit.err /tmp/chooser-baseline-list.err +``` +Expected: `form mnemonics=8/8`, `form: name='probe-host' ... connect_sensitive=1`, `configuration created: 127.0.0.1:8000/probe-host`, `edit form: ... button='Save'`, `configuration saved`, and one `known:` row. These are the lines Task 5 must reproduce (apart from `8/8` becoming `9/9`, because the new Source type row has a mnemonic too). + +- [ ] **Step 3: Amend the spec** + +Edit the spec's Decisions/Design text to say: +- the new probe is `UDPSCOPE_PROBE_SOURCE_KINDS` (the name `UDPSCOPE_PROBE_SOURCES` is the chooser discovery probe), its value is a delay in ms, and its presence also registers the stub `probe` kind; +- `Command` is `std::function`; +- session keys are top-level TOML keys (`source_kind`, `source_opt_`), written only for non-UDPS kinds, not a `[source]` table; non-UDPS session files are named by a pseudo-host `-` with port 1; +- `Acquisition` creates its source lazily in `start()`; +- `Frame` holds pointers into the source's buffers, valid only during `on_data`; +- `make_config_form()` takes no argument (the chooser calls `form.write(cfg)` to fill it); `ConfigForm` is a list of `AdwPreferencesRow` widgets plus `write`/`read`/`valid`/`on_change` closures (so the chooser can place them in its own group); +- panels become pages of the existing `side_stack`. + +- [ ] **Step 4: Commit** + +```bash +git add docs/superpowers/specs/2026-10-31-source-plugins-design.md +git commit -m "docs: settle source-plugin implementation details in the spec" +``` + +--- + +## Task 1: Core types, registry, neutral constants + +Compile-only: nothing uses the new code yet, so behaviour cannot change. + +**Files:** +- Create: `src/source/source_config.hpp`, `src/source/source_config.cpp`, `src/source/frame.hpp`, `src/source/data_source.hpp`, `src/source/data_source.cpp`, `src/source/source_kind.hpp`, `src/source/registry.hpp`, `src/source/registry.cpp` +- Modify: `src/model.hpp` (constants), `src/model.cpp:649`, `CMakeLists.txt` + +**Interfaces:** +- Produces (all in `namespace udpscope`): + - `SourceConfig` with `using Items = std::vector>;`, `explicit SourceConfig(Items)`, `void set(const std::string&, const std::string&)`, `bool has(const std::string&) const`, `std::string get(const std::string&, const std::string& def = {}) const`, `long get_int(key, long def) const`, `double get_double(key, double def) const`, `bool get_bool(key, bool def) const`, `bool parse_assignment(const std::string& kv)`, `const Items& items() const`, `bool empty() const`, `static bool valid_key(const std::string&)`. + - `Frame`, `SignalValues`, `Command`, `SourceStats`, `SourceEvent`, `SourceSink`, `DatagramTap`, `DataSource`, `ConfigForm`, `SourcePanel`, `SourceKind`. + - Registry: `void register_kind(std::unique_ptr)`, `SourceKind* find_kind(const std::string&)`, `const std::vector& kinds()`, `void register_builtin_sources()`, `std::string session_host(const std::string& kind, const SourceConfig&)`, `std::uint16_t session_port(const std::string& kind, const SourceConfig&)`. + - `model.hpp`: `constexpr std::uint8_t kTimePacket = 0, kTimeFullArray = 1, kTimeFirstSample = 2, kTimeLastSample = 3;` + +- [ ] **Step 1: `source_config.hpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include +#include +#include + +namespace udpscope { + +/// A source's settings: an ordered string key/value map. It is the stored form (top-level +/// `source_opt_` entries of a session), the `--source-opt k=v` form, and what a config form +/// reads and writes. Order is insertion order, so a stored session stays diff-friendly. +class SourceConfig { + public: + using Items = std::vector>; + + SourceConfig() = default; + explicit SourceConfig(Items items) : items_(std::move(items)) {} + + /// Replaces the value in place if the key exists, else appends. + void set(const std::string& key, const std::string& value); + bool has(const std::string& key) const; + std::string get(const std::string& key, const std::string& def = {}) const; + long get_int(const std::string& key, long def) const; + double get_double(const std::string& key, double def) const; + /// 1/true/yes/on and 0/false/no/off (case-insensitive); anything else gives `def`. + bool get_bool(const std::string& key, bool def) const; + + /// Parses `k=v`; false (and no change) if there is no `=` or the key is not `valid_key`. + bool parse_assignment(const std::string& kv); + + const Items& items() const noexcept { return items_; } + bool empty() const noexcept { return items_.empty(); } + + /// `[A-Za-z0-9_]+` -- the characters that survive as a TOML bare key suffix. + static bool valid_key(const std::string& key); + + private: + Items items_; +}; + +} // namespace udpscope +``` + +- [ ] **Step 2: `source_config.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/source_config.hpp" + +#include +#include +#include + +namespace udpscope { + +void SourceConfig::set(const std::string& key, const std::string& value) { + for (auto& kv : items_) { + if (kv.first == key) { + kv.second = value; + return; + } + } + items_.emplace_back(key, value); +} + +bool SourceConfig::has(const std::string& key) const { + return std::any_of(items_.begin(), items_.end(), + [&](const auto& kv) { return kv.first == key; }); +} + +std::string SourceConfig::get(const std::string& key, const std::string& def) const { + for (const auto& kv : items_) { + if (kv.first == key) return kv.second; + } + return def; +} + +long SourceConfig::get_int(const std::string& key, long def) const { + if (!has(key)) return def; + const std::string v = get(key); + char* end = nullptr; + const long n = std::strtol(v.c_str(), &end, 10); + return (end == v.c_str() || *end != '\0') ? def : n; +} + +double SourceConfig::get_double(const std::string& key, double def) const { + if (!has(key)) return def; + const std::string v = get(key); + char* end = nullptr; + const double d = std::strtod(v.c_str(), &end); + return (end == v.c_str() || *end != '\0') ? def : d; +} + +bool SourceConfig::get_bool(const std::string& key, bool def) const { + if (!has(key)) return def; + std::string v = get(key); + std::transform(v.begin(), v.end(), v.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (v == "1" || v == "true" || v == "yes" || v == "on") return true; + if (v == "0" || v == "false" || v == "no" || v == "off") return false; + return def; +} + +bool SourceConfig::valid_key(const std::string& key) { + return !key.empty() && std::all_of(key.begin(), key.end(), [](unsigned char c) { + return std::isalnum(c) || c == '_'; + }); +} + +bool SourceConfig::parse_assignment(const std::string& kv) { + const auto eq = kv.find('='); + if (eq == std::string::npos) return false; + const std::string key = kv.substr(0, eq); + if (!valid_key(key)) return false; + set(key, kv.substr(eq + 1)); + return true; +} + +} // namespace udpscope +``` + +- [ ] **Step 3: `frame.hpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "model.hpp" + +#include + +namespace udpscope { + +/// One signal's values in a frame. `values` points into the source's own buffer and is valid +/// only while the `on_data` call that received the frame is running. +struct SignalValues { + const double* values = nullptr; + std::uint32_t count = 0; +}; + +/// A data frame in source-neutral form; it mirrors `udps_frame_t`. A source with no packet +/// counter supplies a running one and `lost = 0`: the hole, backfill and time-base logic only +/// needs `counter`, `lost` and `late`. +struct Frame { + std::uint32_t counter = 0; + std::uint32_t lost = 0; + bool late = false; + double recv_time = 0.0; + std::uint8_t publish_mode = 0; + std::uint32_t num_samples = 0; + std::uint32_t num_signals = 0; + const SignalInfo* signals = nullptr; + const SignalValues* values = nullptr; + + /// Element `elem` of signal `sig` at sample `sample`. Mirrors `udps_frame_value`: a scalar + /// signal carried once per sample is indexed by `sample`, a vector/matrix signal by `elem`. + /// Out of range reads 0.0. + double value(std::uint32_t sig, std::uint32_t sample, std::uint32_t elem) const noexcept { + if (sig >= num_signals) return 0.0; + const SignalValues& v = values[sig]; + const std::uint32_t idx = + (v.count == num_samples && signals[sig].elements() == 1u) ? sample : elem; + return idx < v.count ? v.values[idx] : 0.0; + } +}; + +} // namespace udpscope +``` + +(`Frame::value` is `udps_frame_value` from `deps/udpsclient/udps_client.c:346` with the pointer replaced by `this`; keep the two identical.) + +- [ ] **Step 4: `data_source.hpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/frame.hpp" + +#include +#include +#include +#include +#include +#include + +namespace udpscope { + +/// Work for the producer thread. UI code posts these; `poll()` drains and runs them, so a +/// source's state is only ever touched by the thread that owns it. +using Command = std::function; + +/// The counters `StreamStats` shows, in source-neutral form. Producer thread only. +struct SourceStats { + std::uint64_t packets = 0; + std::uint64_t frames = 0; + std::uint64_t gaps = 0; + std::uint64_t late = 0; + std::uint64_t resequenced = 0; + std::uint64_t stale = 0; + std::uint64_t overflows = 0; + std::uint64_t reconnects = 0; + bool connected = false; +}; + +enum class SourceEvent { Connected, Disconnected, Error }; + +/// Where a source delivers, from inside `poll()`. +class SourceSink { + public: + virtual ~SourceSink() = default; + /// The signal set (re)defined. `publish_mode` is the stream's own publish mode. + virtual void on_config(const std::vector& signals, std::uint8_t publish_mode) = 0; + virtual void on_data(const Frame& frame) = 0; + virtual void on_event(SourceEvent event, const char* message) = 0; +}; + +/// Receives each raw datagram of a UDP-based source: bytes, length, receive time. +using DatagramTap = std::function; + +/// The per-connection object. `Acquisition` owns one and drives it from the producer thread. +class DataSource { + public: + virtual ~DataSource() = default; + + /// Allocates and connects. Throws `std::runtime_error` with a message fit for the user. + /// Does not deliver: CONFIG and `Connected` arrive from `poll()`. + virtual void open(SourceSink& sink) = 0; + /// Runs queued commands, then waits up to `timeout_ms` for work and delivers it. + virtual void poll(int timeout_ms) = 0; + virtual void close() = 0; + /// Producer thread only. + virtual SourceStats stats() const = 0; + virtual bool connected() const = 0; + /// Optional: UDP-based sources feed the raw-packet capture through this. + virtual void set_datagram_tap(DatagramTap) {} + + /// Thread-safe. The command runs inside a later `poll()`. + void post(Command cmd); + + protected: + /// For `poll()` implementations: runs and clears the queue. + void drain_commands(); + + private: + std::mutex cmd_mu_; + std::deque cmds_; +}; + +} // namespace udpscope +``` + +- [ ] **Step 5: `data_source.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/data_source.hpp" + +#include + +namespace udpscope { + +void DataSource::post(Command cmd) { + std::lock_guard lk(cmd_mu_); + cmds_.push_back(std::move(cmd)); +} + +void DataSource::drain_commands() { + std::deque local; + { + std::lock_guard lk(cmd_mu_); + local.swap(cmds_); + } + for (auto& c : local) c(); +} + +} // namespace udpscope +``` + +- [ ] **Step 6: `source_kind.hpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/data_source.hpp" +#include "source/source_config.hpp" + +#include + +#include +#include +#include +#include + +namespace udpscope { + +/// A kind's configuration UI, as pieces the chooser places in its own group. The widgets are +/// floating `AdwPreferencesRow`s; the closures capture them, so a form must not outlive its rows +/// (the chooser drops the form when it removes the rows). +struct ConfigForm { + std::vector rows; + /// Shown under the group's title; empty for none. + std::string description; + /// The widget that takes focus when the form is shown; may be null. + GtkWidget* focus = nullptr; + /// Puts `cfg` into the widgets (absent keys leave the kind's defaults). + std::function write; + /// The widgets as a `SourceConfig`. + std::function read; + /// Whether the form is complete enough to connect. + std::function valid; + /// Calls `fn` whenever any widget changes. + std::function)> on_change; +}; + +/// A capability panel (signal generator, digital I/O, ...) for the side area. +struct SourcePanel { + std::string id; ///< stack page name + std::string title; + std::string icon; ///< icon name for the tab, may be empty + GtkWidget* widget = nullptr; +}; + +/// One plugin type: its identity, factory and UI. +class SourceKind { + public: + virtual ~SourceKind() = default; + virtual std::string id() const = 0; ///< short, `[a-z0-9]+`; the CLI and session name + virtual std::string display_name() const = 0; + /// A stable, human-readable name for one configured source (`host:port`, a serial number). + virtual std::string identity(const SourceConfig& cfg) const = 0; + virtual std::unique_ptr make(const SourceConfig& cfg) const = 0; + virtual ConfigForm make_config_form() const = 0; + /// Panels for a live source. The widgets must reach the source only through `post()`. + virtual std::vector make_panels(DataSource& source) const { + (void)source; + return {}; + } + /// Whether the source reports raw datagrams (enables the packet-capture UI). + virtual bool has_datagrams() const { return false; } +}; + +} // namespace udpscope +``` + +- [ ] **Step 7: `registry.hpp` and `registry.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/source_kind.hpp" + +#include +#include +#include +#include + +namespace udpscope { + +/// Takes ownership. A second kind with the same id is ignored. +void register_kind(std::unique_ptr kind); +SourceKind* find_kind(const std::string& id); +/// In registration order (the chooser's dropdown order). +const std::vector& kinds(); +/// Registers the built-in kinds; idempotent. No static-init self-registration, which a linker +/// may drop. A later `dlopen` loader would only call `register_kind()`. +void register_builtin_sources(); + +/// The built-in kinds, each defined in its own file (`udps_kind.cpp`, `probe_source.cpp`). +std::unique_ptr make_udps_kind(); +std::unique_ptr make_probe_kind(); + +/// The (host, port) pair that names a source's session file. UDPS keeps its legacy +/// `server`/`port`; any other kind is `"-"` (characters outside `[A-Za-z0-9._-]` +/// become `_`) on port 1 -- not 0, which the session-file parser rejects and waveform mode uses. +std::string session_host(const std::string& kind_id, const SourceConfig& cfg); +std::uint16_t session_port(const std::string& kind_id, const SourceConfig& cfg); + +} // namespace udpscope +``` + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/registry.hpp" + +#include +#include + +namespace udpscope { +namespace { + +std::vector>& owned() { + static std::vector> v; + return v; +} +std::vector& raw() { + static std::vector v; + return v; +} + +std::string sanitize(const std::string& s) { + std::string out = s; + for (char& c : out) { + const bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || + c == '.' || c == '_' || c == '-'; + if (!ok) c = '_'; + } + return out; +} + +} // namespace + +void register_kind(std::unique_ptr kind) { + if (!kind || find_kind(kind->id())) return; + raw().push_back(kind.get()); + owned().push_back(std::move(kind)); +} + +SourceKind* find_kind(const std::string& id) { + for (SourceKind* k : raw()) { + if (k->id() == id) return k; + } + return nullptr; +} + +const std::vector& kinds() { return raw(); } + +void register_builtin_sources() { + static bool done = false; + if (done) return; + done = true; + register_kind(make_udps_kind()); + // The stub kind exists for the headless source-kinds probe only; a normal run never sees it. + if (std::getenv("UDPSCOPE_PROBE_SOURCE_KINDS") != nullptr) register_kind(make_probe_kind()); +} + +std::string session_host(const std::string& kind_id, const SourceConfig& cfg) { + if (kind_id == "udps") return cfg.get("server", "127.0.0.1"); + SourceKind* k = find_kind(kind_id); + return sanitize(kind_id + "-" + (k ? k->identity(cfg) : std::string("unknown"))); +} + +std::uint16_t session_port(const std::string& kind_id, const SourceConfig& cfg) { + if (kind_id == "udps") { + const long p = cfg.get_int("port", 8000); + return static_cast(p > 0 && p <= 65535 ? p : 8000); + } + return 1; +} + +} // namespace udpscope +``` + +`make_udps_kind()` (Task 2) and `make_probe_kind()` (Task 6) are declared in `registry.hpp` but not defined yet, so in this task `register_builtin_sources()` must not call them: write its body as only `static bool done = false; if (done) return; done = true;` followed by the two comment lines `// Task 2: register_kind(make_udps_kind());` and `// Task 6: register make_probe_kind() when UDPSCOPE_PROBE_SOURCE_KINDS is set`. Task 2 Step 6 and Task 6 Step 3 replace them with the real calls. + +- [ ] **Step 8: Neutral time-mode constants** + +In `src/model.hpp`, inside `namespace udpscope`, above `struct SignalInfo`: + +```cpp +/// `SignalInfo::time_mode` values. They equal the wire protocol's `UDPS_TIME_*` +/// (`udps_source.cpp` static_asserts it), but the model must not name a source's own header. +constexpr std::uint8_t kTimePacket = 0; +constexpr std::uint8_t kTimeFullArray = 1; +constexpr std::uint8_t kTimeFirstSample = 2; +constexpr std::uint8_t kTimeLastSample = 3; +``` + +In `src/model.cpp:649` replace `UDPS_TIME_FULL_ARRAY` with `kTimeFullArray`. Check line 6 of `model.cpp` (`#include ` or similar): remove it only if nothing else in `model.cpp` needs a `udps_*` name (`grep -n "udps_\|UDPS_" src/model.cpp`). + +- [ ] **Step 9: CMake** + +In `CMakeLists.txt`, add after `src/acquisition.cpp`: + +``` + src/source/source_config.cpp + src/source/data_source.cpp + src/source/registry.cpp +``` + +- [ ] **Step 10: Build** + +Run: `cmake --build build -j 2>&1 | grep -E "warning:|error:" | sort | uniq -c` +Expected: no errors; warning count equal to Step 1 of Task 0. + +- [ ] **Step 11: Commit** + +```bash +git add src/source src/model.hpp src/model.cpp CMakeLists.txt +git commit -m "source: DataSource, Frame, SourceConfig and the registry (unused yet)" +``` + +--- + +## Task 2: The UDPS source and kind + +Wraps `deps/udpsclient` unchanged. Compiled and registered, but `Acquisition` does not use it until Task 3, so the running program is still unchanged. + +**Files:** +- Create: `src/source/udps_source.hpp`, `src/source/udps_source.cpp`, `src/source/udps_kind.cpp` +- Modify: `src/source/registry.cpp`, `CMakeLists.txt` + +**Interfaces:** +- Consumes: Task 1's `DataSource`, `SourceSink`, `Frame`, `SourceKind`, `ConfigForm`, `SourceConfig`, `kTime*`. +- Produces: `class UdpsSource final : public DataSource` constructed from a `SourceConfig` with keys `server` (default `127.0.0.1`), `port` (8000), `multicast`, `interface`, `recv_buffer` (8388608); `std::unique_ptr make_udps_kind()` with id `"udps"`, display name `"UDPStreamer"`, identity `":"`, `has_datagrams() == true`. + +- [ ] **Step 1: `udps_source.hpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/data_source.hpp" +#include "source/source_config.hpp" + +#include + +#include +#include + +namespace udpscope { + +/// The UDPStreamer client behind the `DataSource` interface. A thin adapter: it maps +/// `udps_signal_t`/`udps_frame_t` to `SignalInfo`/`Frame` and forwards the datagram observer; +/// all protocol work stays in `deps/udpsclient`. +class UdpsSource final : public DataSource { + public: + explicit UdpsSource(const SourceConfig& cfg); + ~UdpsSource() override; + + void open(SourceSink& sink) override; + void poll(int timeout_ms) override; + void close() override; + SourceStats stats() const override; + bool connected() const override; + void set_datagram_tap(DatagramTap tap) override { tap_ = std::move(tap); } + + private: + static void config_cb(const udps_signal_t* signals, std::uint32_t count, + std::uint8_t publish_mode, void* user); + static void data_cb(const udps_frame_t* frame, void* user); + static void event_cb(udps_event_t event, const char* detail, void* user); + static void datagram_cb(const std::uint8_t* bytes, std::size_t len, double recv_time, + void* user); + + std::string server_; + std::uint16_t port_ = 8000; + std::string multicast_; + std::string interface_; + int recv_buffer_ = 8 * 1024 * 1024; + + udps_client_t* client_ = nullptr; + SourceSink* sink_ = nullptr; + DatagramTap tap_; + std::vector signals_; + std::vector values_; +}; + +} // namespace udpscope +``` + +- [ ] **Step 2: `udps_source.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/udps_source.hpp" + +#include + +namespace udpscope { +namespace { + +static_assert(kTimePacket == UDPS_TIME_PACKET && kTimeFullArray == UDPS_TIME_FULL_ARRAY && + kTimeFirstSample == UDPS_TIME_FIRST_SAMPLE && + kTimeLastSample == UDPS_TIME_LAST_SAMPLE, + "model.hpp's kTime* must equal the wire protocol's UDPS_TIME_*"); + +SignalInfo to_info(const udps_signal_t& s) { + SignalInfo info; + info.name = s.name; + info.unit = s.unit; + info.type_code = s.type_code; + info.quant_type = s.quant_type; + info.rows = s.num_rows; + info.cols = s.num_cols; + info.range_min = s.range_min; + info.range_max = s.range_max; + info.time_mode = s.time_mode; + info.sampling_rate = s.sampling_rate; + info.time_signal_idx = s.time_signal_idx; + return info; +} + +} // namespace + +UdpsSource::UdpsSource(const SourceConfig& cfg) + : server_(cfg.get("server", "127.0.0.1")), + port_(static_cast(cfg.get_int("port", 8000))), + multicast_(cfg.get("multicast")), + interface_(cfg.get("interface")), + recv_buffer_(static_cast(cfg.get_int("recv_buffer", 8 * 1024 * 1024))) {} + +UdpsSource::~UdpsSource() { close(); } + +void UdpsSource::open(SourceSink& sink) { + close(); + sink_ = &sink; + udps_client_config_t c; + udps_client_config_init(&c); + c.server_addr = server_.c_str(); + c.server_port = port_; + c.multicast_group = multicast_.empty() ? nullptr : multicast_.c_str(); + c.interface_addr = interface_.empty() ? nullptr : interface_.c_str(); + c.recv_buffer_bytes = static_cast(recv_buffer_); + c.silence_timeout_s = 2.0; + c.reconnect_delay_s = 0.5; + c.keepalive_interval_s = 1.0; + + client_ = udps_client_create(&c); + if (client_ == nullptr) { + throw std::runtime_error("could not create the UDPS client for " + server_ + ":" + + std::to_string(port_)); + } + udps_client_set_callbacks(client_, &UdpsSource::config_cb, &UdpsSource::data_cb, + &UdpsSource::event_cb, this); + udps_client_set_datagram_cb(client_, &UdpsSource::datagram_cb, this); +} + +void UdpsSource::poll(int timeout_ms) { + drain_commands(); + if (client_ != nullptr) udps_client_poll(client_, timeout_ms); +} + +void UdpsSource::close() { + if (client_ != nullptr) { + udps_client_destroy(client_); + client_ = nullptr; + } +} + +SourceStats UdpsSource::stats() const { + SourceStats out; + if (client_ == nullptr) return out; + udps_stats_t s; + udps_client_stats(client_, &s); + out.packets = s.packets_received; + out.frames = s.frames_delivered; + out.gaps = s.counter_gaps; + out.late = s.late_delivered; + out.resequenced = s.late_resequenced; + out.stale = s.stale_packets; + out.overflows = s.reorder_overflows; + out.reconnects = s.reconnects; + out.connected = udps_client_is_connected(client_) != 0; + return out; +} + +bool UdpsSource::connected() const { + return client_ != nullptr && udps_client_is_connected(client_) != 0; +} + +void UdpsSource::config_cb(const udps_signal_t* signals, std::uint32_t count, + std::uint8_t publish_mode, void* user) { + auto* self = static_cast(user); + self->signals_.clear(); + self->signals_.reserve(count); + for (std::uint32_t i = 0; i < count; ++i) self->signals_.push_back(to_info(signals[i])); + self->sink_->on_config(self->signals_, publish_mode); +} + +void UdpsSource::data_cb(const udps_frame_t* f, void* user) { + auto* self = static_cast(user); + // The frame's signals are the CONFIG's; the cache is rebuilt only if a frame ever disagrees. + if (self->signals_.size() != f->num_signals) { + self->signals_.clear(); + for (std::uint32_t i = 0; i < f->num_signals; ++i) { + self->signals_.push_back(to_info(f->signals[i])); + } + } + self->values_.resize(f->num_signals); + for (std::uint32_t i = 0; i < f->num_signals; ++i) { + self->values_[i].values = f->values[i].values; + self->values_[i].count = f->values[i].count; + } + Frame fr; + fr.counter = f->counter; + fr.lost = f->lost; + fr.late = f->late != 0; + fr.recv_time = f->recv_time; + fr.publish_mode = f->publish_mode; + fr.num_samples = f->num_samples; + fr.num_signals = f->num_signals; + fr.signals = self->signals_.data(); + fr.values = self->values_.data(); + self->sink_->on_data(fr); +} + +void UdpsSource::event_cb(udps_event_t event, const char* detail, void* user) { + auto* self = static_cast(user); + SourceEvent e = SourceEvent::Error; + if (event == UDPS_EVENT_CONNECTED) e = SourceEvent::Connected; + else if (event == UDPS_EVENT_DISCONNECTED) e = SourceEvent::Disconnected; + self->sink_->on_event(e, detail); +} + +void UdpsSource::datagram_cb(const std::uint8_t* bytes, std::size_t len, double recv_time, + void* user) { + auto* self = static_cast(user); + if (self->tap_) self->tap_(bytes, len, recv_time); +} + +} // namespace udpscope +``` + +- [ ] **Step 3: `udps_kind.cpp`, the kind and its form** + +The four rows keep the titles, mnemonics and defaults of `src/ui/source-form.ui` (Server, Port 1..65535 default 8000, Multicast group, Interface) so the chooser reads the same. + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/registry.hpp" +#include "source/udps_source.hpp" + +#include + +#include + +namespace udpscope { +namespace { + +using ChangeCb = std::shared_ptr>; + +GtkWidget* entry_row(const char* title) { + GtkWidget* r = adw_entry_row_new(); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title); + adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE); + return r; +} + +void on_edited(GtkEditable*, gpointer user) { + const auto& cb = *static_cast(user); + if (cb && *cb) (*cb)(); +} + +void hook(GtkWidget* entry, const ChangeCb& cb) { + g_signal_connect_data(entry, "changed", G_CALLBACK(on_edited), new ChangeCb(cb), + +[](gpointer d, GClosure*) { delete static_cast(d); }, + static_cast(0)); +} + +std::string text_of(GtkWidget* w) { return gtk_editable_get_text(GTK_EDITABLE(w)); } + +class UdpsKind final : public SourceKind { + public: + std::string id() const override { return "udps"; } + std::string display_name() const override { return "UDPStreamer"; } + bool has_datagrams() const override { return true; } + + std::string identity(const SourceConfig& cfg) const override { + return cfg.get("server", "127.0.0.1") + ":" + std::to_string(cfg.get_int("port", 8000)); + } + + std::unique_ptr make(const SourceConfig& cfg) const override { + return std::make_unique(cfg); + } + + ConfigForm make_config_form() const override { + GtkWidget* server = entry_row("_Server"); + GtkWidget* port = GTK_WIDGET(adw_spin_row_new_with_range(1, 65535, 1)); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(port), "_Port"); + adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(port), TRUE); + adw_spin_row_set_value(ADW_SPIN_ROW(port), 8000); + GtkWidget* group = entry_row("Multicast _group"); + GtkWidget* iface = entry_row("_Interface"); + + ChangeCb cb = std::make_shared>(); + hook(server, cb); + hook(group, cb); + hook(iface, cb); + + ConfigForm f; + f.rows = {server, port, group, iface}; + f.description = "Leave the multicast group and the interface empty for unicast on the default route"; + f.focus = server; + f.write = [=](const SourceConfig& c) { + gtk_editable_set_text(GTK_EDITABLE(server), c.get("server", "127.0.0.1").c_str()); + adw_spin_row_set_value(ADW_SPIN_ROW(port), static_cast(c.get_int("port", 8000))); + gtk_editable_set_text(GTK_EDITABLE(group), c.get("multicast").c_str()); + gtk_editable_set_text(GTK_EDITABLE(iface), c.get("interface").c_str()); + }; + f.read = [=]() { + SourceConfig c; + c.set("server", text_of(server)); + c.set("port", std::to_string(static_cast(adw_spin_row_get_value(ADW_SPIN_ROW(port))))); + if (!text_of(group).empty()) c.set("multicast", text_of(group)); + if (!text_of(iface).empty()) c.set("interface", text_of(iface)); + return c; + }; + f.valid = [=]() { return !text_of(server).empty(); }; + f.on_change = [cb](std::function fn) { *cb = std::move(fn); }; + return f; + } +}; + +} // namespace + +std::unique_ptr make_udps_kind() { return std::make_unique(); } + +} // namespace udpscope +``` + +- [ ] **Step 4: Register it** + +In `src/source/registry.cpp` replace the `// Task 2:` comment with `register_kind(make_udps_kind());`. + +- [ ] **Step 5: CMake** + +Add after the Task 1 entries: + +``` + src/source/udps_source.cpp + src/source/udps_kind.cpp +``` + +- [ ] **Step 6: Build** + +Run: `cmake --build build -j 2>&1 | grep -E "warning:|error:" | sort | uniq -c` +Expected: no errors; same warning count as Task 0. + +- [ ] **Step 7: Commit** + +```bash +git add src/source CMakeLists.txt +git commit -m "source: the UDPS client behind DataSource, with its config form" +``` + +--- + +## Task 3: Acquisition, Config and CLI on the registry + +The switch-over. After this task the program reads UDPS through `DataSource` and the self-check must read exactly like the Task 0 baseline. + +**Files:** +- Modify: `src/acquisition.hpp`, `src/acquisition.cpp`, `src/config.hpp`, `src/config.cpp`, `src/main.cpp`, `src/ui.cpp` (lines ~2497, ~5398, ~5917), `src/ui_probe.cpp` (~5414), `src/source_dialog.cpp` (lines ~35-38, ~1132-1135, ~1199) + +**Interfaces:** +- Consumes: Task 1 and 2 (`find_kind`, `session_host`, `session_port`, `Frame`, `SourceSink`, `kTime*`). +- Produces: + - `Config::source_kind` (`"udps"`), `Config::source_specified`, `Config::source_opts` (`SourceConfig`), `bool Config::has_source() const noexcept`, `SourceConfig Config::source_config() const`, `std::string Config::session_host() const`, `std::uint16_t Config::session_port() const`. + - `Acquisition::source()` returning `DataSource*` (null before `start()`). + - CLI: `--source NAME`, repeatable `--source-opt k=v`. + +- [ ] **Step 1: `config.hpp`** + +Add `#include "source/source_config.hpp"` after ``. After `server_specified` add: + +```cpp + /// `--source NAME`: which registered kind opens. `udps` is the default, and `--server/--port/ + /// --multicast/--interface` keep meaning UDPS. + std::string source_kind = "udps"; + /// Whether `--source` was given (the other half of "did the user choose a source"). + bool source_specified = false; + /// Repeatable `--source-opt k=v`: the kind's own settings. For `udps` they overlay the fields above. + SourceConfig source_opts; + + /// A source was named on the command line (`--server` or `--source`), so the chooser is skipped. + bool has_source() const noexcept { return server_specified || source_specified; } + /// The settings the kind's `make()` receives: for UDPS the four connection fields plus the receive + /// buffer, then `source_opts` on top. + SourceConfig source_config() const; + /// The (host, port) that name this source's session file; see `session_host()` in the registry. + std::string session_host() const; + std::uint16_t session_port() const; +``` + +- [ ] **Step 2: `config.cpp`** + +Add `#include "source/registry.hpp"` after the `plot_host.hpp` include. Add before `print_usage`: + +```cpp +SourceConfig Config::source_config() const { + SourceConfig out; + if (source_kind == "udps") { + out.set("server", server); + out.set("port", std::to_string(port)); + if (!multicast.empty()) out.set("multicast", multicast); + if (!interface_addr.empty()) out.set("interface", interface_addr); + out.set("recv_buffer", std::to_string(recv_buffer_bytes)); + } + for (const auto& kv : source_opts.items()) out.set(kv.first, kv.second); + return out; +} + +std::string Config::session_host() const { return udpscope::session_host(source_kind, source_config()); } +std::uint16_t Config::session_port() const { return udpscope::session_port(source_kind, source_config()); } +``` + +In `print_usage`, after the `--interface` line add (and extend the usage line with `[--source NAME]`): + +```cpp + std::string names; + for (const SourceKind* k : kinds()) names += (names.empty() ? "" : ", ") + k->id(); +``` +placed before the `fprintf`, and inside the format string after `--interface`: + +``` + " --source NAME source kind (%s; default udps). --server/--port/\n" + " --multicast/--interface configure udps\n" + " --source-opt K=V a setting for the source kind; repeatable\n" +``` +with `names.c_str()` inserted as the second `fprintf` argument (after `argv0`; the first `%s` is `argv0`, so keep argument order matching the format). + +In `parse_args`, before `--buffer-mb`: + +```cpp + } else if (a == "--source" || a.rfind("--source=", 0) == 0) { + const std::string v = value_of(argc, argv, i, "--source"); + if (find_kind(v) == nullptr) { + std::string names; + for (const SourceKind* k : kinds()) names += (names.empty() ? "" : ", ") + k->id(); + fail("unknown source '" + v + "' (available: " + names + ")"); + } + c.source_kind = v; + c.source_specified = true; + } else if (a == "--source-opt" || a.rfind("--source-opt=", 0) == 0) { + const std::string v = value_of(argc, argv, i, "--source-opt"); + if (!c.source_opts.parse_assignment(v)) fail("--source-opt must be key=value: " + v); +``` + +(`--source=` does not match `--source-opt=`: the prefix includes `=`.) + +- [ ] **Step 3: `main.cpp`** + +Add `#include "source/registry.hpp"`. Immediately before `cfg = udpscope::parse_args(argc, argv);` (line ~21) add `udpscope::register_builtin_sources();`. Replace `cfg.server_specified` with `cfg.has_source()` at lines ~60 and ~66. + +- [ ] **Step 4: `acquisition.hpp`** + +- Replace `#include ` with `#include "source/data_source.hpp"` and add `#include `. +- Change `class Acquisition {` to `class Acquisition : private SourceSink {`. +- Doc of the class: "Owns the data source and the producer thread...". +- Add to the public section: + +```cpp + /// The source this acquisition drives, for the UI's capability panels: it may only call + /// `post()` on it. Null until `start()`. + DataSource* source() noexcept { return source_.get(); } +``` +- Replace the three `on_*` declarations and the four trampoline declarations (lines ~71-80) with: + +```cpp + void on_config(const std::vector& signals, std::uint8_t publish_mode) override; + void on_data(const Frame& frame) override; + void on_event(SourceEvent event, const char* detail) override; +``` +- `build_stamps(const Frame& frame, int time_signal, bool counter_gap_refused)` and `backfill_from(const Frame& frame, ...)`. +- Replace `udps_client* client_ = nullptr;` with `std::unique_ptr source_;`. +- In the doc comment of `build_stamps`, `frame->num_samples` becomes `frame.num_samples`. + +- [ ] **Step 5: `acquisition.cpp`, the mechanical part** + +Run, from the repo root: + +```bash +sed -i \ + -e 's/const udps_frame_t\* frame/const Frame\& frame/g' \ + -e 's/frame->/frame./g' \ + -e 's/udps_frame_value(frame, /frame.value(/g' \ + -e 's/udps_signal_num_elements(&\([^()]*\))/\1.elements()/g' \ + -e 's/const udps_signal_t&/const SignalInfo\&/g' \ + -e 's/UDPS_TIME_PACKET/kTimePacket/g' \ + -e 's/UDPS_TIME_FULL_ARRAY/kTimeFullArray/g' \ + -e 's/UDPS_TIME_FIRST_SAMPLE/kTimeFirstSample/g' \ + -e 's/UDPS_TIME_LAST_SAMPLE/kTimeLastSample/g' \ + src/acquisition.cpp +grep -no "frame\.[a-z_]*" src/acquisition.cpp | cut -d: -f2 | sort | uniq -c +grep -n "num_rows\|num_cols\|num_dimensions\|hrt" src/acquisition.cpp +``` +Expected: every `frame.` is one of `counter lost late recv_time publish_mode num_samples num_signals signals values value`; the second grep prints nothing. If either differs, fix by hand (`Frame` has no `hrt`; `SignalInfo` names the dimensions `rows`/`cols`). + +- [ ] **Step 6: `acquisition.cpp`, the structural part** + +1. Delete `to_info` (lines ~32-46), `#include ` (line 8), the four trampolines at the end of the file (`// Static trampolines ...` through `event_trampoline`), and `datagram_trampoline` (lines ~174-177). +2. Add includes `"source/registry.hpp"`. +3. `start()` becomes: + +```cpp +void Acquisition::start() { + // A started -- or *restarted* -- source is a new stream. Going live from a file view is the restart that + // matters: the store still holds the event's samples, and the new stream's clock can sit behind their + // stamps, so the guard below has to re-base rather than clamp. `ring=[2.5200..2.5200]` with `n` climbing + // was the symptom -- every packet pinned flat at the file view's last stamp. + model_.reset_stream(); + last_stamp_ = -std::numeric_limits::infinity(); + if (!source_) { + SourceKind* kind = find_kind(cfg_.source_kind); + if (kind == nullptr) throw std::runtime_error("unknown source kind: " + cfg_.source_kind); + source_ = kind->make(cfg_.source_config()); + } + source_->set_datagram_tap([this](const std::uint8_t* bytes, std::size_t len, double recv_time) { + on_datagram(bytes, len, recv_time); + }); + source_->open(*this); + // `--capture-packets`: armed before the first datagram, so the first CONNECT and + // its CONFIG -- the hardest strange behaviours to reproduce -- are in the ring. + // The open itself still happens on the producer thread, at the first datagram. + if (cfg_.capture_packets > 0) { + set_capture_want(true, cfg_.capture_packets); + } + + started_ = std::chrono::steady_clock::now(); + running_.store(true, std::memory_order_release); + thread_ = std::thread([this] { run(); }); +} + +void Acquisition::stop() { + if (running_.exchange(false, std::memory_order_acq_rel) && thread_.joinable()) thread_.join(); + if (source_) source_->close(); +} + +void Acquisition::run() { + while (running_.load(std::memory_order_acquire)) { + source_->poll(50); + const SourceStats s = source_->stats(); + StreamStats& out = model_.stats(); + out.packets.store(s.packets, std::memory_order_relaxed); + out.frames.store(s.frames, std::memory_order_relaxed); + out.gaps.store(s.gaps, std::memory_order_relaxed); + out.resequenced.store(s.resequenced, std::memory_order_relaxed); + out.late.store(s.late, std::memory_order_relaxed); + out.stale.store(s.stale, std::memory_order_relaxed); + out.overflows.store(s.overflows, std::memory_order_relaxed); + out.reconnects.store(s.reconnects, std::memory_order_relaxed); + out.connected.store(s.connected, std::memory_order_relaxed); + } +} +``` +4. `set_capture_want`: replace `cfg_.server`/`cfg_.port` with `cfg_.session_host()`/`cfg_.session_port()` for the directory name, and guard the peer address: `if (cfg_.source_kind == "udps" && ::inet_pton(AF_INET, cfg_.server.c_str(), &a) == 1) { ... } capture_.set_peer(addr, cfg_.source_kind == "udps" ? cfg_.port : 0);`. +5. `on_config` signature: `void Acquisition::on_config(const std::vector& info, std::uint8_t publish_mode)`. Delete the `info` vector construction and use `info.size()` for `count` in the log line (`%u` with `static_cast(info.size())`). The mode log chain becomes `kTimePacket`... already done by sed. +6. `on_event`: + +```cpp +void Acquisition::on_event(SourceEvent event, const char* detail) { + (void)detail; + if (event == SourceEvent::Connected) { + model_.stats().connected.store(true, std::memory_order_relaxed); + } else if (event == SourceEvent::Disconnected) { +``` +leaving the body of the old `else if` untouched. + +- [ ] **Step 7: Every other reader of `cfg.server`/`cfg.port`** + +Run: `grep -n "config()\.server\|config()\.port\|cfg\.server\|cfg\.port" src/*.cpp src/*.hpp` and change each that names a *session file or capture directory* to `cfg.session_host()` / `cfg.session_port()`: +- `src/ui.cpp:~2497` `layout_path(app->model->config().server, ...port)` -> `layout_path(config().session_host(), config().session_port())`; +- `src/ui.cpp:~5917-5918` `app.source_host`/`source_port` -> `cfg.session_host()`/`cfg.session_port()` (keep the `open_waveform.empty()` guards); +- `src/ui_probe.cpp:~5414` likewise; +- `src/source_dialog.cpp:~1132-1135` (`apply_stored_settings`) -> `cfg.session_host()`/`cfg.session_port()`. +Leave `ui.cpp:~5398` (a log line), `ui.cpp:5919-5920` (`source_multicast`/`source_interface`) and `source_dialog.cpp:~35-38,1199` (UDPS fields filled from `KnownSource`) alone: they stay correct for UDPS and Task 5 reworks them for other kinds. + +- [ ] **Step 8: CMake and build** + +No new files. Run: `cmake --build build -j 2>&1 | grep -E "warning:|error:" | sort | uniq -c` +Expected: no errors, warning count as in Task 0 (a full rebuild of `acquisition.cpp` and others is incremental, so 1 or 3). Fix any leftover `udps_`/`->` compile errors by hand. + +- [ ] **Step 9: Self-check against the baseline** + +```bash +python3 tools/mock_udpstreamer.py --port 8000 & +MOCK=$! +sleep 1 +xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --server 127.0.0.1 --port 8000 >/tmp/udps-new.out 2>/tmp/udps-new.err +kill $MOCK +grep -c "Gtk-CRITICAL" /tmp/udps-new.err; grep "gtk warnings" /tmp/udps-new.err +diff <(sed 's/[0-9.]\+/N/g' /tmp/udps-baseline.out) <(sed 's/[0-9.]\+/N/g' /tmp/udps-new.out) | head -30 +diff <(sed 's/[0-9.]\+/N/g' /tmp/udps-baseline.err) <(sed 's/[0-9.]\+/N/g' /tmp/udps-new.err) | head -30 +``` +Expected: `0`, `gtk warnings: 0`, and both diffs empty or limited to timing noise (a line present in one and not the other by a count that varies with timing); anything structural (a missing probe line, a changed message) is a regression to fix before continuing. Also run `./build/udpscope --help | grep -A3 -- --source` (expect the new options with `udps`) and `./build/udpscope --source nope` (expect `unknown source 'nope' (available: udps)`, exit 2). + +- [ ] **Step 10: Commit** + +```bash +git add -A src +git commit -m "acquisition: drive a DataSource from the registry; --source and --source-opt" +``` + +--- + +## Task 4: The `probe` kind and per-kind session persistence + +Adds the test-only `probe` kind (a synthetic sine, no panel yet) so a non-UDPS source can run end to end, and makes sessions remember which kind they belong to. + +**Files:** +- Create: `src/source/probe_source.hpp`, `src/source/probe_source.cpp` +- Modify: `src/source/registry.cpp`, `src/layout_store.hpp`, `src/layout_store.cpp`, `src/ui_app.hpp`, `src/ui.cpp`, `src/ui_layout.cpp`, `CMakeLists.txt` + +**Interfaces:** +- Consumes: Tasks 1-3. +- Produces: + - `class ProbeSource final : public DataSource` with `explicit ProbeSource(const SourceConfig&)`, `void note_applied()`, `std::uint64_t commands_applied() const`; option `rate` (Hz, default 1000). + - Kind id `"probe"`, display name `"Probe (test)"`, identity `"rate"`. + - `LayoutState::source_kind` (`std::string`, empty = UDPS) and `LayoutState::source_opts` (`SourceConfig`); `KnownSource::kind` (default `"udps"`) and `KnownSource::opts`. + - `App::source_kind`, `App::source_opts`, `App::source_title`. + - Session file keys: top-level `source_kind = "probe"` and `source_opt_ = ""`, written only for kinds other than `udps`. + +- [ ] **Step 1: `probe_source.hpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/data_source.hpp" +#include "source/source_config.hpp" + +#include +#include +#include + +namespace udpscope { + +/// A synthetic source for the headless self-check: one 1 V sine signal, `kBatch` samples per +/// frame at `rate` Hz, no socket. It exists to prove the framework end to end (open, CONFIG, +/// data, commands, panels) and is registered only when `UDPSCOPE_PROBE_SOURCE_KINDS` is set. +class ProbeSource final : public DataSource { + public: + static constexpr std::uint32_t kBatch = 10; + + explicit ProbeSource(const SourceConfig& cfg); + + void open(SourceSink& sink) override; + void poll(int timeout_ms) override; + void close() override; + SourceStats stats() const override; + bool connected() const override { return open_.load(std::memory_order_relaxed); } + + /// What a panel command does, so the probe can see that `post()` reached the producer thread. + void note_applied() noexcept { applied_.fetch_add(1, std::memory_order_relaxed); } + std::uint64_t commands_applied() const noexcept { + return applied_.load(std::memory_order_relaxed); + } + + private: + double rate_hz_; + SourceSink* sink_ = nullptr; + std::atomic open_{false}; + bool configured_ = false; + std::uint32_t counter_ = 0; + std::uint64_t frames_ = 0; + double phase_ = 0.0; + std::vector signals_; + std::vector batch_; + SignalValues values_; + std::atomic applied_{0}; +}; + +} // namespace udpscope +``` + +- [ ] **Step 2: `probe_source.cpp`** + +The kind's `make_panels` is added in Task 6; the form lives here. + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/probe_source.hpp" + +#include "source/registry.hpp" + +#include + +#include +#include +#include +#include + +namespace udpscope { + +ProbeSource::ProbeSource(const SourceConfig& cfg) + : rate_hz_(std::max(1.0, cfg.get_double("rate", 1000.0))), batch_(kBatch, 0.0) { + SignalInfo s; + s.name = "probe"; + s.unit = "V"; + s.type_code = 9; // float64 + s.rows = 1; + s.cols = 1; + s.range_min = -1.0; + s.range_max = 1.0; + s.time_mode = kTimePacket; + s.sampling_rate = rate_hz_; + signals_.push_back(s); +} + +void ProbeSource::open(SourceSink& sink) { + sink_ = &sink; + configured_ = false; + open_.store(true, std::memory_order_relaxed); +} + +void ProbeSource::close() { open_.store(false, std::memory_order_relaxed); } + +SourceStats ProbeSource::stats() const { + SourceStats s; + s.packets = frames_; + s.frames = frames_; + s.connected = open_.load(std::memory_order_relaxed); + return s; +} + +void ProbeSource::poll(int timeout_ms) { + drain_commands(); + if (!open_.load(std::memory_order_relaxed) || sink_ == nullptr) return; + if (!configured_) { + configured_ = true; + sink_->on_config(signals_, /*publish_mode=*/1); + sink_->on_event(SourceEvent::Connected, ""); + } + // One batch takes kBatch / rate seconds to "arrive". + const int period_ms = std::max(1, static_cast(1000.0 * kBatch / rate_hz_)); + std::this_thread::sleep_for(std::chrono::milliseconds(std::min(period_ms, timeout_ms))); + const double step = 2.0 * M_PI * 5.0 / rate_hz_; // a 5 Hz sine + for (std::uint32_t k = 0; k < kBatch; ++k) { + batch_[k] = std::sin(phase_); + phase_ += step; + } + values_.values = batch_.data(); + values_.count = kBatch; + Frame f; + f.counter = counter_++; + f.lost = 0; + f.late = false; + f.recv_time = std::chrono::duration(std::chrono::system_clock::now().time_since_epoch()) + .count(); + f.publish_mode = 1; + f.num_samples = kBatch; + f.num_signals = 1; + f.signals = signals_.data(); + f.values = &values_; + ++frames_; + sink_->on_data(f); +} + +namespace { + +void on_rate_changed(GObject*, GParamSpec*, gpointer user) { + const auto& cb = *static_cast>*>(user); + if (cb && *cb) (*cb)(); +} + +class ProbeKind final : public SourceKind { + public: + std::string id() const override { return "probe"; } + std::string display_name() const override { return "Probe (test)"; } + std::string identity(const SourceConfig& cfg) const override { + return "rate" + std::to_string(cfg.get_int("rate", 1000)); + } + std::unique_ptr make(const SourceConfig& cfg) const override { + return std::make_unique(cfg); + } + + ConfigForm make_config_form() const override { + GtkWidget* rate = GTK_WIDGET(adw_spin_row_new_with_range(1, 100000, 1)); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(rate), "_Rate (Hz)"); + adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(rate), TRUE); + adw_spin_row_set_value(ADW_SPIN_ROW(rate), 1000); + + auto cb = std::make_shared>(); + g_signal_connect_data(rate, "notify::value", G_CALLBACK(on_rate_changed), + new std::shared_ptr>(cb), + +[](gpointer d, GClosure*) { + delete static_cast>*>(d); + }, + static_cast(0)); + + ConfigForm f; + f.rows = {rate}; + f.description = "A synthetic 5 Hz sine, for the self-check"; + f.focus = rate; + f.write = [=](const SourceConfig& c) { + adw_spin_row_set_value(ADW_SPIN_ROW(rate), static_cast(c.get_int("rate", 1000))); + }; + f.read = [=]() { + SourceConfig c; + c.set("rate", std::to_string(static_cast(adw_spin_row_get_value(ADW_SPIN_ROW(rate))))); + return c; + }; + f.valid = []() { return true; }; + f.on_change = [cb](std::function fn) { *cb = std::move(fn); }; + return f; + } +}; + +} // namespace + +std::unique_ptr make_probe_kind() { return std::make_unique(); } + +} // namespace udpscope +``` + +(Add `#include ` and `#include ` at the top if the compiler asks.) + +- [ ] **Step 3: Register it** + +In `src/source/registry.cpp`, replace the `// Task 6:` comment with the real call from the listing in Task 1 Step 7: `if (std::getenv("UDPSCOPE_PROBE_SOURCE_KINDS") != nullptr) register_kind(make_probe_kind());`. Add `src/source/probe_source.cpp` to `CMakeLists.txt` after `udps_kind.cpp`. + +- [ ] **Step 4: `layout_store.hpp`** + +Add `#include "source/source_config.hpp"`. In `LayoutState`, after `interface_addr`: + +```cpp + /// Which source kind this session belongs to. Empty means `udps`: every file written before the + /// plugins existed. Only a kind other than `udps` is written, so a UDPS session file is + /// byte-identical to what earlier versions wrote. + std::string source_kind; + /// That kind's settings (`SourceConfig`), stored as top-level `source_opt_` entries. + SourceConfig source_opts; +``` + +In `KnownSource`, after `interface_addr`: + +```cpp + std::string kind = "udps"; ///< the session's source kind + SourceConfig opts; ///< that kind's settings (empty for UDPS, whose fields are above) +``` + +- [ ] **Step 5: `layout_store.cpp`** + +Parser, next to `key == "interface"` (line ~559): + +```cpp + } else if (key == "source_kind") { + st.source_kind = strip_quote(value); + } else if (key.rfind("source_opt_", 0) == 0) { + const std::string k = key.substr(11); + if (SourceConfig::valid_key(k)) st.source_opts.set(k, strip_quote(value)); +``` +Writer, right after the `interface` block (line ~700): + +```cpp + if (!state.source_kind.empty() && state.source_kind != "udps") { + o << "source_kind = " << toml_escape(state.source_kind) << "\n"; + for (const auto& kv : state.source_opts.items()) { + o << "source_opt_" << kv.first << " = " << toml_escape(kv.second) << "\n"; + } + } +``` +`known_sources()`, after `s.interface_addr = st.interface_addr;` (line ~324): + +```cpp + if (!st.source_kind.empty()) { + s.kind = st.source_kind; + s.opts = st.source_opts; + } +``` + +- [ ] **Step 6: App plumbing** + +`src/ui_app.hpp`: add `#include "source/source_config.hpp"` and after `source_interface` (line ~839): + +```cpp + /// The source kind and its settings, so the session can say which kind it belongs to, and the + /// words that name the source in dialogs (`host:port` for UDPS, ` ` otherwise). + std::string source_kind = "udps"; + SourceConfig source_opts; + std::string source_title; +``` +`src/ui.cpp` in `run_ui`, after `app.source_interface = cfg.interface_addr;` (line ~5920): + +```cpp + app.source_kind = cfg.source_kind; + app.source_opts = cfg.source_opts; + if (cfg.source_kind != "udps" && find_kind(cfg.source_kind) != nullptr) { + app.source_title = find_kind(cfg.source_kind)->display_name() + " " + + find_kind(cfg.source_kind)->identity(cfg.source_config()); + } else { + app.source_title = app.source_host + ":" + std::to_string(app.source_port); + } +``` +(add `#include "source/registry.hpp"` to `ui.cpp` if not present). At `ui.cpp:~103` replace `app.source_host + ":" + std::to_string(app.source_port)` with `app.source_title`. +`src/ui_layout.cpp:~99`, after `st.interface_addr = app.source_interface;`: + +```cpp + st.source_kind = app.source_kind; + st.source_opts = app.source_opts; +``` + +- [ ] **Step 7: Build** + +Run: `cmake --build build -j 2>&1 | grep -E "warning:|error:" | sort | uniq -c`. Expected: no errors, baseline warning count. + +- [ ] **Step 8: Verify a non-UDPS source runs and its session is tagged** + +```bash +CFG=$(mktemp -d) +xvfb-run -a env XDG_CONFIG_HOME=$CFG UDPSCOPE_PROBE_SOURCE_KINDS=1500 UDPSCOPE_EXIT_AFTER_MS=4000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --source probe --source-opt rate=500 >/tmp/probe.out 2>/tmp/probe.err +grep -c "Gtk-CRITICAL" /tmp/probe.err; grep "gtk warnings" /tmp/probe.err +grep "CONFIG:" /tmp/probe.err +grep -rn "source_" $(find $CFG -name '*.toml') +find $CFG -name '*.toml' +``` +Expected: `0`, `gtk warnings: 0`, `[udpscope] CONFIG: 1 signals, publish=accumulate`, a `'probe' 1x1 ... rate=500 Hz` line, and a session file under a directory named `probe-rate500_1` containing `source_kind = "probe"` and `source_opt_rate = "500"`. If no `.toml` appears, find where the exit path saves the session (`save_layout_now` in `src/ui_layout.cpp`) and what condition skips it, before changing anything. + +Then confirm UDPS sessions are untouched: re-run the Task 3 Step 9 self-check, then `grep -rl source_kind $CFG2` over its config dir must print nothing, and both diffs against the baseline must still be empty. + +- [ ] **Step 9: Commit** + +```bash +git add src CMakeLists.txt +git commit -m "source: a synthetic probe kind, and sessions that remember their source kind" +``` + +--- + +## Task 5: The chooser picks a source type + +The connect dialog gets a "Source type" dropdown; choosing a kind swaps in that kind's `ConfigForm`. For UDPS it must behave as before. + +**Files:** +- Modify: `src/ui/source-form.ui`, `src/source_dialog.cpp` + +**Interfaces:** +- Consumes: `ConfigForm`, `SourceKind`, `kinds()`, `find_kind()`, `session_host/port`, `KnownSource::kind/opts`, `LayoutState::source_kind/source_opts`, `Config::source_kind/source_opts`. +- Produces: new chooser probe variable `UDPSCOPE_PROBE_NEWHOST_KIND=[:k=v,...]` (with `UDPSCOPE_PROBE_NEWHOST` still required to open the form; its value is ignored for a non-UDPS kind) and, for a non-UDPS pick, one extra stderr line `[sources] picked kind=K opts=k=v,... session=HOST:PORT`. + +- [ ] **Step 1: `src/ui/source-form.ui`** + +Update the header comment ("eight rows" -> "nine rows, the connection ones supplied by the chosen source kind"). Replace the whole first `AdwPreferencesGroup` (title `Connection`, with the four rows) by these two groups: + +```xml + + + + + Source _type + True + + + + + + + Connection + + +``` +The Configuration group stays as it is. + +- [ ] **Step 2: `source_dialog.cpp`, the Chooser struct and `apply_source`** + +Includes: `#include "source/registry.hpp"`. + +In `struct Chooser` replace `form_server`, `form_port`, `form_group`, `form_iface` (lines ~108-111) with: + +```cpp + /// The source-type dropdown and the group the chosen kind's rows live in. The rows themselves, + /// and the closures that read and write them, are the kind's (`ConfigForm`); the chooser only + /// swaps them when the dropdown moves. + AdwComboRow* form_kind_row = nullptr; + AdwPreferencesGroup* form_source_group = nullptr; + std::vector form_kinds; ///< dropdown order + ConfigForm form_cfg; + std::vector form_cfg_rows; ///< the rows currently in `form_source_group` +``` + +`apply_source` becomes: + +```cpp +void apply_source(Config& cfg, const KnownSource& src) { + cfg.source_kind = src.kind; + if (src.kind == "udps") { + cfg.server = src.host; + cfg.port = src.port; + cfg.multicast = src.multicast; + cfg.interface_addr = src.interface_addr; + cfg.source_opts = SourceConfig(); + } else { + cfg.source_opts = src.opts; + } + // (the rest of the function is unchanged) +``` + +- [ ] **Step 3: `form_rebuild` and the kind handler** + +Replace `on_form_server_changed` (lines ~435-444) with: + +```cpp +/// The kind selected in the dropdown, or null. +SourceKind* form_selected_kind(const Chooser& c) { + if (c.form_kind_row == nullptr) return nullptr; + const guint i = adw_combo_row_get_selected(c.form_kind_row); + return i < c.form_kinds.size() ? c.form_kinds[i] : nullptr; +} + +/// Swaps the Connection group's rows for `kind`'s form and fills them from `initial`. The +/// connect button follows the form's own `valid()`: the one validation here is the kind's. +void form_rebuild(Chooser& c, SourceKind& kind, const SourceConfig& initial) { + for (GtkWidget* r : c.form_cfg_rows) adw_preferences_group_remove(c.form_source_group, r); + c.form_cfg_rows.clear(); + c.form_cfg = kind.make_config_form(); + for (GtkWidget* r : c.form_cfg.rows) { + adw_preferences_group_add(c.form_source_group, r); + c.form_cfg_rows.push_back(r); + } + adw_preferences_group_set_description( + c.form_source_group, + c.form_cfg.description.empty() ? nullptr : c.form_cfg.description.c_str()); + c.form_cfg.write(initial); + c.form_cfg.on_change([&c] { + if (c.form_connect != nullptr) { + gtk_widget_set_sensitive(c.form_connect, c.form_cfg.valid() ? TRUE : FALSE); + } + }); + if (c.form_connect != nullptr) { + gtk_widget_set_sensitive(c.form_connect, c.form_cfg.valid() ? TRUE : FALSE); + } + if (c.form_cfg.focus != nullptr) gtk_widget_grab_focus(c.form_cfg.focus); +} + +void on_form_kind_changed(GObject*, GParamSpec*, gpointer user) { + Chooser& c = *static_cast(user); + if (SourceKind* k = form_selected_kind(c)) form_rebuild(c, *k, SourceConfig()); +} +``` +(`form_rebuild` is called with a `Chooser&` that outlives the form, as the existing comment on the struct explains, so `[&c]` is safe.) + +- [ ] **Step 4: `on_form_connect`** + +Replace the head of the function (up to and including the `c.cfg->interface_addr = iface;` line) with: + +```cpp + SourceKind* kind = form_selected_kind(c); + if (kind == nullptr || !c.form_cfg.valid()) return; + const SourceConfig scfg = c.form_cfg.read(); + const bool udps = kind->id() == "udps"; + const std::string host = session_host(kind->id(), scfg); + const std::uint16_t port = session_port(kind->id(), scfg); + const std::string group = udps ? scfg.get("multicast") : std::string(); + const std::string iface = udps ? scfg.get("interface") : std::string(); + std::string name = gtk_editable_get_text(GTK_EDITABLE(c.form_name)); + const std::string note = gtk_editable_get_text(GTK_EDITABLE(c.form_note)); + const double buffer_mb = adw_spin_row_get_value(c.form_buffer); + const bool relative = adw_switch_row_get_active(c.form_relative) != FALSE; + if (name.empty()) name = "default"; + + c.cfg->source_kind = kind->id(); + if (udps) { + c.cfg->server = scfg.get("server"); + c.cfg->port = port; + c.cfg->multicast = group; + c.cfg->interface_addr = iface; + c.cfg->source_opts = SourceConfig(); + } else { + c.cfg->source_opts = scfg; + } +``` +and after `st.interface_addr = iface;` add: + +```cpp + st.source_kind = udps ? std::string() : kind->id(); + st.source_opts = udps ? SourceConfig() : scfg; +``` +Everything else in the function (the save, the `moved` test, the log line) stays: it already works on `host`, `port`, `name`. + +- [ ] **Step 5: `open_form`** + +1. Remove the four `gtk_builder_get_object` lines for `server_row/port_row/group_row/iface_row`, and in the null-check list drop `form_server/form_port/form_group/form_iface`. Add: + +```cpp + c.form_kind_row = ADW_COMBO_ROW(gtk_builder_get_object(c.form_ui, "kind_row")); + c.form_source_group = + ADW_PREFERENCES_GROUP(gtk_builder_get_object(c.form_ui, "source_group")); +``` +and add both to the null check. +2. Delete the four value lines for server/port/group/iface. After the `form_relative` line, add: + +```cpp + // The dropdown lists the registered kinds. A new host starts on the kind the command line named + // (UDPS by default); an edit starts on the configuration's own kind and cannot change it -- + // disabled, not hidden, like every other control that does not apply. + c.form_kinds = kinds(); + GtkStringList* names = gtk_string_list_new(nullptr); + guint selected = 0; + const std::string want_kind = src != nullptr ? src->kind : c.cfg->source_kind; + for (guint i = 0; i < c.form_kinds.size(); ++i) { + gtk_string_list_append(names, c.form_kinds[i]->display_name().c_str()); + if (c.form_kinds[i]->id() == want_kind) selected = i; + } + adw_combo_row_set_model(c.form_kind_row, G_LIST_MODEL(names)); + adw_combo_row_set_selected(c.form_kind_row, selected); + gtk_widget_set_sensitive(GTK_WIDGET(c.form_kind_row), src == nullptr); + + SourceConfig initial; + if (src != nullptr && src->kind == "udps") { + initial.set("server", src->host); + initial.set("port", std::to_string(src->port)); + if (!src->multicast.empty()) initial.set("multicast", src->multicast); + if (!src->interface_addr.empty()) initial.set("interface", src->interface_addr); + } else if (src != nullptr) { + initial = src->opts; + } else if (c.cfg->source_kind != "udps") { + initial = c.cfg->source_opts; + } + if (!c.form_kinds.empty()) form_rebuild(c, *c.form_kinds[selected], initial); + // Connected after the first rebuild: setting `selected` above must not rebuild a second time. + g_signal_connect(c.form_kind_row, "notify::selected", G_CALLBACK(on_form_kind_changed), &c); +``` +3. Delete the `g_signal_connect(c.form_server, "changed", ...)` line (it is replaced by `on_change`) and the final `gtk_widget_grab_focus(GTK_WIDGET(c.form_server));` (the rebuild focused the kind's `focus` widget). Note the rebuild's `grab_focus` happens before the dialog is presented, as the old code did after; if focus does not land (probe: `mnemonics`/`connect_sensitive` lines are unaffected), call `if (c.form_cfg.focus) gtk_widget_grab_focus(c.form_cfg.focus);` again after `adw_dialog_present`. +4. In `form_close`'s caller and the `closed` handler, also clear the form: in the `closed` lambda add `Chooser& ch = *static_cast(user); ch.form = nullptr; ch.form_cfg = ConfigForm(); ch.form_cfg_rows.clear(); ch.form_kind_row = nullptr; ch.form_source_group = nullptr;` (the rows are gone with the dialog; dropping the closures that captured them keeps a stale `read()` from ever being callable). + +- [ ] **Step 6: Row titles, loading window, `report_pick`** + +- `chooser_populate` title (line ~375): + +```cpp + SourceKind* sk = s.kind == "udps" ? nullptr : find_kind(s.kind); + const std::string where = sk != nullptr ? sk->display_name() + " " + sk->identity(s.opts) + : s.host + ":" + std::to_string(s.port); + const std::string title = where + " \u00b7 " + s.config; +``` + (a non-UDPS session whose kind is not registered keeps its pseudo-host as the title.) +- `loading_show` (line ~1199): for `cfg.source_kind != "udps"` and a registered kind, `head = "Connecting to " + kind->display_name() + " " + kind->identity(cfg.source_config());` instead of the host:port form; the UDPS branch is unchanged. +- `report_pick`: after the existing `fprintf`, add + +```cpp + if (c.cfg->source_kind != "udps") { + std::string opts; + for (const auto& kv : c.cfg->source_opts.items()) { + opts += (opts.empty() ? "" : ",") + kv.first + "=" + kv.second; + } + std::fprintf(stderr, "[sources] picked kind=%s opts=%s session=%s:%u\n", + c.cfg->source_kind.c_str(), opts.c_str(), c.cfg->session_host().c_str(), + c.cfg->session_port()); + std::fflush(stderr); + } +``` + The first `picked` line prints `c.cfg->server`; for a non-UDPS pick it prints the default server, which is why the second line exists. Leave it. + +- [ ] **Step 7: The probes** + +In the `UDPSCOPE_PROBE_NEWHOST` block, replace the `gtk_editable_set_text(... form_server ...)` / `adw_spin_row_set_value(c.form_port ...)` lines with: + +```cpp + SourceConfig sc; + if (const char* kspec = g_getenv("UDPSCOPE_PROBE_NEWHOST_KIND")) { + // "kind" or "kind:k=v,k=v": pick the kind in the dropdown (which rebuilds the form), then + // write the options into it. + std::string ks = kspec; + std::string opts; + if (const std::size_t sep = ks.find(':'); sep != std::string::npos) { + opts = ks.substr(sep + 1); + ks = ks.substr(0, sep); + } + for (guint i = 0; i < c.form_kinds.size(); ++i) { + if (c.form_kinds[i]->id() == ks) adw_combo_row_set_selected(c.form_kind_row, i); + } + for (std::size_t pos = 0; pos < opts.size();) { + const std::size_t end = std::min(opts.find(',', pos), opts.size()); + sc.parse_assignment(opts.substr(pos, end - pos)); + pos = end + 1; + } + } else { + sc.set("server", colon == std::string::npos ? s : s.substr(0, colon)); + if (colon != std::string::npos) sc.set("port", s.substr(colon + 1)); + } + c.form_cfg.write(sc); +``` +(Keep the `colon` computation above it.) Change the mnemonic line to count the kind row and the form's rows: + +```cpp + int underlines = row_underlines(G_OBJECT(c.form_kind_row)) + row_underlines(G_OBJECT(c.form_name)) + + row_underlines(G_OBJECT(c.form_note)) + row_underlines(G_OBJECT(c.form_buffer)) + + row_underlines(G_OBJECT(c.form_relative)); + for (GtkWidget* r : c.form_cfg.rows) underlines += row_underlines(G_OBJECT(r)); + std::fprintf(stderr, "[sources] form mnemonics=%d/%zu\n", underlines, 5 + c.form_cfg.rows.size()); +``` +For UDPS this prints `9/9`. (The old `/8` was a hard-coded total; the new total follows the kind's row count.) + +The EDITHOST probe needs no code change: it opens the form with `open_form(c, idx)` and edits the config rows. Its match `known[i].host:port == want` still works for UDPS, and for another kind `want` is the pseudo-host (`probe-rate250:1`). + +- [ ] **Step 8: Build and run the UDPS chooser probes against the baseline** + +```bash +cmake --build build -j 2>&1 | grep -E "warning:|error:" | sort | uniq -c +python3 tools/mock_udpstreamer.py --port 8000 & +MOCK=$! +sleep 1 +CFGN=$(mktemp -d) +xvfb-run -a env XDG_CONFIG_HOME=$CFGN UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_PICK_MS=900 \ + UDPSCOPE_PROBE_NEWHOST=127.0.0.1:8000 ./build/udpscope 2>/tmp/chooser-new-new.err >/dev/null +xvfb-run -a env XDG_CONFIG_HOME=$CFGN UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_PICK_MS=900 \ + UDPSCOPE_PROBE_EDITHOST=127.0.0.1:8000 ./build/udpscope 2>/tmp/chooser-new-edit.err >/dev/null +xvfb-run -a env XDG_CONFIG_HOME=$CFGN UDPSCOPE_PROBE_SOURCES=1 ./build/udpscope 2>/tmp/chooser-new-list.err >/dev/null +kill $MOCK +for k in new edit list; do + diff <(grep -h "^\[sources\]" /tmp/chooser-baseline-$k.err | sed 's/[0-9.]\+/N/g') \ + <(grep -h "^\[sources\]" /tmp/chooser-new-$k.err | sed 's/[0-9.]\+/N/g') +done +grep -c "Gtk-CRITICAL" /tmp/chooser-new-*.err +``` +Expected: no errors; the only diff is `form mnemonics=8/8` -> `form mnemonics=9/9`; `Gtk-CRITICAL` counts all `0`. + +- [ ] **Step 9: Run the non-UDPS path through the form** + +```bash +CFGP=$(mktemp -d) +xvfb-run -a env XDG_CONFIG_HOME=$CFGP UDPSCOPE_PROBE_SOURCE_KINDS=9999 UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_PICK_MS=900 \ + UDPSCOPE_PROBE_NEWHOST=x UDPSCOPE_PROBE_NEWHOST_KIND=probe:rate=250 ./build/udpscope 2>/tmp/chooser-probe.err >/dev/null +grep "^\[sources\]" /tmp/chooser-probe.err +xvfb-run -a env XDG_CONFIG_HOME=$CFGP UDPSCOPE_PROBE_SOURCE_KINDS=9999 UDPSCOPE_PROBE_SOURCES=1 ./build/udpscope 2>&1 >/dev/null | grep "^\[sources\]" +``` +Expected: `form mnemonics=6/6`, `configuration created: probe-rate250:1/probe-host`, `picked kind=probe opts=rate=250 session=probe-rate250:1`, then `CONFIG: 1 signals` in the app half; and the second run lists one `known:` row for `probe-rate250:1`. + +- [ ] **Step 10: Commit** + +```bash +git add src/ui/source-form.ui src/source_dialog.cpp +git commit -m "chooser: pick the source type; each kind supplies its own connection form" +``` + +--- + +--- + +## Task 6: Capability panels in the side stack, and the source-kinds probe + +A kind's `make_panels()` widgets become pages of the existing `side_stack`, next to Measure and Signals. The `probe` kind carries one panel (a "Poke" button) so the headless run can prove the whole path: page built, insensitive while disconnected, a clicked button's command drained by `poll()`. + +**Files:** +- Create: `src/ui_source.cpp` +- Modify: `src/source/probe_source.cpp` (the panel), `src/ui_app.hpp`, `src/ui.cpp`, `src/ui_probe.cpp`, `CMakeLists.txt` + +**Interfaces:** +- Consumes: `SourceKind::make_panels(DataSource&)`, `SourcePanel{id,title,icon,widget}`, `Acquisition::source()`, `App::source_kind`, `ScopeModel::stats().connected` (an atomic the producer keeps current), `ProbeSource::commands_applied()`. +- Produces: + - `struct SourcePanelPage { std::string name; std::string title; GtkWidget* content = nullptr; };` and `App::source_panels` (`std::vector`). `name` is the stack page name, `"source-" + SourcePanel::id`; `content` is the kind's own widget (the page wraps it in a scroller). + - `void build_source_panels(App&)`, `void apply_source_panels(App&, bool connected)`, `void sync_source_panels(App&)` (declared in `ui_app.hpp`, defined in `ui_source.cpp`). + - `gboolean source_kinds_probe(gpointer)` (declared in `ui_app.hpp`, defined in `ui_probe.cpp`), scheduled by `UDPSCOPE_PROBE_SOURCE_KINDS=`. + +- [ ] **Step 1: The probe kind's panel** + +In `src/source/probe_source.cpp`, inside `class ProbeKind`, add after `make_config_form()`: + +```cpp + std::vector make_panels(DataSource& source) const override { + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_widget_set_margin_top(box, 12); + gtk_widget_set_margin_bottom(box, 12); + gtk_widget_set_margin_start(box, 12); + gtk_widget_set_margin_end(box, 12); + GtkWidget* label = gtk_label_new("Test panel"); + gtk_widget_set_halign(label, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(box), label); + // The probe finds the button as the box's last child. It reaches the source only through + // `post()`: the command runs inside the next `poll()`, on the producer thread. + GtkWidget* poke = gtk_button_new_with_label("Poke"); + gtk_box_append(GTK_BOX(box), poke); + DataSource* src = &source; + g_signal_connect_data( + poke, "clicked", + G_CALLBACK(+[](GtkButton*, gpointer user) { + DataSource* s = static_cast(user); + s->post([s] { static_cast(s)->note_applied(); }); + }), + src, nullptr, static_cast(0)); + SourcePanel p; + p.id = "poke"; + p.title = "Probe"; + p.widget = box; + return {p}; + } +``` + +(The `static_cast` is sound because only `ProbeKind::make` creates the source this kind's panels are built for. `src` outlives the button: `Acquisition` owns the source and outlives the window.) + +- [ ] **Step 2: `ui_app.hpp`** + +Above `struct App` add: + +```cpp +/// One capability panel a source kind contributed to the side stack. `content` is the kind's own widget +/// (made insensitive while the source is disconnected); the page wrapping it is named `name`. +struct SourcePanelPage { + std::string name; ///< stack page name: "source-" + the kind's panel id + std::string title; ///< the pill's tooltip and accessible name; the page's own title stays empty + GtkWidget* content = nullptr; +}; +``` + +In `App`, after `source_title` (added in Task 4): + +```cpp + /// The panels the source kind added to `side_stack`, in tab order. Empty for UDPS and in file mode. + std::vector source_panels; +``` + +Next to `void save_layout_now(App& app);` add: + +```cpp +/// Capability panels (`src/ui_source.cpp`). `build_source_panels` adds the source kind's panels as +/// pages of `side_stack`, once, before the first tick. `apply_source_panels` sets every panel's +/// sensitivity from `connected` -- disabled, never hidden: the tab switcher owns visibility. +/// `sync_source_panels` is the per-tick call: it applies the model's own connection flag. +void build_source_panels(App& app); +void apply_source_panels(App& app, bool connected); +void sync_source_panels(App& app); +``` + +Next to `gboolean side_tab_probe(gpointer user);` add `gboolean source_kinds_probe(gpointer user);`. + +- [ ] **Step 3: `src/ui_source.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/registry.hpp" +#include "ui_app.hpp" + +#include + +namespace udpscope { + +void build_source_panels(App& app) { + if (app.side_stack == nullptr || app.acq == nullptr || app.acq->source() == nullptr) return; + SourceKind* kind = find_kind(app.source_kind); + if (kind == nullptr) return; + for (const SourcePanel& p : kind->make_panels(*app.acq->source())) { + if (p.widget == nullptr || p.id.empty()) continue; + // Each page scrolls on its own, like Measure and Signals: panels differ in height. + GtkWidget* scroller = gtk_scrolled_window_new(); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER, + GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), p.widget); + const std::string name = "source-" + p.id; + // An empty title on purpose, as for the built-in tabs: the pill is an icon, and + // `name_side_tabs()` puts `title` on it as tooltip and accessible name. + adw_view_stack_add_titled_with_icon( + ADW_VIEW_STACK(app.side_stack), scroller, name.c_str(), "", + p.icon.empty() ? "applications-engineering-symbolic" : p.icon.c_str()); + app.source_panels.push_back({name, p.title, p.widget}); + } + // Disconnected until the first Connected event says otherwise. + apply_source_panels(app, false); +} + +void apply_source_panels(App& app, bool connected) { + for (const SourcePanelPage& p : app.source_panels) { + if (gtk_widget_get_sensitive(p.content) != (connected ? TRUE : FALSE)) { + gtk_widget_set_sensitive(p.content, connected); + } + } +} + +void sync_source_panels(App& app) { + if (app.source_panels.empty() || app.model == nullptr) return; + apply_source_panels(app, app.model->stats().connected.load(std::memory_order_relaxed)); +} + +} // namespace udpscope +``` + +Add `src/ui_source.cpp` to `CMakeLists.txt` after `src/ui_layout.cpp`. + +- [ ] **Step 4: Hook it into `ui.cpp`** + +1. `side_tab_title` (line ~2981) becomes `static const char* side_tab_title(const App& app, const char* name)`: after the two built-in `strcmp` lines and before `return "";` add + +```cpp + if (name != nullptr) { + for (const SourcePanelPage& p : app.source_panels) { + if (p.name == name) return p.title.c_str(); + } + } +``` +and in `name_side_tabs` (line ~3003) call `side_tab_title(app, adw_view_stack_page_get_name(page))`. + +2. In `on_activate`, immediately after the block that selects the `signals` page and connects `notify::visible-child-name` (line ~5280-5284, ending `}`), add: + +```cpp + // The source kind's own tabs (a generator, digital I/O). Before the first tick: that is when + // `name_side_tabs()` puts the words on the pills, and it must see these pages too. + build_source_panels(*app); +``` + +3. In `tick()`, directly after `note_panel_controls(*app);` add `sync_source_panels(*app);`. + +4. In `run_ui`, next to the other `g_getenv("UDPSCOPE_PROBE_*_MS")` schedules (line ~6090, after `UDPSCOPE_PROBE_DENSITY_MS`): + +```cpp + if (const char* env = g_getenv("UDPSCOPE_PROBE_SOURCE_KINDS")) { + g_timeout_add(static_cast(std::atoi(env)), source_kinds_probe, &app); + } +``` + +5. The packet-capture button is for UDP sources only (spec: "left disabled, not hidden"). In `ui_app.hpp`, after `source_title`, add `bool source_has_datagrams = true; ///< the kind reports raw datagrams; false leaves the capture button disabled`. In `run_ui`, after the `source_title` block added in Task 4, add `if (SourceKind* k = find_kind(cfg.source_kind)) app.source_has_datagrams = k->has_datagrams();`. At `ui.cpp:~4554` change `snap.connected ? TRUE : FALSE` to `snap.connected && app->source_has_datagrams ? TRUE : FALSE`. + +- [ ] **Step 5: The probe, in `ui_probe.cpp`** + +Add `#include "source/probe_source.hpp"` and `#include "source/registry.hpp"` to the includes, and, next to `side_tab_probe` (define the report first): + +```cpp +namespace { +std::uint64_t g_source_kinds_before = 0; +} + +/// Phase two: the command the Poke button posted must have been run by `poll()` on the producer. +gboolean source_kinds_probe_report(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + const ProbeSource* src = a->acq != nullptr ? dynamic_cast(a->acq->source()) + : nullptr; + const std::uint64_t after = src != nullptr ? src->commands_applied() : 0; + std::fprintf(stderr, "[source-kinds] command: before=%" G_GUINT64_FORMAT " after=%" G_GUINT64_FORMAT + " drained=%d\n", + g_source_kinds_before, after, after == g_source_kinds_before + 1 ? 1 : 0); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} + +/// What the registry offers and what a source kind brings: the kinds, each config form's +/// round trip, the panel page (insensitive while disconnected, still present), and a posted command. +gboolean source_kinds_probe(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + + std::string ids; + for (const SourceKind* k : kinds()) ids += (ids.empty() ? "" : ",") + k->id(); + std::fprintf(stderr, "[source-kinds] kinds=%s\n", ids.c_str()); + + // Each kind's form must hand back what it was given. + const auto sample = [](const std::string& id) { + SourceConfig c; + if (id == "udps") { + c.set("server", "10.0.0.5"); + c.set("port", "9001"); + c.set("multicast", "239.1.2.3"); + c.set("interface", "10.0.0.9"); + } else if (id == "probe") { + c.set("rate", "750"); + } + return c; + }; + for (const SourceKind* k : kinds()) { + ConfigForm f = k->make_config_form(); + const SourceConfig in = sample(k->id()); + f.write(in); + const SourceConfig out = f.read(); + bool same = !in.empty(); + for (const auto& kv : in.items()) same = same && out.get(kv.first) == kv.second; + std::fprintf(stderr, "[source-kinds] form %s: round_trip=%d valid=%d\n", k->id().c_str(), + same ? 1 : 0, f.valid() ? 1 : 0); + // The rows were never parented; sink and release them so the form leaves nothing behind. + for (GtkWidget* row : f.rows) { + g_object_ref_sink(row); + g_object_unref(row); + } + } + + // The panel page: built, insensitive while disconnected, present either way. + const SourcePanelPage* page = a->source_panels.empty() ? nullptr : &a->source_panels.front(); + const auto report = [&](const char* state) { + const bool present = + page != nullptr && a->side_stack != nullptr && + adw_view_stack_get_child_by_name(ADW_VIEW_STACK(a->side_stack), page->name.c_str()) != + nullptr; + std::fprintf(stderr, "[source-kinds] panel %s: panels=%zu present=%d sensitive=%d\n", state, + a->source_panels.size(), present ? 1 : 0, + page != nullptr && gtk_widget_get_sensitive(page->content) ? 1 : 0); + }; + apply_source_panels(*a, false); + report("disconnected"); + apply_source_panels(*a, true); + report("connected"); + + // The probe kind has no datagrams, so the packet-capture button stays disabled, and is still there. + std::fprintf(stderr, "[source-kinds] capture button: present=%d sensitive=%d\n", + a->capture_button != nullptr ? 1 : 0, + a->capture_button != nullptr && gtk_widget_get_sensitive(a->capture_button) ? 1 : 0); + + // Click the panel's button: it posts a command, and a later `poll()` runs it. + ProbeSource* src = a->acq != nullptr ? dynamic_cast(a->acq->source()) : nullptr; + g_source_kinds_before = src != nullptr ? src->commands_applied() : 0; + if (page != nullptr) { + if (GtkWidget* poke = gtk_widget_get_last_child(page->content)) { + g_signal_emit_by_name(poke, "clicked"); + } + } + std::fflush(stderr); + g_timeout_add(500, source_kinds_probe_report, a); + return G_SOURCE_REMOVE; +} +``` + +Add the declaration `gboolean source_kinds_probe_report(gpointer user);` beside `source_kinds_probe` in `ui_app.hpp`. If `ProbeSource`/`SourceKind` names need `udpscope::` qualification the compiler will say so (the probe file is inside `namespace udpscope`). + +(The probe forces `apply_source_panels(..., true)` last, and the per-tick `sync_source_panels` also drives it from the model flag. The producer never sets the flag false in this run, so the two agree.) + +- [ ] **Step 6: Build** + +Run: `cmake --build build -j 2>&1 | grep -E "warning:|error:" | sort | uniq -c` +Expected: no errors; baseline warning count. + +- [ ] **Step 7: Verify** + +```bash +CFG=$(mktemp -d) +xvfb-run -a env XDG_CONFIG_HOME=$CFG UDPSCOPE_PROBE_SOURCE_KINDS=2000 UDPSCOPE_EXIT_AFTER_MS=5000 \ + ./build/udpscope --source probe --source-opt rate=500 >/tmp/sk.out 2>/tmp/sk.err +grep -c "Gtk-CRITICAL" /tmp/sk.err; grep "gtk warnings" /tmp/sk.err; grep "^\[source-kinds\]" /tmp/sk.err +``` +Expected: + +``` +0 +gtk warnings: 0 +[source-kinds] kinds=udps,probe +[source-kinds] form udps: round_trip=1 valid=1 +[source-kinds] form probe: round_trip=1 valid=1 +[source-kinds] panel disconnected: panels=1 present=1 sensitive=0 +[source-kinds] panel connected: panels=1 present=1 sensitive=1 +[source-kinds] capture button: present=1 sensitive=0 +[source-kinds] command: before=0 after=1 drained=1 +``` +(Every line of the form `[source-kinds] form ...` appears once per registered kind.) If `drained=0` the command was posted but `poll()` never ran it: check that `ProbeSource::poll` calls `drain_commands()` before returning on any path. If `sensitive=0` on the connected line, `apply_source_panels` is not reaching `content` (it must act on the kind's widget, not the scroller). + +Then the regression checks, serially: the Task 3 Step 9 UDPS self-check against the mock (both diffs against `/tmp/udps-baseline.*` still empty; no `source-` page appears, because UDPS has no panels), and the Task 5 chooser runs. + +- [ ] **Step 8: Commit** + +```bash +git add src CMakeLists.txt +git commit -m "source: capability panels as side-stack pages, with a probe for the path" +``` + +--- + +## Task 7: Documentation and the final check + +**Files:** +- Modify: `docs/claude/architecture.md`, `docs/claude/build-and-run.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md`, `README.md`, `CLAUDE.md` + +**Interfaces:** none; documentation only. + +- [ ] **Step 1: `architecture.md`** + +In "Threads" (line ~13) replace "a producer thread in `Acquisition` owns `udps_client_t` and calls `udps_client_poll()`" with "a producer thread in `Acquisition` owns a `DataSource` and calls `source->poll(50)`". Add a "### Sources" section after "### Threads", covering exactly: + +- The kinds live in `src/source/`; `register_builtin_sources()` fills the registry explicitly (no static-init registration); a future `dlopen` loader would only call `register_kind()`. +- `DataSource` delivers into `Acquisition` (a `SourceSink`) from inside `poll()`; `Frame` is source-neutral and its pointers are valid only during `on_data`. +- The GTK thread reaches a source **only** through `DataSource::post(Command)`; the queue is drained at the top of `poll()`. A panel never calls a source directly. +- `UdpsSource` wraps `deps/udpsclient` unchanged; `udps_*` names appear only in `src/source/udps_*.cpp`. +- The frame is single-timebase; a multi-timebase source (ABScope) will change the interface and the data path together. +- Panels are pages of `side_stack` named `source-`; disconnected means insensitive, not hidden. +- Session file naming for non-UDPS kinds (pseudo-host `-`, port 1; `source_kind` / `source_opt_` keys; UDPS files unchanged). + +- [ ] **Step 2: `build-and-run.md`** + +After the two example launches (line ~15) add: + +```sh +./build/udpscope --source udps --source-opt server=127.0.0.1 --source-opt port=8000 # same as --server/--port +``` +and say that `--help` lists the registered kinds. In the Options line (line ~99) add `--source NAME` and the repeatable `--source-opt k=v`, and say `--server/--port/--multicast/--interface` populate the UDPS entries. + +- [ ] **Step 3: `verification.md`** + +Add under the probe list, after the `UDPSCOPE_PROBE_SIDE_TAB_MS` entry (line ~415): + +``` +- `UDPSCOPE_PROBE_SOURCE_KINDS=2000` with `UDPSCOPE_EXIT_AFTER_MS=5000`, run as `--source probe --source-opt rate=500` + -- the source-plugin path end to end. The variable's presence also registers the test-only `probe` kind (a + synthetic sine with one panel), so it is the only way a run sees that kind; its value is the delay in ms. + Seven `[source-kinds]` lines; the reading's own entry is in `probe-readings.md`. +``` +Also state, in the baselines section, that the UDPS runs must be unchanged by the plugin work. + +- [ ] **Step 4: `probe-readings.md`** + +Add a `## [source-kinds]` section after `[side-tab-probe]` containing the seven expected lines from Task 6 Step 7 and, for each, the defect a wrong reading names: +- `kinds=udps,probe`: a missing kind means `register_builtin_sources()` was not called before `parse_args()`, or the env var gate is wrong. +- `round_trip=0`: a form's `write` and `read` disagree about a key (a renamed `SourceConfig` key, or an absent key not defaulted). +- `panel disconnected: sensitive=0 present=1`: **disable, don't hide** -- `present=0` is a panel that was hidden or never added; `sensitive=1` is a panel that stays live with no source. +- `panel connected: sensitive=1`: `apply_source_panels` acted on the wrong widget. +- `capture button: present=1 sensitive=0`: a source with no datagrams leaves the capture UI **disabled, not hidden**; `sensitive=1` is the button live for a source that has nothing to capture, `present=0` a hidden one. +- `drained=1`: the command reached the producer thread through `post()`; `drained=0` is a queue that `poll()` does not drain, `after` greater than `before + 1` is a command run twice. + +Then update every existing `mnemonics=` / `form mnemonics=8/8` mention (`grep -n "8/8" docs/claude/*.md README.md`) to `9/9` with one sentence: the chooser's new Source type row has a mnemonic. + +- [ ] **Step 5: `README.md` and `CLAUDE.md`** + +- `README.md` line ~25 (Options): add `--source`, `--source-opt`. Line ~101 (Threading): "one producer thread owns the data source (the UDPStreamer client is the built-in one) and calls `poll()`". Add a short "Data sources" subsection: the chooser's Source type, the UDPS kind, the stub that exists only for the self-check, that legacy sessions are read as UDPS, and that the flat UDP, MCC E-1608 and ABScope kinds are planned (separate sub-projects). +- `CLAUDE.md`: in "What this project is", change "receives one UDP stream with `deps/udpsclient`" to "receives one data source -- a UDPStreamer stream through `deps/udpsclient` by default, or another registered source kind (see `docs/claude/architecture.md`)"; change "a producer that owns the UDP client" to "a producer that owns the data source". Add to the Architecture row of the table: "the source plugins". + +- [ ] **Step 6: Final check** + +1. Clean full build: `rm -rf build && cmake -S . -B build -DCMAKE_BUILD_TYPE=Release >/dev/null && cmake --build build -j 2>&1 | grep -c "warning:"`. Expected: `3`. +2. UDPS self-check, then compare (number-normalised) with the baseline: + +```bash +python3 tools/mock_udpstreamer.py --port 8000 & +MOCK=$! +sleep 1 +xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --server 127.0.0.1 --port 8000 >/tmp/udps-final.out 2>/tmp/udps-final.err +kill $MOCK +diff <(sed 's/[0-9.]\+/N/g' /tmp/udps-baseline.out) <(sed 's/[0-9.]\+/N/g' /tmp/udps-final.out) +diff <(sed 's/[0-9.]\+/N/g' /tmp/udps-baseline.err) <(sed 's/[0-9.]\+/N/g' /tmp/udps-final.err) +grep -c "Gtk-CRITICAL" /tmp/udps-final.err; grep "gtk warnings" /tmp/udps-final.err +``` +Expected: both diffs empty, `0`, `gtk warnings: 0`. +3. The Task 6 Step 7 run and the Task 5 chooser runs, once each. +4. `grep -rn "udps_" src --include=*.cpp --include=*.hpp -l` must list only `src/source/udps_source.cpp`, `src/source/udps_source.hpp`, and `src/source/udps_kind.cpp` (if it names a `udps_*` symbol). Any other file is a leak of the old coupling; fix it. +5. Dev hygiene: `pgrep -f mock_udpstreamer` prints nothing. + +- [ ] **Step 7: Commit** + +```bash +git add docs README.md CLAUDE.md +git commit -m "docs: the source-plugin framework, the --source options and the source-kinds probe" +``` From 71b904a93bc9e846552fa9102ecc2b531cdac420 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sat, 31 Oct 2026 19:54:23 +0100 Subject: [PATCH 011/127] docs: settle source-plugin implementation details in the spec --- .../specs/2026-10-31-source-plugins-design.md | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-10-31-source-plugins-design.md b/docs/superpowers/specs/2026-10-31-source-plugins-design.md index 802cebb..21ed3f1 100644 --- a/docs/superpowers/specs/2026-10-31-source-plugins-design.md +++ b/docs/superpowers/specs/2026-10-31-source-plugins-design.md @@ -42,19 +42,24 @@ later plugins need. (a TOML table), the `--source-opt` form, and what a config widget reads and writes. - **`Frame`**: source-neutral, mirrors `udps_frame_t`: `counter`, `lost`, `late`, `recv_time`, `publish_mode`, `num_samples`, `signals[]` (the existing `SignalInfo`) and - `values[]` (doubles). The hole, backfill and time-base logic needs `counter/lost/late`; a + `values[]` (doubles). `signals` and `values` are pointers into the source's own buffers, + valid only for the duration of `on_data`; `Acquisition` copies what it keeps. The hole, backfill and time-base logic needs `counter/lost/late`; a source with no counter supplies a running counter and `lost = 0`. `Frame::value(sig, k, col)` replaces `udps_frame_value`. - **`DataSource`**: the per-connection object. - `open()`, `poll(timeout_ms)`, `close()`. - `stats()` returns the counters now in `StreamStats`; `connected()` reports link state. - - `post(Command)`: thread-safe queue, drained inside `poll()`. + - `post(Command)`: thread-safe queue, drained inside `poll()`. `Command` is + `std::function`. - `set_datagram_tap(fn)`: optional; lets UDP-based sources feed the raw-packet capture. - Output goes to a `SourceSink` (`on_config`, `on_data`, `on_event`) called inside `poll()`, as `udps_client` does today. - **`SourceKind`**: factory and UI description of one plugin type: `id`, `display_name`, - `identity(cfg)`, `make(cfg)`, `make_config_form(cfg)` (a `GtkWidget*` plus a `read()` - closure) and `make_panels(source)` (each `{id, title, GtkWidget*}`). + `identity(cfg)`, `make(cfg)`, `make_config_form()` and `make_panels(source)` (each + `{id, title, GtkWidget*}`). `make_config_form()` takes no argument: the chooser calls + `form.write(cfg)` to fill the form. A `ConfigForm` is a list of `AdwPreferencesRow` widgets + plus `write` / `read` / `valid` / `on_change` closures, so the chooser can place the rows in + its own group and gate its Connect button on `valid()`. - **`SourceRegistry`**: filled by an explicit `register_builtin_sources()` call. No static-init self-registration (linkers can drop it). A future `dlopen` loader would only call `register_kind()`. @@ -62,7 +67,8 @@ later plugins need. ### 2. Acquisition and the UDPS plugin - `Acquisition` takes a `std::unique_ptr` instead of building a `udps_client_t` - and implements `SourceSink`; the four trampolines go away. + and implements `SourceSink`; it creates the source lazily in `start()`, from the kind and + `SourceConfig` it was given; the four trampolines go away. - `on_config` / `on_data` change only in the type they read. Time base, holes, backfill, decimation, trigger and math code are untouched. - `run()` calls `source_->poll(50)` and copies `source_->stats()` into @@ -81,7 +87,8 @@ later plugins need. interface) moves into the UDPS plugin. The dialog keeps the known-sources list, buffer size, relative time and capture ring size. - **Capability panels**: after the source is created the app calls `make_panels()` and adds - each panel as a page of a "Source" stack in the side area. A panel is insensitive when + each panel as a page of the existing `side_stack` (the side area's page stack, owned by + the tab switcher). A panel is insensitive when disconnected (disabled, not hidden; the tab switcher owns visibility). A source with no panels adds no tab; UDPS has none. Panels call `source->post(Command)`, never the source directly, so cross-thread safety lives in one place. A helper kit for common panels (DIO @@ -90,8 +97,10 @@ later plugins need. - **Config / CLI**: `Config` gains `source_kind` (default `udps`) and a `SourceConfig`. `--server/--port/--multicast/--interface` populate the UDPS entries. `--source NAME` and repeatable `--source-opt k=v` work for any kind. `--help` lists the registered kinds. -- **Sessions**: file `_.toml` with the source's config in a `[source]` - table. Legacy `host_port.toml` is read as `udps`; nothing is migrated destructively. +- **Sessions**: the source's config is stored as top-level TOML keys, `source_kind` and + `source_opt_`, written only for non-UDPS kinds (a UDPS session file is unchanged, so + legacy `host_port.toml` files read as `udps` with nothing migrated). It is not a `[source]` + table. A non-UDPS session file is named by a pseudo-host `-` with port 1. - **Mocks**: `tools/mock_udpstreamer.py` remains the UDPS mock. Each later plugin ships its own mock under `tools/` so the headless self-check keeps working. @@ -99,7 +108,9 @@ later plugins need. - This is a pure refactor for UDPS: the existing headless self-check must read exactly as on `main` -- exactly three build warnings, no `Gtk-CRITICAL` (see `docs/claude/verification.md`). -- New probe `UDPSCOPE_PROBE_SOURCES`, extending the probe/dump in `src/ui.cpp` rather than +- New probe `UDPSCOPE_PROBE_SOURCE_KINDS` (`UDPSCOPE_PROBE_SOURCES` is already the chooser + discovery probe). Its value is a delay in ms, and its presence also registers the stub + `probe` kind. It extends the probe/dump in `src/ui.cpp` rather than adding a harness. It checks that the registry lists its kinds, that a config form round-trips its `SourceConfig`, and -- with a stub test-only kind carrying one panel -- that the panel's tab is built, insensitive while disconnected, and that a posted command is From 3896f404a92d761e075cb3a0db077fbbabc4e0c8 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sat, 31 Oct 2026 19:56:00 +0100 Subject: [PATCH 012/127] source: DataSource, Frame, SourceConfig and the registry (unused yet) Co-Authored-By: Claude Opus 4.6 --- CMakeLists.txt | 3 ++ src/model.cpp | 4 +- src/model.hpp | 7 ++++ src/source/data_source.cpp | 24 +++++++++++ src/source/data_source.hpp | 78 ++++++++++++++++++++++++++++++++++++ src/source/frame.hpp | 45 +++++++++++++++++++++ src/source/registry.cpp | 70 ++++++++++++++++++++++++++++++++ src/source/registry.hpp | 34 ++++++++++++++++ src/source/source_config.cpp | 75 ++++++++++++++++++++++++++++++++++ src/source/source_config.hpp | 44 ++++++++++++++++++++ src/source/source_kind.hpp | 64 +++++++++++++++++++++++++++++ 11 files changed, 445 insertions(+), 3 deletions(-) create mode 100644 src/source/data_source.cpp create mode 100644 src/source/data_source.hpp create mode 100644 src/source/frame.hpp create mode 100644 src/source/registry.cpp create mode 100644 src/source/registry.hpp create mode 100644 src/source/source_config.cpp create mode 100644 src/source/source_config.hpp create mode 100644 src/source/source_kind.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e13e79f..0a95d3b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -236,6 +236,9 @@ add_executable(udpscope src/waveform.cpp src/event_store.cpp src/acquisition.cpp + src/source/source_config.cpp + src/source/data_source.cpp + src/source/registry.cpp src/plot_host.cpp src/export.cpp src/search.cpp diff --git a/src/model.cpp b/src/model.cpp index 79fe9f1..765459f 100644 --- a/src/model.cpp +++ b/src/model.cpp @@ -3,8 +3,6 @@ #include "model.hpp" -#include - #include #include #include @@ -646,7 +644,7 @@ bool ScopeModel::is_time_signal(const std::vector& signals, // signal: it has stamps but no cadence of its own, because it is not data. // Data channels can also be declared full-array (per-element stamps), and they // keep a declared rate -- treating them as time signals hid every real channel. - if (s.time_mode == UDPS_TIME_FULL_ARRAY && s.elements() > 1 && s.sampling_rate == 0.0) { + if (s.time_mode == kTimeFullArray && s.elements() > 1 && s.sampling_rate == 0.0) { return true; } return false; diff --git a/src/model.hpp b/src/model.hpp index 338caef..88e4def 100644 --- a/src/model.hpp +++ b/src/model.hpp @@ -24,6 +24,13 @@ namespace udpscope { +/// `SignalInfo::time_mode` values. They equal the wire protocol's `UDPS_TIME_*` +/// (`udps_source.cpp` static_asserts it), but the model must not name a source's own header. +constexpr std::uint8_t kTimePacket = 0; +constexpr std::uint8_t kTimeFullArray = 1; +constexpr std::uint8_t kTimeFirstSample = 2; +constexpr std::uint8_t kTimeLastSample = 3; + /// One streamed signal, as the CONFIG packet described it. struct SignalInfo { std::string name; diff --git a/src/source/data_source.cpp b/src/source/data_source.cpp new file mode 100644 index 0000000..d6eeae2 --- /dev/null +++ b/src/source/data_source.cpp @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/data_source.hpp" + +#include + +namespace udpscope { + +void DataSource::post(Command cmd) { + std::lock_guard lk(cmd_mu_); + cmds_.push_back(std::move(cmd)); +} + +void DataSource::drain_commands() { + std::deque local; + { + std::lock_guard lk(cmd_mu_); + local.swap(cmds_); + } + for (auto& c : local) c(); +} + +} // namespace udpscope diff --git a/src/source/data_source.hpp b/src/source/data_source.hpp new file mode 100644 index 0000000..b6feb58 --- /dev/null +++ b/src/source/data_source.hpp @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/frame.hpp" + +#include +#include +#include +#include +#include +#include + +namespace udpscope { + +/// Work for the producer thread. UI code posts these; `poll()` drains and runs them, so a +/// source's state is only ever touched by the thread that owns it. +using Command = std::function; + +/// The counters `StreamStats` shows, in source-neutral form. Producer thread only. +struct SourceStats { + std::uint64_t packets = 0; + std::uint64_t frames = 0; + std::uint64_t gaps = 0; + std::uint64_t late = 0; + std::uint64_t resequenced = 0; + std::uint64_t stale = 0; + std::uint64_t overflows = 0; + std::uint64_t reconnects = 0; + bool connected = false; +}; + +enum class SourceEvent { Connected, Disconnected, Error }; + +/// Where a source delivers, from inside `poll()`. +class SourceSink { + public: + virtual ~SourceSink() = default; + /// The signal set (re)defined. `publish_mode` is the stream's own publish mode. + virtual void on_config(const std::vector& signals, std::uint8_t publish_mode) = 0; + virtual void on_data(const Frame& frame) = 0; + virtual void on_event(SourceEvent event, const char* message) = 0; +}; + +/// Receives each raw datagram of a UDP-based source: bytes, length, receive time. +using DatagramTap = std::function; + +/// The per-connection object. `Acquisition` owns one and drives it from the producer thread. +class DataSource { + public: + virtual ~DataSource() = default; + + /// Allocates and connects. Throws `std::runtime_error` with a message fit for the user. + /// Does not deliver: CONFIG and `Connected` arrive from `poll()`. + virtual void open(SourceSink& sink) = 0; + /// Runs queued commands, then waits up to `timeout_ms` for work and delivers it. + virtual void poll(int timeout_ms) = 0; + virtual void close() = 0; + /// Producer thread only. + virtual SourceStats stats() const = 0; + virtual bool connected() const = 0; + /// Optional: UDP-based sources feed the raw-packet capture through this. + virtual void set_datagram_tap(DatagramTap) {} + + /// Thread-safe. The command runs inside a later `poll()`. + void post(Command cmd); + + protected: + /// For `poll()` implementations: runs and clears the queue. + void drain_commands(); + + private: + std::mutex cmd_mu_; + std::deque cmds_; +}; + +} // namespace udpscope diff --git a/src/source/frame.hpp b/src/source/frame.hpp new file mode 100644 index 0000000..15ff9e1 --- /dev/null +++ b/src/source/frame.hpp @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "model.hpp" + +#include + +namespace udpscope { + +/// One signal's values in a frame. `values` points into the source's own buffer and is valid +/// only while the `on_data` call that received the frame is running. +struct SignalValues { + const double* values = nullptr; + std::uint32_t count = 0; +}; + +/// A data frame in source-neutral form; it mirrors `udps_frame_t`. A source with no packet +/// counter supplies a running one and `lost = 0`: the hole, backfill and time-base logic only +/// needs `counter`, `lost` and `late`. +struct Frame { + std::uint32_t counter = 0; + std::uint32_t lost = 0; + bool late = false; + double recv_time = 0.0; + std::uint8_t publish_mode = 0; + std::uint32_t num_samples = 0; + std::uint32_t num_signals = 0; + const SignalInfo* signals = nullptr; + const SignalValues* values = nullptr; + + /// Element `elem` of signal `sig` at sample `sample`. Mirrors `udps_frame_value`: a scalar + /// signal carried once per sample is indexed by `sample`, a vector/matrix signal by `elem`. + /// Out of range reads 0.0. + double value(std::uint32_t sig, std::uint32_t sample, std::uint32_t elem) const noexcept { + if (sig >= num_signals) return 0.0; + const SignalValues& v = values[sig]; + const std::uint32_t idx = + (v.count == num_samples && signals[sig].elements() == 1u) ? sample : elem; + return idx < v.count ? v.values[idx] : 0.0; + } +}; + +} // namespace udpscope diff --git a/src/source/registry.cpp b/src/source/registry.cpp new file mode 100644 index 0000000..3c3c88e --- /dev/null +++ b/src/source/registry.cpp @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/registry.hpp" + +#include +#include + +namespace udpscope { +namespace { + +std::vector>& owned() { + static std::vector> v; + return v; +} +std::vector& raw() { + static std::vector v; + return v; +} + +std::string sanitize(const std::string& s) { + std::string out = s; + for (char& c : out) { + const bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || + c == '.' || c == '_' || c == '-'; + if (!ok) c = '_'; + } + return out; +} + +} // namespace + +void register_kind(std::unique_ptr kind) { + if (!kind || find_kind(kind->id())) return; + raw().push_back(kind.get()); + owned().push_back(std::move(kind)); +} + +SourceKind* find_kind(const std::string& id) { + for (SourceKind* k : raw()) { + if (k->id() == id) return k; + } + return nullptr; +} + +const std::vector& kinds() { return raw(); } + +void register_builtin_sources() { + static bool done = false; + if (done) return; + done = true; + // Task 2: register_kind(make_udps_kind()); + // Task 6: register make_probe_kind() when UDPSCOPE_PROBE_SOURCE_KINDS is set +} + +std::string session_host(const std::string& kind_id, const SourceConfig& cfg) { + if (kind_id == "udps") return cfg.get("server", "127.0.0.1"); + SourceKind* k = find_kind(kind_id); + return sanitize(kind_id + "-" + (k ? k->identity(cfg) : std::string("unknown"))); +} + +std::uint16_t session_port(const std::string& kind_id, const SourceConfig& cfg) { + if (kind_id == "udps") { + const long p = cfg.get_int("port", 8000); + return static_cast(p > 0 && p <= 65535 ? p : 8000); + } + return 1; +} + +} // namespace udpscope diff --git a/src/source/registry.hpp b/src/source/registry.hpp new file mode 100644 index 0000000..5293000 --- /dev/null +++ b/src/source/registry.hpp @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/source_kind.hpp" + +#include +#include +#include +#include + +namespace udpscope { + +/// Takes ownership. A second kind with the same id is ignored. +void register_kind(std::unique_ptr kind); +SourceKind* find_kind(const std::string& id); +/// In registration order (the chooser's dropdown order). +const std::vector& kinds(); +/// Registers the built-in kinds; idempotent. No static-init self-registration, which a linker +/// may drop. A later `dlopen` loader would only call `register_kind()`. +void register_builtin_sources(); + +/// The built-in kinds, each defined in its own file (`udps_kind.cpp`, `probe_source.cpp`). +std::unique_ptr make_udps_kind(); +std::unique_ptr make_probe_kind(); + +/// The (host, port) pair that names a source's session file. UDPS keeps its legacy +/// `server`/`port`; any other kind is `"-"` (characters outside `[A-Za-z0-9._-]` +/// become `_`) on port 1 -- not 0, which the session-file parser rejects and waveform mode uses. +std::string session_host(const std::string& kind_id, const SourceConfig& cfg); +std::uint16_t session_port(const std::string& kind_id, const SourceConfig& cfg); + +} // namespace udpscope diff --git a/src/source/source_config.cpp b/src/source/source_config.cpp new file mode 100644 index 0000000..99f810f --- /dev/null +++ b/src/source/source_config.cpp @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/source_config.hpp" + +#include +#include +#include + +namespace udpscope { + +void SourceConfig::set(const std::string& key, const std::string& value) { + for (auto& kv : items_) { + if (kv.first == key) { + kv.second = value; + return; + } + } + items_.emplace_back(key, value); +} + +bool SourceConfig::has(const std::string& key) const { + return std::any_of(items_.begin(), items_.end(), + [&](const auto& kv) { return kv.first == key; }); +} + +std::string SourceConfig::get(const std::string& key, const std::string& def) const { + for (const auto& kv : items_) { + if (kv.first == key) return kv.second; + } + return def; +} + +long SourceConfig::get_int(const std::string& key, long def) const { + if (!has(key)) return def; + const std::string v = get(key); + char* end = nullptr; + const long n = std::strtol(v.c_str(), &end, 10); + return (end == v.c_str() || *end != '\0') ? def : n; +} + +double SourceConfig::get_double(const std::string& key, double def) const { + if (!has(key)) return def; + const std::string v = get(key); + char* end = nullptr; + const double d = std::strtod(v.c_str(), &end); + return (end == v.c_str() || *end != '\0') ? def : d; +} + +bool SourceConfig::get_bool(const std::string& key, bool def) const { + if (!has(key)) return def; + std::string v = get(key); + std::transform(v.begin(), v.end(), v.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (v == "1" || v == "true" || v == "yes" || v == "on") return true; + if (v == "0" || v == "false" || v == "no" || v == "off") return false; + return def; +} + +bool SourceConfig::valid_key(const std::string& key) { + return !key.empty() && std::all_of(key.begin(), key.end(), [](unsigned char c) { + return std::isalnum(c) || c == '_'; + }); +} + +bool SourceConfig::parse_assignment(const std::string& kv) { + const auto eq = kv.find('='); + if (eq == std::string::npos) return false; + const std::string key = kv.substr(0, eq); + if (!valid_key(key)) return false; + set(key, kv.substr(eq + 1)); + return true; +} + +} // namespace udpscope diff --git a/src/source/source_config.hpp b/src/source/source_config.hpp new file mode 100644 index 0000000..01fa0ec --- /dev/null +++ b/src/source/source_config.hpp @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include +#include +#include + +namespace udpscope { + +/// A source's settings: an ordered string key/value map. It is the stored form (top-level +/// `source_opt_` entries of a session), the `--source-opt k=v` form, and what a config form +/// reads and writes. Order is insertion order, so a stored session stays diff-friendly. +class SourceConfig { + public: + using Items = std::vector>; + + SourceConfig() = default; + explicit SourceConfig(Items items) : items_(std::move(items)) {} + + /// Replaces the value in place if the key exists, else appends. + void set(const std::string& key, const std::string& value); + bool has(const std::string& key) const; + std::string get(const std::string& key, const std::string& def = {}) const; + long get_int(const std::string& key, long def) const; + double get_double(const std::string& key, double def) const; + /// 1/true/yes/on and 0/false/no/off (case-insensitive); anything else gives `def`. + bool get_bool(const std::string& key, bool def) const; + + /// Parses `k=v`; false (and no change) if there is no `=` or the key is not `valid_key`. + bool parse_assignment(const std::string& kv); + + const Items& items() const noexcept { return items_; } + bool empty() const noexcept { return items_.empty(); } + + /// `[A-Za-z0-9_]+` -- the characters that survive as a TOML bare key suffix. + static bool valid_key(const std::string& key); + + private: + Items items_; +}; + +} // namespace udpscope diff --git a/src/source/source_kind.hpp b/src/source/source_kind.hpp new file mode 100644 index 0000000..fac37bc --- /dev/null +++ b/src/source/source_kind.hpp @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/data_source.hpp" +#include "source/source_config.hpp" + +#include + +#include +#include +#include +#include + +namespace udpscope { + +/// A kind's configuration UI, as pieces the chooser places in its own group. The widgets are +/// floating `AdwPreferencesRow`s; the closures capture them, so a form must not outlive its rows +/// (the chooser drops the form when it removes the rows). +struct ConfigForm { + std::vector rows; + /// Shown under the group's title; empty for none. + std::string description; + /// The widget that takes focus when the form is shown; may be null. + GtkWidget* focus = nullptr; + /// Puts `cfg` into the widgets (absent keys leave the kind's defaults). + std::function write; + /// The widgets as a `SourceConfig`. + std::function read; + /// Whether the form is complete enough to connect. + std::function valid; + /// Calls `fn` whenever any widget changes. + std::function)> on_change; +}; + +/// A capability panel (signal generator, digital I/O, ...) for the side area. +struct SourcePanel { + std::string id; ///< stack page name + std::string title; + std::string icon; ///< icon name for the tab, may be empty + GtkWidget* widget = nullptr; +}; + +/// One plugin type: its identity, factory and UI. +class SourceKind { + public: + virtual ~SourceKind() = default; + virtual std::string id() const = 0; ///< short, `[a-z0-9]+`; the CLI and session name + virtual std::string display_name() const = 0; + /// A stable, human-readable name for one configured source (`host:port`, a serial number). + virtual std::string identity(const SourceConfig& cfg) const = 0; + virtual std::unique_ptr make(const SourceConfig& cfg) const = 0; + virtual ConfigForm make_config_form() const = 0; + /// Panels for a live source. The widgets must reach the source only through `post()`. + virtual std::vector make_panels(DataSource& source) const { + (void)source; + return {}; + } + /// Whether the source reports raw datagrams (enables the packet-capture UI). + virtual bool has_datagrams() const { return false; } +}; + +} // namespace udpscope From 46076312cc00b2b91293132ed33829b3387d6bc9 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sat, 31 Oct 2026 19:57:11 +0100 Subject: [PATCH 013/127] source: the UDPS client behind DataSource, with its config form --- CMakeLists.txt | 2 + src/source/registry.cpp | 2 +- src/source/udps_kind.cpp | 92 +++++++++++++++++++++++ src/source/udps_source.cpp | 150 +++++++++++++++++++++++++++++++++++++ src/source/udps_source.hpp | 52 +++++++++++++ 5 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 src/source/udps_kind.cpp create mode 100644 src/source/udps_source.cpp create mode 100644 src/source/udps_source.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 0a95d3b..90f9cd1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -239,6 +239,8 @@ add_executable(udpscope src/source/source_config.cpp src/source/data_source.cpp src/source/registry.cpp + src/source/udps_source.cpp + src/source/udps_kind.cpp src/plot_host.cpp src/export.cpp src/search.cpp diff --git a/src/source/registry.cpp b/src/source/registry.cpp index 3c3c88e..2c2f1ac 100644 --- a/src/source/registry.cpp +++ b/src/source/registry.cpp @@ -49,7 +49,7 @@ void register_builtin_sources() { static bool done = false; if (done) return; done = true; - // Task 2: register_kind(make_udps_kind()); + register_kind(make_udps_kind()); // Task 6: register make_probe_kind() when UDPSCOPE_PROBE_SOURCE_KINDS is set } diff --git a/src/source/udps_kind.cpp b/src/source/udps_kind.cpp new file mode 100644 index 0000000..ffe53c7 --- /dev/null +++ b/src/source/udps_kind.cpp @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/registry.hpp" +#include "source/udps_source.hpp" + +#include + +#include + +namespace udpscope { +namespace { + +using ChangeCb = std::shared_ptr>; + +GtkWidget* entry_row(const char* title) { + GtkWidget* r = adw_entry_row_new(); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title); + adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE); + return r; +} + +void on_edited(GtkEditable*, gpointer user) { + const auto& cb = *static_cast(user); + if (cb && *cb) (*cb)(); +} + +void hook(GtkWidget* entry, const ChangeCb& cb) { + g_signal_connect_data(entry, "changed", G_CALLBACK(on_edited), new ChangeCb(cb), + +[](gpointer d, GClosure*) { delete static_cast(d); }, + static_cast(0)); +} + +std::string text_of(GtkWidget* w) { return gtk_editable_get_text(GTK_EDITABLE(w)); } + +class UdpsKind final : public SourceKind { + public: + std::string id() const override { return "udps"; } + std::string display_name() const override { return "UDPStreamer"; } + bool has_datagrams() const override { return true; } + + std::string identity(const SourceConfig& cfg) const override { + return cfg.get("server", "127.0.0.1") + ":" + std::to_string(cfg.get_int("port", 8000)); + } + + std::unique_ptr make(const SourceConfig& cfg) const override { + return std::make_unique(cfg); + } + + ConfigForm make_config_form() const override { + GtkWidget* server = entry_row("_Server"); + GtkWidget* port = GTK_WIDGET(adw_spin_row_new_with_range(1, 65535, 1)); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(port), "_Port"); + adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(port), TRUE); + adw_spin_row_set_value(ADW_SPIN_ROW(port), 8000); + GtkWidget* group = entry_row("Multicast _group"); + GtkWidget* iface = entry_row("_Interface"); + + ChangeCb cb = std::make_shared>(); + hook(server, cb); + hook(group, cb); + hook(iface, cb); + + ConfigForm f; + f.rows = {server, port, group, iface}; + f.description = "Leave the multicast group and the interface empty for unicast on the default route"; + f.focus = server; + f.write = [=](const SourceConfig& c) { + gtk_editable_set_text(GTK_EDITABLE(server), c.get("server", "127.0.0.1").c_str()); + adw_spin_row_set_value(ADW_SPIN_ROW(port), static_cast(c.get_int("port", 8000))); + gtk_editable_set_text(GTK_EDITABLE(group), c.get("multicast").c_str()); + gtk_editable_set_text(GTK_EDITABLE(iface), c.get("interface").c_str()); + }; + f.read = [=]() { + SourceConfig c; + c.set("server", text_of(server)); + c.set("port", std::to_string(static_cast(adw_spin_row_get_value(ADW_SPIN_ROW(port))))); + if (!text_of(group).empty()) c.set("multicast", text_of(group)); + if (!text_of(iface).empty()) c.set("interface", text_of(iface)); + return c; + }; + f.valid = [=]() { return !text_of(server).empty(); }; + f.on_change = [cb](std::function fn) { *cb = std::move(fn); }; + return f; + } +}; + +} // namespace + +std::unique_ptr make_udps_kind() { return std::make_unique(); } + +} // namespace udpscope diff --git a/src/source/udps_source.cpp b/src/source/udps_source.cpp new file mode 100644 index 0000000..1ae89f8 --- /dev/null +++ b/src/source/udps_source.cpp @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/udps_source.hpp" + +#include + +namespace udpscope { +namespace { + +static_assert(kTimePacket == UDPS_TIME_PACKET && kTimeFullArray == UDPS_TIME_FULL_ARRAY && + kTimeFirstSample == UDPS_TIME_FIRST_SAMPLE && + kTimeLastSample == UDPS_TIME_LAST_SAMPLE, + "model.hpp's kTime* must equal the wire protocol's UDPS_TIME_*"); + +SignalInfo to_info(const udps_signal_t& s) { + SignalInfo info; + info.name = s.name; + info.unit = s.unit; + info.type_code = s.type_code; + info.quant_type = s.quant_type; + info.rows = s.num_rows; + info.cols = s.num_cols; + info.range_min = s.range_min; + info.range_max = s.range_max; + info.time_mode = s.time_mode; + info.sampling_rate = s.sampling_rate; + info.time_signal_idx = s.time_signal_idx; + return info; +} + +} // namespace + +UdpsSource::UdpsSource(const SourceConfig& cfg) + : server_(cfg.get("server", "127.0.0.1")), + port_(static_cast(cfg.get_int("port", 8000))), + multicast_(cfg.get("multicast")), + interface_(cfg.get("interface")), + recv_buffer_(static_cast(cfg.get_int("recv_buffer", 8 * 1024 * 1024))) {} + +UdpsSource::~UdpsSource() { close(); } + +void UdpsSource::open(SourceSink& sink) { + close(); + sink_ = &sink; + udps_client_config_t c; + udps_client_config_init(&c); + c.server_addr = server_.c_str(); + c.server_port = port_; + c.multicast_group = multicast_.empty() ? nullptr : multicast_.c_str(); + c.interface_addr = interface_.empty() ? nullptr : interface_.c_str(); + c.recv_buffer_bytes = static_cast(recv_buffer_); + c.silence_timeout_s = 2.0; + c.reconnect_delay_s = 0.5; + c.keepalive_interval_s = 1.0; + + client_ = udps_client_create(&c); + if (client_ == nullptr) { + throw std::runtime_error("could not create the UDPS client for " + server_ + ":" + + std::to_string(port_)); + } + udps_client_set_callbacks(client_, &UdpsSource::config_cb, &UdpsSource::data_cb, + &UdpsSource::event_cb, this); + udps_client_set_datagram_cb(client_, &UdpsSource::datagram_cb, this); +} + +void UdpsSource::poll(int timeout_ms) { + drain_commands(); + if (client_ != nullptr) udps_client_poll(client_, timeout_ms); +} + +void UdpsSource::close() { + if (client_ != nullptr) { + udps_client_destroy(client_); + client_ = nullptr; + } +} + +SourceStats UdpsSource::stats() const { + SourceStats out; + if (client_ == nullptr) return out; + udps_stats_t s; + udps_client_stats(client_, &s); + out.packets = s.packets_received; + out.frames = s.frames_delivered; + out.gaps = s.counter_gaps; + out.late = s.late_delivered; + out.resequenced = s.late_resequenced; + out.stale = s.stale_packets; + out.overflows = s.reorder_overflows; + out.reconnects = s.reconnects; + out.connected = udps_client_is_connected(client_) != 0; + return out; +} + +bool UdpsSource::connected() const { + return client_ != nullptr && udps_client_is_connected(client_) != 0; +} + +void UdpsSource::config_cb(const udps_signal_t* signals, std::uint32_t count, + std::uint8_t publish_mode, void* user) { + auto* self = static_cast(user); + self->signals_.clear(); + self->signals_.reserve(count); + for (std::uint32_t i = 0; i < count; ++i) self->signals_.push_back(to_info(signals[i])); + self->sink_->on_config(self->signals_, publish_mode); +} + +void UdpsSource::data_cb(const udps_frame_t* f, void* user) { + auto* self = static_cast(user); + // The frame's signals are the CONFIG's; the cache is rebuilt only if a frame ever disagrees. + if (self->signals_.size() != f->num_signals) { + self->signals_.clear(); + for (std::uint32_t i = 0; i < f->num_signals; ++i) { + self->signals_.push_back(to_info(f->signals[i])); + } + } + self->values_.resize(f->num_signals); + for (std::uint32_t i = 0; i < f->num_signals; ++i) { + self->values_[i].values = f->values[i].values; + self->values_[i].count = f->values[i].count; + } + Frame fr; + fr.counter = f->counter; + fr.lost = f->lost; + fr.late = f->late != 0; + fr.recv_time = f->recv_time; + fr.publish_mode = f->publish_mode; + fr.num_samples = f->num_samples; + fr.num_signals = f->num_signals; + fr.signals = self->signals_.data(); + fr.values = self->values_.data(); + self->sink_->on_data(fr); +} + +void UdpsSource::event_cb(udps_event_t event, const char* detail, void* user) { + auto* self = static_cast(user); + SourceEvent e = SourceEvent::Error; + if (event == UDPS_EVENT_CONNECTED) e = SourceEvent::Connected; + else if (event == UDPS_EVENT_DISCONNECTED) e = SourceEvent::Disconnected; + self->sink_->on_event(e, detail); +} + +void UdpsSource::datagram_cb(const std::uint8_t* bytes, std::size_t len, double recv_time, + void* user) { + auto* self = static_cast(user); + if (self->tap_) self->tap_(bytes, len, recv_time); +} + +} // namespace udpscope diff --git a/src/source/udps_source.hpp b/src/source/udps_source.hpp new file mode 100644 index 0000000..a44d9a7 --- /dev/null +++ b/src/source/udps_source.hpp @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/data_source.hpp" +#include "source/source_config.hpp" + +#include + +#include +#include + +namespace udpscope { + +/// The UDPStreamer client behind the `DataSource` interface. A thin adapter: it maps +/// `udps_signal_t`/`udps_frame_t` to `SignalInfo`/`Frame` and forwards the datagram observer; +/// all protocol work stays in `deps/udpsclient`. +class UdpsSource final : public DataSource { + public: + explicit UdpsSource(const SourceConfig& cfg); + ~UdpsSource() override; + + void open(SourceSink& sink) override; + void poll(int timeout_ms) override; + void close() override; + SourceStats stats() const override; + bool connected() const override; + void set_datagram_tap(DatagramTap tap) override { tap_ = std::move(tap); } + + private: + static void config_cb(const udps_signal_t* signals, std::uint32_t count, + std::uint8_t publish_mode, void* user); + static void data_cb(const udps_frame_t* frame, void* user); + static void event_cb(udps_event_t event, const char* detail, void* user); + static void datagram_cb(const std::uint8_t* bytes, std::size_t len, double recv_time, + void* user); + + std::string server_; + std::uint16_t port_ = 8000; + std::string multicast_; + std::string interface_; + int recv_buffer_ = 8 * 1024 * 1024; + + udps_client_t* client_ = nullptr; + SourceSink* sink_ = nullptr; + DatagramTap tap_; + std::vector signals_; + std::vector values_; +}; + +} // namespace udpscope From 504bc193ea8d3c4aef69084a0927798530507597 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sat, 31 Oct 2026 19:59:51 +0100 Subject: [PATCH 014/127] acquisition: drive a DataSource from the registry; --source and --source-opt --- src/acquisition.cpp | 213 +++++++++++++++--------------------------- src/acquisition.hpp | 32 +++---- src/config.cpp | 38 +++++++- src/config.hpp | 19 ++++ src/main.cpp | 6 +- src/source_dialog.cpp | 4 +- src/ui.cpp | 6 +- src/ui_probe.cpp | 2 +- 8 files changed, 156 insertions(+), 164 deletions(-) diff --git a/src/acquisition.cpp b/src/acquisition.cpp index a00dcb8..3fd1f9c 100644 --- a/src/acquisition.cpp +++ b/src/acquisition.cpp @@ -5,9 +5,8 @@ #include -#include - #include "layout_store.hpp" +#include "source/registry.hpp" #include @@ -29,22 +28,6 @@ namespace { /// ring to make room for samples that are not coming. constexpr double kGlitchPackets = 256.0; -SignalInfo to_info(const udps_signal_t& s) { - SignalInfo info; - info.name = s.name; - info.unit = s.unit; - info.type_code = s.type_code; - info.quant_type = s.quant_type; - info.rows = s.num_rows; - info.cols = s.num_cols; - info.range_min = s.range_min; - info.range_max = s.range_max; - info.time_mode = s.time_mode; - info.sampling_rate = s.sampling_rate; - info.time_signal_idx = s.time_signal_idx; - return info; -} - } // namespace Acquisition::Acquisition(ScopeModel& model, const Config& cfg) : model_(model), cfg_(cfg) {} @@ -52,31 +35,21 @@ Acquisition::Acquisition(ScopeModel& model, const Config& cfg) : model_(model), Acquisition::~Acquisition() { stop(); } void Acquisition::start() { - // A started -- or *restarted* -- client is a new stream. Going live from a file view is the restart that + // A started -- or *restarted* -- source is a new stream. Going live from a file view is the restart that // matters: the store still holds the event's samples, and the new stream's clock can sit behind their // stamps, so the guard below has to re-base rather than clamp. `ring=[2.5200..2.5200]` with `n` climbing // was the symptom -- every packet pinned flat at the file view's last stamp. model_.reset_stream(); last_stamp_ = -std::numeric_limits::infinity(); - udps_client_config_t c; - udps_client_config_init(&c); - c.server_addr = cfg_.server.c_str(); - c.server_port = cfg_.port; - c.multicast_group = cfg_.multicast.empty() ? nullptr : cfg_.multicast.c_str(); - c.interface_addr = cfg_.interface_addr.empty() ? nullptr : cfg_.interface_addr.c_str(); - c.recv_buffer_bytes = static_cast(cfg_.recv_buffer_bytes); - c.silence_timeout_s = 2.0; - c.reconnect_delay_s = 0.5; - c.keepalive_interval_s = 1.0; - - client_ = udps_client_create(&c); - if (client_ == nullptr) { - throw std::runtime_error("could not create the UDPS client for " + cfg_.server + ":" + - std::to_string(cfg_.port)); + if (!source_) { + SourceKind* kind = find_kind(cfg_.source_kind); + if (kind == nullptr) throw std::runtime_error("unknown source kind: " + cfg_.source_kind); + source_ = kind->make(cfg_.source_config()); } - udps_client_set_callbacks(client_, &Acquisition::config_trampoline, &Acquisition::data_trampoline, - &Acquisition::event_trampoline, this); - udps_client_set_datagram_cb(client_, &Acquisition::datagram_trampoline, this); + source_->set_datagram_tap([this](const std::uint8_t* bytes, std::size_t len, double recv_time) { + on_datagram(bytes, len, recv_time); + }); + source_->open(*this); // `--capture-packets`: armed before the first datagram, so the first CONNECT and // its CONFIG -- the hardest strange behaviours to reproduce -- are in the ring. // The open itself still happens on the producer thread, at the first datagram. @@ -90,35 +63,24 @@ void Acquisition::start() { } void Acquisition::stop() { - if (!running_.exchange(false, std::memory_order_acq_rel)) { - if (client_ != nullptr) { - udps_client_destroy(client_); - client_ = nullptr; - } - return; - } - if (thread_.joinable()) thread_.join(); - if (client_ != nullptr) { - udps_client_destroy(client_); - client_ = nullptr; - } + if (running_.exchange(false, std::memory_order_acq_rel) && thread_.joinable()) thread_.join(); + if (source_) source_->close(); } void Acquisition::run() { while (running_.load(std::memory_order_acquire)) { - udps_client_poll(client_, 50); - udps_stats_t s; - udps_client_stats(client_, &s); + source_->poll(50); + const SourceStats s = source_->stats(); StreamStats& out = model_.stats(); - out.packets.store(s.packets_received, std::memory_order_relaxed); - out.frames.store(s.frames_delivered, std::memory_order_relaxed); - out.gaps.store(s.counter_gaps, std::memory_order_relaxed); - out.resequenced.store(s.late_resequenced, std::memory_order_relaxed); - out.late.store(s.late_delivered, std::memory_order_relaxed); - out.stale.store(s.stale_packets, std::memory_order_relaxed); - out.overflows.store(s.reorder_overflows, std::memory_order_relaxed); + out.packets.store(s.packets, std::memory_order_relaxed); + out.frames.store(s.frames, std::memory_order_relaxed); + out.gaps.store(s.gaps, std::memory_order_relaxed); + out.resequenced.store(s.resequenced, std::memory_order_relaxed); + out.late.store(s.late, std::memory_order_relaxed); + out.stale.store(s.stale, std::memory_order_relaxed); + out.overflows.store(s.overflows, std::memory_order_relaxed); out.reconnects.store(s.reconnects, std::memory_order_relaxed); - out.connected.store(udps_client_is_connected(client_) != 0, std::memory_order_relaxed); + out.connected.store(s.connected, std::memory_order_relaxed); } } @@ -127,19 +89,19 @@ void Acquisition::set_capture_want(bool want, std::uint32_t slots) { if (slots > 0) capture_slots_ = slots; if (capture_path_.empty()) { // Keyed on the source: one path per source, the previous capture kept one deep. - std::string host = cfg_.server; + std::string host = cfg_.session_host(); for (char& ch : host) { if (ch == ':' || ch == '/' || ch == ' ') ch = '_'; } - std::string dir = cache_dir() + "/" + host + "-" + std::to_string(cfg_.port); + std::string dir = cache_dir() + "/" + host + "-" + std::to_string(cfg_.session_port()); g_mkdir_with_parents(dir.c_str(), 0700); capture_path_ = dir + "/capture.bin"; std::uint32_t addr = 0; struct ::in_addr a; - if (::inet_pton(AF_INET, cfg_.server.c_str(), &a) == 1) { + if (cfg_.source_kind == "udps" && ::inet_pton(AF_INET, cfg_.server.c_str(), &a) == 1) { addr = a.s_addr; // already network order, which is what the header wants } - capture_.set_peer(addr, cfg_.port); + capture_.set_peer(addr, cfg_.source_kind == "udps" ? cfg_.port : 0); } } capture_want_.store(want, std::memory_order_release); @@ -171,29 +133,20 @@ void Acquisition::on_datagram(const std::uint8_t* bytes, std::size_t len, double } } -void Acquisition::datagram_trampoline(const std::uint8_t* bytes, std::size_t len, - double recv_time, void* user) { - static_cast(user)->on_datagram(bytes, len, recv_time); -} - -void Acquisition::on_config(const udps_signal_t* signals, std::uint32_t count, - std::uint8_t publish_mode) { - std::vector info; - info.reserve(count); - for (std::uint32_t i = 0; i < count; ++i) info.push_back(to_info(signals[i])); - +void Acquisition::on_config(const std::vector& info, std::uint8_t publish_mode) { // One line per CONFIG on stderr: what the stream says it is. This is the first // thing to look at when a signal refuses to plot. static const char* const kModes[] = {"strict", "accumulate", "decimate"}; - std::fprintf(stderr, "[udpscope] CONFIG: %u signals, publish=%s\n", count, + std::fprintf(stderr, "[udpscope] CONFIG: %u signals, publish=%s\n", + static_cast(info.size()), publish_mode < 3 ? kModes[publish_mode] : "?"); for (const SignalInfo& sig : info) { std::fprintf(stderr, "[udpscope] '%s' %ux%u (%u elem) rate=%g Hz time=%s idx=%u unit='%s'\n", sig.name.c_str(), sig.rows, sig.cols, sig.elements(), sig.sampling_rate, - sig.time_mode == UDPS_TIME_PACKET ? "packet" - : sig.time_mode == UDPS_TIME_FULL_ARRAY ? "full-array" - : sig.time_mode == UDPS_TIME_FIRST_SAMPLE ? "first-sample" + sig.time_mode == kTimePacket ? "packet" + : sig.time_mode == kTimeFullArray ? "full-array" + : sig.time_mode == kTimeFirstSample ? "first-sample" : "last-sample", sig.time_signal_idx, sig.unit.c_str()); } @@ -213,14 +166,14 @@ void Acquisition::on_config(const udps_signal_t* signals, std::uint32_t count, time_source_ = 0; reset_session_state(); model_.stats().have_config.store(true, std::memory_order_relaxed); - model_.set_signals(std::move(info)); + model_.set_signals(info); } -void Acquisition::on_event(int event, const char* detail) { +void Acquisition::on_event(SourceEvent event, const char* detail) { (void)detail; - if (event == UDPS_EVENT_CONNECTED) { + if (event == SourceEvent::Connected) { model_.stats().connected.store(true, std::memory_order_relaxed); - } else if (event == UDPS_EVENT_DISCONNECTED) { + } else if (event == SourceEvent::Disconnected) { model_.stats().connected.store(false, std::memory_order_relaxed); // The session dropped mid-flight: everything the producer knows about *that* // session's numbering and clocks is now a lie about the next one. Clearing it here @@ -300,15 +253,15 @@ bool Acquisition::gap_plausible(std::uint32_t missing, double elapsed, const Par return true; } -std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_signal, +std::uint32_t Acquisition::build_stamps(const Frame& frame, int time_signal, bool counter_gap_refused) { // The frame's span in base ticks: a scalar signal carries `num_samples` values // (one per batched cycle), a block signal (rows*cols > 1) carries its whole // element block. The largest of those is how many base ticks this frame spans -- // in `strict` mode `num_samples` is 1 while a 1x1000 block carries 1000 samples. - std::uint32_t n = frame->num_samples; - for (std::uint32_t i = 0; i < frame->num_signals; ++i) { - n = std::max(n, udps_signal_num_elements(&frame->signals[i])); + std::uint32_t n = frame.num_samples; + for (std::uint32_t i = 0; i < frame.num_signals; ++i) { + n = std::max(n, frame.signals[i].elements()); } stamps_.assign(n, 0.0); abs_stamps_.assign(n, 0.0); @@ -359,9 +312,9 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign // refused, because the advance and the reservation are the *same* claim about the wire: // believing a phantom gap here would stretch the timeline by exactly the loss that did not // happen. - if (!frame->late && frame->lost > 0 && batch_ > 0 && !counter_gap_refused && - double(frame->lost) <= kGlitchPackets) { - rel_base_s_ += double(frame->lost) * double(batch_) * dt; + if (!frame.late && frame.lost > 0 && batch_ > 0 && !counter_gap_refused && + double(frame.lost) <= kGlitchPackets) { + rel_base_s_ += double(frame.lost) * double(batch_) * dt; } for (std::uint32_t k = 0; k < n; ++k) { stamps_[k] = rel_base_s_ + double(k) * dt; @@ -370,14 +323,14 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign // The absolute clock is still read, for export only. When the stream carries no usable // time signal there is no absolute clock to record and the relative stamp is the honest // answer -- an export then says what the counter says, which is all anyone had. - if (time_signal >= 0 && time_signal < static_cast(frame->num_signals)) { - const udps_signal_t& ts = frame->signals[time_signal]; - const bool scalar_time = udps_signal_num_elements(&ts) == 1; + if (time_signal >= 0 && time_signal < static_cast(frame.num_signals)) { + const SignalInfo& ts = frame.signals[time_signal]; + const bool scalar_time = ts.elements() == 1; for (std::uint32_t k = 0; k < n; ++k) { abs_stamps_[k] = scalar_time - ? udps_frame_value(frame, static_cast(time_signal), k, 0) - : udps_frame_value(frame, static_cast(time_signal), 0, k); + ? frame.value(static_cast(time_signal), k, 0) + : frame.value(static_cast(time_signal), 0, k); } } else { for (std::uint32_t k = 0; k < n; ++k) abs_stamps_[k] = stamps_[k]; @@ -411,8 +364,8 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign // arrival clock for the rest of its life (until a CONFIG says otherwise): letting // the decision flip per frame is what interleaved two timelines in one ring. if (time_source_ != 2 && time_signal >= 0 && - time_signal < static_cast(frame->num_signals)) { - const udps_signal_t& ts = frame->signals[time_signal]; + time_signal < static_cast(frame.num_signals)) { + const SignalInfo& ts = frame.signals[time_signal]; // How the client indexes a signal's values depends on its shape // (`udps_frame_value`): a scalar carries one value per batched cycle, so the // stamp of sample k is (sample=k, elem=0); a multi-element signal carries one @@ -420,18 +373,18 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign // (sample=0, elem=k). Passing the scalar form for both is why FULL_ARRAY time // signals -- the only mode that carries a stamp per element -- read as // element 0 for every sample and were then judged degenerate. - const bool scalar_time = udps_signal_num_elements(&ts) == 1; + const bool scalar_time = ts.elements() == 1; auto value_of = [&](std::uint32_t k) { return scalar_time - ? udps_frame_value(frame, static_cast(time_signal), k, 0) - : udps_frame_value(frame, static_cast(time_signal), 0, k); + ? frame.value(static_cast(time_signal), k, 0) + : frame.value(static_cast(time_signal), 0, k); }; switch (ts.time_mode) { - case UDPS_TIME_FULL_ARRAY: + case kTimeFullArray: for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = value_of(k); used_stream_time = true; break; - case UDPS_TIME_FIRST_SAMPLE: { + case kTimeFirstSample: { // 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. @@ -445,7 +398,7 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign used_stream_time = true; break; } - case UDPS_TIME_LAST_SAMPLE: { + case kTimeLastSample: { const double anchor = value_of(n > 0 ? n - 1 : 0); for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = anchor - double(n - 1 - k) * cadence; @@ -549,7 +502,7 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign // was produced, and the earlier ones are dated backwards. A gap (`lost`) // shows up as extra elapsed time between arrivals, which is exactly the // spacing the samples had. - const double end = frame->recv_time; + const double end = frame.recv_time; if (!have_arrival_epoch_) { arrival_epoch_ = end; have_arrival_epoch_ = true; @@ -605,7 +558,7 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign // to a dead clock; they do not, and the wipe is what made a single glitching packet // look like the stream restarting from zero. time_source_ = 2; - const double end = frame->recv_time; + const double end = frame.recv_time; arrival_epoch_ = end - (last_stamp_ + model_.epoch()); arrival_prev_ = end; } @@ -625,7 +578,7 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign return n; } -void Acquisition::on_data(const udps_frame_t* frame) { +void Acquisition::on_data(const Frame& frame) { std::lock_guard lock(model_.mutex()); if (!running_.load(std::memory_order_relaxed)) return; @@ -633,8 +586,8 @@ void Acquisition::on_data(const udps_frame_t* frame) { // The counter is this frame's identity and the hole's address. A *late* frame's counter is // behind the sequence, so it must not move the mark -- the hole it belongs in was addressed // from the counter that revealed it. - if (!have_counter_ || static_cast(frame->counter - last_counter_) > 0) { - last_counter_ = frame->counter; + if (!have_counter_ || static_cast(frame.counter - last_counter_) > 0) { + last_counter_ = frame.counter; have_counter_ = true; } @@ -668,7 +621,7 @@ void Acquisition::on_data(const udps_frame_t* frame) { // Placed here and not earlier because `part`/`channels` are not in scope above, and not // later because `build_stamps()` is stateful: a late frame's stamps would walk the clock // backwards and can throw the whole stream onto the arrival clock. - if (frame->late) { + if (frame.late) { backfill_from(frame, part, channels, model_.live(), part.time_stride); return; } @@ -688,7 +641,7 @@ void Acquisition::on_data(const udps_frame_t* frame) { // (full-array, more than one element, no declared rate). for (std::size_t i = 0; i < model_.signals().size(); ++i) { const SignalInfo& sig = model_.signals()[i]; - if (sig.time_mode == UDPS_TIME_FULL_ARRAY && sig.elements() > 1 && + if (sig.time_mode == kTimeFullArray && sig.elements() > 1 && sig.sampling_rate == 0.0) { time_signal = static_cast(i); break; @@ -702,11 +655,11 @@ void Acquisition::on_data(const udps_frame_t* frame) { // let one burst raise the very number that is supposed to corroborate it -- a 200-packet outage // smooths the interval to 40 ms and then refuses itself. const bool first_frame = !have_prev_frame_recv_; - const double since_prev = first_frame ? 0.0 : frame->recv_time - prev_frame_recv_; - const std::uint32_t missing = frame->lost; + const double since_prev = first_frame ? 0.0 : frame.recv_time - prev_frame_recv_; + const std::uint32_t missing = frame.lost; const bool gap_refused = missing > 0 && !gap_plausible(missing, since_prev, part, part.time_stride); - note_frame_arrival(frame->recv_time); + note_frame_arrival(frame.recv_time); const std::uint32_t n = build_stamps(frame, time_signal, gap_refused); if (n == 0) return; @@ -721,7 +674,7 @@ void Acquisition::on_data(const udps_frame_t* frame) { epoch_adopted_ = true; model_.set_epoch_if_unset(stamps_[0]); std::fprintf(stderr, "[udpscope] time base: epoch=%.3f, first frame recv=%.3f, n=%u\n", - model_.epoch(), frame->recv_time, n); + model_.epoch(), frame.recv_time, n); std::fflush(stderr); } const double epoch = model_.epoch(); @@ -930,7 +883,7 @@ void Acquisition::on_data(const udps_frame_t* frame) { edge, last_stamp_, span - post_s, post_s, snap ? snap->pre_s : 0.0, snap ? snap->post_s : 0.0, static_cast(snap ? snap->stamps.size() : 0), - frame->recv_time, static_cast(tick_), + frame.recv_time, static_cast(tick_), std::chrono::duration(std::chrono::steady_clock::now() - started_) .count()); std::fflush(stderr); @@ -1012,9 +965,9 @@ void Acquisition::on_data(const udps_frame_t* frame) { // the cycle's sample at element `k`. Either way element 0 is the channel. const double raw = ch.elements > 1 - ? udps_frame_value(frame, static_cast(ch.signal), 0, + ? frame.value(static_cast(ch.signal), 0, std::min(k, ch.elements - 1)) - : udps_frame_value(frame, static_cast(ch.signal), k, 0); + : frame.value(static_cast(ch.signal), k, 0); // Every sample is read and accumulated even when only one in `period` is stored: // that is the whole point of the filter. Skipping the read -- which is what the // old stride did -- is what made decimation alias. @@ -1091,7 +1044,7 @@ void Acquisition::on_data(const udps_frame_t* frame) { if (engine.pending()) model_.set_capture_edge(engine.last_trigger_time()); model_.set_trigger_pending(engine.pending()); static std::int64_t next_engine_log = 0; - const std::int64_t now_e = static_cast(frame->recv_time * 1.0e6); + const std::int64_t now_e = static_cast(frame.recv_time * 1.0e6); if (now_e >= next_engine_log) { next_engine_log = now_e + 1000000; // `elapsed/post_want` is the capture's progress in the only unit that matters now @@ -1196,7 +1149,7 @@ void Acquisition::reserve_hole(std::uint32_t missing, const Partition& part, model_.stats().hole_slots.fetch_add(h.slot_count, std::memory_order_relaxed); } -void Acquisition::backfill_from(const udps_frame_t* frame, const Partition& part, +void Acquisition::backfill_from(const Frame& frame, const Partition& part, const std::vector& channels, BufferStore& store, u64 g) { // `batch_` is learned in `build_stamps()`, which a late frame never reaches. A late frame @@ -1213,7 +1166,7 @@ void Acquisition::backfill_from(const udps_frame_t* frame, const Partition& part // late packets would be dropped on the next line. Hole* hole = nullptr; for (Hole& h : model_.holes()) { - const std::int32_t into = static_cast(frame->counter - h.counter_first); + const std::int32_t into = static_cast(frame.counter - h.counter_first); if (into >= 0 && into < static_cast(h.packets)) { hole = &h; break; @@ -1224,7 +1177,7 @@ void Acquisition::backfill_from(const udps_frame_t* frame, const Partition& part // The packet's own span in absolute base ticks. `tick_begin` is the hole's first raw sample // and the packets inside a hole are consecutive, so the counter's distance from // `counter_first` places this one exactly. - const u64 lo = hole->tick_begin + u64(frame->counter - hole->counter_first) * u64(batch_); + const u64 lo = hole->tick_begin + u64(frame.counter - hole->counter_first) * u64(batch_); const u64 hi = lo + u64(batch_); u64 filled = 0; @@ -1248,10 +1201,10 @@ void Acquisition::backfill_from(const udps_frame_t* frame, const Partition& part const u64 k = s * period + j - lo; // this packet's own sample index, 0..batch_-1 const double raw = channels[i].elements > 1 - ? udps_frame_value(frame, static_cast(channels[i].signal), 0, + ? frame.value(static_cast(channels[i].signal), 0, std::min(static_cast(k), channels[i].elements - 1)) - : udps_frame_value(frame, static_cast(channels[i].signal), + : frame.value(static_cast(channels[i].signal), static_cast(k), 0); acc += raw * channels[i].gain + channels[i].offset; } @@ -1274,18 +1227,4 @@ void Acquisition::backfill_from(const udps_frame_t* frame, const Partition& part model_.stats().backfilled.fetch_add(filled, std::memory_order_relaxed); } -// Static trampolines: the client's C ABI cannot call a member function directly. -void Acquisition::config_trampoline(const udps_signal_t* signals, std::uint32_t count, - std::uint8_t publish_mode, void* user) { - static_cast(user)->on_config(signals, count, publish_mode); -} - -void Acquisition::data_trampoline(const udps_frame_t* frame, void* user) { - static_cast(user)->on_data(frame); -} - -void Acquisition::event_trampoline(udps_event_t event, const char* detail, void* user) { - static_cast(user)->on_event(static_cast(event), detail); -} - } // namespace udpscope diff --git a/src/acquisition.hpp b/src/acquisition.hpp index c2fa94e..c9e72c2 100644 --- a/src/acquisition.hpp +++ b/src/acquisition.hpp @@ -8,21 +8,22 @@ #include "model.hpp" #include "trigger.hpp" -#include +#include "source/data_source.hpp" #include #include #include #include +#include #include #include namespace udpscope { -/// Owns the UDPStreamer client and the producer thread that feeds the model's +/// Owns the data source and the producer thread that feeds the model's /// acquisition buffer. Everything it touches from the thread is serialised on /// `ScopeModel::mutex()`, so a UI-side structural change cannot race a frame. -class Acquisition { +class Acquisition : private SourceSink { public: Acquisition(ScopeModel& model, const Config& cfg); ~Acquisition(); @@ -62,22 +63,19 @@ class Acquisition { /// read by a packet whose engine was still disarmed, and then dropped. void request_force() noexcept { force_want_.store(true, std::memory_order_release); } + /// The source this acquisition drives, for the UI's capability panels: it may only call + /// `post()` on it. Null until `start()`. + DataSource* source() noexcept { return source_.get(); } + /// The capture, for the popover's row and the probe. Counters and errors are read /// cross-thread through PacketCapture's own published fields. const PacketCapture& capture() const noexcept { return capture_; } private: void run(); - void on_config(const udps_signal_t* signals, std::uint32_t count, std::uint8_t publish_mode); - void on_data(const udps_frame_t* frame); - void on_event(int event, const char* detail); - - static void config_trampoline(const udps_signal_t* signals, std::uint32_t count, - std::uint8_t publish_mode, void* user); - static void data_trampoline(const udps_frame_t* frame, void* user); - static void event_trampoline(udps_event_t event, const char* detail, void* user); - static void datagram_trampoline(const std::uint8_t* bytes, std::size_t len, - double recv_time, void* user); + void on_config(const std::vector& signals, std::uint8_t publish_mode) override; + void on_data(const Frame& frame) override; + void on_event(SourceEvent event, const char* detail) override; /// Drops the producer state that is per *connection*: the counter mark, the time /// anchors and the clock choice. A reconnect re-sends the CONFIG and the stream may /// have restarted its numbering; carrying the old session's marks across makes the @@ -89,9 +87,9 @@ class Acquisition { void on_datagram(const std::uint8_t* bytes, std::size_t len, double recv_time); /// Fills `stamps_` with this frame's base-tick timestamps, in stream seconds, - /// and returns how many were written (`frame->num_samples` unless the frame is + /// and returns how many were written (`frame.num_samples` unless the frame is /// malformed). - std::uint32_t build_stamps(const udps_frame_t* frame, int time_signal, + std::uint32_t build_stamps(const Frame& frame, int time_signal, bool counter_gap_refused); /// Pushes NaN for the `missing` packets' worth of samples that were never delivered, @@ -104,7 +102,7 @@ class Acquisition { /// Writes a late frame's values into the NaN-filled holes their counter addresses, /// amending the value ring directly (producer path) and queuing pyramid amends for /// `ChannelBuffer::acquire()` to apply on the consumer thread. - void backfill_from(const udps_frame_t* frame, const Partition& part, + void backfill_from(const Frame& frame, const Partition& part, const std::vector& channels, BufferStore& store, u64 g); /// Keeps the wire's own cadence up to date from the arrival times, in the modes where @@ -118,7 +116,7 @@ class Acquisition { ScopeModel& model_; Config cfg_; - udps_client* client_ = nullptr; + std::unique_ptr source_; std::thread thread_; std::atomic running_{false}; std::atomic paused_{false}; diff --git a/src/config.cpp b/src/config.cpp index 1b71402..73ab661 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -6,6 +6,7 @@ // For `RowSet::kMaxRows`: `--plots` names a row count, and one constant for the ceiling // is what stops the message and the clamp from drifting apart. #include "plot_host.hpp" +#include "source/registry.hpp" #include #include @@ -40,14 +41,35 @@ double parse_double(const char* name, const std::string& v) { } // namespace +SourceConfig Config::source_config() const { + SourceConfig out; + if (source_kind == "udps") { + out.set("server", server); + out.set("port", std::to_string(port)); + if (!multicast.empty()) out.set("multicast", multicast); + if (!interface_addr.empty()) out.set("interface", interface_addr); + out.set("recv_buffer", std::to_string(recv_buffer_bytes)); + } + for (const auto& kv : source_opts.items()) out.set(kv.first, kv.second); + return out; +} + +std::string Config::session_host() const { return udpscope::session_host(source_kind, source_config()); } +std::uint16_t Config::session_port() const { return udpscope::session_port(source_kind, source_config()); } + void print_usage(std::FILE* out, const char* argv0) { + std::string names; + for (const SourceKind* k : kinds()) names += (names.empty() ? "" : ", ") + k->id(); std::fprintf(out, - "usage: %s --server ADDR --port PORT [options]\n" + "usage: %s --server ADDR --port PORT [--source NAME] [options]\n" "\n" " --server ADDR stream source IPv4 address (default 127.0.0.1)\n" " --port PORT UDP port (unicast) or TCP port (multicast) (default 8000)\n" " --multicast GROUP join GROUP for DATA instead of unicast\n" " --interface ADDR local IPv4 of the interface to join on\n" + " --source NAME source kind (%s; default udps). --server/--port/\n" + " --multicast/--interface configure udps\n" + " --source-opt K=V a setting for the source kind; repeatable\n" " --buffer-mb MB size of each of the two sample buffers (default 200)\n" " --max-window SEC largest time window the toolbar offers (default 600)\n" " --holdoff SEC default trigger hold-off (default 0.010)\n" @@ -69,7 +91,7 @@ void print_usage(std::FILE* out, const char* argv0) { " and the GL area has no hardware context, so the window\n" " never paints or allocates\n" " -h, --help this text\n", - argv0); + argv0, names.c_str()); } Config parse_args(int argc, char** argv) { @@ -91,6 +113,18 @@ Config parse_args(int argc, char** argv) { c.multicast = value_of(argc, argv, i, "--multicast"); } else if (a == "--interface" || a.rfind("--interface=", 0) == 0) { c.interface_addr = value_of(argc, argv, i, "--interface"); + } else if (a == "--source" || a.rfind("--source=", 0) == 0) { + const std::string v = value_of(argc, argv, i, "--source"); + if (find_kind(v) == nullptr) { + std::string names; + for (const SourceKind* k : kinds()) names += (names.empty() ? "" : ", ") + k->id(); + fail("unknown source '" + v + "' (available: " + names + ")"); + } + c.source_kind = v; + c.source_specified = true; + } else if (a == "--source-opt" || a.rfind("--source-opt=", 0) == 0) { + const std::string v = value_of(argc, argv, i, "--source-opt"); + if (!c.source_opts.parse_assignment(v)) fail("--source-opt must be key=value: " + v); } else if (a == "--buffer-mb" || a.rfind("--buffer-mb=", 0) == 0) { c.buffer_mb = parse_double("--buffer-mb", value_of(argc, argv, i, "--buffer-mb")); c.buffer_mb_specified = true; diff --git a/src/config.hpp b/src/config.hpp index 3304834..7144a0b 100644 --- a/src/config.hpp +++ b/src/config.hpp @@ -6,6 +6,8 @@ #include #include +#include "source/source_config.hpp" + namespace udpscope { /// Everything the command line can say. Defaults are the ones the spec names @@ -21,6 +23,23 @@ struct Config { /// `server` valid". The default stays a usable value for every other path. bool server_specified = false; + /// `--source NAME`: which registered kind opens. `udps` is the default, and `--server/--port/ + /// --multicast/--interface` keep meaning UDPS. + std::string source_kind = "udps"; + /// Whether `--source` was given (the other half of "did the user choose a source"). + bool source_specified = false; + /// Repeatable `--source-opt k=v`: the kind's own settings. For `udps` they overlay the fields above. + SourceConfig source_opts; + + /// A source was named on the command line (`--server` or `--source`), so the chooser is skipped. + bool has_source() const noexcept { return server_specified || source_specified; } + /// The settings the kind's `make()` receives: for UDPS the four connection fields plus the receive + /// buffer, then `source_opts` on top. + SourceConfig source_config() const; + /// The (host, port) that name this source's session file; see `session_host()` in the registry. + std::string session_host() const; + std::uint16_t session_port() const; + /// Size of *each* of the two sample buffers, in megabytes. The application /// always allocates two identical buffers (spec item 1). double buffer_mb = 200.0; diff --git a/src/main.cpp b/src/main.cpp index b4e439c..c6be5c6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -7,6 +7,7 @@ #include "waveform.hpp" #include "model.hpp" #include "plot_host.hpp" +#include "source/registry.hpp" #include "source_dialog.hpp" #include "ui.hpp" @@ -18,6 +19,7 @@ int main(int argc, char** argv) { udpscope::Config cfg; try { + udpscope::register_builtin_sources(); cfg = udpscope::parse_args(argc, argv); // The preferences fill in what the command line did not decide. `--relative-time` is an explicit // instruction for this run, so a preference can only turn the mode on, never off -- which is also @@ -57,13 +59,13 @@ int main(int argc, char** argv) { // declining must cost no buffer and no socket, and nothing below has to cope with "no source". // `--open` is the other answer to that question, and the chooser knows it: its Waveforms page // fills `open_waveform` in instead of the connection fields. - const bool asked = !cfg.server_specified && cfg.open_waveform.empty(); + const bool asked = !cfg.has_source() && cfg.open_waveform.empty(); if (asked && !udpscope::choose_source(cfg)) return 0; // A source named on the command line skips the chooser, but its *stored* configuration still // applies: the buffer size, the clock and the capture ring are decided here, before the model -- // which is the only moment they can be -- and a saved host whose buffer was ignored because the // address came from the command line is an edit that looks like it never took effect. - if (cfg.server_specified) udpscope::apply_stored_settings(cfg); + if (cfg.has_source()) udpscope::apply_stored_settings(cfg); // Everything from here to `gtk_window_present()` inside `run_ui()` runs with no main loop: the // buffer is allocated, a recording is read, then the whole UI is built. The chooser's window is diff --git a/src/source_dialog.cpp b/src/source_dialog.cpp index ee31ac6..b2a9dd8 100644 --- a/src/source_dialog.cpp +++ b/src/source_dialog.cpp @@ -1129,10 +1129,10 @@ gboolean pick_probe(gpointer user) { } // namespace void apply_stored_settings(Config& cfg) { - const std::string want = active_configuration(cfg.server, cfg.port); + const std::string want = active_configuration(cfg.session_host(), cfg.session_port()); if (want.empty()) return; LayoutState st; - if (!load_layout(configuration_path(cfg.server, cfg.port, want), st)) return; + if (!load_layout(configuration_path(cfg.session_host(), cfg.session_port(), want), st)) return; // Only where nothing newer was said: a flag the user typed, or the preferences they just applied, // wins -- because it is the more recent statement of intent, and the file is not a place to hide a // surprise from whoever just made it. diff --git a/src/ui.cpp b/src/ui.cpp index 19d4f8e..959554e 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -2494,7 +2494,7 @@ void on_about_clicked(GtkButton*, gpointer user) { App* app = static_cast(user); close_overflow_menu(*app); show_about(GTK_WINDOW(app->window), - layout_path(app->model->config().server, app->model->config().port)); + layout_path(app->model->config().session_host(), app->model->config().session_port())); } void on_help_clicked(GtkButton*, gpointer user) { @@ -5914,8 +5914,8 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit // both overrode the arrangement the file was opened with and could clobber a real session. With no // port, `save_layout_now()` refuses and the first-CONFIG layout load never runs, which is exactly // a source-less run. - app.source_host = cfg.open_waveform.empty() ? cfg.server : std::string(); - app.source_port = cfg.open_waveform.empty() ? cfg.port : 0; + app.source_host = cfg.open_waveform.empty() ? cfg.session_host() : std::string(); + app.source_port = cfg.open_waveform.empty() ? cfg.session_port() : 0; app.source_multicast = cfg.open_waveform.empty() ? cfg.multicast : std::string(); app.source_interface = cfg.interface_addr; // The event cache is keyed on the source, like the packet capture. A waveform has no source, so a diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 20cdc1c..9dca011 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -5411,7 +5411,7 @@ gboolean help_probe(gpointer user) { App* a = static_cast(user); const HelpReport report = help_self_check(); const std::string session = - layout_path(a->model->config().server, a->model->config().port); + layout_path(a->model->config().session_host(), a->model->config().session_port()); // The manual is *launched* here and read no further: it opens in another process (Yelp, through // the `help:` URI) and neither it nor this box can draw a window for it, so there is nothing to From 4ae91bd58b5cd2cb95c172d53bfdd51a34066996 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sat, 31 Oct 2026 20:02:55 +0100 Subject: [PATCH 015/127] source: normalise UDPS signal dimensions in to_info; comment tidy --- src/acquisition.cpp | 12 ++++++------ src/acquisition.hpp | 4 ++-- src/source/udps_source.cpp | 13 +++++++++++-- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/acquisition.cpp b/src/acquisition.cpp index 3fd1f9c..7d67af7 100644 --- a/src/acquisition.cpp +++ b/src/acquisition.cpp @@ -366,8 +366,8 @@ std::uint32_t Acquisition::build_stamps(const Frame& frame, int time_signal, if (time_source_ != 2 && time_signal >= 0 && time_signal < static_cast(frame.num_signals)) { const SignalInfo& ts = frame.signals[time_signal]; - // How the client indexes a signal's values depends on its shape - // (`udps_frame_value`): a scalar carries one value per batched cycle, so the + // How a frame indexes a signal's values depends on its shape + // (`Frame::value`): a scalar carries one value per batched cycle, so the // stamp of sample k is (sample=k, elem=0); a multi-element signal carries one // *sample* of `elements` values per packet, so the stamp of sample k is // (sample=0, elem=k). Passing the scalar form for both is why FULL_ARRAY time @@ -966,7 +966,7 @@ void Acquisition::on_data(const Frame& frame) { const double raw = ch.elements > 1 ? frame.value(static_cast(ch.signal), 0, - std::min(k, ch.elements - 1)) + std::min(k, ch.elements - 1)) : frame.value(static_cast(ch.signal), k, 0); // Every sample is read and accumulated even when only one in `period` is stored: // that is the whole point of the filter. Skipping the read -- which is what the @@ -1202,10 +1202,10 @@ void Acquisition::backfill_from(const Frame& frame, const Partition& part, const double raw = channels[i].elements > 1 ? frame.value(static_cast(channels[i].signal), 0, - std::min(static_cast(k), - channels[i].elements - 1)) + std::min(static_cast(k), + channels[i].elements - 1)) : frame.value(static_cast(channels[i].signal), - static_cast(k), 0); + static_cast(k), 0); acc += raw * channels[i].gain + channels[i].offset; } const f32 value = static_cast(acc / double(period)); diff --git a/src/acquisition.hpp b/src/acquisition.hpp index c9e72c2..a585c32 100644 --- a/src/acquisition.hpp +++ b/src/acquisition.hpp @@ -31,8 +31,8 @@ class Acquisition : private SourceSink { Acquisition(const Acquisition&) = delete; Acquisition& operator=(const Acquisition&) = delete; - /// Opens the client (no socket until the first poll) and starts the thread. - /// Throws std::runtime_error when the client cannot be created. + /// Opens the source (no socket until the first poll) and starts the thread. + /// Throws std::runtime_error when the source cannot be created. void start(); /// Stops the thread and destroys the client. Idempotent. void stop(); diff --git a/src/source/udps_source.cpp b/src/source/udps_source.cpp index 1ae89f8..e0a1f54 100644 --- a/src/source/udps_source.cpp +++ b/src/source/udps_source.cpp @@ -19,8 +19,17 @@ SignalInfo to_info(const udps_signal_t& s) { info.unit = s.unit; info.type_code = s.type_code; info.quant_type = s.quant_type; - info.rows = s.num_rows; - info.cols = s.num_cols; + // Store the effective dimensions `udps_signal_num_elements` uses, so `SignalInfo::elements()` + // (rows * cols) gives the same count for any CONFIG, hostile or odd ones included: a zero + // dimension means 1 on its own (rows 0, cols 5 is 5 elements), and the product is clamped to + // UDPS_MAX_ELEMENTS, which also keeps rows * cols from overflowing uint32. + std::uint64_t rows = s.num_rows == 0 ? 1u : s.num_rows; + std::uint64_t cols = s.num_cols == 0 ? 1u : s.num_cols; + if (rows > UDPS_MAX_ELEMENTS) rows = UDPS_MAX_ELEMENTS; + if (cols > UDPS_MAX_ELEMENTS) cols = UDPS_MAX_ELEMENTS; + if (rows * cols > UDPS_MAX_ELEMENTS) cols = UDPS_MAX_ELEMENTS / rows; + info.rows = static_cast(rows); + info.cols = static_cast(cols); info.range_min = s.range_min; info.range_max = s.range_max; info.time_mode = s.time_mode; From 059b660586110d20a54a6d3e7d39d76770b975a1 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sat, 31 Oct 2026 20:04:42 +0100 Subject: [PATCH 016/127] source: a synthetic probe kind, and sessions that remember their source kind Co-Authored-By: Claude Opus 4.6 --- CMakeLists.txt | 1 + src/layout_store.cpp | 15 ++++ src/layout_store.hpp | 9 +++ src/source/probe_source.cpp | 137 ++++++++++++++++++++++++++++++++++++ src/source/probe_source.hpp | 50 +++++++++++++ src/source/registry.cpp | 2 +- src/ui.cpp | 12 +++- src/ui_app.hpp | 6 ++ src/ui_layout.cpp | 2 + 9 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 src/source/probe_source.cpp create mode 100644 src/source/probe_source.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 90f9cd1..9080f6b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -241,6 +241,7 @@ add_executable(udpscope src/source/registry.cpp src/source/udps_source.cpp src/source/udps_kind.cpp + src/source/probe_source.cpp src/plot_host.cpp src/export.cpp src/search.cpp diff --git a/src/layout_store.cpp b/src/layout_store.cpp index 857cfb9..0b42d37 100644 --- a/src/layout_store.cpp +++ b/src/layout_store.cpp @@ -322,6 +322,10 @@ std::vector known_sources() { s.capture_packets = st.capture_packets; s.multicast = st.multicast; s.interface_addr = st.interface_addr; + if (!st.source_kind.empty()) { + s.kind = st.source_kind; + s.opts = st.source_opts; + } s.rows = static_cast(st.rows.size()); for (const LayoutState::Row& row : st.rows) { for (const std::string& sig : row.signals) { @@ -558,6 +562,11 @@ bool load_layout(const std::string& path, LayoutState& out) { st.multicast = strip_quote(value); } else if (key == "interface") { st.interface_addr = strip_quote(value); + } else if (key == "source_kind") { + st.source_kind = strip_quote(value); + } else if (key.rfind("source_opt_", 0) == 0) { + const std::string k = key.substr(11); + if (SourceConfig::valid_key(k)) st.source_opts.set(k, strip_quote(value)); } else if (key == "comment") { st.comment = strip_quote(value); } else if (key == "buffer_mb") { @@ -699,6 +708,12 @@ bool save_layout(const std::string& path, const LayoutState& state) { if (!state.interface_addr.empty()) { o << "interface = " << toml_escape(state.interface_addr) << "\n"; } + if (!state.source_kind.empty() && state.source_kind != "udps") { + o << "source_kind = " << toml_escape(state.source_kind) << "\n"; + for (const auto& kv : state.source_opts.items()) { + o << "source_opt_" << kv.first << " = " << toml_escape(kv.second) << "\n"; + } + } // Top level, before any table. Emitted after the `[[calibration]]` tables it belonged to the last // of them -- a bare key after a header is that table's key in TOML -- so the parser, correctly, // never saw it and a session's enable state was silently lost. diff --git a/src/layout_store.hpp b/src/layout_store.hpp index c919289..eb7db18 100644 --- a/src/layout_store.hpp +++ b/src/layout_store.hpp @@ -8,6 +8,7 @@ #include "math_channel.hpp" #include "measure.hpp" +#include "source/source_config.hpp" namespace udpscope { @@ -54,6 +55,12 @@ struct LayoutState { /// unicast; empty `interface_addr` is the default route. std::string multicast; std::string interface_addr; + /// Which source kind this session belongs to. Empty means `udps`: every file written before the + /// plugins existed. Only a kind other than `udps` is written, so a UDPS session file is + /// byte-identical to what earlier versions wrote. + std::string source_kind; + /// That kind's settings (`SourceConfig`), stored as top-level `source_opt_` entries. + SourceConfig source_opts; /// The derived channels, graphs and all: the editor's work is part of the session, not of the /// process. A graph is stored as one string per node so the file stays in this store's TOML @@ -144,6 +151,8 @@ struct KnownSource { std::uint16_t port = 0; std::string multicast; ///< empty = unicast std::string interface_addr; ///< empty = the default route + std::string kind = "udps"; ///< the session's source kind + SourceConfig opts; ///< that kind's settings (empty for UDPS, whose fields are above) int rows = 0; ///< plots the session had int signals = 0; ///< named slots across those plots std::int64_t mtime = 0; ///< orders the list; never shown diff --git a/src/source/probe_source.cpp b/src/source/probe_source.cpp new file mode 100644 index 0000000..218c126 --- /dev/null +++ b/src/source/probe_source.cpp @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/probe_source.hpp" + +#include "source/registry.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +namespace udpscope { + +ProbeSource::ProbeSource(const SourceConfig& cfg) + : rate_hz_(std::max(1.0, cfg.get_double("rate", 1000.0))), batch_(kBatch, 0.0) { + SignalInfo s; + s.name = "probe"; + s.unit = "V"; + s.type_code = 9; // float64 + s.rows = 1; + s.cols = 1; + s.range_min = -1.0; + s.range_max = 1.0; + s.time_mode = kTimePacket; + s.sampling_rate = rate_hz_; + signals_.push_back(s); +} + +void ProbeSource::open(SourceSink& sink) { + sink_ = &sink; + configured_ = false; + open_.store(true, std::memory_order_relaxed); +} + +void ProbeSource::close() { open_.store(false, std::memory_order_relaxed); } + +SourceStats ProbeSource::stats() const { + SourceStats s; + s.packets = frames_; + s.frames = frames_; + s.connected = open_.load(std::memory_order_relaxed); + return s; +} + +void ProbeSource::poll(int timeout_ms) { + drain_commands(); + if (!open_.load(std::memory_order_relaxed) || sink_ == nullptr) return; + if (!configured_) { + configured_ = true; + sink_->on_config(signals_, /*publish_mode=*/1); + sink_->on_event(SourceEvent::Connected, ""); + } + // One batch takes kBatch / rate seconds to "arrive". + const int period_ms = std::max(1, static_cast(1000.0 * kBatch / rate_hz_)); + std::this_thread::sleep_for(std::chrono::milliseconds(std::min(period_ms, timeout_ms))); + const double step = 2.0 * M_PI * 5.0 / rate_hz_; // a 5 Hz sine + for (std::uint32_t k = 0; k < kBatch; ++k) { + batch_[k] = std::sin(phase_); + phase_ += step; + } + values_.values = batch_.data(); + values_.count = kBatch; + Frame f; + f.counter = counter_++; + f.lost = 0; + f.late = false; + f.recv_time = std::chrono::duration(std::chrono::system_clock::now().time_since_epoch()) + .count(); + f.publish_mode = 1; + f.num_samples = kBatch; + f.num_signals = 1; + f.signals = signals_.data(); + f.values = &values_; + ++frames_; + sink_->on_data(f); +} + +namespace { + +void on_rate_changed(GObject*, GParamSpec*, gpointer user) { + const auto& cb = *static_cast>*>(user); + if (cb && *cb) (*cb)(); +} + +class ProbeKind final : public SourceKind { + public: + std::string id() const override { return "probe"; } + std::string display_name() const override { return "Probe (test)"; } + std::string identity(const SourceConfig& cfg) const override { + return "rate" + std::to_string(cfg.get_int("rate", 1000)); + } + std::unique_ptr make(const SourceConfig& cfg) const override { + return std::make_unique(cfg); + } + + ConfigForm make_config_form() const override { + GtkWidget* rate = GTK_WIDGET(adw_spin_row_new_with_range(1, 100000, 1)); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(rate), "_Rate (Hz)"); + adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(rate), TRUE); + adw_spin_row_set_value(ADW_SPIN_ROW(rate), 1000); + + auto cb = std::make_shared>(); + g_signal_connect_data(rate, "notify::value", G_CALLBACK(on_rate_changed), + new std::shared_ptr>(cb), + +[](gpointer d, GClosure*) { + delete static_cast>*>(d); + }, + static_cast(0)); + + ConfigForm f; + f.rows = {rate}; + f.description = "A synthetic 5 Hz sine, for the self-check"; + f.focus = rate; + f.write = [=](const SourceConfig& c) { + adw_spin_row_set_value(ADW_SPIN_ROW(rate), static_cast(c.get_int("rate", 1000))); + }; + f.read = [=]() { + SourceConfig c; + c.set("rate", std::to_string(static_cast(adw_spin_row_get_value(ADW_SPIN_ROW(rate))))); + return c; + }; + f.valid = []() { return true; }; + f.on_change = [cb](std::function fn) { *cb = std::move(fn); }; + return f; + } +}; + +} // namespace + +std::unique_ptr make_probe_kind() { return std::make_unique(); } + +} // namespace udpscope diff --git a/src/source/probe_source.hpp b/src/source/probe_source.hpp new file mode 100644 index 0000000..0e1b8ab --- /dev/null +++ b/src/source/probe_source.hpp @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/data_source.hpp" +#include "source/source_config.hpp" + +#include +#include +#include + +namespace udpscope { + +/// A synthetic source for the headless self-check: one 1 V sine signal, `kBatch` samples per +/// frame at `rate` Hz, no socket. It exists to prove the framework end to end (open, CONFIG, +/// data, commands, panels) and is registered only when `UDPSCOPE_PROBE_SOURCE_KINDS` is set. +class ProbeSource final : public DataSource { + public: + static constexpr std::uint32_t kBatch = 10; + + explicit ProbeSource(const SourceConfig& cfg); + + void open(SourceSink& sink) override; + void poll(int timeout_ms) override; + void close() override; + SourceStats stats() const override; + bool connected() const override { return open_.load(std::memory_order_relaxed); } + + /// What a panel command does, so the probe can see that `post()` reached the producer thread. + void note_applied() noexcept { applied_.fetch_add(1, std::memory_order_relaxed); } + std::uint64_t commands_applied() const noexcept { + return applied_.load(std::memory_order_relaxed); + } + + private: + double rate_hz_; + SourceSink* sink_ = nullptr; + std::atomic open_{false}; + bool configured_ = false; + std::uint32_t counter_ = 0; + std::uint64_t frames_ = 0; + double phase_ = 0.0; + std::vector signals_; + std::vector batch_; + SignalValues values_; + std::atomic applied_{0}; +}; + +} // namespace udpscope diff --git a/src/source/registry.cpp b/src/source/registry.cpp index 2c2f1ac..5b06128 100644 --- a/src/source/registry.cpp +++ b/src/source/registry.cpp @@ -50,7 +50,7 @@ void register_builtin_sources() { if (done) return; done = true; register_kind(make_udps_kind()); - // Task 6: register make_probe_kind() when UDPSCOPE_PROBE_SOURCE_KINDS is set + if (std::getenv("UDPSCOPE_PROBE_SOURCE_KINDS") != nullptr) register_kind(make_probe_kind()); } std::string session_host(const std::string& kind_id, const SourceConfig& cfg) { diff --git a/src/ui.cpp b/src/ui.cpp index 959554e..60a8cb6 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -33,6 +33,7 @@ #include "model.hpp" #include "plot_host.hpp" #include "search.hpp" +#include "source/registry.hpp" #include "source_dialog.hpp" #include @@ -100,8 +101,7 @@ void open_configurations(App& app) { GtkWidget* title = gtk_label_new(nullptr); gtk_label_set_markup(GTK_LABEL(title), - ("" + app.source_host + ":" + std::to_string(app.source_port) + - "") + ("" + app.source_title + "") .c_str()); gtk_widget_set_halign(title, GTK_ALIGN_START); gtk_box_append(GTK_BOX(box), title); @@ -5918,6 +5918,14 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit app.source_port = cfg.open_waveform.empty() ? cfg.session_port() : 0; app.source_multicast = cfg.open_waveform.empty() ? cfg.multicast : std::string(); app.source_interface = cfg.interface_addr; + app.source_kind = cfg.source_kind; + app.source_opts = cfg.source_opts; + if (cfg.source_kind != "udps" && find_kind(cfg.source_kind) != nullptr) { + app.source_title = find_kind(cfg.source_kind)->display_name() + " " + + find_kind(cfg.source_kind)->identity(cfg.source_config()); + } else { + app.source_title = app.source_host + ":" + std::to_string(app.source_port); + } // The event cache is keyed on the source, like the packet capture. A waveform has no source, so a // file-mode run has no cache either -- there are no latches in a file. if (!app.source_host.empty() && app.source_port != 0) { diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 2af2da5..144e5a6 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -29,6 +29,7 @@ #include "plot_host.hpp" #include "search.hpp" #include "settings.hpp" +#include "source/source_config.hpp" #include "ui_prefs.hpp" #include "shortcuts.hpp" #include "status_text.hpp" @@ -837,6 +838,11 @@ struct App { /// chooser needs both to reconnect to a multicast source from a stored row. std::string source_multicast; std::string source_interface; + /// The source kind and its settings, so the session can say which kind it belongs to, and the + /// words that name the source in dialogs (`host:port` for UDPS, ` ` otherwise). + std::string source_kind = "udps"; + SourceConfig source_opts; + std::string source_title; guint layout_save_source = 0; bool layout_loaded = false; unsigned signal_generation = ~0u; diff --git a/src/ui_layout.cpp b/src/ui_layout.cpp index d6c059f..329dc57 100644 --- a/src/ui_layout.cpp +++ b/src/ui_layout.cpp @@ -97,6 +97,8 @@ LayoutState collect_layout(App& app) { // The connection is the session's too: the file's name carries only host and port. st.multicast = app.source_multicast; st.interface_addr = app.source_interface; + st.source_kind = app.source_kind; + st.source_opts = app.source_opts; // The configuration's own identity and the startup settings it carries. The buffer size is a // *startup* parameter -- the rings are allocated before any stream is known -- so a configuration // is the only place it can be remembered per source, and the chooser applies it on open. From 14f1421b2bb2668aa1072750972a05d71aced0a0 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sat, 31 Oct 2026 20:08:39 +0100 Subject: [PATCH 017/127] chooser: pick the source type; each kind supplies its own connection form --- src/source_dialog.cpp | 224 ++++++++++++++++++++++++++++++++---------- src/ui/source-form.ui | 45 +++------ 2 files changed, 182 insertions(+), 87 deletions(-) diff --git a/src/source_dialog.cpp b/src/source_dialog.cpp index b2a9dd8..8bc7d32 100644 --- a/src/source_dialog.cpp +++ b/src/source_dialog.cpp @@ -8,6 +8,7 @@ #include "help_window.hpp" #include "layout_store.hpp" #include "settings.hpp" +#include "source/registry.hpp" // The app's shared chrome -- `ui_impl::menu_button()`/`menu_toggle()` and `install_css()` -- so the // chooser's help menu is the *same* menu the window's Help view is, not a second one that merely // looks like it. Heavy header, but a row shape copied here would be a row shape that drifts. @@ -19,6 +20,7 @@ #include +#include #include #include #include @@ -32,10 +34,16 @@ namespace { /// group: it is the local address the join happens on, so storing one without the other would /// reconnect to the right group on the wrong path. void apply_source(Config& cfg, const KnownSource& src) { - cfg.server = src.host; - cfg.port = src.port; - cfg.multicast = src.multicast; - cfg.interface_addr = src.interface_addr; + cfg.source_kind = src.kind; + if (src.kind == "udps") { + cfg.server = src.host; + cfg.port = src.port; + cfg.multicast = src.multicast; + cfg.interface_addr = src.interface_addr; + cfg.source_opts = SourceConfig(); + } else { + cfg.source_opts = src.opts; + } // The configuration's own startup settings. They are read *before* the model exists, which is the // only moment a buffer size can be applied at all -- and the reason they are stored per // configuration rather than only passed on the command line. @@ -105,10 +113,14 @@ struct Chooser { /// are typed: a switch is read with `adw_switch_row_get_active()` and not off a widget. GtkBuilder* form_ui = nullptr; AdwWindowTitle* form_title = nullptr; - AdwEntryRow* form_server = nullptr; - AdwSpinRow* form_port = nullptr; - AdwEntryRow* form_group = nullptr; - AdwEntryRow* form_iface = nullptr; + /// The source-type dropdown and the group the chosen kind's rows live in. The rows themselves, + /// and the closures that read and write them, are the kind's (`ConfigForm`); the chooser only + /// swaps them when the dropdown moves. + AdwComboRow* form_kind_row = nullptr; + AdwPreferencesGroup* form_source_group = nullptr; + std::vector form_kinds; ///< dropdown order + ConfigForm form_cfg; + std::vector form_cfg_rows; ///< the rows currently in `form_source_group` GtkWidget* form_connect = nullptr; /// The configuration half of the form: what a source's *session* is called and the settings that /// can only be applied before the model exists. A new host needs all of them, and so does an @@ -373,7 +385,11 @@ void chooser_populate(Chooser& c) { // The *server*, even for a multicast session: that is the host the file is named after and // the one CONFIG is fetched from. Titling the row by the group would name it after something // that is not its identity. - const std::string title = s.host + ":" + std::to_string(s.port) + " \u00b7 " + s.config; + // A non-UDPS session whose kind is not registered keeps its pseudo-host as the title. + SourceKind* sk = s.kind == "udps" ? nullptr : find_kind(s.kind); + const std::string where = sk != nullptr ? sk->display_name() + " " + sk->identity(s.opts) + : s.host + ":" + std::to_string(s.port); + const std::string title = where + " \u00b7 " + s.config; GtkWidget* title_label = gtk_label_new(title.c_str()); gtk_widget_set_halign(title_label, GTK_ALIGN_START); gtk_box_append(GTK_BOX(text), title_label); @@ -432,34 +448,69 @@ void form_close(Chooser& c) { if (c.form != nullptr) adw_dialog_close(c.form); } -void on_form_server_changed(GtkEditable* entry, gpointer user) { +/// The kind selected in the dropdown, or null. +SourceKind* form_selected_kind(const Chooser& c) { + if (c.form_kind_row == nullptr) return nullptr; + const guint i = adw_combo_row_get_selected(c.form_kind_row); + return i < c.form_kinds.size() ? c.form_kinds[i] : nullptr; +} + +/// Swaps the Connection group's rows for `kind`'s form and fills them from `initial`. The +/// connect button follows the form's own `valid()`: the one validation here is the kind's. +void form_rebuild(Chooser& c, SourceKind& kind, const SourceConfig& initial) { + for (GtkWidget* r : c.form_cfg_rows) adw_preferences_group_remove(c.form_source_group, r); + c.form_cfg_rows.clear(); + c.form_cfg = kind.make_config_form(); + for (GtkWidget* r : c.form_cfg.rows) { + adw_preferences_group_add(c.form_source_group, r); + c.form_cfg_rows.push_back(r); + } + adw_preferences_group_set_description( + c.form_source_group, + c.form_cfg.description.empty() ? nullptr : c.form_cfg.description.c_str()); + c.form_cfg.write(initial); + c.form_cfg.on_change([&c] { + if (c.form_connect != nullptr) { + gtk_widget_set_sensitive(c.form_connect, c.form_cfg.valid() ? TRUE : FALSE); + } + }); + if (c.form_connect != nullptr) { + gtk_widget_set_sensitive(c.form_connect, c.form_cfg.valid() ? TRUE : FALSE); + } + if (c.form_cfg.focus != nullptr) gtk_widget_grab_focus(c.form_cfg.focus); +} + +void on_form_kind_changed(GObject*, GParamSpec*, gpointer user) { Chooser& c = *static_cast(user); - if (c.form_connect == nullptr) return; - // The only validation here. A *malformed* address is the acquisition layer's to report -- it - // already does, and the starvation notice covers a source that takes the socket and says - // nothing -- so checking it twice would be two expressions of one rule. - const char* text = gtk_editable_get_text(entry); - gtk_widget_set_sensitive(c.form_connect, text != nullptr && text[0] != '\0'); + if (SourceKind* k = form_selected_kind(c)) form_rebuild(c, *k, SourceConfig()); } void on_form_connect(GtkButton*, gpointer user) { Chooser& c = *static_cast(user); - const char* server = gtk_editable_get_text(GTK_EDITABLE(c.form_server)); - if (server == nullptr || server[0] == '\0') return; - const std::string host = server; - const std::uint16_t port = static_cast(adw_spin_row_get_value(c.form_port)); - const std::string group = gtk_editable_get_text(GTK_EDITABLE(c.form_group)); - const std::string iface = gtk_editable_get_text(GTK_EDITABLE(c.form_iface)); + SourceKind* kind = form_selected_kind(c); + if (kind == nullptr || !c.form_cfg.valid()) return; + const SourceConfig scfg = c.form_cfg.read(); + const bool udps = kind->id() == "udps"; + const std::string host = session_host(kind->id(), scfg); + const std::uint16_t port = session_port(kind->id(), scfg); + const std::string group = udps ? scfg.get("multicast") : std::string(); + const std::string iface = udps ? scfg.get("interface") : std::string(); std::string name = gtk_editable_get_text(GTK_EDITABLE(c.form_name)); const std::string note = gtk_editable_get_text(GTK_EDITABLE(c.form_note)); const double buffer_mb = adw_spin_row_get_value(c.form_buffer); const bool relative = adw_switch_row_get_active(c.form_relative) != FALSE; if (name.empty()) name = "default"; - c.cfg->server = host; - c.cfg->port = port; - c.cfg->multicast = group; - c.cfg->interface_addr = iface; + c.cfg->source_kind = kind->id(); + if (udps) { + c.cfg->server = scfg.get("server"); + c.cfg->port = port; + c.cfg->multicast = group; + c.cfg->interface_addr = iface; + c.cfg->source_opts = SourceConfig(); + } else { + c.cfg->source_opts = scfg; + } // The startup settings. Applied here because this runs *before* `ScopeModel` exists, which is the // only moment a buffer size can be applied at all -- and stored, so the next run gets them back. c.cfg->buffer_mb = buffer_mb; @@ -476,6 +527,8 @@ void on_form_connect(GtkButton*, gpointer user) { st.relative_time = relative; st.multicast = group; st.interface_addr = iface; + st.source_kind = udps ? std::string() : kind->id(); + st.source_opts = udps ? SourceConfig() : scfg; const bool editing = !c.edit_config.empty(); const bool moved = @@ -527,10 +580,9 @@ void open_form(Chooser& c, int edit_index = -1) { c.form_ui = gtk_builder_new_from_resource("/org/udpscope/ui/source-form.ui"); if (c.form_ui == nullptr) return; GObject* dialog = gtk_builder_get_object(c.form_ui, "source_form"); - c.form_server = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "server_row")); - c.form_port = ADW_SPIN_ROW(gtk_builder_get_object(c.form_ui, "port_row")); - c.form_group = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "group_row")); - c.form_iface = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "iface_row")); + c.form_kind_row = ADW_COMBO_ROW(gtk_builder_get_object(c.form_ui, "kind_row")); + c.form_source_group = + ADW_PREFERENCES_GROUP(gtk_builder_get_object(c.form_ui, "source_group")); c.form_name = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "name_row")); c.form_note = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "note_row")); c.form_buffer = ADW_SPIN_ROW(gtk_builder_get_object(c.form_ui, "buffer_row")); @@ -539,8 +591,8 @@ void open_form(Chooser& c, int edit_index = -1) { c.form_connect = GTK_WIDGET(gtk_builder_get_object(c.form_ui, "form_apply")); // A missing id is the one mistake neither the build nor `GtkBuilder` sees, and every read below would // be a null row. - if (dialog == nullptr || c.form_server == nullptr || c.form_port == nullptr || - c.form_group == nullptr || c.form_iface == nullptr || c.form_name == nullptr || + if (dialog == nullptr || c.form_kind_row == nullptr || c.form_source_group == nullptr || + c.form_name == nullptr || c.form_note == nullptr || c.form_buffer == nullptr || c.form_relative == nullptr || c.form_connect == nullptr) { g_clear_object(&c.form_ui); @@ -558,11 +610,6 @@ void open_form(Chooser& c, int edit_index = -1) { // The values: the configuration being edited, or what the command line asked for. The hints live in // the rows' subtitles, so an empty field still says what empty means. - gtk_editable_set_text(GTK_EDITABLE(c.form_server), src != nullptr ? src->host.c_str() : "127.0.0.1"); - adw_spin_row_set_value(c.form_port, src != nullptr ? src->port : 8000); - gtk_editable_set_text(GTK_EDITABLE(c.form_group), src != nullptr ? src->multicast.c_str() : ""); - gtk_editable_set_text(GTK_EDITABLE(c.form_iface), - src != nullptr ? src->interface_addr.c_str() : ""); gtk_editable_set_text(GTK_EDITABLE(c.form_name), src != nullptr ? src->config.c_str() : "default"); gtk_editable_set_text(GTK_EDITABLE(c.form_note), src != nullptr ? src->comment.c_str() : ""); adw_spin_row_set_value(c.form_buffer, src != nullptr && src->buffer_mb > 0.0 @@ -572,6 +619,37 @@ void open_form(Chooser& c, int edit_index = -1) { (src != nullptr ? src->relative_time : c.cfg->relative_time) ? TRUE : FALSE); + // The dropdown lists the registered kinds. A new host starts on the kind the command line named + // (UDPS by default); an edit starts on the configuration's own kind and cannot change it -- + // disabled, not hidden, like every other control that does not apply. + c.form_kinds = kinds(); + GtkStringList* names = gtk_string_list_new(nullptr); + guint selected = 0; + const std::string want_kind = src != nullptr ? src->kind : c.cfg->source_kind; + for (guint i = 0; i < c.form_kinds.size(); ++i) { + gtk_string_list_append(names, c.form_kinds[i]->display_name().c_str()); + if (c.form_kinds[i]->id() == want_kind) selected = i; + } + adw_combo_row_set_model(c.form_kind_row, G_LIST_MODEL(names)); + adw_combo_row_set_selected(c.form_kind_row, selected); + gtk_widget_set_sensitive(GTK_WIDGET(c.form_kind_row), src == nullptr); + g_object_unref(names); + + SourceConfig initial; + if (src != nullptr && src->kind == "udps") { + initial.set("server", src->host); + initial.set("port", std::to_string(src->port)); + if (!src->multicast.empty()) initial.set("multicast", src->multicast); + if (!src->interface_addr.empty()) initial.set("interface", src->interface_addr); + } else if (src != nullptr) { + initial = src->opts; + } else if (c.cfg->source_kind != "udps") { + initial = c.cfg->source_opts; + } + if (!c.form_kinds.empty()) form_rebuild(c, *c.form_kinds[selected], initial); + // Connected after the first rebuild: setting `selected` above must not rebuild a second time. + g_signal_connect(c.form_kind_row, "notify::selected", G_CALLBACK(on_form_kind_changed), &c); + if (GObject* cancel = gtk_builder_get_object(c.form_ui, "form_cancel"); cancel != nullptr) { g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { form_close(*static_cast(user)); @@ -579,16 +657,22 @@ void open_form(Chooser& c, int edit_index = -1) { &c); } g_signal_connect(c.form_connect, "clicked", G_CALLBACK(on_form_connect), &c); - g_signal_connect(c.form_server, "changed", G_CALLBACK(on_form_server_changed), &c); // `closed` clears the pointer. An `AdwDialog` emits that and not `close-request`, and it fires only // when the dialog really went away -- which is what the comment here used to be about. g_signal_connect(c.form, "closed", G_CALLBACK(+[](AdwDialog*, gpointer user) { - static_cast(user)->form = nullptr; + Chooser& ch = *static_cast(user); + ch.form = nullptr; + // The rows are gone with the dialog; dropping the closures that captured them + // keeps a stale `read()` from ever being callable. + ch.form_cfg = ConfigForm(); + ch.form_cfg_rows.clear(); + ch.form_kind_row = nullptr; + ch.form_source_group = nullptr; }), &c); adw_dialog_present(c.form, GTK_WIDGET(c.window)); - gtk_widget_grab_focus(GTK_WIDGET(c.form_server)); + if (c.form_cfg.focus != nullptr) gtk_widget_grab_focus(c.form_cfg.focus); } /// What the pick probes report: the connection the chooser came back with. One helper rather than @@ -614,6 +698,15 @@ void report_pick(const Chooser& c) { (c.saved_config.empty() ? (c.known.empty() ? "-" : c.known.front().comment) : c.saved_note) .c_str()); + if (c.cfg->source_kind != "udps") { + std::string opts; + for (const auto& kv : c.cfg->source_opts.items()) { + opts += (opts.empty() ? "" : ",") + kv.first + "=" + kv.second; + } + std::fprintf(stderr, "[sources] picked kind=%s opts=%s session=%s:%u\n", + c.cfg->source_kind.c_str(), opts.c_str(), c.cfg->session_host().c_str(), + c.cfg->session_port()); + } std::fflush(stderr); } @@ -1039,22 +1132,42 @@ gboolean pick_probe(gpointer user) { open_form(c); const std::string s = spec; const std::size_t colon = s.rfind(':'); - gtk_editable_set_text(GTK_EDITABLE(c.form_server), - colon == std::string::npos ? s.c_str() : s.substr(0, colon).c_str()); - if (colon != std::string::npos) { - adw_spin_row_set_value(c.form_port, std::atof(s.c_str() + colon + 1)); + SourceConfig sc; + if (const char* kspec = g_getenv("UDPSCOPE_PROBE_NEWHOST_KIND")) { + // "kind" or "kind:k=v,k=v": pick the kind in the dropdown (which rebuilds the form), then + // write the options into it. + std::string ks = kspec; + std::string opts; + if (const std::size_t sep = ks.find(':'); sep != std::string::npos) { + opts = ks.substr(sep + 1); + ks = ks.substr(0, sep); + } + for (guint i = 0; i < c.form_kinds.size(); ++i) { + if (c.form_kinds[i]->id() == ks) adw_combo_row_set_selected(c.form_kind_row, i); + } + for (std::size_t pos = 0; pos < opts.size();) { + const std::size_t end = std::min(opts.find(',', pos), opts.size()); + sc.parse_assignment(opts.substr(pos, end - pos)); + pos = end + 1; + } + } else { + sc.set("server", colon == std::string::npos ? s : s.substr(0, colon)); + if (colon != std::string::npos) sc.set("port", s.substr(colon + 1)); } + c.form_cfg.write(sc); gtk_editable_set_text(GTK_EDITABLE(c.form_name), "probe-host"); gtk_editable_set_text(GTK_EDITABLE(c.form_note), "made by the new-host probe"); adw_spin_row_set_value(c.form_buffer, 32); adw_switch_row_set_active(c.form_relative, TRUE); - // 8/8: the form's row titles are mnemonics (`_Server`, `_Port`, `Multicast _group`, ...). The two - // buttons already asked for it; the rows did not, and drew their underscores. - std::fprintf(stderr, "[sources] form mnemonics=%d/8\n", - (row_underlines(G_OBJECT(c.form_server)) + row_underlines(G_OBJECT(c.form_port)) + - row_underlines(G_OBJECT(c.form_group)) + row_underlines(G_OBJECT(c.form_iface)) + - row_underlines(G_OBJECT(c.form_name)) + row_underlines(G_OBJECT(c.form_note)) + - row_underlines(G_OBJECT(c.form_buffer)) + row_underlines(G_OBJECT(c.form_relative)))); + // 9/9 for UDPS: the form's row titles are mnemonics (`Source _type`, `_Server`, `_Port`, ...). The + // total follows the kind's row count: five rows are the chooser's, the rest the kind's own. + int underlines = row_underlines(G_OBJECT(c.form_kind_row)) + + row_underlines(G_OBJECT(c.form_name)) + row_underlines(G_OBJECT(c.form_note)) + + row_underlines(G_OBJECT(c.form_buffer)) + + row_underlines(G_OBJECT(c.form_relative)); + for (GtkWidget* r : c.form_cfg.rows) underlines += row_underlines(G_OBJECT(r)); + std::fprintf(stderr, "[sources] form mnemonics=%d/%zu\n", underlines, + 5 + c.form_cfg.rows.size()); std::fprintf(stderr, "[sources] form: name='%s' note='%s' buffer=%.6g relative=%d " "connect_sensitive=%d\n", @@ -1196,8 +1309,13 @@ void loading_show(const Config& cfg) { std::string head; std::string sub; if (cfg.open_waveform.empty()) { - head = "Connecting to " + cfg.server + ":" + std::to_string(cfg.port); - if (!cfg.multicast.empty()) head += " (" + cfg.multicast + ")"; + SourceKind* kind = cfg.source_kind != "udps" ? find_kind(cfg.source_kind) : nullptr; + if (kind != nullptr) { + head = "Connecting to " + kind->display_name() + " " + kind->identity(cfg.source_config()); + } else { + head = "Connecting to " + cfg.server + ":" + std::to_string(cfg.port); + if (!cfg.multicast.empty()) head += " (" + cfg.multicast + ")"; + } char buf[128]; std::snprintf(buf, sizeof(buf), "planning the %.0f MB sample buffer", cfg.buffer_mb); sub = buf; diff --git a/src/ui/source-form.ui b/src/ui/source-form.ui index c21c1ad..b0f2797 100644 --- a/src/ui/source-form.ui +++ b/src/ui/source-form.ui @@ -2,7 +2,7 @@ - - - - - diff --git a/src/icons/scalable/actions/udpscope-tab-signals-symbolic.svg b/src/icons/scalable/actions/udpscope-tab-signals-symbolic.svg deleted file mode 100644 index ad68985..0000000 --- a/src/icons/scalable/actions/udpscope-tab-signals-symbolic.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - diff --git a/src/icons/udpscope.gresource.xml b/src/icons/udpscope.gresource.xml index 1370c9f..b8e6e77 100644 --- a/src/icons/udpscope.gresource.xml +++ b/src/icons/udpscope.gresource.xml @@ -20,8 +20,6 @@ scalable/actions/udpscope-trigger-symbolic.svg scalable/actions/udpscope-cursors-symbolic.svg scalable/actions/udpscope-measure-symbolic.svg - scalable/actions/udpscope-tab-measure-symbolic.svg - scalable/actions/udpscope-tab-signals-symbolic.svg scalable/actions/udpscope-events-symbolic.svg scalable/actions/udpscope-resize-rows-symbolic.svg scalable/apps/org.udpscope.app.svg diff --git a/src/source/e1608/e1608_panels.cpp b/src/source/e1608/e1608_panels.cpp index b9997d6..ecae53e 100644 --- a/src/source/e1608/e1608_panels.cpp +++ b/src/source/e1608/e1608_panels.cpp @@ -332,12 +332,10 @@ std::vector make_e1608_panels(DataSource& source) { SourcePanel ao; ao.id = "ao"; ao.title = "Analog out"; - ao.icon = "audio-volume-high-symbolic"; ao.widget = build_ao(src); SourcePanel dio; dio.id = "dio"; dio.title = "Digital I/O"; - dio.icon = "input-dialpad-symbolic"; dio.widget = build_dio(src); return {ao, dio}; } diff --git a/src/source/source_kind.hpp b/src/source/source_kind.hpp index fac37bc..fe065b4 100644 --- a/src/source/source_kind.hpp +++ b/src/source/source_kind.hpp @@ -38,7 +38,6 @@ struct ConfigForm { struct SourcePanel { std::string id; ///< stack page name std::string title; - std::string icon; ///< icon name for the tab, may be empty GtkWidget* widget = nullptr; }; diff --git a/src/ui.cpp b/src/ui.cpp index a8f00ad..347f373 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -2975,64 +2975,6 @@ std::string icon_report(GtkIconTheme* theme, const char* name, const char* resou /// and Reset is inside the cursors pane. Recorded on the first tick -- the widgets exist and the /// window is alive, neither of which is true when the dump runs -- so the dump can print a fact /// instead of asking GTK about a destroyed widget. -/// The words the pills do not draw. The pages' `title` is empty on purpose (see the `.ui`), so the -/// human name of a tab lives here and is put on the pill — its tooltip and its accessible name — by -/// `name_side_tabs()`. That keeps an icon-only pill a *named* control for a screen reader. -static const char* side_tab_title(const App& app, const char* name) { - if (name != nullptr && std::strcmp(name, "measure") == 0) return "Measure"; - if (name != nullptr && std::strcmp(name, "signals") == 0) return "Signals"; - if (name != nullptr) { - for (const SourcePanelPage& p : app.source_panels) { - if (p.name == name) return p.title.c_str(); - } - } - return ""; -} - -/// Names the switcher's pills and takes their empty labels out of the layout: an empty `GtkLabel` -/// still asks for its own width, which leaves the icon off-centre in a pill meant to be one glyph. -/// Called from the first tick, because the pills are `AdwViewSwitcher`'s own and exist only once the -/// widget has been mapped. -void name_side_tabs(App& app) { - if (app.side_tabs_named || app.side_switcher == nullptr) return; - GListModel* pages = app.side_stack != nullptr - ? G_LIST_MODEL(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack))) - : nullptr; - // The switcher's children *are* its pills, in page order, and `AdwViewSwitcherButton` is not a - // public type (there is no `ADW_IS_VIEW_SWITCHER_BUTTON`), so the two lists are walked together by - // index rather than checked by type. - GtkWidget* pill = gtk_widget_get_first_child(app.side_switcher); - for (guint i = 0; pages != nullptr && pill != nullptr && i < g_list_model_get_n_items(pages); - pill = gtk_widget_get_next_sibling(pill), ++i) { - AdwViewStackPage* page = ADW_VIEW_STACK_PAGE(g_list_model_get_item(pages, i)); - const char* title = side_tab_title(app, adw_view_stack_page_get_name(page)); - g_object_unref(page); - if (title[0] == '\0') continue; - gtk_widget_set_tooltip_text(pill, title); - gtk_accessible_update_property(GTK_ACCESSIBLE(pill), GTK_ACCESSIBLE_PROPERTY_LABEL, title, -1); - const std::function hide_labels = [&](GtkWidget* w) { - if (GTK_IS_LABEL(w)) gtk_widget_set_visible(w, FALSE); - for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr; - c = gtk_widget_get_next_sibling(c)) { - hide_labels(c); - } - }; - hide_labels(pill); - // The tabs' glyphs are 32x16 documents, and the pill has the width for them: drawn at 32 they are - // 1:1, so both motifs sit side by side at the size they were authored at and the 2px bars stay - // crisp. A 16px box would scale the pair down to a smear, and a fractional scale would blur it. - const std::function size_images = [&](GtkWidget* w) { - if (GTK_IS_IMAGE(w)) gtk_image_set_pixel_size(GTK_IMAGE(w), 32); - for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr; - c = gtk_widget_get_next_sibling(c)) { - size_images(c); - } - }; - size_images(pill); - app.side_tabs_named = true; - } -} - void note_panel_controls(App& app) { if (!app.dbg_panel_controls.empty()) return; const auto in_bar = [](GtkWidget* w) { @@ -3110,39 +3052,17 @@ void note_panel_controls(App& app) { icon_report(theme, "org.udpscope.app-symbolic", "/org/udpscope/icons/scalable/apps/org.udpscope.app-symbolic.svg"); - // The two sidebar pages' own icons, named and resolved the same way, and what the switcher is - // drawing for them. The pills are `AdwViewSwitcher`'s own buttons, built when the stack is mapped, - // so this reads on the first tick: `pills` is the guard (0 pills would make a label count of 0 - // meaningless) and `labels` must be 0, because the switcher's policy is `narrow` -- the pages are - // icons, not words, in a 300px column. - // Walked through `get_pages()`, not the widget tree: an `AdwViewStackPage` is a plain `GObject` - // (only its `child` is a widget), so `gtk_widget_get_first_child()` on the stack yields the pages' - // scrolled windows and this line read empty the first time it was written. - GListModel* pages = app.side_stack != nullptr - ? G_LIST_MODEL(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack))) - : nullptr; - for (guint i = 0; pages != nullptr && i < g_list_model_get_n_items(pages); ++i) { - AdwViewStackPage* page = ADW_VIEW_STACK_PAGE(g_list_model_get_item(pages, i)); - const char* page_name = adw_view_stack_page_get_name(page); - const char* icon = adw_view_stack_page_get_icon_name(page); - app.dbg_page_icons += std::string(app.dbg_page_icons.empty() ? "" : " ") + - (page_name != nullptr ? page_name : "?") + "=" + - (icon != nullptr ? icon : "-") + - (icon != nullptr && gtk_icon_theme_has_icon(theme, icon) ? "(ok)" - : "(MISSING)"); - g_object_unref(page); - } - // `mapped`, not `visible`: visibility is per widget, so a label whose *parent* is hidden still - // reads visible, and in the narrow policy that is exactly the case. `mapped` requires every - // ancestor, which is what "on screen" means -- and it is why the pill count is read the same way: - // if nothing is mapped the pill count reads 0 and the label count means nothing, rather than - // passing vacuously. + // What the switcher is drawing: the sidebar's tabs are words (`wide` policy, a title per page, no + // icon). The pills are `AdwViewSwitcher`'s own buttons, built when the stack is mapped, so this + // reads on the first tick. `mapped`, not `visible`: visibility is per widget, so a label whose + // *parent* is hidden still reads visible; `mapped` requires every ancestor, which is what "on + // screen" means -- and it is why the pill count is read the same way: if nothing is mapped the + // pill count reads 0 and the label count means nothing, rather than passing vacuously. int pills = 0; int drawn_labels = 0; const std::function count_drawn = [&](GtkWidget* w) { if (GTK_IS_TOGGLE_BUTTON(w) && gtk_widget_get_mapped(w)) ++pills; - // A *mapped* label with text: libadwaita's narrow pill keeps an empty label in the layout, so - // "mapped" alone would count the empty ones and read 2 whether the pills were icons or words. + // A *mapped* label with text: an empty label would be counted by "mapped" alone. if (GTK_IS_LABEL(w) && gtk_widget_get_mapped(w) && gtk_label_get_text(GTK_LABEL(w))[0] != '\0') { ++drawn_labels; } @@ -3154,14 +3074,12 @@ void note_panel_controls(App& app) { if (app.side_switcher != nullptr) count_drawn(app.side_switcher); app.dbg_switcher_pills = pills; app.dbg_switcher_labels = drawn_labels; - int named_pills = 0; - for (GtkWidget* w = app.side_switcher != nullptr ? gtk_widget_get_first_child(app.side_switcher) - : nullptr; - w != nullptr; w = gtk_widget_get_next_sibling(w)) { - const char* tip = gtk_widget_get_tooltip_text(w); - if (tip != nullptr && tip[0] != '\0') ++named_pills; - } - app.dbg_switcher_names = named_pills; + // Walked through `get_pages()`, not the widget tree: an `AdwViewStackPage` is a plain `GObject`. + app.dbg_switcher_pages = + app.side_stack != nullptr + ? static_cast(g_list_model_get_n_items( + G_LIST_MODEL(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack))))) + : 0; if (app.side_switcher != nullptr) { g_object_get(G_OBJECT(app.side_switcher), "policy", &app.dbg_switcher_policy, nullptr); } @@ -3456,11 +3374,6 @@ void apply_density(App& app, DisplayMode display, bool live_display) { gboolean tick(gpointer user) { App* app = static_cast(user); if (app->window == nullptr) return G_SOURCE_REMOVE; - // Before the reading, and not after it: the pills are `AdwViewSwitcher`'s own widgets and exist - // only once the switcher is mapped, so this is the first frame that can name them -- and their - // page titles are deliberately empty, so a frame that read before this ran would read pills with - // no text and no name either. - name_side_tabs(*app); note_panel_controls(*app); sync_source_panels(*app); // The tour, re-measured every frame: the target's allocation is 0 on the frame it is first shown @@ -5289,8 +5202,8 @@ void on_activate(GtkApplication* application, gpointer user) { g_signal_connect(app->side_stack, "notify::visible-child-name", G_CALLBACK(on_side_tab_changed), app); } - // The source kind's own tabs (a generator, digital I/O). Before the first tick: that is when - // `name_side_tabs()` puts the words on the pills, and it must see these pages too. + // The source kind's own tabs (a generator, digital I/O), added before the first tick so the + // pages-versus-pills reading below counts them too. build_source_panels(*app); // The left side is the events pane *and* the plot, in one row: the pane is a column of its own @@ -6324,12 +6237,10 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit app.dbg_panel_switch_trigger, app.dbg_toolbar_switch_trigger, app.dbg_panel_switch_cursors, app.dbg_toolbar_switch_cursors, app.dbg_panel_drove_toolbar); - std::printf("[udpscope] switcher: policy=%s pills=%d labels=%d names=%d icon=%dx%d " - "size=%dx%d\n", + std::printf("[udpscope] switcher: policy=%s pills=%d labels=%d pages=%d size=%dx%d\n", app.dbg_switcher_policy == 0 ? "narrow" : (app.dbg_switcher_policy == 1 ? "wide" : "?"), - app.dbg_switcher_pills, app.dbg_switcher_labels, app.dbg_switcher_names, - app.dbg_switcher_icon_w, app.dbg_switcher_icon_h, app.dbg_switcher_w, - app.dbg_switcher_h); + app.dbg_switcher_pills, app.dbg_switcher_labels, app.dbg_switcher_pages, + app.dbg_switcher_w, app.dbg_switcher_h); // The help affordance layer: what the contextual-help toggle left on the surfaces, and where the // guided tour stood when the last capture ran. Both are captured on the tick (`capture_help_state`), // so neither asks a destroyed widget anything. @@ -6463,7 +6374,6 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit // critical (which is how the first version of this line earned four of them). std::printf("[udpscope] panel controls: %s\n", app.dbg_panel_controls.c_str()); std::printf("[udpscope] toolbar icons: %s\n", app.dbg_toolbar_icons.c_str()); - std::printf("[udpscope] page icons: %s\n", app.dbg_page_icons.c_str()); std::printf("[udpscope] app icons: %s\n", app.dbg_app_icons.c_str()); // The measurements pane, with the numbers the engine computed: a probe adds measurements and // this is where their values are read, so a wrong threshold or a window that moved under a diff --git a/src/ui/main-window.ui b/src/ui/main-window.ui index 628ba55..a6bf93f 100644 --- a/src/ui/main-window.ui +++ b/src/ui/main-window.ui @@ -54,25 +54,17 @@ child of either scroller: the one control that chooses what you are looking at must not be the first thing to scroll away. - The policy is `narrow` and the **titles are empty**: `narrow` still draws a label - beside an icon (measured: a 48px "Measure" inside a 54px pill), so an empty title is - what makes a pill an icon. The pages' *names* are what identify them to - `select_side_tab()` and the session file, and the words are re-attached to the pills in - code — tooltip and accessible name — by `name_side_tabs()` on the first tick, so an - icon-only pill is still a named thing rather than an anonymous button. - Each page's icon is a glyph of its own, and each one is a *pair*: the Measure tab is - `udpscope-tab-measure-symbolic` (the cursors and the ruler they read against), and the - Signals tab is `udpscope-tab-signals-symbolic` (the trigger's edge and the channel list - below it). They are drawn for the tabs rather than reusing the icons the toolbar and the - Preferences window already carry: a glyph the toolbar uses for one thing means two things - the moment a tab shows it too. --> + The tabs are words: the policy is `wide` and every page has a title, no icon. (They + were icon-only glyphs once; the glyphs were not understandable enough.) The pages' + *names* are what identify them to `select_side_tab()` and the session file; a source + kind's own tabs are added in code with their `SourcePanel::title`. --> vertical 300 side_stack - narrow + wide 4 4 6 @@ -85,10 +77,7 @@ measure - - - udpscope-tab-measure-symbolic + Measure never @@ -105,9 +94,7 @@ signals - - - udpscope-tab-signals-symbolic + Signals never diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 76497c8..6090e9a 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -440,21 +440,13 @@ struct App { /// works, and every other line of the dump still passes. std::string dbg_toolbar_icons; std::string dbg_app_icons; - /// The two sidebar pages' own icons and whether the theme resolved them, read on the first tick - /// like the two lines above. - std::string dbg_page_icons; - /// What the switcher is: how many pills it holds, and how many labels it is *drawing*. The label - /// count is the one that says the pages are icons rather than words: `narrow` hides them, and the - /// titles stay set because they are the pages' names for a screen reader and for the tooltip. + /// What the switcher is: how many pills it holds, and how many labels it is *drawing*. The tabs + /// are words (`wide` policy, a title per page), so the label count must equal the page count. int dbg_switcher_pills = -1; int dbg_switcher_labels = -1; - int dbg_switcher_names = -1; - /// The pills' icon as it is *rendered*: a 32x16 glyph in a 32px box is drawn 1:1, which is what - /// keeps its 2px bars crisp. - int dbg_switcher_icon_w = -1; - int dbg_switcher_icon_h = -1; - /// The switcher's own size, beside it: the pills carry 32px icon boxes now, so the tab bar's height - /// is what it costs. + /// How many pages the sidebar stack holds: `pills` and `labels` must both equal it. + int dbg_switcher_pages = -1; + /// The switcher's own size, beside it: what the tab bar costs the column. int dbg_switcher_w = -1; int dbg_switcher_h = -1; /// The panes' own switches and the header bar's buttons, read together by the exit probe: the two @@ -473,8 +465,6 @@ struct App { /// thing in the body that *is* disabled with the mode. int dbg_arm_sensitive = -1; int dbg_switcher_policy = -1; - /// Whether `name_side_tabs()` has named the pills: it can only run once the switcher is mapped. - bool side_tabs_named = false; /// The header bar, so the dump can report the order the controls are in: a header bar is a /// *titlebar*, not a child of the window, so walking down from the window does not reach it. GtkWidget* toolbar = nullptr; diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 1f04995..e9ac7a7 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -104,9 +104,8 @@ gboolean exit_probe_step3(gpointer user) { a->dbg_signal_h = gtk_widget_get_height(a->signal_panel); a->dbg_trigger_w = gtk_widget_get_width(a->trigger_panel); a->dbg_trigger_h = gtk_widget_get_height(a->trigger_panel); - // The switcher's icon as it is *rendered*: a 32x16 glyph in a 32px box draws 1:1, which is what - // keeps its 2px bars crisp rather than blurred by a fractional scale. Read here and not on the - // first tick, where the window has not been allocated yet and every widget still measures 0x0. + // The switcher's size as allocated: read here and not on the first tick, where the window has not + // been allocated yet and every widget still measures 0x0. if (a->side_switcher != nullptr) { a->dbg_switcher_w = gtk_widget_get_width(a->side_switcher); a->dbg_switcher_h = gtk_widget_get_height(a->side_switcher); @@ -180,21 +179,6 @@ gboolean exit_probe_step3(gpointer user) { ? 1 : 0; a->dbg_panel_drove_toolbar = a->dbg_toolbar_switch_cursors; } - for (GtkWidget* pill = a->side_switcher != nullptr ? gtk_widget_get_first_child(a->side_switcher) - : nullptr; - pill != nullptr; pill = gtk_widget_get_next_sibling(pill)) { - const std::function measure_icon = [&](GtkWidget* node) { - if (GTK_IS_IMAGE(node) && gtk_widget_get_mapped(node)) { - a->dbg_switcher_icon_w = std::max(a->dbg_switcher_icon_w, gtk_widget_get_width(node)); - a->dbg_switcher_icon_h = std::max(a->dbg_switcher_icon_h, gtk_widget_get_height(node)); - } - for (GtkWidget* c = gtk_widget_get_first_child(node); c != nullptr; - c = gtk_widget_get_next_sibling(c)) { - measure_icon(c); - } - }; - measure_icon(pill); - } // The height of one signal row, which is the thing "the channel toggle is too tall" // was about. The panel's own height cannot stand in for it: it also covers the // heading, the hint and the box margins. diff --git a/src/ui_source.cpp b/src/ui_source.cpp index c7c7c80..d81b723 100644 --- a/src/ui_source.cpp +++ b/src/ui_source.cpp @@ -27,11 +27,9 @@ void build_source_panels(App& app) { GTK_POLICY_AUTOMATIC); gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), p.widget); const std::string name = "source-" + p.id; - // An empty title on purpose, as for the built-in tabs: the pill is an icon, and - // `name_side_tabs()` puts `title` on it as tooltip and accessible name. - adw_view_stack_add_titled_with_icon( - ADW_VIEW_STACK(app.side_stack), scroller, name.c_str(), "", - p.icon.empty() ? "applications-engineering-symbolic" : p.icon.c_str()); + // The tab is its title: the sidebar's pills are words, not glyphs. + adw_view_stack_add_titled(ADW_VIEW_STACK(app.side_stack), scroller, name.c_str(), + p.title.c_str()); app.source_panels.push_back({name, p.title, p.widget}); } // Disconnected until the first Connected event says otherwise. From 9426a619198521e8980dc9ad016b7c9e069b15c1 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 2 Nov 2026 10:07:50 +0100 Subject: [PATCH 046/127] docs: implementation plan for the E-1608 network scan --- .../plans/2026-11-02-e1608-network-scan.md | 681 ++++++++++++++++++ 1 file changed, 681 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-02-e1608-network-scan.md diff --git a/docs/superpowers/plans/2026-11-02-e1608-network-scan.md b/docs/superpowers/plans/2026-11-02-e1608-network-scan.md new file mode 100644 index 0000000..8362d02 --- /dev/null +++ b/docs/superpowers/plans/2026-11-02-e1608-network-scan.md @@ -0,0 +1,681 @@ +# E-1608 Network Scan Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The E-1608 connection form gets a "Scan network" row that finds boards on every local interface and fills the address or the MAC from a click; the MAC lookup in `E1608Source` stops depending on the default route. + +**Architecture:** A non-blocking `Scan` class in `e1608_link` opens one UDP socket per up IPv4 broadcast interface (bound to the device, or to its address), sends `D` on each and drains replies on `poll()`. The form drives it with a 50 ms GLib timer on the GTK main thread (the project has exactly two threads, so no worker). `discover_broadcast()` is `Scan` plus a blocking wait and replaces the limited-broadcast `discover()` call in the source's MAC lookup. + +**Tech Stack:** C++20, GTK4/libadwaita (`AdwExpanderRow`, `AdwActionRow`), POSIX sockets (`getifaddrs`, `SO_BINDTODEVICE`), `tools/mock_e1608.py`, the in-app headless probe under `xvfb-run`. + +## Global Constraints + +- Spec: `docs/superpowers/specs/2026-11-02-e1608-network-scan-design.md` (approved 2026-11-02). +- Exactly two threads: no worker thread for the scan; the form polls with `g_timeout_add*`. +- Every source file carries the SPDX MIT header (no new source files in this plan). +- Text buttons only (no theme icons) on the new controls. +- A `subnet` that is given still uses `sweep_targets` + `discover` unchanged. +- An interface that cannot be opened or sent on is skipped, never an error; no interfaces is an empty result. +- Scan results are never persisted: the session reads and writes only the filled fields. +- Baselines: a clean build has exactly 3 warnings (an incremental build shows the 2 `ui_probe.cpp` MathNode ones); `gtk warnings: 0`; no `Gtk-CRITICAL`. +- GTK traps (`docs/claude/invariants-gtk-and-chrome.md`): handler signatures are notify `(GObject*, GParamSpec*, gpointer)`, clicked `(GtkButton*, gpointer)`, changed `(GtkEditable*, gpointer)`; handlers behind `Form::alive`; probes use `g_object_ref_sink` + `g_object_unref` on never-parented rows. +- Probe code must avoid `x({...}).empty() || y({...}).empty()` (GCC `-Wdangling-pointer`). +- Verification runs are short and serial; kill every mock afterwards. Do not push or merge. + +## File Structure + +- Modify `src/source/e1608/e1608_link.hpp` / `.cpp`: `class Scan`, `discover_broadcast()`, a shared reply parser used by `discover()` too. +- Modify `src/source/e1608/e1608_source.cpp`: the MAC lookup with an empty subnet uses `discover_broadcast()`. +- Modify `src/source/e1608/e1608_kind.cpp`: the scan row, result rows, fills, timer. +- Modify `src/ui_probe.cpp`, `src/ui_app.hpp`, `src/ui.cpp`: the `UDPSCOPE_PROBE_E1608_SCAN` probe. +- Modify docs: `README.md`, `docs/claude/{build-and-run,verification,probe-readings,architecture}.md`. + +One design note the spec leaves open: `AdwExpanderRow` can only append rows, so "results right after the scan row" is done by making the scan row itself an `AdwExpanderRow` ("Scan network", a text "Scan" suffix button) at the top of the Connection expander, with the results as its child rows. It reads the same: results sit directly under the scan row. + +--- + +### Task 1: The `Scan` class, `discover_broadcast()`, and the MAC lookup + +**Files:** +- Modify: `src/source/e1608/e1608_link.hpp` (after `discover`, line ~77) +- Modify: `src/source/e1608/e1608_link.cpp` (includes; anonymous namespace; `discover`; new definitions) +- Modify: `src/source/e1608/e1608_source.cpp:270-293` + +**Interfaces:** +- Produces (used by Task 2): + ```cpp + class Scan { + public: + explicit Scan(int port); + Scan(const Scan&) = delete; + Scan& operator=(const Scan&) = delete; + const std::vector& poll(); // drains non-blocking; cumulative, one entry per MAC + std::size_t interfaces() const noexcept; // sockets opened + }; + std::vector discover_broadcast(int port, int wait_ms); + ``` +- Consumes: existing `Fd`, `DeviceInfo`, `kProductId`, `Clock`, `ms_left`. + +- [ ] **Step 1: Declare the API** + +In `e1608_link.hpp`, after the `discover` declaration add: + +```cpp +/// A broadcast discovery that never blocks. The limited broadcast alone leaves through the default +/// route only, so on a host with several interfaces it can miss a board on another one; this opens +/// one socket per up, non-loopback IPv4 interface with a broadcast address (bound to the device, or +/// to the interface address when binding to the device is refused), sends `D` to +/// 255.255.255.255 on each, and collects the replies as `poll()` is called. The caller owns the +/// clock. An interface that cannot be opened or sent on is skipped; no interface is an empty scan. +class Scan { + public: + explicit Scan(int port); + Scan(const Scan&) = delete; + Scan& operator=(const Scan&) = delete; + /// Drains every socket without blocking. The devices found so far: E-1608s only, one per MAC. + const std::vector& poll(); + /// How many interfaces a probe was sent on. + std::size_t interfaces() const noexcept { return socks_.size(); } + + private: + std::vector socks_; + std::vector found_; +}; + +/// `Scan` plus a blocking wait of `wait_ms`: what a lookup by MAC with no subnet uses. +std::vector discover_broadcast(int port, int wait_ms); +``` + +- [ ] **Step 2: Share the reply parser** + +In `e1608_link.cpp` add includes `#include `, `#include `, `#include ` (keep the list alphabetical with the existing ones). Inside the existing anonymous namespace, just before its closing `} // namespace` (after `tcp_connect`), add: + +```cpp +/// A `D` reply (at least 29 bytes, first byte `D`, our product id). True when it added a board +/// that `found` did not already hold (one entry per MAC). +bool take_reply(const std::uint8_t* buf, ssize_t n, const sockaddr_in& from, + std::vector& found) { + if (n < 29 || buf[0] != 'D') return false; + DeviceInfo info; + info.product_id = static_cast(buf[7] | (buf[8] << 8)); + if (info.product_id != kProductId) return false; + std::copy(buf + 1, buf + 7, info.mac.begin()); + for (const DeviceInfo& d : found) { + if (d.mac == info.mac) return false; + } + char ip[INET_ADDRSTRLEN] = {}; + ::inet_ntop(AF_INET, &from.sin_addr, ip, sizeof ip); + info.ip = ip; + found.push_back(info); + return true; +} +``` + +In `discover()`, replace the block from `if (n < 29 || buf[0] != 'D') continue;` through `found.push_back(info);` (the lines that build `info`, test the MAC for a duplicate and push) with the single call: + +```cpp + take_reply(buf, n, from, found); +``` + +- [ ] **Step 3: Implement `Scan` and `discover_broadcast`** + +After `discover()` (before the `// --- Link` banner) add: + +```cpp +Scan::Scan(int port) { + ifaddrs* list = nullptr; + if (::getifaddrs(&list) != 0) return; + sockaddr_in to{}; + to.sin_family = AF_INET; + to.sin_port = htons(static_cast(port)); + to.sin_addr.s_addr = htonl(INADDR_BROADCAST); + const std::uint8_t probe = 'D'; + std::vector done; // an interface with several addresses needs one probe, not several + for (const ifaddrs* a = list; a != nullptr; a = a->ifa_next) { + if (a->ifa_addr == nullptr || a->ifa_addr->sa_family != AF_INET) continue; + const unsigned fl = a->ifa_flags; + if ((fl & IFF_UP) == 0 || (fl & IFF_LOOPBACK) != 0 || (fl & IFF_BROADCAST) == 0) continue; + const std::string name = a->ifa_name; + if (std::find(done.begin(), done.end(), name) != done.end()) continue; + Fd s(::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, 0)); + if (!s.valid()) continue; + const int one = 1; + ::setsockopt(s.get(), SOL_SOCKET, SO_BROADCAST, &one, sizeof one); + if (::setsockopt(s.get(), SOL_SOCKET, SO_BINDTODEVICE, name.c_str(), + static_cast(name.size())) != 0) { + sockaddr_in me = *reinterpret_cast(a->ifa_addr); + me.sin_port = 0; + if (::bind(s.get(), reinterpret_cast(&me), sizeof me) != 0) continue; + } + if (::sendto(s.get(), &probe, 1, MSG_NOSIGNAL, reinterpret_cast(&to), + sizeof to) != 1) + continue; + done.push_back(name); + socks_.push_back(std::move(s)); + } + ::freeifaddrs(list); +} + +const std::vector& Scan::poll() { + for (const Fd& s : socks_) { + for (;;) { + std::uint8_t buf[128]; + sockaddr_in from{}; + socklen_t flen = sizeof from; + const ssize_t n = ::recvfrom(s.get(), buf, sizeof buf, MSG_DONTWAIT, + reinterpret_cast(&from), &flen); + if (n < 0) break; // nothing more on this socket for now + take_reply(buf, n, from, found_); + } + } + return found_; +} + +std::vector discover_broadcast(int port, int wait_ms) { + Scan scan(port); + const auto deadline = Clock::now() + std::chrono::milliseconds(wait_ms); + while (ms_left(deadline) > 0) { + scan.poll(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return scan.poll(); +} +``` + +- [ ] **Step 4: Use it in the source's lookup** + +In `e1608_source.cpp` `connect_and_start()`, replace + +```cpp + const std::vector targets = + host.empty() ? sweep_targets(cfg_.subnet) : std::vector{host}; + const std::vector found = + discover(targets, cfg_.port, std::min(cfg_.timeout_ms, 1500)); +``` + +with + +```cpp + const std::vector targets = + host.empty() ? sweep_targets(cfg_.subnet) : std::vector{host}; + const int wait_ms = std::min(cfg_.timeout_ms, 1500); + // No address and no subnet: the limited broadcast, sent on every interface rather than only + // the default route's. + const bool broadcast = + host.empty() && targets.size() == 1 && targets[0] == "255.255.255.255"; + const std::vector found = + broadcast ? discover_broadcast(cfg_.port, wait_ms) : discover(targets, cfg_.port, wait_ms); +``` + +- [ ] **Step 5: Build** + +Run: `cmake --build build -j8 2>&1 | grep -E "warning:|error:" ; echo done` +Expected: no `error:`; only the 2 baseline `ui_probe.cpp` MathNode warnings (incremental) -- nothing new from `e1608_link.cpp` or `e1608_source.cpp`. + +- [ ] **Step 6: Verify the MAC lookup end to end against the mock** + +The mock answers a broadcast `D` on UDP 54211. A run with only a `mac` option (no address, no subnet) must find it and stream: + +```sh +python3 tools/mock_e1608.py --port 54211 > /tmp/mock-scan.log 2>&1 & +MOCK=$! +UDPSCOPE_PROBE_E1608=1500 UDPSCOPE_EXIT_AFTER_MS=4000 xvfb-run -a ./build/udpscope --source e1608 \ + --source-opt mac=00:80:2f:00:16:08 --source-opt scan_rate=1000 2>&1 | grep -E "^\[e1608\] (stream|state)" +kill $MOCK +``` + +Expected: `[e1608] stream: connected=1 ... ok=1` and `state: connects=1 message=''`. +If it reads `connected=0` with `no E-1608 with MAC ... answered`: the sandbox did not loop the broadcast back to the mock. Check with `ip -4 -o addr` that an up non-loopback interface with a broadcast address exists. If one does and it still fails, report that to the user as a finding; do not paper over it. + +- [ ] **Step 7: Commit** + +```bash +git add src/source/e1608/e1608_link.hpp src/source/e1608/e1608_link.cpp src/source/e1608/e1608_source.cpp +git commit -m "e1608: discover boards on every interface, not only the default route's" +``` + +--- + +### Task 2: The form's "Scan network" row, results and fills + +**Files:** +- Modify: `src/source/e1608/e1608_kind.cpp` + +**Interfaces:** +- Consumes: `Scan`, `DeviceInfo`, `format_mac` (Task 1 / existing, via `e1608_source.hpp`'s includes of `e1608_link.hpp`; add the include if the build says otherwise). +- Produces (used by Task 3's probe), by widget name: the scan row `AdwExpanderRow` named `e1608-scan-row`; its suffix `GtkButton` named `e1608-scan`; each result an `AdwActionRow` named `e1608-result` (title IP, subtitle MAC, `activated` fills the address) holding a suffix button named `e1608-use-mac`. Subtitle texts: `Scanning...`, `1 board found`, `N boards found`, `No board found`. + +- [ ] **Step 1: Add the state and `fill()` to `Form`** + +In `struct Form`, after `bool alive = true;...` add: + +```cpp + GtkWidget* scan_row = nullptr; ///< the "Scan network" expander; the results are its rows + std::vector results; ///< the result rows of the current scan + std::unique_ptr scan; ///< non-null while a scan is running + guint timer = 0; ///< the GLib source polling `scan`, 0 when none + int scan_ms = 0; ///< time spent in the running scan +``` + +and, after `write(...)`: + +```cpp + /// A result click: puts one way of naming the board in and the other two fields out, under the + /// `updating` guard so the dialog is told once. + void fill(const std::string& addr, const std::string& mac_text) { + if (!alive) return; + updating = true; + gtk_editable_set_text(GTK_EDITABLE(address), addr.c_str()); + gtk_editable_set_text(GTK_EDITABLE(mac), mac_text.c_str()); + gtk_editable_set_text(GTK_EDITABLE(subnet), ""); + updating = false; + if (changed && *changed) (*changed)(); + } + + void stop_scan() { + if (timer != 0) g_source_remove(timer); + timer = 0; + scan.reset(); + } +``` + +Add `#include ` to the includes if absent. + +- [ ] **Step 2: Destroy hook stops the timer** + +Replace `on_form_destroy` with: + +```cpp +void on_form_destroy(GtkWidget*, gpointer user) { + Form& f = **static_cast(user); + f.alive = false; + f.stop_scan(); // the poll must not outlive the rows it writes to +} +``` + +- [ ] **Step 3: The scan functions** + +After `hook_entry(...)` and before `class E1608Kind`, add: + +```cpp +constexpr int kScanMs = 1500; ///< how long a scan listens +constexpr int kTickMs = 50; ///< how often it drains the sockets + +/// A result row's two ways to name the board, with the form they fill. +struct Pick { + FormPtr form; + std::string ip, mac; +}; + +void free_pick(gpointer d, GClosure*) { delete static_cast(d); } + +void on_pick_address(AdwActionRow*, gpointer user) { + Pick& p = *static_cast(user); + p.form->fill(p.ip, ""); +} + +void on_pick_mac(GtkButton*, gpointer user) { + Pick& p = *static_cast(user); + p.form->fill("", p.mac); +} + +void add_result(const FormPtr& f, const DeviceInfo& d) { + const std::string mac = format_mac(d.mac); + GtkWidget* row = adw_action_row_new(); + gtk_widget_set_name(row, "e1608-result"); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(row), d.ip.c_str()); + adw_action_row_set_subtitle(ADW_ACTION_ROW(row), mac.c_str()); + gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), TRUE); + GtkWidget* use_mac = gtk_button_new_with_label("Use MAC"); + gtk_widget_set_name(use_mac, "e1608-use-mac"); + gtk_widget_set_valign(use_mac, GTK_ALIGN_CENTER); + adw_action_row_add_suffix(ADW_ACTION_ROW(row), use_mac); + g_signal_connect_data(row, "activated", G_CALLBACK(on_pick_address), + new Pick{f, d.ip, mac}, free_pick, static_cast(0)); + g_signal_connect_data(use_mac, "clicked", G_CALLBACK(on_pick_mac), new Pick{f, d.ip, mac}, + free_pick, static_cast(0)); + adw_expander_row_add_row(ADW_EXPANDER_ROW(f->scan_row), row); + adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f->scan_row), TRUE); + f->results.push_back(row); +} + +void set_scan_status(Form& f, const std::string& text) { + adw_expander_row_set_subtitle(ADW_EXPANDER_ROW(f.scan_row), text.c_str()); +} + +/// 50 ms poll of the running scan. The rows appear as boards answer; at `kScanMs` the status is +/// settled and the scan (its sockets) is released. +gboolean on_scan_tick(gpointer user) { + const FormPtr& fp = *static_cast(user); + Form& f = *fp; + if (!f.alive || !f.scan) { + f.timer = 0; + return G_SOURCE_REMOVE; + } + const std::vector found = f.scan->poll(); + while (f.results.size() < found.size()) add_result(fp, found[f.results.size()]); + f.scan_ms += kTickMs; + if (f.scan_ms < kScanMs) return G_SOURCE_CONTINUE; + if (found.empty()) + set_scan_status(f, "No board found"); + else if (found.size() == 1) + set_scan_status(f, "1 board found"); + else + set_scan_status(f, std::to_string(found.size()) + " boards found"); + f.scan.reset(); + f.timer = 0; + return G_SOURCE_REMOVE; +} + +void free_form_ptr(gpointer d) { delete static_cast(d); } + +/// A new scan on the Port field's value: the previous results go, a running scan is replaced. +void start_scan(const FormPtr& fp) { + Form& f = *fp; + if (!f.alive) return; + f.stop_scan(); + for (GtkWidget* r : f.results) adw_expander_row_remove(ADW_EXPANDER_ROW(f.scan_row), r); + f.results.clear(); + adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f.scan_row), FALSE); + f.scan = std::make_unique(static_cast(value_of(f.port))); + f.scan_ms = 0; + set_scan_status(f, "Scanning..."); + f.timer = g_timeout_add_full(G_PRIORITY_DEFAULT, kTickMs, on_scan_tick, new FormPtr(fp), + free_form_ptr); +} + +void on_scan_clicked(GtkButton*, gpointer user) { + start_scan(*static_cast(user)); +} +``` + +(`f.stop_scan()` removes the timer; `g_source_remove` fires `free_form_ptr`, which only drops one `FormPtr` copy, never the last. `on_scan_tick` never calls `g_source_remove` on itself: it zeroes `timer` and returns `G_SOURCE_REMOVE`.) + +- [ ] **Step 4: Build the row in `make_config_form()`** + +After the `f->ao_init[1] = ...` line, add: + +```cpp + f->scan_row = adw_expander_row_new(); + gtk_widget_set_name(f->scan_row, "e1608-scan-row"); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(f->scan_row), "Scan network"); + GtkWidget* scan_button = gtk_button_new_with_label("Scan"); + gtk_widget_set_name(scan_button, "e1608-scan"); + gtk_widget_set_valign(scan_button, GTK_ALIGN_CENTER); + adw_expander_row_add_suffix(ADW_EXPANDER_ROW(f->scan_row), scan_button); + g_signal_connect_data(scan_button, "clicked", G_CALLBACK(on_scan_clicked), new FormPtr(f), + free_form, static_cast(0)); +``` + +Change the Connection group to start with the scan row: + +```cpp + GtkWidget* conn = expander("Connection", {f->scan_row, f->address, f->mac, f->subnet, f->port, + f->code, f->timeout}); +``` + +Update the description string to: + +```cpp + out.description = + "Give an address, or a MAC address to look up (optionally with a subnet such as 192.168.1), " + "or scan the network for boards"; +``` + +- [ ] **Step 5: Build, then check the existing form round trip and warnings** + +```sh +cmake --build build -j8 2>&1 | grep -E "warning:|error:" ; echo done +UDPSCOPE_PROBE_SOURCE_KINDS=1000 UDPSCOPE_EXIT_AFTER_MS=2500 xvfb-run -a ./build/udpscope \ + --source probe --source-opt rate=500 2>&1 | grep -E "form e1608|gtk warnings|Gtk-CRITICAL" +``` + +Expected: no `error:`, only the 2 baseline warnings; `[source-kinds] form e1608: round_trip=1 valid=1`; `gtk warnings: 0`; no `Gtk-CRITICAL`. + +- [ ] **Step 6: Commit** + +```bash +git add src/source/e1608/e1608_kind.cpp +git commit -m "e1608: a Scan network row in the connection form, with click-to-fill results" +``` + +--- + +### Task 3: The scan probe, docs, and final verification + +**Files:** +- Modify: `src/ui_probe.cpp` (after `e1608_probe_reconnect`, ~line 2459) +- Modify: `src/ui_app.hpp:1874-1875` +- Modify: `src/ui.cpp:6031-6033` +- Modify: `README.md` (E-1608 paragraph, lines ~118-146), `docs/claude/build-and-run.md:118-130`, `docs/claude/verification.md` (after the `UDPSCOPE_PROBE_E1608_RECONNECT` bullet, ~line 443), `docs/claude/probe-readings.md` (`## [e1608]` section, ~line 1848-1890), `docs/claude/architecture.md:45-48` + +**Interfaces:** +- Consumes: the widget names from Task 2; `find_named()`, `find_kind()` in `ui_probe.cpp`. +- Produces: env var `UDPSCOPE_PROBE_E1608_SCAN=` and optional `UDPSCOPE_PROBE_E1608_SCAN_MAC` (default `00:80:2f:00:16:08`, the mock's); one stderr line `[e1608] scan: ...`. + +- [ ] **Step 1: Declare and register the probe** + +In `ui_app.hpp`, after `gboolean e1608_probe_resumed(gpointer user);` add: + +```cpp +/// The E-1608 form's "Scan network" row: presses Scan against the mock, then (phases `_fill`, +/// `_rescan`) clicks a result to fill the address, "Use MAC" to fill the MAC, scans again and checks +/// the results were replaced, not added to. +gboolean e1608_scan_probe(gpointer user); +gboolean e1608_scan_probe_fill(gpointer user); +gboolean e1608_scan_probe_rescan(gpointer user); +``` + +In `ui.cpp`, after the `UDPSCOPE_PROBE_E1608_RECONNECT` block add: + +```cpp + if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_SCAN")) { + g_timeout_add(static_cast(std::atoi(env)), e1608_scan_probe, &app); + } +``` + +- [ ] **Step 2: Write the probe** + +In `ui_probe.cpp`, after `e1608_probe_reconnect`, add (this is outside the file's anonymous namespace, like its neighbours; the helpers it uses, `find_named` and `find_kind`, are visible): + +```cpp +namespace { + +/// The scan probe's state, alive from phase 1 to phase 3. +struct E1608ScanProbe { + ConfigForm form; + int changes = 0; ///< how many times the form told the dialog it changed + std::string want_mac; ///< the board the probe expects (the mock's by default) + std::string status; ///< the scan row's subtitle, as read at the end of the first scan + std::string ip; ///< the expected board's address, as listed + std::size_t first = 0; ///< result rows after the first scan + bool listed = false; ///< the expected board was among them + bool fill_addr = false; + bool fill_mac = false; +}; + +E1608ScanProbe* g_scan_probe = nullptr; + +void collect_named(GtkWidget* root, const char* name, std::vector& out) { + if (root == nullptr) return; + if (std::strcmp(gtk_widget_get_name(root), name) == 0) out.push_back(root); + for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr; + c = gtk_widget_get_next_sibling(c)) + collect_named(c, name, out); +} + +std::string scan_status(E1608ScanProbe& p) { + GtkWidget* row = find_named(p.form.rows[0], "e1608-scan-row"); + const char* t = row != nullptr ? adw_expander_row_get_subtitle(ADW_EXPANDER_ROW(row)) : nullptr; + return t != nullptr ? t : ""; +} + +/// A form holding stale values in all three naming fields, so a fill is seen to clear the others. +void scan_probe_stale(E1608ScanProbe& p) { + SourceConfig c; + c.set("address", "1.2.3.4"); + c.set("mac", "aa:bb:cc:dd:ee:ff"); + c.set("subnet", "10.0.0"); + p.form.write(c); + p.changes = 0; +} + +void scan_probe_click(E1608ScanProbe& p) { + GtkWidget* button = find_named(p.form.rows[0], "e1608-scan"); + if (button != nullptr) g_signal_emit_by_name(button, "clicked"); +} + +void scan_probe_cleanup() { + if (g_scan_probe == nullptr) return; + for (GtkWidget* row : g_scan_probe->form.rows) { + g_object_ref_sink(row); + g_object_unref(row); + } + delete g_scan_probe; + g_scan_probe = nullptr; +} + +} // namespace + +/// Phase 3, 2 s after the second Scan: the results were replaced (same count), and the one line. +gboolean e1608_scan_probe_rescan(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr || g_scan_probe == nullptr) return G_SOURCE_REMOVE; + E1608ScanProbe& p = *g_scan_probe; + std::vector rows; + collect_named(p.form.rows[0], "e1608-result", rows); + const bool replaced = rows.size() == p.first; + const std::string status = scan_status(p); + const bool status_ok = status.find("found") != std::string::npos && status.rfind("No", 0) != 0; + const bool ok = p.listed && p.fill_addr && p.fill_mac && replaced && status_ok; + std::fprintf(stderr, + "[e1608] scan: found=%zu ip=%s mac=%s status='%s' fill_addr=%d fill_mac=%d " + "rescan_replaced=%d ok=%d\n", + p.first, p.ip.c_str(), p.want_mac.c_str(), p.status.c_str(), p.fill_addr ? 1 : 0, + p.fill_mac ? 1 : 0, replaced ? 1 : 0, ok ? 1 : 0); + if (!p.listed) + std::fprintf(stderr, + "[e1608] scan: %s did not answer; no up broadcast interface reaches it, or the " + "broadcast was refused (this run proves nothing about the scan)\n", + p.want_mac.c_str()); + std::fflush(stderr); + scan_probe_cleanup(); + return G_SOURCE_REMOVE; +} + +/// Phase 2, 2 s after the first Scan: the expected board is listed, a result click fills the address +/// only, "Use MAC" fills the MAC only (each telling the dialog exactly once), then Scan again. +gboolean e1608_scan_probe_fill(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr || g_scan_probe == nullptr) return G_SOURCE_REMOVE; + E1608ScanProbe& p = *g_scan_probe; + p.status = scan_status(p); + std::vector rows; + collect_named(p.form.rows[0], "e1608-result", rows); + p.first = rows.size(); + GtkWidget* mine = nullptr; + for (GtkWidget* r : rows) { + const char* sub = adw_action_row_get_subtitle(ADW_ACTION_ROW(r)); + if (sub != nullptr && p.want_mac == sub) mine = r; + } + p.listed = mine != nullptr; + if (mine != nullptr) { + const char* title = adw_preferences_row_get_title(ADW_PREFERENCES_ROW(mine)); + p.ip = title != nullptr ? title : ""; + + scan_probe_stale(p); + g_signal_emit_by_name(mine, "activated"); + const SourceConfig by_addr = p.form.read(); + p.fill_addr = by_addr.get("address") == p.ip && by_addr.get("mac").empty() && + by_addr.get("subnet").empty() && p.changes == 1; + + scan_probe_stale(p); + GtkWidget* use = find_named(mine, "e1608-use-mac"); + if (use != nullptr) g_signal_emit_by_name(use, "clicked"); + const SourceConfig by_mac = p.form.read(); + p.fill_mac = by_mac.get("mac") == p.want_mac && by_mac.get("address").empty() && + by_mac.get("subnet").empty() && p.changes == 1; + } + scan_probe_click(p); + g_timeout_add(2000, e1608_scan_probe_rescan, a); + return G_SOURCE_REMOVE; +} + +/// Phase 1: the form, Scan pressed (the row must read `Scanning...` at once), phase 2 in 2 s. +gboolean e1608_scan_probe(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + SourceKind* k = find_kind("e1608"); + if (k == nullptr) { + std::fprintf(stderr, "[e1608] scan: no e1608 kind\n"); + return G_SOURCE_REMOVE; + } + g_scan_probe = new E1608ScanProbe; + E1608ScanProbe& p = *g_scan_probe; + p.form = k->make_config_form(); + const char* env = g_getenv("UDPSCOPE_PROBE_E1608_SCAN_MAC"); + p.want_mac = env != nullptr ? env : "00:80:2f:00:16:08"; + p.form.on_change([]() { + if (g_scan_probe != nullptr) ++g_scan_probe->changes; + }); + scan_probe_click(p); + const std::string now = scan_status(p); + if (now != "Scanning...") + std::fprintf(stderr, "[e1608] scan: status right after Scan reads '%s', expected 'Scanning...'\n", + now.c_str()); + g_timeout_add(2000, e1608_scan_probe_fill, a); + return G_SOURCE_REMOVE; +} +``` + +Notes for the implementer: `SourceConfig::get` returns `std::string` (the existing probe calls `f.read().get("signals") == "..."`); `find_kind`, `SourceConfig` and `ConfigForm` are already used in this file. Do not write `x({...}).empty() || y({...}).empty()`. + +- [ ] **Step 3: Build and run against the mock** + +```sh +cmake --build build -j8 2>&1 | grep -E "warning:|error:" ; echo done +python3 tools/mock_e1608.py --port 54211 > /tmp/mock-scan.log 2>&1 & +MOCK=$! +UDPSCOPE_PROBE_E1608_SCAN=1000 UDPSCOPE_EXIT_AFTER_MS=6500 xvfb-run -a ./build/udpscope \ + --source e1608 --source-opt address=127.0.0.1 --source-opt scan_rate=1000 2>&1 \ + | grep -E "^\[e1608\] scan|gtk warnings|Gtk-CRITICAL" +kill $MOCK +``` + +Expected: `[e1608] scan: found=1 ip= mac=00:80:2f:00:16:08 status='1 board found' fill_addr=1 fill_mac=1 rescan_replaced=1 ok=1`; `gtk warnings: 0`; no `Gtk-CRITICAL`. `found=2` or more is fine if a real board is on the network (status then reads `N boards found`; the line still needs `ok=1`). +If `ok=0` and the second line says the board did not answer, check Task 1 Step 6's loopback note, and tell the user honestly; do not mark this step passed. +If a flag other than `listed` reads 0, debug that flag (the fill reads the form back; `changes` must be exactly 1 per fill). + +- [ ] **Step 4: Clean-build warning count** + +```sh +rm -rf /tmp/scan-clean && cmake -S . -B /tmp/scan-clean >/dev/null && cmake --build /tmp/scan-clean -j8 2>&1 | grep -c "warning:"; rm -rf /tmp/scan-clean +``` + +Expected: `3`. + +- [ ] **Step 5: Docs** + +- `README.md`, E-1608 paragraph (line ~119): after "...(optionally with the subnet to sweep)," add the scan: the sentence "A **Scan network** row at the top of the form probes every network interface of the host for boards (about a second and a half), lists each one by address and MAC, and a click on a result fills the address, or **Use MAC** fills the MAC and leaves the address empty. Results are not saved: only the filled field is." Also add one sentence that a MAC with no subnet is now looked up on every interface, not only the default route's, because a single limited broadcast leaves through the default route alone and missed a board on another interface (found on a real board 2026-11-02, where the board was on `net0` and the default route was `wlan0`). +- `docs/claude/build-and-run.md` line ~125: change "a MAC with no `address` is looked up by broadcast" to "...looked up by a broadcast sent on every interface (`Scan`), or by a sweep of `subnet`". +- `docs/claude/verification.md`: after the `UDPSCOPE_PROBE_E1608_RECONNECT=8000` bullet add a bullet for `UDPSCOPE_PROBE_E1608_SCAN=1000` with `UDPSCOPE_EXIT_AFTER_MS=6500`, run as `--source e1608 --source-opt address=127.0.0.1` against the mock on its default port 54211 (the form scans that port), optional `UDPSCOPE_PROBE_E1608_SCAN_MAC` for a real board; one `[e1608] scan:` line; note it needs a non-loopback up interface with a broadcast address, and that the line says so when the board did not answer. +- `docs/claude/probe-readings.md`, `## [e1608]`: add the sample line `[e1608] scan: found=1 ip=172.21.121.113 mac=00:80:2f:00:16:08 status='1 board found' fill_addr=1 fill_mac=1 rescan_replaced=1 ok=1` to the block, and one bullet per field: `found` (result rows after the first scan; 0 with the "did not answer" second line is an environment without a reachable broadcast interface, not a pass), `status` (the row subtitle; "No board found" with a mock running is the probe sockets not reaching it), `fill_addr=0` (a result click did not set only the address, or told the dialog other than once), `fill_mac=0` (same for Use MAC), `rescan_replaced=0` (the results of the first scan were kept or doubled: `start_scan` did not remove them). +- `docs/claude/architecture.md` line ~45: in the E-1608 three-layers bullet, add that discovery is `Scan` (one socket per interface, polled by the caller; the form polls it from a GLib timer on the main thread, the source's lookup through `discover_broadcast`). + +- [ ] **Step 6: Commit** + +```bash +git add src/ui_probe.cpp src/ui_app.hpp src/ui.cpp README.md docs/claude +git commit -m "e1608: probe and docs for the network scan" +``` + +- [ ] **Step 7: Manual run on the real board (only if it is attached)** + +Check `ip -4 -o addr` for the board's interface and `ping`-free: simply run the probe without the mock, naming the board's MAC (the user knows it; the MAC printed by the earlier hardware run was on `net0`, address 169.254.88.75): + +```sh +UDPSCOPE_PROBE_E1608_SCAN=1000 UDPSCOPE_PROBE_E1608_SCAN_MAC= UDPSCOPE_EXIT_AFTER_MS=6500 \ + xvfb-run -a ./build/udpscope --source e1608 --source-opt address=127.0.0.1 2>&1 | grep -E "^\[e1608\] scan" +``` + +If it finds the board (`listed`, `ok=1`), add one sentence with the date to the README's E-1608 paragraph ("the network scan was checked against a real board on ") and commit `docs: record the scan check on a real board`. If the board is not attached or the MAC is unknown, say so; claim nothing. From 43c21a0cc2ddd153760b149b8441672f8f6d0652 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 2 Nov 2026 10:14:47 +0100 Subject: [PATCH 047/127] e1608: discover boards on every interface, not only the default route's Co-Authored-By: Claude Opus 4.6 --- src/source/e1608/e1608_link.cpp | 94 +++++++++++++++++++++++++++---- src/source/e1608/e1608_link.hpp | 24 ++++++++ src/source/e1608/e1608_source.cpp | 7 ++- 3 files changed, 112 insertions(+), 13 deletions(-) diff --git a/src/source/e1608/e1608_link.cpp b/src/source/e1608/e1608_link.cpp index 566d0a0..bbb2c9a 100644 --- a/src/source/e1608/e1608_link.cpp +++ b/src/source/e1608/e1608_link.cpp @@ -9,11 +9,14 @@ #include #include #include +#include +#include #include #include #include #include #include +#include #include namespace udpscope::e1608 { @@ -120,6 +123,25 @@ Fd tcp_connect(const std::string& host, int port, int timeout_ms, int rcvbuf) { return fd; } +/// A `D` reply (at least 29 bytes, first byte `D`, our product id). True when it added a board +/// that `found` did not already hold (one entry per MAC). +bool take_reply(const std::uint8_t* buf, ssize_t n, const sockaddr_in& from, + std::vector& found) { + if (n < 29 || buf[0] != 'D') return false; + DeviceInfo info; + info.product_id = static_cast(buf[7] | (buf[8] << 8)); + if (info.product_id != kProductId) return false; + std::copy(buf + 1, buf + 7, info.mac.begin()); + for (const DeviceInfo& d : found) { + if (d.mac == info.mac) return false; + } + char ip[INET_ADDRSTRLEN] = {}; + ::inet_ntop(AF_INET, &from.sin_addr, ip, sizeof ip); + info.ip = ip; + found.push_back(info); + return true; +} + } // namespace // --- addresses ------------------------------------------------------------------------------ @@ -222,22 +244,70 @@ std::vector discover(const std::vector& targets, int po if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue; break; } - if (n < 29 || buf[0] != 'D') continue; - DeviceInfo info; - info.product_id = static_cast(buf[7] | (buf[8] << 8)); - if (info.product_id != kProductId) continue; - std::copy(buf + 1, buf + 7, info.mac.begin()); - bool dup = false; - for (const DeviceInfo& d : found) dup = dup || d.mac == info.mac; - if (dup) continue; - char ip[INET_ADDRSTRLEN] = {}; - ::inet_ntop(AF_INET, &from.sin_addr, ip, sizeof ip); - info.ip = ip; - found.push_back(info); + take_reply(buf, n, from, found); } return found; } +Scan::Scan(int port) { + ifaddrs* list = nullptr; + if (::getifaddrs(&list) != 0) return; + sockaddr_in to{}; + to.sin_family = AF_INET; + to.sin_port = htons(static_cast(port)); + to.sin_addr.s_addr = htonl(INADDR_BROADCAST); + const std::uint8_t probe = 'D'; + std::vector done; // an interface with several addresses needs one probe, not several + for (const ifaddrs* a = list; a != nullptr; a = a->ifa_next) { + if (a->ifa_addr == nullptr || a->ifa_addr->sa_family != AF_INET) continue; + const unsigned fl = a->ifa_flags; + if ((fl & IFF_UP) == 0 || (fl & IFF_LOOPBACK) != 0 || (fl & IFF_BROADCAST) == 0) continue; + const std::string name = a->ifa_name; + if (std::find(done.begin(), done.end(), name) != done.end()) continue; + Fd s(::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, 0)); + if (!s.valid()) continue; + const int one = 1; + ::setsockopt(s.get(), SOL_SOCKET, SO_BROADCAST, &one, sizeof one); + if (::setsockopt(s.get(), SOL_SOCKET, SO_BINDTODEVICE, name.c_str(), + static_cast(name.size())) != 0) { + sockaddr_in me = *reinterpret_cast(a->ifa_addr); + me.sin_port = 0; + if (::bind(s.get(), reinterpret_cast(&me), sizeof me) != 0) continue; + } + if (::sendto(s.get(), &probe, 1, MSG_NOSIGNAL, reinterpret_cast(&to), + sizeof to) != 1) + continue; + done.push_back(name); + socks_.push_back(std::move(s)); + } + ::freeifaddrs(list); +} + +const std::vector& Scan::poll() { + for (const Fd& s : socks_) { + for (;;) { + std::uint8_t buf[128]; + sockaddr_in from{}; + socklen_t flen = sizeof from; + const ssize_t n = ::recvfrom(s.get(), buf, sizeof buf, MSG_DONTWAIT, + reinterpret_cast(&from), &flen); + if (n < 0) break; // nothing more on this socket for now + take_reply(buf, n, from, found_); + } + } + return found_; +} + +std::vector discover_broadcast(int port, int wait_ms) { + Scan scan(port); + const auto deadline = Clock::now() + std::chrono::milliseconds(wait_ms); + while (ms_left(deadline) > 0) { + scan.poll(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return scan.poll(); +} + // --- Link ----------------------------------------------------------------------------------- void Link::connect(const std::string& host, int port, std::uint32_t code, int timeout_ms) { diff --git a/src/source/e1608/e1608_link.hpp b/src/source/e1608/e1608_link.hpp index 7c0a98d..7516fb0 100644 --- a/src/source/e1608/e1608_link.hpp +++ b/src/source/e1608/e1608_link.hpp @@ -76,6 +76,30 @@ std::vector sweep_targets(const std::string& subnet); /// `kProductId`) and one entry per MAC. std::vector discover(const std::vector& targets, int port, int wait_ms); +/// A broadcast discovery that never blocks. The limited broadcast alone leaves through the default +/// route only, so on a host with several interfaces it can miss a board on another one; this opens +/// one socket per up, non-loopback IPv4 interface with a broadcast address (bound to the device, or +/// to the interface address when binding to the device is refused), sends `D` to +/// 255.255.255.255 on each, and collects the replies as `poll()` is called. The caller owns the +/// clock. An interface that cannot be opened or sent on is skipped; no interface is an empty scan. +class Scan { + public: + explicit Scan(int port); + Scan(const Scan&) = delete; + Scan& operator=(const Scan&) = delete; + /// Drains every socket without blocking. The devices found so far: E-1608s only, one per MAC. + const std::vector& poll(); + /// How many interfaces a probe was sent on. + std::size_t interfaces() const noexcept { return socks_.size(); } + + private: + std::vector socks_; + std::vector found_; +}; + +/// `Scan` plus a blocking wait of `wait_ms`: what a lookup by MAC with no subnet uses. +std::vector discover_broadcast(int port, int wait_ms); + /// One board: the UDP handshake, the TCP command channel, and the scan socket (`port + 1`). /// Not thread safe; the source's producer thread owns it. class Link { diff --git a/src/source/e1608/e1608_source.cpp b/src/source/e1608/e1608_source.cpp index d7d8d7f..ebda74a 100644 --- a/src/source/e1608/e1608_source.cpp +++ b/src/source/e1608/e1608_source.cpp @@ -274,8 +274,13 @@ void E1608Source::connect_and_start() { if (host.empty() || have_mac) { const std::vector targets = host.empty() ? sweep_targets(cfg_.subnet) : std::vector{host}; + const int wait_ms = std::min(cfg_.timeout_ms, 1500); + // No address and no subnet: the limited broadcast, sent on every interface rather than only + // the default route's. + const bool broadcast = + host.empty() && targets.size() == 1 && targets[0] == "255.255.255.255"; const std::vector found = - discover(targets, cfg_.port, std::min(cfg_.timeout_ms, 1500)); + broadcast ? discover_broadcast(cfg_.port, wait_ms) : discover(targets, cfg_.port, wait_ms); if (host.empty()) { for (const DeviceInfo& d : found) { if (d.mac == want) host = d.ip; From 24bdd6b9301a393462827a7aa8d920d93861c89e Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 2 Nov 2026 10:17:01 +0100 Subject: [PATCH 048/127] e1608: a Scan network row in the connection form, with click-to-fill results Co-Authored-By: Claude Opus 4.6 --- src/source/e1608/e1608_kind.cpp | 137 +++++++++++++++++++++++++++++++- 1 file changed, 133 insertions(+), 4 deletions(-) diff --git a/src/source/e1608/e1608_kind.cpp b/src/source/e1608/e1608_kind.cpp index 2f89931..5dc847b 100644 --- a/src/source/e1608/e1608_kind.cpp +++ b/src/source/e1608/e1608_kind.cpp @@ -11,6 +11,7 @@ #include #include #include +#include namespace udpscope { namespace { @@ -82,6 +83,11 @@ struct Form { ChangeCb changed = std::make_shared>(); bool updating = false; bool alive = true; ///< cleared when the chooser destroys the rows; handlers then touch nothing + GtkWidget* scan_row = nullptr; ///< the "Scan network" expander; the results are its rows + std::vector results; ///< the result rows of the current scan + std::unique_ptr scan; ///< non-null while a scan is running + guint timer = 0; ///< the GLib source polling `scan`, 0 when none + int scan_ms = 0; ///< time spent in the running scan bool diff() const { return sel_of(mode) == 1; } int enabled_ai() const { @@ -164,6 +170,24 @@ struct Form { refresh(); } + /// A result click: puts one way of naming the board in and the other two fields out, under the + /// `updating` guard so the dialog is told once. + void fill(const std::string& addr, const std::string& mac_text) { + if (!alive) return; + updating = true; + gtk_editable_set_text(GTK_EDITABLE(address), addr.c_str()); + gtk_editable_set_text(GTK_EDITABLE(mac), mac_text.c_str()); + gtk_editable_set_text(GTK_EDITABLE(subnet), ""); + updating = false; + if (changed && *changed) (*changed)(); + } + + void stop_scan() { + if (timer != 0) g_source_remove(timer); + timer = 0; + scan.reset(); + } + bool valid() const { return enabled_ai() > 0 && E1608Settings::from(read()).problem().empty(); } }; @@ -185,7 +209,11 @@ void on_edited(GtkEditable*, gpointer user) { void free_form(gpointer d, GClosure*) { delete static_cast(d); } /// "destroy" on a row group: the siblings the handlers reach by raw pointer may already be gone. -void on_form_destroy(GtkWidget*, gpointer user) { (**static_cast(user)).alive = false; } +void on_form_destroy(GtkWidget*, gpointer user) { + Form& f = **static_cast(user); + f.alive = false; + f.stop_scan(); // the poll must not outlive the rows it writes to +} void hook_destroy(GtkWidget* w, const FormPtr& f) { g_signal_connect_data(w, "destroy", G_CALLBACK(on_form_destroy), new FormPtr(f), free_form, @@ -202,6 +230,96 @@ void hook_entry(GtkWidget* w, const FormPtr& f) { static_cast(0)); } +constexpr int kScanMs = 1500; ///< how long a scan listens +constexpr int kTickMs = 50; ///< how often it drains the sockets + +/// A result row's two ways to name the board, with the form they fill. +struct Pick { + FormPtr form; + std::string ip, mac; +}; + +void free_pick(gpointer d, GClosure*) { delete static_cast(d); } + +void on_pick_address(AdwActionRow*, gpointer user) { + Pick& p = *static_cast(user); + p.form->fill(p.ip, ""); +} + +void on_pick_mac(GtkButton*, gpointer user) { + Pick& p = *static_cast(user); + p.form->fill("", p.mac); +} + +void add_result(const FormPtr& f, const DeviceInfo& d) { + const std::string mac = format_mac(d.mac); + GtkWidget* row = adw_action_row_new(); + gtk_widget_set_name(row, "e1608-result"); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(row), d.ip.c_str()); + adw_action_row_set_subtitle(ADW_ACTION_ROW(row), mac.c_str()); + gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), TRUE); + GtkWidget* use_mac = gtk_button_new_with_label("Use MAC"); + gtk_widget_set_name(use_mac, "e1608-use-mac"); + gtk_widget_set_valign(use_mac, GTK_ALIGN_CENTER); + adw_action_row_add_suffix(ADW_ACTION_ROW(row), use_mac); + g_signal_connect_data(row, "activated", G_CALLBACK(on_pick_address), + new Pick{f, d.ip, mac}, free_pick, static_cast(0)); + g_signal_connect_data(use_mac, "clicked", G_CALLBACK(on_pick_mac), new Pick{f, d.ip, mac}, + free_pick, static_cast(0)); + adw_expander_row_add_row(ADW_EXPANDER_ROW(f->scan_row), row); + adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f->scan_row), TRUE); + f->results.push_back(row); +} + +void set_scan_status(Form& f, const std::string& text) { + adw_expander_row_set_subtitle(ADW_EXPANDER_ROW(f.scan_row), text.c_str()); +} + +/// 50 ms poll of the running scan. The rows appear as boards answer; at `kScanMs` the status is +/// settled and the scan (its sockets) is released. +gboolean on_scan_tick(gpointer user) { + const FormPtr& fp = *static_cast(user); + Form& f = *fp; + if (!f.alive || !f.scan) { + f.timer = 0; + return G_SOURCE_REMOVE; + } + const std::vector found = f.scan->poll(); + while (f.results.size() < found.size()) add_result(fp, found[f.results.size()]); + f.scan_ms += kTickMs; + if (f.scan_ms < kScanMs) return G_SOURCE_CONTINUE; + if (found.empty()) + set_scan_status(f, "No board found"); + else if (found.size() == 1) + set_scan_status(f, "1 board found"); + else + set_scan_status(f, std::to_string(found.size()) + " boards found"); + f.scan.reset(); + f.timer = 0; + return G_SOURCE_REMOVE; +} + +void free_form_ptr(gpointer d) { delete static_cast(d); } + +/// A new scan on the Port field's value: the previous results go, a running scan is replaced. +void start_scan(const FormPtr& fp) { + Form& f = *fp; + if (!f.alive) return; + f.stop_scan(); + for (GtkWidget* r : f.results) adw_expander_row_remove(ADW_EXPANDER_ROW(f.scan_row), r); + f.results.clear(); + adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f.scan_row), FALSE); + f.scan = std::make_unique(static_cast(value_of(f.port))); + f.scan_ms = 0; + set_scan_status(f, "Scanning..."); + f.timer = g_timeout_add_full(G_PRIORITY_DEFAULT, kTickMs, on_scan_tick, new FormPtr(fp), + free_form_ptr); +} + +void on_scan_clicked(GtkButton*, gpointer user) { + start_scan(*static_cast(user)); +} + class E1608Kind final : public SourceKind { public: std::string id() const override { return "e1608"; } @@ -253,8 +371,18 @@ class E1608Kind final : public SourceKind { f->ao_init[0] = spin_row("Initial DAC 0 (V)", -10, 10, 0.1, 0, 3); f->ao_init[1] = spin_row("Initial DAC 1 (V)", -10, 10, 0.1, 0, 3); - GtkWidget* conn = expander("Connection", {f->address, f->mac, f->subnet, f->port, f->code, - f->timeout}); + f->scan_row = adw_expander_row_new(); + gtk_widget_set_name(f->scan_row, "e1608-scan-row"); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(f->scan_row), "Scan network"); + GtkWidget* scan_button = gtk_button_new_with_label("Scan"); + gtk_widget_set_name(scan_button, "e1608-scan"); + gtk_widget_set_valign(scan_button, GTK_ALIGN_CENTER); + adw_expander_row_add_suffix(ADW_EXPANDER_ROW(f->scan_row), scan_button); + g_signal_connect_data(scan_button, "clicked", G_CALLBACK(on_scan_clicked), new FormPtr(f), + free_form, static_cast(0)); + + GtkWidget* conn = expander("Connection", {f->scan_row, f->address, f->mac, f->subnet, f->port, + f->code, f->timeout}); GtkWidget* acq = expander("Acquisition", {f->rate, f->ext_clock, f->trigger, f->auto_restart, f->calibrate, f->poll_rate}); GtkWidget* ai = expander("Analog inputs", {f->mode, f->ai[0], f->ai[1], f->ai[2], f->ai[3], @@ -283,7 +411,8 @@ class E1608Kind final : public SourceKind { ConfigForm out; out.rows = {conn, acq, ai, dio}; out.description = - "Give an address, or a MAC address to look up (optionally with a subnet such as 192.168.1)"; + "Give an address, or a MAC address to look up (optionally with a subnet such as 192.168.1), " + "or scan the network for boards"; out.focus = f->address; out.write = [f](const SourceConfig& c) { f->write(c); }; out.read = [f]() { return f->read(); }; From dbf145094ac9859d066bc9f5308ea617bf3244ba Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 2 Nov 2026 10:20:16 +0100 Subject: [PATCH 049/127] e1608: probe and docs for the network scan Co-Authored-By: Claude Opus 4.6 --- README.md | 8 +- docs/claude/architecture.md | 4 +- docs/claude/build-and-run.md | 3 +- docs/claude/probe-readings.md | 15 +++- docs/claude/verification.md | 7 ++ src/ui.cpp | 3 + src/ui_app.hpp | 6 ++ src/ui_probe.cpp | 148 ++++++++++++++++++++++++++++++++++ 8 files changed, 189 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a5af09c..5b194bf 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,13 @@ only when `UDPSCOPE_PROBE_SOURCE_KINDS` is set, and exists only for the self-che The second built-in kind is the **MCC E-1608** Ethernet DAQ (8 single-ended or 4 differential 16-bit inputs, two analog outputs, eight digital lines and a 32-bit counter). Its connection form takes an address, or a MAC address -to look up (optionally with the subnet to sweep), and groups the acquisition settings (scan rate, external clock, +to look up (optionally with the subnet to sweep). A **Scan network** row at the top of the form probes every +network interface of the host for boards (about a second and a half), lists each one by address and MAC, and a +click on a result fills the address, or **Use MAC** fills the MAC and leaves the address empty. Results are not +saved: only the filled field is. A MAC with no subnet is now looked up on every interface, not only the default +route's, because a single limited broadcast leaves through the default route alone and missed a board on another +interface (found on a real board on 2026-11-02, where the board was on `net0` and the default route was +`wlan0`). The form also groups the acquisition settings (scan rate, external clock, trigger, restart after an overrun, factory calibration, the digital poll rate), a range per analog input, and the direction and initial value of each digital line. The analog inputs are the plotted channels; each digital input and the counter is an extra channel only if you ask for it. Two side-stack tabs belong to it: **Analog out** diff --git a/docs/claude/architecture.md b/docs/claude/architecture.md index a093c44..9b21c8d 100644 --- a/docs/claude/architecture.md +++ b/docs/claude/architecture.md @@ -44,7 +44,9 @@ What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*. has neither key, so a legacy session is read as UDPS and nothing existing moves. - **The E-1608 kind has three layers, and only the top one knows the framework.** `e1608_link` (discovery, handshake, framed TCP commands, the scan socket) and `e1608_device` (typed commands, - factory calibration, `decode_scans`) are plain POSIX C++ with no GTK or `DataSource` types; + factory calibration, `decode_scans`) are plain POSIX C++ with no GTK or `DataSource` types; discovery is + `Scan` (one socket per interface, polled by the caller: the form polls it from a GLib timer on the main + thread, the source's lookup goes through `discover_broadcast`); `e1608_source` is the `DataSource`; `e1608_kind` and `e1608_panels` are the UI. A change to the wire never reaches the UI files. - The E-1608 source runs **everything inside `poll()`** on the producer thread: posted commands, the diff --git a/docs/claude/build-and-run.md b/docs/claude/build-and-run.md index b525c21..0f79a33 100644 --- a/docs/claude/build-and-run.md +++ b/docs/claude/build-and-run.md @@ -122,7 +122,8 @@ is exported when the option is absent, so GTK's own choice stands. drops the default**: only a run with no `aiN` key gets `ai0=BIP10V`, so `ai1=BIP5V` alone is channel 1 alone, and a value that is not a range name is a channel that is off), `dir0`..`dir7` `i|o`, `plot0`..`plot7`, `plot_counter`, `do_init`, `ao0_init`, `ao1_init`). Either `address` or `mac` is -required; a MAC with no `address` is looked up by broadcast, or by a sweep of `subnet` (`a.b.c`). +required; a MAC with no `address` is looked up by a broadcast sent on every interface (`Scan`), or by a sweep of +`subnet` (`a.b.c`). The enabled channels times `scan_rate` must not exceed 250 kS/s. `tools/mock_e1608.py` is a protocol mock of the board for development (UDP discovery and handshake, TCP commands, a scan stream with a sine per channel, calibration memory, `--port`, `--mac`, `--log FILE`, `--drop-after S` and `--overrun-after S` diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 525bbeb..8585e82 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -1859,11 +1859,12 @@ The UDPS run has no `source-` page in `side_stack`, because the UDPS kind has no [e1608] scan socket closed by the board [e1608] reconnect: connects=2 connected=1 ok=1 [e1608] resumed: frames_before=612 frames_after=662 ok=1 +[e1608] scan: found=1 ip=172.21.121.113 mac=00:80:2f:00:16:08 status='1 board found' fill_addr=1 fill_mac=1 rescan_replaced=1 ok=1 ``` `UDPSCOPE_PROBE_E1608=1500` (the first eight lines; `frames=` and the exact mean vary) and -`UDPSCOPE_PROBE_E1608_RECONNECT=8000` (the last two) run as `--source e1608` against `tools/mock_e1608.py` -with the options in `verification.md`. The line between `decode` and `reconnect` is the source's own stderr message +`UDPSCOPE_PROBE_E1608_RECONNECT=8000` (the next two) run as `--source e1608` against `tools/mock_e1608.py` +with the options in `verification.md`; `UDPSCOPE_PROBE_E1608_SCAN=1000` (the `scan` line) is a run of its own. The line between `decode` and `reconnect` is the source's own stderr message about the drop, not a probe line (its text is the error the source met, once per distinct message). `connected=0` throughout, with `actions: skipped (not connected)`, is the no-board run; it is a reading and not a failure, and it also prints the one `[e1608] handshake with ...: Connection refused` line, `stream: ... signals=0 expect=1 ... ok=0`, @@ -1894,6 +1895,16 @@ the one `[e1608] handshake with ...: Connection refused` line, `stream: ... sign drop (neither the scan read nor the status check raised it), `resumed: ok=0` is a reconnect that did not restart the scan (it also reads 0 when `connects` is still 1, since a frame count that grows on a link that never dropped proves nothing). +- `scan: found=1 ... ok=1`: the form's **Scan network** row against the mock. `found` is the result rows after + the first scan; `0` with the second line `did not answer` is an environment with no up, non-loopback + broadcast interface that reaches the mock (or a refused broadcast), **not a pass**. `ip` is the listed address + of the expected board and `mac` the MAC the probe looked for. +- `status='1 board found'`: the row's subtitle at the end of the first scan (`N boards found` with more + than one). `No board found` with a mock running is the probe's sockets not reaching it. +- `fill_addr=0`: a click on a result did not set only the address (the MAC or subnet kept a stale value), or it + told the dialog something other than once. `fill_mac=0`: the same for **Use MAC**. +- `rescan_replaced=0`: the results of the first scan were kept or doubled by the second, so `start_scan` did not + remove them. ## `[flatudp]` diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 45ff23f..26b3f79 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -441,6 +441,13 @@ precedent, not an exception. - **a rejected code:** mock `--code 1234`, app `code=7`: one error line, the retries stop. - `UDPSCOPE_PROBE_E1608_RECONNECT=8000` -- reads the source after the mock dropped the link and 500 ms later checks the frame count moved on. The reading's own entry is in `probe-readings.md`. +- `UDPSCOPE_PROBE_E1608_SCAN=1000` with `UDPSCOPE_EXIT_AFTER_MS=6500`, run as `--source e1608 --source-opt + address=127.0.0.1` against `tools/mock_e1608.py` on its default port 54211 (the form scans that port) -- builds + the connection form, presses **Scan network**, expects the mock to be listed, clicks the result (address + filled, MAC and subnet cleared), presses **Use MAC** (MAC filled, the others cleared), scans again and checks + the results were replaced. `UDPSCOPE_PROBE_E1608_SCAN_MAC` names another board (a real one) instead of the + mock's `00:80:2f:00:16:08`. One `[e1608] scan:` line. It needs a non-loopback up interface with a broadcast + address; when the board did not answer a second line says so, and the run proves nothing about the scan. - `UDPSCOPE_PROBE_FLATUDP=2500` with `UDPSCOPE_EXIT_AFTER_MS`, run as `--source flatudp` against `tools/mock_flatudp.py` -- the flat UDP source end to end: a hand-computed codec and decode check and the connect form's validity rules and signal-row editor (both need no source; it covers the `w` suffix, the waveform and 256-channel rules, the channel decode and the diff --git a/src/ui.cpp b/src/ui.cpp index 347f373..fe5c28d 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -6031,6 +6031,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_RECONNECT")) { g_timeout_add(static_cast(std::atoi(env)), e1608_probe_reconnect, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_SCAN")) { + g_timeout_add(static_cast(std::atoi(env)), e1608_scan_probe, &app); + } if (const char* env = g_getenv("UDPSCOPE_PROBE_FLATUDP")) { g_timeout_add(static_cast(std::atoi(env)), flatudp_probe, &app); } diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 6090e9a..95852f4 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -1873,6 +1873,12 @@ gboolean e1608_probe(gpointer user); gboolean e1608_probe_report(gpointer user); gboolean e1608_probe_reconnect(gpointer user); gboolean e1608_probe_resumed(gpointer user); +/// The E-1608 form's "Scan network" row: presses Scan against the mock, then (phases `_fill`, +/// `_rescan`) clicks a result to fill the address, "Use MAC" to fill the MAC, scans again and checks +/// the results were replaced, not added to. +gboolean e1608_scan_probe(gpointer user); +gboolean e1608_scan_probe_fill(gpointer user); +gboolean e1608_scan_probe_rescan(gpointer user); /// The flat UDP (MARTe2 UDPSender) source: the signal-list codec and a hand-computed decode, then, /// against `tools/mock_flatudp.py`, the stream, the value continuity, the wrong-size accounting and /// the capture button (`_report`, a second later). `_gap` starts a 100 ms poll that waits for the diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index e9ac7a7..bb334b2 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -2458,6 +2458,154 @@ gboolean e1608_probe_reconnect(gpointer user) { return G_SOURCE_REMOVE; } +namespace { + +/// The scan probe's state, alive from phase 1 to phase 3. +struct E1608ScanProbe { + ConfigForm form; + int changes = 0; ///< how many times the form told the dialog it changed + std::string want_mac; ///< the board the probe expects (the mock's by default) + std::string status; ///< the scan row's subtitle, as read at the end of the first scan + std::string ip; ///< the expected board's address, as listed + std::size_t first = 0; ///< result rows after the first scan + bool listed = false; ///< the expected board was among them + bool fill_addr = false; + bool fill_mac = false; +}; + +E1608ScanProbe* g_scan_probe = nullptr; + +void collect_named(GtkWidget* root, const char* name, std::vector& out) { + if (root == nullptr) return; + if (std::strcmp(gtk_widget_get_name(root), name) == 0) out.push_back(root); + for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr; + c = gtk_widget_get_next_sibling(c)) + collect_named(c, name, out); +} + +std::string scan_status(E1608ScanProbe& p) { + GtkWidget* row = find_named(p.form.rows[0], "e1608-scan-row"); + const char* t = row != nullptr ? adw_expander_row_get_subtitle(ADW_EXPANDER_ROW(row)) : nullptr; + return t != nullptr ? t : ""; +} + +/// A form holding stale values in all three naming fields, so a fill is seen to clear the others. +void scan_probe_stale(E1608ScanProbe& p) { + SourceConfig c; + c.set("address", "1.2.3.4"); + c.set("mac", "aa:bb:cc:dd:ee:ff"); + c.set("subnet", "10.0.0"); + p.form.write(c); + p.changes = 0; +} + +void scan_probe_click(E1608ScanProbe& p) { + GtkWidget* button = find_named(p.form.rows[0], "e1608-scan"); + if (button != nullptr) g_signal_emit_by_name(button, "clicked"); +} + +void scan_probe_cleanup() { + if (g_scan_probe == nullptr) return; + for (GtkWidget* row : g_scan_probe->form.rows) { + g_object_ref_sink(row); + g_object_unref(row); + } + delete g_scan_probe; + g_scan_probe = nullptr; +} + +} // namespace + +/// Phase 3, 2 s after the second Scan: the results were replaced (same count), and the one line. +gboolean e1608_scan_probe_rescan(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr || g_scan_probe == nullptr) return G_SOURCE_REMOVE; + E1608ScanProbe& p = *g_scan_probe; + std::vector rows; + collect_named(p.form.rows[0], "e1608-result", rows); + const bool replaced = rows.size() == p.first; + const std::string status = scan_status(p); + const bool status_ok = status.find("found") != std::string::npos && status.rfind("No", 0) != 0; + const bool ok = p.listed && p.fill_addr && p.fill_mac && replaced && status_ok; + std::fprintf(stderr, + "[e1608] scan: found=%zu ip=%s mac=%s status='%s' fill_addr=%d fill_mac=%d " + "rescan_replaced=%d ok=%d\n", + p.first, p.ip.c_str(), p.want_mac.c_str(), p.status.c_str(), p.fill_addr ? 1 : 0, + p.fill_mac ? 1 : 0, replaced ? 1 : 0, ok ? 1 : 0); + if (!p.listed) + std::fprintf(stderr, + "[e1608] scan: %s did not answer; no up broadcast interface reaches it, or the " + "broadcast was refused (this run proves nothing about the scan)\n", + p.want_mac.c_str()); + std::fflush(stderr); + scan_probe_cleanup(); + return G_SOURCE_REMOVE; +} + +/// Phase 2, 2 s after the first Scan: the expected board is listed, a result click fills the address +/// only, "Use MAC" fills the MAC only (each telling the dialog exactly once), then Scan again. +gboolean e1608_scan_probe_fill(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr || g_scan_probe == nullptr) return G_SOURCE_REMOVE; + E1608ScanProbe& p = *g_scan_probe; + p.status = scan_status(p); + std::vector rows; + collect_named(p.form.rows[0], "e1608-result", rows); + p.first = rows.size(); + GtkWidget* mine = nullptr; + for (GtkWidget* r : rows) { + const char* sub = adw_action_row_get_subtitle(ADW_ACTION_ROW(r)); + if (sub != nullptr && p.want_mac == sub) mine = r; + } + p.listed = mine != nullptr; + if (mine != nullptr) { + const char* title = adw_preferences_row_get_title(ADW_PREFERENCES_ROW(mine)); + p.ip = title != nullptr ? title : ""; + + scan_probe_stale(p); + g_signal_emit_by_name(mine, "activated"); + const SourceConfig by_addr = p.form.read(); + p.fill_addr = by_addr.get("address") == p.ip && by_addr.get("mac").empty() && + by_addr.get("subnet").empty() && p.changes == 1; + + scan_probe_stale(p); + GtkWidget* use = find_named(mine, "e1608-use-mac"); + if (use != nullptr) g_signal_emit_by_name(use, "clicked"); + const SourceConfig by_mac = p.form.read(); + p.fill_mac = by_mac.get("mac") == p.want_mac && by_mac.get("address").empty() && + by_mac.get("subnet").empty() && p.changes == 1; + } + scan_probe_click(p); + g_timeout_add(2000, e1608_scan_probe_rescan, a); + return G_SOURCE_REMOVE; +} + +/// Phase 1: the form, Scan pressed (the row must read `Scanning...` at once), phase 2 in 2 s. +gboolean e1608_scan_probe(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + SourceKind* k = find_kind("e1608"); + if (k == nullptr) { + std::fprintf(stderr, "[e1608] scan: no e1608 kind\n"); + return G_SOURCE_REMOVE; + } + g_scan_probe = new E1608ScanProbe; + E1608ScanProbe& p = *g_scan_probe; + p.form = k->make_config_form(); + const char* env = g_getenv("UDPSCOPE_PROBE_E1608_SCAN_MAC"); + p.want_mac = env != nullptr ? env : "00:80:2f:00:16:08"; + p.form.on_change([]() { + if (g_scan_probe != nullptr) ++g_scan_probe->changes; + }); + scan_probe_click(p); + const std::string now = scan_status(p); + if (now != "Scanning...") + std::fprintf(stderr, "[e1608] scan: status right after Scan reads '%s', expected 'Scanning...'\n", + now.c_str()); + g_timeout_add(2000, e1608_scan_probe_fill, a); + return G_SOURCE_REMOVE; +} + /// The mock's channels, as the model must list them: a scalar `Name`, the elements of a `channels` /// vector `Name[k]`, a waveform `Name` at `rate * count`. struct FlatudpExpect { From 8245324b51c51a4f665e4ea8fe91506523a4a9d3 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 2 Nov 2026 10:22:42 +0100 Subject: [PATCH 050/127] docs: reconcile the E-1608 README with the real-board run and the scan lookup Co-Authored-By: Claude Opus 4.6 --- README.md | 22 +++++++++++++--------- docs/claude/build-and-run.md | 4 ++-- docs/claude/verification.md | 5 ++++- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 5b194bf..cea447c 100644 --- a/README.md +++ b/README.md @@ -120,10 +120,9 @@ analog outputs, eight digital lines and a 32-bit counter). Its connection form t to look up (optionally with the subnet to sweep). A **Scan network** row at the top of the form probes every network interface of the host for boards (about a second and a half), lists each one by address and MAC, and a click on a result fills the address, or **Use MAC** fills the MAC and leaves the address empty. Results are not -saved: only the filled field is. A MAC with no subnet is now looked up on every interface, not only the default -route's, because a single limited broadcast leaves through the default route alone and missed a board on another -interface (found on a real board on 2026-11-02, where the board was on `net0` and the default route was -`wlan0`). The form also groups the acquisition settings (scan rate, external clock, +saved: only the filled field is. A MAC with no subnet is looked up by a broadcast sent on every up interface: a +single limited broadcast leaves through the default route only, and on 2026-11-02 it missed a real board on +`net0` while the default route was `wlan0`. The form also groups the acquisition settings (scan rate, external clock, trigger, restart after an overrun, factory calibration, the digital poll rate), a range per analog input, and the direction and initial value of each digital line. The analog inputs are the plotted channels; each digital input and the counter is an extra channel only if you ask for it. Two side-stack tabs belong to it: **Analog out** @@ -133,12 +132,17 @@ Acquisition is hardware-paced only (the scan rate or an external clock), the cha or below 250 kS/s, and the form refuses more. With the command-line options, a run that names no `aiN` key gets `ai0=BIP10V`; naming any `aiN` key drops that default, so `ai1=BIP5V` alone is channel 1 alone. -**This kind has not been run against a board.** It is verified against `tools/mock_e1608.py`, a protocol mock written -from two independent implementations of the wire protocol. The following rest on a single reference or the two -disagree, and are the first thing to check when a board is attached (the mock's header lists the same items): -the counter reset command (0x31, from one reference only); the pacer value (the source sends the *aggregate* +**This kind has been run against a real board once, on 2026-11-02, with one channel.** The board was at +169.254.88.75 on `net0`, `ai0=BIP10V` single-ended with the input grounded, `scan_rate=1000`, for 7 s: the connection +and handshake succeeded, the source configured one channel and streamed it at the declared 1000 S/s (about 1250 +samples in the first 1.25 s, no gaps, no overflows, no `[e1608]` errors), and the readings sat within a few LSB of +0 V (about 0.9 to 1.8 mV on a +-10 V range). Nothing else has been run on hardware; everything else is verified only +against `tools/mock_e1608.py`, a protocol mock written from two independent implementations of the wire protocol. +The following rest on a single reference or the two disagree, and are the first thing to check with more of the board +exercised (the mock's header lists the same items): +the counter reset command (0x31, from one reference only; not run on the board); the pacer value (the source sends the *aggregate* rate, `round(80e6 / (scan_rate * channels) - 1)`, as one reference does; the other sends the per-channel -frequency); the scan-start trigger option bits (none 0, rising 4, falling 8, high 12, low 16, one reference only); +frequency; the two differ only with two or more channels, so the one-channel run cannot settle it); the scan-start trigger option bits (none 0, rising 4, falling 8, high 12, low 16, one reference only); whether the handshake may answer status 2 ("code ignored"; one reference accepts it, the other refuses it, and the source accepts it); the first frame id; and the maximum frame size. Two further things are worth a look on a board even though the mock's header does not flag them: the calibration memory layout and diff --git a/docs/claude/build-and-run.md b/docs/claude/build-and-run.md index 0f79a33..9cbff1a 100644 --- a/docs/claude/build-and-run.md +++ b/docs/claude/build-and-run.md @@ -127,8 +127,8 @@ required; a MAC with no `address` is looked up by a broadcast sent on every inte The enabled channels times `scan_rate` must not exceed 250 kS/s. `tools/mock_e1608.py` is a protocol mock of the board for development (UDP discovery and handshake, TCP commands, a scan stream with a sine per channel, calibration memory, `--port`, `--mac`, `--log FILE`, `--drop-after S` and `--overrun-after S` -switches, `--code N`); there is no real board in the loop, and the mock's header lists the items whose -layout is unverified. Errors are written to stderr as `[e1608] ...`, once per distinct message. +switches, `--code N`); the mock stands in for the board (a real board has been run once, with one channel; see +the README), and the mock's header lists the items whose layout is unverified. Errors are written to stderr as `[e1608] ...`, once per distinct message. ```sh python3 tools/mock_e1608.py --port 54711 & diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 26b3f79..e840516 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -442,7 +442,10 @@ precedent, not an exception. - `UDPSCOPE_PROBE_E1608_RECONNECT=8000` -- reads the source after the mock dropped the link and 500 ms later checks the frame count moved on. The reading's own entry is in `probe-readings.md`. - `UDPSCOPE_PROBE_E1608_SCAN=1000` with `UDPSCOPE_EXIT_AFTER_MS=6500`, run as `--source e1608 --source-opt - address=127.0.0.1` against `tools/mock_e1608.py` on its default port 54211 (the form scans that port) -- builds + address=127.0.0.1`. Start `tools/mock_e1608.py` first on its default port 54211 (the form scans that port; do + not pass `--port`) and kill it afterwards. The value is the delay of phase 1; phase 2 follows 2 s after it and + phase 3 (the line) 2 s after that, so phase 3 runs 4 s after phase 1 and the delay must stay below about + 2500 ms for the line to print before the exit at 6500 ms. It builds the connection form, presses **Scan network**, expects the mock to be listed, clicks the result (address filled, MAC and subnet cleared), presses **Use MAC** (MAC filled, the others cleared), scans again and checks the results were replaced. `UDPSCOPE_PROBE_E1608_SCAN_MAC` names another board (a real one) instead of the From f74df49d3369587eb3e1852e71af19653902591c Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 2 Nov 2026 10:27:00 +0100 Subject: [PATCH 051/127] e1608: scan review fixes (EINTR in Scan::poll, cfg-based broadcast test, no per-tick copy) Co-Authored-By: Claude Opus 4.6 --- src/source/e1608/e1608_kind.cpp | 14 +++++++------- src/source/e1608/e1608_link.cpp | 5 ++++- src/source/e1608/e1608_source.cpp | 10 +++++----- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/source/e1608/e1608_kind.cpp b/src/source/e1608/e1608_kind.cpp index 5dc847b..9ba2d16 100644 --- a/src/source/e1608/e1608_kind.cpp +++ b/src/source/e1608/e1608_kind.cpp @@ -233,22 +233,22 @@ void hook_entry(GtkWidget* w, const FormPtr& f) { constexpr int kScanMs = 1500; ///< how long a scan listens constexpr int kTickMs = 50; ///< how often it drains the sockets -/// A result row's two ways to name the board, with the form they fill. +/// A result row's two ways to name the board: what each fills into the form (the other is cleared). struct Pick { FormPtr form; - std::string ip, mac; + std::string value; }; void free_pick(gpointer d, GClosure*) { delete static_cast(d); } void on_pick_address(AdwActionRow*, gpointer user) { Pick& p = *static_cast(user); - p.form->fill(p.ip, ""); + p.form->fill(p.value, ""); } void on_pick_mac(GtkButton*, gpointer user) { Pick& p = *static_cast(user); - p.form->fill("", p.mac); + p.form->fill("", p.value); } void add_result(const FormPtr& f, const DeviceInfo& d) { @@ -263,8 +263,8 @@ void add_result(const FormPtr& f, const DeviceInfo& d) { gtk_widget_set_valign(use_mac, GTK_ALIGN_CENTER); adw_action_row_add_suffix(ADW_ACTION_ROW(row), use_mac); g_signal_connect_data(row, "activated", G_CALLBACK(on_pick_address), - new Pick{f, d.ip, mac}, free_pick, static_cast(0)); - g_signal_connect_data(use_mac, "clicked", G_CALLBACK(on_pick_mac), new Pick{f, d.ip, mac}, + new Pick{f, d.ip}, free_pick, static_cast(0)); + g_signal_connect_data(use_mac, "clicked", G_CALLBACK(on_pick_mac), new Pick{f, mac}, free_pick, static_cast(0)); adw_expander_row_add_row(ADW_EXPANDER_ROW(f->scan_row), row); adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f->scan_row), TRUE); @@ -284,7 +284,7 @@ gboolean on_scan_tick(gpointer user) { f.timer = 0; return G_SOURCE_REMOVE; } - const std::vector found = f.scan->poll(); + const auto& found = f.scan->poll(); // valid until f.scan.reset() below, after its last use while (f.results.size() < found.size()) add_result(fp, found[f.results.size()]); f.scan_ms += kTickMs; if (f.scan_ms < kScanMs) return G_SOURCE_CONTINUE; diff --git a/src/source/e1608/e1608_link.cpp b/src/source/e1608/e1608_link.cpp index bbb2c9a..e2a4371 100644 --- a/src/source/e1608/e1608_link.cpp +++ b/src/source/e1608/e1608_link.cpp @@ -291,7 +291,10 @@ const std::vector& Scan::poll() { socklen_t flen = sizeof from; const ssize_t n = ::recvfrom(s.get(), buf, sizeof buf, MSG_DONTWAIT, reinterpret_cast(&from), &flen); - if (n < 0) break; // nothing more on this socket for now + if (n < 0) { + if (errno == EINTR) continue; + break; // nothing more on this socket for now + } take_reply(buf, n, from, found_); } } diff --git a/src/source/e1608/e1608_source.cpp b/src/source/e1608/e1608_source.cpp index ebda74a..7e85e95 100644 --- a/src/source/e1608/e1608_source.cpp +++ b/src/source/e1608/e1608_source.cpp @@ -272,15 +272,15 @@ void E1608Source::connect_and_start() { Mac want{}; const bool have_mac = !cfg_.mac_text.empty() && parse_mac(cfg_.mac_text, want); if (host.empty() || have_mac) { - const std::vector targets = - host.empty() ? sweep_targets(cfg_.subnet) : std::vector{host}; const int wait_ms = std::min(cfg_.timeout_ms, 1500); // No address and no subnet: the limited broadcast, sent on every interface rather than only // the default route's. - const bool broadcast = - host.empty() && targets.size() == 1 && targets[0] == "255.255.255.255"; + const bool broadcast = host.empty() && cfg_.subnet.empty(); const std::vector found = - broadcast ? discover_broadcast(cfg_.port, wait_ms) : discover(targets, cfg_.port, wait_ms); + broadcast ? discover_broadcast(cfg_.port, wait_ms) + : discover(host.empty() ? sweep_targets(cfg_.subnet) + : std::vector{host}, + cfg_.port, wait_ms); if (host.empty()) { for (const DeviceInfo& d : found) { if (d.mac == want) host = d.ip; From 5bb23e6a0acdac479395027adaad966f84d91957 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 2 Nov 2026 12:06:45 +0100 Subject: [PATCH 052/127] probe: name the MathNode fields; the build baseline is now one warning Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- CMakeLists.txt | 2 +- docs/claude/verification.md | 20 +++++++++----------- src/ui_probe.cpp | 15 +++++++-------- 4 files changed, 18 insertions(+), 21 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9d82a14..76d822c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ The detail lives in `docs/claude/`. Read the files that touch the area you are e | File | What it covers | | --- | --- | | [build-and-run.md](docs/claude/build-and-run.md) | Configuring and building, the desktop-entry target, the command-line options, and the two vendored dependencies (`deps/plotter`, `deps/udpsclient`) including what was changed upstream and why. | -| [verification.md](docs/claude/verification.md) | How to run the headless self-check, the **two baselines every run is read against** (exactly three build warnings; no `Gtk-CRITICAL`), and the environment variable that schedules each probe. | +| [verification.md](docs/claude/verification.md) | How to run the headless self-check, the **two baselines every run is read against** (exactly one build warning; no `Gtk-CRITICAL`), and the environment variable that schedules each probe. | | [probe-readings.md](docs/claude/probe-readings.md) | The per-line reference for the self-check's output: what every probe and dump line means, the value it must read, and the defect a wrong reading names. | ### How the application is put together diff --git a/CMakeLists.txt b/CMakeLists.txt index eb8c066..f3a9785 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -151,7 +151,7 @@ add_custom_command( # The generated source carries each file as one C string literal, and a `.ui` is big enough to pass the # 4095-character minimum the standard requires a compiler to support: `-Woverlength-strings` fires on # generated *data*, not on anything anyone wrote. Silenced for this one generated file (the icon bundle -# is small enough per file never to need it), so the build stays at its three warnings. The manual is +# is small enough per file never to need it), so the build stays at its one warning. The manual is # no longer compiled in, so the help bundle's own warning is gone with it. set_source_files_properties(${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c PROPERTIES COMPILE_OPTIONS "-Wno-overlength-strings") diff --git a/docs/claude/verification.md b/docs/claude/verification.md index e840516..8b86b8e 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -15,23 +15,21 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ Two baselines every run is read against. The UDPS runs must be **unchanged by the source-plugin work**: a UDPS self-check, number-normalised (`sed 's/[0-9.]\+/N/g'`), diffs empty against one taken before it. -A clean build emits **three** warnings and no more: two -`-Wmissing-field-initializers` on `MathNode::source` and one `-Woverlength-strings` on the icons' +A clean build emits **one** warning and no more: a `-Woverlength-strings` on the icons' generated GResource `.c`, whose blob is **9248** against C99's 4095-byte string-literal limit with the five action icons and the two app icons that remain (a note about a generated file, which GCC -itself supports). The *fourth* was the help pages' generated `.c` (**516044** bytes); the manual is -now a Mallard set installed for Yelp rather than a GResource compiled into the binary, so that -bundle — and its warning — is gone, and the baseline moved from four to **three**. Re-measure by -touching each `*.gresource.xml` under `src/` and rebuilding; a third `-Wmissing-field-initializers` -or any other warning is a finding. (The fifth warning that used to appear here was an unused -`output_built` in `math_probe()`; it was **deleted** as dead code — redundant with the -`output_after_add` already printed on the same line — which dropped the baseline from five to -four.) And an exit-dump run emits **no** `Gtk-CRITICAL` at all, nor any `Gtk-WARNING`: the dump's +itself supports). History: the help pages' generated `.c` (**516044** bytes) went when the manual +became a Mallard set installed for Yelp; an unused `output_built` in `math_probe()` was deleted as +dead code; and the last two, `-Wmissing-field-initializers` on `MathNode::source` in `math_probe()`, +went when the probe's `MathNode{MathOp::Gain, {}}` / `MathNode{MathOp::LowPass, {}}` named the +field (a partial brace-initialiser on `MathNode` re-adds one: `source` has no default member +initialiser). Re-measure by touching `src/icons/udpscope.gresource.xml` and rebuilding; any +second warning is a finding. And an exit-dump run emits **no** `Gtk-CRITICAL` at all, nor any `Gtk-WARNING`: the dump's `gtk warnings:` line counts GTK's own log (through a writer function — GTK is built with structured logging, so the handler API never sees its messages) and must read `0`; see [probe-readings.md](probe-readings.md). -An *incremental* build reads **one** of those three: the generated GResource `.c` is not recompiled +An *incremental* build reads **zero**: the generated GResource `.c` is not recompiled when only `src/` changed, so its `-Woverlength-strings` does not reappear. A third reading every run offers for free: **how many `[udpscope] buffer allocated:` lines it diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index bb334b2..d2a9bbc 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -467,7 +467,7 @@ gboolean math_probe(gpointer user) { MathChannel gain; gain.name = "math x2"; gain.enabled = true; - gain.nodes = {MathNode{MathOp::Source, name}, MathNode{MathOp::Gain}}; + gain.nodes = {MathNode{MathOp::Source, name}, MathNode{MathOp::Gain, {}}}; gain.nodes[1].in_a = 0; gain.nodes[1].gain = 2.0; gain.output = 1; @@ -476,14 +476,14 @@ gboolean math_probe(gpointer user) { MathChannel lp; lp.name = "math lp"; lp.enabled = true; - lp.nodes = {MathNode{MathOp::Source, late_name}, MathNode{MathOp::LowPass}}; + lp.nodes = {MathNode{MathOp::Source, late_name}, MathNode{MathOp::LowPass, {}}}; lp.nodes[1].in_a = 0; lp.nodes[1].fc = 20.0; lp.output = 1; channels.push_back(lp); // A third channel so the editor's new form row is built and synced on a baseline run. Assigned field - // by field: a partial brace-initialiser would add a fifth build warning. + // by field: a partial brace-initialiser would add a build warning. MathChannel dn; dn.name = "math denoise"; dn.enabled = true; @@ -1269,8 +1269,8 @@ gboolean mathwrap_probe(gpointer user) { MathChannel gain; gain.name = "math wrap"; gain.enabled = true; - // Field by field: a partial brace-initialiser would add a fourth build warning, and the baseline - // is exactly three. + // Field by field: a partial brace-initialiser would add a build warning, and the baseline + // is exactly one on a clean build. MathNode src; src.op = MathOp::Source; src.source = name; @@ -5843,9 +5843,8 @@ int noise_channel(App* a, const char* name) { // the older feature's proof, so the new channel stays out of the acquisition plan until they have // all run, and only the de-noise comparison is taken with it in. // -// Fields assigned rather than brace-initialised: a partial brace-initialiser on `MathNode` is -// what the two `-Wmissing-field-initializers` in this file already are, and the build baseline -// is exactly four warnings. +// Fields assigned rather than brace-initialised: a partial brace-initialiser on `MathNode` leaves +// `source` out and warns (`-Wmissing-field-initializers`), and the build baseline is one warning. gboolean noise_probe_setup(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; From 0c6e69957dac573c5b3c82f03432fe0f4f9d6a28 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 2 Nov 2026 12:11:16 +0100 Subject: [PATCH 053/127] ui: draw the sidebar tabs without the placeholder icon Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- docs/claude/invariants-gtk-and-chrome.md | 7 +++- docs/claude/probe-readings.md | 10 ++++-- src/ui.cpp | 14 +++++--- src/ui_app.hpp | 8 +++++ src/ui_source.cpp | 42 ++++++++++++++++++++++++ 6 files changed, 75 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index cea447c..2e62249 100644 --- a/README.md +++ b/README.md @@ -2909,7 +2909,7 @@ now — they are listed so they are not mistaken for anything else: unlocked, because configuring the host is what a first run is for. Each pane's heading also carries its **own switch**, the same control as the header bar's button — clicking either moves both — because a pane that is *disabled* while its feature is off is a pane whose controls you would otherwise have to leave to turn it back on. The - switcher's policy is `wide` and each page has a title. With the math + switcher's policy is `wide` and each page has a title and no icon (the switcher's own placeholder icon is hidden). With the math list folded into the signal list under a `MATH` divider whose `+` creates a channel — a derived channel already *is* a channel in the model, so the two lists were the same list drawn twice. A tab is one-of-*N*, so no toggle hides a pane any more: cursors-off **disables** its readout and leaves it in place, and diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md index 9d24b0d..c594499 100644 --- a/docs/claude/invariants-gtk-and-chrome.md +++ b/docs/claude/invariants-gtk-and-chrome.md @@ -252,7 +252,12 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear session file. An icon-only version existed (policy `narrow`, empty titles, the pills named in code by `name_side_tabs()`, because `narrow` still draws a label beside an icon); it was reversed because the glyphs were not understandable. A page added with an empty title is a blank pill, and - `switcher: … pills=N labels=N pages=N` reads it as `labels` below `pills`. + `switcher: … pills=N labels=N images=0 pages=N` reads it as `labels` below `pills`. **No icon at all** + is not what "no `icon-name`" gives you: `AdwViewSwitcherButton` always builds a `GtkImage` and falls + back to `image-missing` (the `[x]` placeholder, which also squeezed the title to "Meas..."), and no + property turns it off. `keep_tabs_icon_free()` (`src/ui_source.cpp`) hides every image in the switcher, + re-hides one that is shown again, and re-runs on the stack's `items-changed` (connected *after* the + switcher's own, so the new button exists) and on the switcher's `map`; `images=0` is the reading. - **An *overlaid* sidebar covers the plot, so a drag out of it has nowhere to land.** `collapsed` in an `AdwOverlaySplitView` means the sidebar sits *on* the plot rather than beside it: the pointer is over the list, never over a drop target, and a signal can be dragged but not dropped. `row_drag_sidebar()` hides diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 8585e82..0b58c9a 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -479,9 +479,15 @@ 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 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. -- `switcher: policy=wide pills=4 labels=4 pages=4 size=308x34` (in the exit dump, beside `panels:`) — the +- `switcher: policy=wide pills=4 labels=4 images=0 pages=4 size=308x34` (in the exit dump, beside `panels:`) — the sidebar tabs as the switcher actually draws them, and they are **words**: `labels` counts *mapped labels - with text*, so `pills=labels=pages` says every page has a visible title. `pages` is read from the stack + with text*, so `pills=labels=pages` says every page has a visible title. **`images` must be `0`**: it + counts *mapped* `GtkImage`s inside the switcher. `AdwViewSwitcherButton` builds an icon in every tab, and + a page with a title and no `icon-name` gets `image-missing` -- the `[x]` placeholder; the tabs hide it + (`keep_tabs_icon_free()`, which also covers pages `build_source_panels()` adds). A nonzero `images` + names a tab still drawing an icon or its placeholder (`images=pages` is the unfixed state), and + `pills`/`labels` never saw it. Read it on a source with extra pages (`--source probe` under + `UDPSCOPE_PROBE_SOURCE_KINDS`) as well as on UDPS. `pages` is read from the stack (2 for UDPS, 3 for the probe source, 4 for the E-1608 with its Analog out and Digital I/O tabs). A `labels` below `pills` is a page added with an empty title; `pills=0` means nothing is mapped, which makes the rest of the line meaningless rather than a pass. `policy=wide` is read rather than assumed. diff --git a/src/ui.cpp b/src/ui.cpp index fe5c28d..632d46e 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -3060,8 +3060,11 @@ void note_panel_controls(App& app) { // pill count reads 0 and the label count means nothing, rather than passing vacuously. int pills = 0; int drawn_labels = 0; + int drawn_images = 0; const std::function count_drawn = [&](GtkWidget* w) { if (GTK_IS_TOGGLE_BUTTON(w) && gtk_widget_get_mapped(w)) ++pills; + // A *mapped* image is an icon (or its missing-icon placeholder) on a tab: tabs are words only. + if (GTK_IS_IMAGE(w) && gtk_widget_get_mapped(w)) ++drawn_images; // A *mapped* label with text: an empty label would be counted by "mapped" alone. if (GTK_IS_LABEL(w) && gtk_widget_get_mapped(w) && gtk_label_get_text(GTK_LABEL(w))[0] != '\0') { ++drawn_labels; @@ -3074,6 +3077,7 @@ void note_panel_controls(App& app) { if (app.side_switcher != nullptr) count_drawn(app.side_switcher); app.dbg_switcher_pills = pills; app.dbg_switcher_labels = drawn_labels; + app.dbg_switcher_images = drawn_images; // Walked through `get_pages()`, not the widget tree: an `AdwViewStackPage` is a plain `GObject`. app.dbg_switcher_pages = app.side_stack != nullptr @@ -5203,7 +5207,9 @@ void on_activate(GtkApplication* application, gpointer user) { G_CALLBACK(on_side_tab_changed), app); } // The source kind's own tabs (a generator, digital I/O), added before the first tick so the - // pages-versus-pills reading below counts them too. + // pages-versus-pills reading below counts them too. The tabs are words, so the switcher's icons + // are hidden first and kept hidden for the pages this call adds. + keep_tabs_icon_free(*app); build_source_panels(*app); // The left side is the events pane *and* the plot, in one row: the pane is a column of its own @@ -6240,10 +6246,10 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit app.dbg_panel_switch_trigger, app.dbg_toolbar_switch_trigger, app.dbg_panel_switch_cursors, app.dbg_toolbar_switch_cursors, app.dbg_panel_drove_toolbar); - std::printf("[udpscope] switcher: policy=%s pills=%d labels=%d pages=%d size=%dx%d\n", + std::printf("[udpscope] switcher: policy=%s pills=%d labels=%d images=%d pages=%d size=%dx%d\n", app.dbg_switcher_policy == 0 ? "narrow" : (app.dbg_switcher_policy == 1 ? "wide" : "?"), - app.dbg_switcher_pills, app.dbg_switcher_labels, app.dbg_switcher_pages, - app.dbg_switcher_w, app.dbg_switcher_h); + app.dbg_switcher_pills, app.dbg_switcher_labels, app.dbg_switcher_images, + app.dbg_switcher_pages, app.dbg_switcher_w, app.dbg_switcher_h); // The help affordance layer: what the contextual-help toggle left on the surfaces, and where the // guided tour stood when the last capture ran. Both are captured on the tick (`capture_help_state`), // so neither asks a destroyed widget anything. diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 95852f4..414f087 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -444,6 +444,9 @@ struct App { /// are words (`wide` policy, a title per page), so the label count must equal the page count. int dbg_switcher_pills = -1; int dbg_switcher_labels = -1; + /// How many `GtkImage`s the switcher has *mapped*: a tab is its title and nothing else, so this + /// must be 0 -- an icon, or the missing-icon placeholder GTK draws for a page with no `icon-name`. + int dbg_switcher_images = -1; /// How many pages the sidebar stack holds: `pills` and `labels` must both equal it. int dbg_switcher_pages = -1; /// The switcher's own size, beside it: what the tab bar costs the column. @@ -1560,6 +1563,11 @@ void config_dialog_refresh(ConfigDialog& d); void open_configurations(App& app); void save_layout_now(App& app); +/// The sidebar's tabs are words: hide the icon every `AdwViewSwitcherButton` draws (the +/// missing-icon placeholder, for a page with a title and no `icon-name`), for the buttons that exist +/// and for any page added later. Call once, before `build_source_panels()`. +void keep_tabs_icon_free(App& app); + /// Capability panels (`src/ui_source.cpp`). `build_source_panels` adds the source kind's panels as /// pages of `side_stack`, once, before the first tick. `apply_source_panels` sets every panel's /// sensitivity from `connected` -- disabled, never hidden: the tab switcher owns visibility. diff --git a/src/ui_source.cpp b/src/ui_source.cpp index d81b723..d880485 100644 --- a/src/ui_source.cpp +++ b/src/ui_source.cpp @@ -11,6 +11,48 @@ namespace udpscope { namespace ui_impl { +namespace { + +/// Hide every `GtkImage` inside `w`, and keep it hidden. `AdwViewSwitcherButton` always builds an +/// icon -- in both of its layouts -- and a page with no `icon-name` gets `image-missing`, which is +/// the placeholder the sidebar's text tabs used to draw. There is no property or policy that turns +/// the icon off, so the image is hidden (a hidden child takes no space: the label gets the pill) and +/// a `notify::visible` handler puts it back to hidden if the button ever shows it again. +void hide_tab_icons(GtkWidget* w) { + if (GTK_IS_IMAGE(w)) { + if (g_object_get_data(G_OBJECT(w), "udpscope-icon-hidden") == nullptr) { + g_object_set_data(G_OBJECT(w), "udpscope-icon-hidden", GINT_TO_POINTER(1)); + g_signal_connect(w, "notify::visible", G_CALLBACK(+[](GObject* o, GParamSpec*, gpointer) { + GtkWidget* img = GTK_WIDGET(o); + if (gtk_widget_get_visible(img)) gtk_widget_set_visible(img, FALSE); + }), + nullptr); + } + gtk_widget_set_visible(w, FALSE); + return; + } + for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr; c = gtk_widget_get_next_sibling(c)) { + hide_tab_icons(c); + } +} + +void on_tab_pages_changed(GListModel*, guint, guint, guint, gpointer sw) { + hide_tab_icons(GTK_WIDGET(sw)); +} + +} // namespace + +void keep_tabs_icon_free(App& app) { + if (app.side_switcher == nullptr || app.side_stack == nullptr) return; + hide_tab_icons(app.side_switcher); + // A page added later gets a button when the model reports it; this handler is connected *after* + // the switcher's own, so the button already exists when it runs. + g_signal_connect_after(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack)), "items-changed", + G_CALLBACK(on_tab_pages_changed), app.side_switcher); + // And once more when the switcher is (re)mapped, which also covers a rebuild of the buttons. + g_signal_connect_swapped(app.side_switcher, "map", G_CALLBACK(hide_tab_icons), app.side_switcher); +} + void build_source_panels(App& app) { if (app.side_stack == nullptr || app.acq == nullptr || app.acq->source() == nullptr) return; SourceKind* kind = find_kind(app.source_kind); From 0d8c583bbbd184e56f77c2179fc73e5c54b23506 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 2 Nov 2026 12:19:23 +0100 Subject: [PATCH 054/127] e1608: a configurable generator update rate, and the achieved rate in the Analog out panel Co-Authored-By: Claude Opus 4.6 --- README.md | 8 +++- docs/claude/architecture.md | 2 +- docs/claude/build-and-run.md | 2 +- docs/claude/probe-readings.md | 15 ++++++- docs/claude/verification.md | 9 ++++ src/source/e1608/e1608_kind.cpp | 9 ++-- src/source/e1608/e1608_panels.cpp | 30 ++++++++++++- src/source/e1608/e1608_source.cpp | 31 +++++++++++++- src/source/e1608/e1608_source.hpp | 6 +++ src/ui.cpp | 3 ++ src/ui_app.hpp | 6 +++ src/ui_probe.cpp | 71 +++++++++++++++++++++++++++++++ 12 files changed, 181 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2e62249..ded6c36 100644 --- a/README.md +++ b/README.md @@ -123,11 +123,17 @@ click on a result fills the address, or **Use MAC** fills the MAC and leaves the saved: only the filled field is. A MAC with no subnet is looked up by a broadcast sent on every up interface: a single limited broadcast leaves through the default route only, and on 2026-11-02 it missed a real board on `net0` while the default route was `wlan0`. The form also groups the acquisition settings (scan rate, external clock, -trigger, restart after an overrun, factory calibration, the digital poll rate), a range per analog input, and the +trigger, restart after an overrun, factory calibration, the digital poll rate, the generator update rate), a range per analog input, and the direction and initial value of each digital line. The analog inputs are the plotted channels; each digital input and the counter is an extra channel only if you ask for it. Two side-stack tabs belong to it: **Analog out** (per DAC: a static level, or a sine, square, triangle or ramp from 0.1 to 10 Hz) and **Digital I/O** (a live LED per input bit, a switch per output bit, the counter with a reset); they are insensitive while no board is connected. +The waveform generator writes the DACs at the **generator update rate** (`gen_rate`, 1 to 200 Hz, default 50), which +is separate from the input poll rate (`poll_rate`, which only reads the digital inputs and the counter). Each update +is a synchronous command to the board, so a board that answers slowly achieves less than was asked: the **Analog out** +tab ends with an **Update rate** row that shows the rate the generator actually achieved (measured over about a +second) next to the requested one, or `off` when no generator runs. The waveform frequency itself is still limited +to 0.1 to 10 Hz. Acquisition is hardware-paced only (the scan rate or an external clock), the channels times the rate must stay at or below 250 kS/s, and the form refuses more. With the command-line options, a run that names no `aiN` key gets `ai0=BIP10V`; naming any `aiN` key drops that default, so `ai1=BIP5V` alone is channel 1 alone. diff --git a/docs/claude/architecture.md b/docs/claude/architecture.md index 9b21c8d..b295015 100644 --- a/docs/claude/architecture.md +++ b/docs/claude/architecture.md @@ -51,7 +51,7 @@ What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*. never reaches the UI files. - The E-1608 source runs **everything inside `poll()`** on the producer thread: posted commands, the connect state machine (Down, Scanning, Halted), the scan read, the 500 ms status check (its not-running restart is skipped when a hardware trigger is configured), the digital - input/counter poll and the 20 ms waveform tick. There is no second thread. A connect attempt (discovery, + input/counter poll and the waveform tick (every `1 / gen_rate` s, 20 ms by default; one synchronous DAC write per running channel, so a slow board lowers the rate it achieves, which the Analog out panel reports). There is no second thread. A connect attempt (discovery, handshake, calibration read, scan start) can block `poll()` for as long as the command timeout allows each step; that delays only the command queue. Every exception is caught inside `poll()`: an escape would end the producer thread, and `drain_commands()` runs inside the same `try` for that reason. diff --git a/docs/claude/build-and-run.md b/docs/claude/build-and-run.md index 9cbff1a..4e78ed1 100644 --- a/docs/claude/build-and-run.md +++ b/docs/claude/build-and-run.md @@ -118,7 +118,7 @@ is exported when the option is absent, so GTK's own choice stands. `--source e1608` selects the MCC E-1608 Ethernet DAQ; its settings are `--source-opt` keys, all optional (`address` or `mac`+`subnet`, `port` 54211, `code` 0, `timeout_ms` 2000, `scan_rate` 1000 per channel, `ext_clock`, `trigger` `none|rising|falling|high|low`, `auto_restart` 1, `calibrate` 1, `poll_rate` 10, -`mode` `se|diff`, `ai0`..`ai7` `BIP10V|BIP5V|BIP2V|BIP1V` (an absent key = off, **and any `aiN` key at all +`gen_rate` 50 (Hz, 1 to 200: the waveform generator's DAC update rate), `mode` `se|diff`, `ai0`..`ai7` `BIP10V|BIP5V|BIP2V|BIP1V` (an absent key = off, **and any `aiN` key at all drops the default**: only a run with no `aiN` key gets `ai0=BIP10V`, so `ai1=BIP5V` alone is channel 1 alone, and a value that is not a range name is a channel that is off), `dir0`..`dir7` `i|o`, `plot0`..`plot7`, `plot_counter`, `do_init`, `ao0_init`, `ao1_init`). Either `address` or `mac` is diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 0b58c9a..36faa0d 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -1838,7 +1838,7 @@ kind, so a run without it never shows that kind. The `form` line appears once pe - `kinds=udps,e1608,flatudp,probe`: a missing kind means `register_builtin_sources()` was not called before `parse_args()`, or the env var gate is wrong. - `round_trip=0`: a form's `write` and `read` disagree about a key -- a renamed `SourceConfig` key, or an - absent key that was not defaulted. `valid=0` is a default configuration the form refuses. The e1608 form's sample sets address, port, scan_rate, mode, ai1, trigger, dir1 and plot0; a mismatch names one of those. The flatudp sample sets port, bind, multicast, iface, rate, endian and signals. + absent key that was not defaulted. `valid=0` is a default configuration the form refuses. The e1608 form's sample sets address, port, scan_rate, mode, ai1, trigger, dir1, plot0 and gen_rate; a mismatch names one of those. The flatudp sample sets port, bind, multicast, iface, rate, endian and signals. - `panel disconnected: sensitive=0 present=1`: **disable, don't hide.** `present=0` is a panel that was hidden or never added; `sensitive=1` is a panel that stays live with no source behind it. - `panel connected: sensitive=1`: anything else means `apply_source_panels` acted on the wrong widget (it must @@ -1865,6 +1865,8 @@ The UDPS run has no `source-` page in `side_stack`, because the UDPS kind has no [e1608] scan socket closed by the board [e1608] reconnect: connects=2 connected=1 ok=1 [e1608] resumed: frames_before=612 frames_after=662 ok=1 +[e1608] generator: requested=100.0 achieved=99.7 panel='100 Hz (requested 100 Hz)' ok=1 +[e1608] generator stopped: achieved=0.0 panel='off (requested 100 Hz)' ok=1 [e1608] scan: found=1 ip=172.21.121.113 mac=00:80:2f:00:16:08 status='1 board found' fill_addr=1 fill_mac=1 rescan_replaced=1 ok=1 ``` @@ -1901,6 +1903,17 @@ the one `[e1608] handshake with ...: Connection refused` line, `stream: ... sign drop (neither the scan read nor the status check raised it), `resumed: ok=0` is a reconnect that did not restart the scan (it also reads 0 when `connects` is still 1, since a frame count that grows on a link that never dropped proves nothing). +- `generator: requested=100.0 achieved=99.7 ... ok=1`, `generator stopped: ... ok=1` + (`UDPSCOPE_PROBE_E1608_GEN=1500`, a run of its own with `gen_rate=100`): `requested` is `gen_rate`, `achieved` + the source's own measurement (generator ticks that wrote, per second of wall time, over a window of about 1 s), + `panel` the subtitle of the Analog out panel's "Update rate" row. `ok` needs `achieved` within 70% to 110% of + `requested` **and** the panel to quote the requested rate; the mock answers at once, so it reads within a + percent of `requested`. Far below is a `run_timers()` that re-arms with a constant (the 20 ms the setting + replaced reads 50) or a tick that runs late; `panel='off'` while `achieved` is non-zero is a panel that + did not refresh (it ticks only while its page is on screen) or one that reads the wrong readout. This line says + nothing about a real board, whose DAC writes are slower; there the same row is the measurement. The stopped + line must read `achieved=0.0` and a panel that starts with `off`; a non-zero value is a rate that + `update_gen_flags()` did not reset when the last generator stopped. - `scan: found=1 ... ok=1`: the form's **Scan network** row against the mock. `found` is the result rows after the first scan; `0` with the second line `did not answer` is an environment with no up, non-loopback broadcast interface that reaches the mock (or a refused broadcast), **not a pass**. `ip` is the listed address diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 8b86b8e..92c7393 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -437,6 +437,15 @@ precedent, not an exception. levels `1.5, 2.5, 1.5` (the log prints the quantised voltage, so `1.4999, 2.4999, 1.4999` is the expected text), the DOUT bytes `0x00, 0x02, 0x00`, one `COUNTER_RESET`, one `DROP`; - **a rejected code:** mock `--code 1234`, app `code=7`: one error line, the retries stop. +- `UDPSCOPE_PROBE_E1608_GEN=1500` with `UDPSCOPE_EXIT_AFTER_MS=5500`, run as `--source e1608 --source-opt + address=127.0.0.1 --source-opt port=54711 --source-opt ao0_init=0 --source-opt gen_rate=100` against + `tools/mock_e1608.py --port 54711 --log /tmp/e1608-gen100.log` -- the generator's update rate: it starts a Sine on + DAC 0 through the Analog out panel (and switches the side stack to that page, since the panel refreshes on its + frame clock, which does not tick for a page that is off screen), waits 2.5 s, reads the achieved rate and the + panel's "Update rate" row, stops the generator and reads both again 400 ms later. Two lines, in + `probe-readings.md`. Then count the mock log's `AOUT` lines: about 250 with `gen_rate=100` (the generator ran + for about 2.5 s) and about 125 for a run without the option (default 50 Hz). Short and serial, the mock killed + afterwards. It enables nothing else, so it can run alone. - `UDPSCOPE_PROBE_E1608_RECONNECT=8000` -- reads the source after the mock dropped the link and 500 ms later checks the frame count moved on. The reading's own entry is in `probe-readings.md`. - `UDPSCOPE_PROBE_E1608_SCAN=1000` with `UDPSCOPE_EXIT_AFTER_MS=6500`, run as `--source e1608 --source-opt diff --git a/src/source/e1608/e1608_kind.cpp b/src/source/e1608/e1608_kind.cpp index 9ba2d16..7d3d851 100644 --- a/src/source/e1608/e1608_kind.cpp +++ b/src/source/e1608/e1608_kind.cpp @@ -77,7 +77,7 @@ bool on_of(GtkWidget* w) { return adw_switch_row_get_active(ADW_SWITCH_ROW(w)); /// closure shares; the raw widget pointers are valid while the chooser keeps the rows. struct Form { GtkWidget *address, *mac, *subnet, *port, *code, *timeout; - GtkWidget *rate, *ext_clock, *trigger, *auto_restart, *calibrate, *poll_rate; + GtkWidget *rate, *ext_clock, *trigger, *auto_restart, *calibrate, *poll_rate, *gen_rate; GtkWidget *mode, *ai[8], *ai_group; GtkWidget *dir[8], *do_init, *plot_counter, *ao_init[2]; ChangeCb changed = std::make_shared>(); @@ -121,6 +121,7 @@ struct Form { c.set("auto_restart", on_of(auto_restart) ? "1" : "0"); c.set("calibrate", on_of(calibrate) ? "1" : "0"); c.set("poll_rate", num(value_of(poll_rate))); + c.set("gen_rate", num(value_of(gen_rate))); c.set("mode", diff() ? "diff" : "se"); static const char* const kRange[] = {"", "BIP10V", "BIP5V", "BIP2V", "BIP1V"}; for (int i = 0; i < 8; ++i) { @@ -156,6 +157,7 @@ struct Form { adw_switch_row_set_active(ADW_SWITCH_ROW(auto_restart), s.auto_restart); adw_switch_row_set_active(ADW_SWITCH_ROW(calibrate), s.calibrate); adw_spin_row_set_value(ADW_SPIN_ROW(poll_rate), s.poll_rate); + adw_spin_row_set_value(ADW_SPIN_ROW(gen_rate), s.gen_rate); adw_combo_row_set_selected(ADW_COMBO_ROW(mode), s.diff ? 1 : 0); for (int i = 0; i < 8; ++i) { adw_combo_row_set_selected( @@ -356,6 +358,7 @@ class E1608Kind final : public SourceKind { f->auto_restart = switch_row("_Restart after an overrun", true); f->calibrate = switch_row("Apply _factory calibration", true); f->poll_rate = spin_row("_Input poll rate (Hz)", 0.1, 100, 1, 10, 1); + f->gen_rate = spin_row("_Generator update rate (Hz)", 1, 200, 1, 50, 0); f->mode = combo_row("Input _mode", kMode); for (int i = 0; i < 8; ++i) { const std::string title = "Channel " + std::to_string(i); @@ -384,7 +387,7 @@ class E1608Kind final : public SourceKind { GtkWidget* conn = expander("Connection", {f->scan_row, f->address, f->mac, f->subnet, f->port, f->code, f->timeout}); GtkWidget* acq = expander("Acquisition", {f->rate, f->ext_clock, f->trigger, f->auto_restart, - f->calibrate, f->poll_rate}); + f->calibrate, f->poll_rate, f->gen_rate}); GtkWidget* ai = expander("Analog inputs", {f->mode, f->ai[0], f->ai[1], f->ai[2], f->ai[3], f->ai[4], f->ai[5], f->ai[6], f->ai[7]}); GtkWidget* dio = expander("Digital I/O", {f->dir[0], f->dir[1], f->dir[2], f->dir[3], f->dir[4], @@ -395,7 +398,7 @@ class E1608Kind final : public SourceKind { for (GtkWidget* w : {conn, acq, ai, dio}) hook_destroy(w, f); for (GtkWidget* w : {f->address, f->mac, f->subnet}) hook_entry(w, f); - for (GtkWidget* w : {f->port, f->code, f->timeout, f->rate, f->poll_rate, f->do_init, + for (GtkWidget* w : {f->port, f->code, f->timeout, f->rate, f->poll_rate, f->gen_rate, f->do_init, f->ao_init[0], f->ao_init[1]}) hook_notify(w, "notify::value", f); for (GtkWidget* w : {f->ext_clock, f->auto_restart, f->calibrate, f->plot_counter}) diff --git a/src/source/e1608/e1608_panels.cpp b/src/source/e1608/e1608_panels.cpp index ecae53e..18408b3 100644 --- a/src/source/e1608/e1608_panels.cpp +++ b/src/source/e1608/e1608_panels.cpp @@ -55,7 +55,7 @@ void set_text(GtkWidget* label, const char* text) { if (std::strcmp(gtk_label_get_text(GTK_LABEL(label)), text) != 0) gtk_label_set_text(GTK_LABEL(label), text); } -GtkWidget* panel_root(GtkWidget* a, GtkWidget* b = nullptr) { +GtkWidget* panel_root(GtkWidget* a, GtkWidget* b = nullptr, GtkWidget* c = nullptr) { GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 18); gtk_widget_set_margin_top(box, 12); gtk_widget_set_margin_bottom(box, 12); @@ -63,6 +63,7 @@ GtkWidget* panel_root(GtkWidget* a, GtkWidget* b = nullptr) { gtk_widget_set_margin_end(box, 12); gtk_box_append(GTK_BOX(box), a); if (b != nullptr) gtk_box_append(GTK_BOX(box), b); + if (c != nullptr) gtk_box_append(GTK_BOX(box), c); return box; } @@ -77,6 +78,7 @@ struct AoCtx { E1608Source* src = nullptr; std::shared_ptr ro; AoWidgets w[2]; + GtkWidget* gen_rate = nullptr; ///< the "Update rate" row: the achieved rate against the requested one Hook hooks[2]; bool updating = false; ///< set while the panel writes its own widgets: those writes post nothing unsigned epoch_seen = 0; @@ -148,6 +150,22 @@ void ao_refresh(AoCtx* c) { if (cur == nullptr || std::strcmp(cur, b) != 0) adw_action_row_set_subtitle(ADW_ACTION_ROW(c->w[ch].last), b); } + + // The achieved generator rate: ticks completed per second, measured by the producer thread. Below + // the requested rate, the board's command latency is what limits it. + char r[64]; + const double req = c->src->settings().gen_rate; + const bool running = c->ro->gen_on[0].load() || c->ro->gen_on[1].load(); + const double hz = c->ro->gen_hz.load(); + if (!running) + std::snprintf(r, sizeof r, "off (requested %.0f Hz)", req); + else if (hz <= 0.0) + std::snprintf(r, sizeof r, "measuring... (requested %.0f Hz)", req); + else + std::snprintf(r, sizeof r, "%.0f Hz (requested %.0f Hz)", hz, req); + const char* cur = adw_action_row_get_subtitle(ADW_ACTION_ROW(c->gen_rate)); + if (cur == nullptr || std::strcmp(cur, r) != 0) + adw_action_row_set_subtitle(ADW_ACTION_ROW(c->gen_rate), r); } gboolean ao_tick(GtkWidget*, GdkFrameClock*, gpointer user) { @@ -198,7 +216,15 @@ GtkWidget* build_ao(E1608Source* src) { ao_sensitivity(w); } - GtkWidget* root = panel_root(groups[0], groups[1]); + c->gen_rate = adw_action_row_new(); + gtk_widget_set_name(c->gen_rate, "e1608-gen-rate"); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(c->gen_rate), "Update rate"); + adw_action_row_set_subtitle(ADW_ACTION_ROW(c->gen_rate), "off"); + GtkWidget* gen_group = adw_preferences_group_new(); + adw_preferences_group_set_title(ADW_PREFERENCES_GROUP(gen_group), "Generator"); + adw_preferences_group_add(ADW_PREFERENCES_GROUP(gen_group), c->gen_rate); + + GtkWidget* root = panel_root(groups[0], groups[1], gen_group); g_object_set_data_full(G_OBJECT(root), "e1608-ao-ctx", c, +[](gpointer p) { delete static_cast(p); }); gtk_widget_add_tick_callback(root, ao_tick, c, nullptr); diff --git a/src/source/e1608/e1608_source.cpp b/src/source/e1608/e1608_source.cpp index 7e85e95..fb66793 100644 --- a/src/source/e1608/e1608_source.cpp +++ b/src/source/e1608/e1608_source.cpp @@ -61,6 +61,7 @@ E1608Settings E1608Settings::from(const SourceConfig& c) { s.auto_restart = c.get_bool("auto_restart", true); s.calibrate = c.get_bool("calibrate", true); s.poll_rate = c.get_double("poll_rate", 10.0); + s.gen_rate = c.get_double("gen_rate", 50.0); s.diff = lower(c.get("mode", "se")) == "diff"; bool any_key = false; @@ -133,6 +134,7 @@ std::string E1608Settings::problem() const { } if (aggregate() < kMinAggregateHz) return "The aggregate rate must be at least 0.02 S/s"; if (!(poll_rate >= 0.1 && poll_rate <= 100.0)) return "The input poll rate must be 0.1 to 100 Hz"; + if (!(gen_rate >= 1.0 && gen_rate <= 200.0)) return "The generator update rate must be 1 to 200 Hz"; return {}; } @@ -230,6 +232,7 @@ void E1608Source::fail(const std::string& msg, bool fatal) { any_gen_ = false; readouts_->gen_on[0].store(false); readouts_->gen_on[1].store(false); + gen_rate_reset(); if (fatal) { state_ = State::Halted; } else { @@ -327,7 +330,7 @@ void E1608Source::connect_and_start() { const auto now = Clock::now(); next_slow_ = now; next_status_ = now + std::chrono::milliseconds(500); - next_gen_ = now + std::chrono::milliseconds(20); + next_gen_ = now + gen_period(); slow_read(); state_ = State::Scanning; @@ -409,7 +412,7 @@ void E1608Source::run_timers() { check_status(); } if (any_gen_ && now >= next_gen_) { - next_gen_ = now + std::chrono::milliseconds(20); + next_gen_ = now + gen_period(); // from now, not from the old deadline: a slow board gets no catch-up burst generator_tick(now); } } @@ -475,22 +478,46 @@ void E1608Source::write_ao(int ch, double volts) { readouts_->ao[ch].store(dev_.aout(ch, volts, cal_)); } +E1608Source::Clock::duration E1608Source::gen_period() const { + return std::chrono::duration_cast(std::chrono::duration(1.0 / cfg_.gen_rate)); +} + +void E1608Source::gen_rate_reset() { + gen_win_ticks_ = 0; + gen_win_t0_ = Clock::now(); + readouts_->gen_hz.store(0.0); +} + void E1608Source::update_gen_flags() { + const bool was_gen = any_gen_; any_gen_ = false; for (int ch = 0; ch < 2; ++ch) { const bool on = ao_[ch].mode != AoMode::Level && ao_[ch].run; readouts_->gen_on[ch].store(on); any_gen_ = any_gen_ || on; } + if (any_gen_ != was_gen) gen_rate_reset(); // a generator started or the last one stopped } void E1608Source::generator_tick(Clock::time_point now) { + bool wrote = false; for (int ch = 0; ch < 2; ++ch) { const AoSettings& s = ao_[ch]; if (s.mode == AoMode::Level || !s.run) continue; const double t = std::chrono::duration(now - gen_t0_[ch]).count(); const double phase = std::fmod(t * s.freq_hz, 1.0); write_ao(ch, clamp(s.offset + s.amplitude * wave(s.mode, phase), -10.0, 10.0)); + wrote = true; + } + if (!wrote) return; + // The achieved rate: ticks that wrote, per second of wall time, published once a window closes. + // The DAC writes are synchronous, so a slow board shows up here as a rate below the requested one. + ++gen_win_ticks_; + const double elapsed = std::chrono::duration(Clock::now() - gen_win_t0_).count(); + if (elapsed >= 1.0) { + readouts_->gen_hz.store(gen_win_ticks_ / elapsed); + gen_win_ticks_ = 0; + gen_win_t0_ = Clock::now(); } } diff --git a/src/source/e1608/e1608_source.hpp b/src/source/e1608/e1608_source.hpp index 81bb1e7..dfc7ad3 100644 --- a/src/source/e1608/e1608_source.hpp +++ b/src/source/e1608/e1608_source.hpp @@ -36,6 +36,7 @@ struct E1608Settings { bool auto_restart = true; bool calibrate = true; double poll_rate = 10.0; ///< Hz, for the digital inputs and the counter + double gen_rate = 50.0; ///< Hz, the waveform generator's DAC update rate bool diff = false; bool ai_on[8] = {}; Range ai_range[8] = {}; @@ -62,6 +63,7 @@ struct E1608Readouts { std::atomic counter{0}; std::atomic ao[2]; ///< the last DAC voltage written; NaN before any std::atomic gen_on[2]; + std::atomic gen_hz{0.0}; ///< generator ticks completed per second, over ~1 s; 0 when off std::atomic epoch{0}; ///< bumped at each (re)connect: panels re-sync from it E1608Readouts() { for (int i = 0; i < 2; ++i) { @@ -117,6 +119,8 @@ class E1608Source final : public DataSource { void generator_tick(Clock::time_point now); void write_ao(int ch, double volts); void update_gen_flags(); + void gen_rate_reset(); + Clock::duration gen_period() const; void fail(const std::string& msg, bool fatal); void note(const std::string& msg); int wait_ms(int timeout_ms, Clock::time_point now) const; @@ -153,6 +157,8 @@ class E1608Source final : public DataSource { AoSettings ao_[2]; Clock::time_point gen_t0_[2]; bool any_gen_ = false; + Clock::time_point gen_win_t0_; ///< start of the current rate-measurement window + unsigned gen_win_ticks_ = 0; ///< ticks that wrote since `gen_win_t0_` Clock::time_point retry_at_; Clock::time_point next_slow_, next_status_, next_gen_; diff --git a/src/ui.cpp b/src/ui.cpp index 632d46e..c6c715d 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -6034,6 +6034,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608")) { g_timeout_add(static_cast(std::atoi(env)), e1608_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_GEN")) { + g_timeout_add(static_cast(std::atoi(env)), e1608_gen_probe, &app); + } if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_RECONNECT")) { g_timeout_add(static_cast(std::atoi(env)), e1608_probe_reconnect, &app); } diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 414f087..8437d53 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -1881,6 +1881,12 @@ gboolean e1608_probe(gpointer user); gboolean e1608_probe_report(gpointer user); gboolean e1608_probe_reconnect(gpointer user); gboolean e1608_probe_resumed(gpointer user); +/// The generator's update rate: starts a Sine on DAC 0 through the panel, reads the achieved rate the +/// source measured and the Analog out panel's "Update rate" row (`_report`, 2.5 s later), then stops +/// it and checks both read "off" (`_stopped`). +gboolean e1608_gen_probe(gpointer user); +gboolean e1608_gen_probe_report(gpointer user); +gboolean e1608_gen_probe_stopped(gpointer user); /// The E-1608 form's "Scan network" row: presses Scan against the mock, then (phases `_fill`, /// `_rescan`) clicks a result to fill the address, "Use MAC" to fill the MAC, scans again and checks /// the results were replaced, not added to. diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index d2a9bbc..fe5dc8c 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -1923,6 +1923,7 @@ gboolean source_kinds_probe(gpointer user) { c.set("trigger", "rising"); c.set("dir1", "o"); c.set("plot0", "1"); + c.set("gen_rate", "120"); } else if (id == "flatudp") { c.set("port", "44600"); c.set("bind", "10.0.0.8"); @@ -2428,6 +2429,76 @@ gboolean e1608_probe(gpointer user) { return G_SOURCE_REMOVE; } +/// Phase 3 of the generator probe: the generator switched off 400 ms ago; the achieved rate and the +/// panel must both have gone back to "off". +gboolean e1608_gen_probe_stopped(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + e1608::E1608Source* src = e1608_source(a); + if (src == nullptr) return G_SOURCE_REMOVE; + GtkWidget* row = find_named(source_panel_content(a, "source-ao"), "e1608-gen-rate"); + const char* sub = row != nullptr ? adw_action_row_get_subtitle(ADW_ACTION_ROW(row)) : nullptr; + const double hz = src->readouts()->gen_hz.load(); + const bool off = sub != nullptr && std::strncmp(sub, "off", 3) == 0; + std::fprintf(stderr, "[e1608] generator stopped: achieved=%.1f panel='%s' ok=%d\n", hz, + sub != nullptr ? sub : "", hz == 0.0 && off ? 1 : 0); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} + +/// Phase 2 of the generator probe, 2.5 s after a Sine generator was started on DAC 0: the achieved +/// update rate the source measured, and the text the Analog out panel shows for it. +gboolean e1608_gen_probe_report(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + e1608::E1608Source* src = e1608_source(a); + if (src == nullptr) return G_SOURCE_REMOVE; + GtkWidget* ao = source_panel_content(a, "source-ao"); + GtkWidget* row = find_named(ao, "e1608-gen-rate"); + const char* sub = row != nullptr ? adw_action_row_get_subtitle(ADW_ACTION_ROW(row)) : nullptr; + const double req = src->settings().gen_rate; + const double hz = src->readouts()->gen_hz.load(); + // The mock answers at once, so the achieved rate sits at the requested one; the band is loose. + const bool in_range = hz >= 0.7 * req && hz <= 1.1 * req; + char want[32]; + std::snprintf(want, sizeof want, "(requested %.0f Hz)", req); + const bool shown = sub != nullptr && std::strstr(sub, want) != nullptr && std::strstr(sub, "Hz") != nullptr; + std::fprintf(stderr, "[e1608] generator: requested=%.1f achieved=%.1f panel='%s' ok=%d\n", req, hz, + sub != nullptr ? sub : "", in_range && shown ? 1 : 0); + std::fflush(stderr); + GtkWidget* run = find_named(ao, "e1608-ao0-run"); + if (run != nullptr) adw_switch_row_set_active(ADW_SWITCH_ROW(run), FALSE); + g_timeout_add(400, e1608_gen_probe_stopped, a); + return G_SOURCE_REMOVE; +} + +/// Starts a Sine generator on DAC 0 through the panel's own controls, then reads it 2.5 s later. +gboolean e1608_gen_probe(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + e1608::E1608Source* src = e1608_source(a); + if (src == nullptr) { + std::fprintf(stderr, "[e1608] generator: no e1608 source (run with --source e1608)\n"); + std::fflush(stderr); + return G_SOURCE_REMOVE; + } + GtkWidget* ao = source_panel_content(a, "source-ao"); + GtkWidget* mode = find_named(ao, "e1608-ao0-mode"); + GtkWidget* run = find_named(ao, "e1608-ao0-run"); + if (mode == nullptr || run == nullptr) { + std::fprintf(stderr, "[e1608] generator: controls missing (mode=%d run=%d)\n", mode != nullptr ? 1 : 0, + run != nullptr ? 1 : 0); + std::fflush(stderr); + return G_SOURCE_REMOVE; + } + // The panel refreshes on its frame clock, which only ticks while the page is on screen. + if (a->side_stack != nullptr) adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(a->side_stack), "source-ao"); + adw_combo_row_set_selected(ADW_COMBO_ROW(mode), 1); // Sine + adw_switch_row_set_active(ADW_SWITCH_ROW(run), TRUE); + g_timeout_add(2500, e1608_gen_probe_report, a); + return G_SOURCE_REMOVE; +} + /// After the mock dropped the link: has the source reconnected, and does the stream move again? gboolean e1608_probe_resumed(gpointer user) { App* a = static_cast(user); From c572ec280fd7db25f30bbeabaa707424b16b53d7 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 2 Nov 2026 19:28:40 +0100 Subject: [PATCH 055/127] docs: design spec for the badge bar, readouts dock and slide-over panel Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-11-02-ui-badges-dock-design.md | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md diff --git a/docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md b/docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md new file mode 100644 index 0000000..c089c63 --- /dev/null +++ b/docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md @@ -0,0 +1,175 @@ +# Badge bar, readouts dock and slide-over panel -- design + +Date: 2026-11-02. Status: approved in conversation, awaiting written-spec review. + +## Goal + +Replace the sidebar's tab switcher and the separate events pane with a layout built the way bench +scopes build theirs: setup is reached by *the object it configures* (a badge under the plot), results +are always on screen (a compact dock), and anything bigger than a readout opens in one slide-over +panel. The sidebar tabs and the left events pane go away. + +## Why (the problems, as found in the code) + +- **Unreadable tab titles.** `side_view` is pinned at 320 px (`min-sidebar-width` = `max-sidebar-width` + in `main-window.ui`), so four text tabs ("Measure / Signals / Analog out / Digital I/O") ellipsize, + and a source can add more. +- **Hard to find things.** The Signals tab stacks a trigger form of up to 15 rows (plus two + expanders) on top of the signal list. Cursors and measurements are in Measure, source controls are + in their own tabs, the header is icon-only, and the trigger/cursor toggles live apart from their + panels. +- **The events pane is a different application.** A `GtkPaned` + `GtkRevealer` on the left with its + own bold title and header, a dragged width that is not saved, thumbnails the rest of the UI does + not have, and a header toggle that is hidden until events exist. + +## State of the art this follows + +- Tektronix 5/6 Series: a badge bar under the waveform (a badge per channel/math waveform, plus + Horizontal, Trigger, Acquisition badges); a tap opens *only that item's* settings; results + (measurements, cursors, search) are a separate bar that does not cover the waveform. +- Scopy and PicoScope 7: a channel list at the edge and a settings panel that slides in for the + selected item (PicoScope lets it overlay or squeeze the trace). +- Saleae Logic 2: one side panel at a time; decoded results also in a list beside the waveform. +- Keysight InfiniiVision: dialogs that can be undocked so cursor and measurement values show together. + +## Layout + +``` +┌ header: [window 2 s ▾] [Trigger] [Pause] [Cursors] [Dock] [☰] ┐ +├──────────────────────────────────────────────┬────────────────────┤ +│ plot rows ┌───────────┐ │ READOUTS │ +│ │ slide-over│ │ cursors, measures │ +│ │ settings │ ├────────────────────┤ +│ └───────────┘ │ EVENTS (n) │ +├───────────────────────────────────────────────┴────────────────────┤ +│ [●sine][●square][+] … [Trigger ▸][Source ▸]│ +├ status line ───────────────────────────────────────────────────────┤ +``` + +### Header bar + +Keeps the time-window control, the Trigger, Pause and Cursors toggles and the overflow menu. The +Events toggle is removed (Events is always in the dock). The sidebar toggle becomes the **Dock** +toggle, so a narrow window can still hide the dock. There is no "Window" badge: the header's control +is the timebase, and a second one would duplicate it. + +### Right dock ("Readouts" and "Events") + +- Fixed width, about 280 px; always on screen, shown but insensitive until the stream has signals + ("disable, do not hide"). It stays an `AdwOverlaySplitView` sidebar, which already gives it its + own fixed width, the hide toggle and the overlay behaviour on a narrow window + (`invariants-plot-and-layout.md`). +- **Readouts** (top): the active cursor values and measurement values, read-only, one compact line per + value, through the one formatter both panes share (`invariants-readouts-and-cursors.md`). + An "Edit" affordance opens the slide-over's `measure` or `cursors` page; clicking a measurement row + opens that measurement's editor. +- **Events** (bottom): compact rows -- title, time, a small marker; no thumbnails. Insensitive outside + trigger mode. A "Manage" affordance opens the slide-over's `events` page. + +### Badge bar + +A fixed-height row between the plot and the status line. + +- **Signals** (left): one badge per enabled signal, up to 6; beyond that a single "N signals" badge. + A badge shows a colour dot and the name, is a drag handle (reorder, or drop on a plot row, which + replaces today's drag from the signal list), and opens that signal's page (colour, calibration, + row, math). A trailing `+` opens the `signals` page (the full list, to enable more; the math editor + entry). +- **System** (right): **Trigger** shows its state (off / armed / triggered) and opens the trigger + form; **Source** shows the connection state and the source name and opens that source's pages + (`SourcePanel` pages, behind a small segmented switch inside the panel when there are several, as + E-1608 has Analog out and Digital I/O). + +### Slide-over panel + +- Overlays the plot's right edge (about 360 px wide), never the dock; one page at a time; Esc or a + click outside closes it. The plot is not resized by it. +- Pages, by id: `trigger`, `signal:`, `signals`, `measure`, `cursors`, `events`, + `source-`. Each page has its own scroller, so a tall page does not leave a shorter one scrolled + (the reason the sidebar had a scroller per page). +- Content: the trigger form (conditional rows, Advanced, Search the buffer), signal and calibration + settings, measurement add/edit/remove and statistics, cursor options, event actions (Live, Compare, + Accumulate), the math editor entry, and source pages. + +### Status line + +Unchanged. + +## Components + +New files, because `ui.cpp` is already very large. + +| Unit | File | Responsibility | +| --- | --- | --- | +| `BadgeBar` | `ui_badges.cpp` | Builds signal / Trigger / Source badges from the model; emits "open page X"; owns badge drag. | +| `SlideOver` | `ui_slideover.cpp` | A `GtkOverlay` child of the plot area: revealer + page stack keyed by id; Esc/click-away; open/close API. | +| `ReadoutsDock` | `ui_dock.cpp` | The compact readouts and the compact events list, reading the same model the panes read today. | + +Reuse, not rewrite: + +- The trigger form, signal list and calibration, measurement editor and cursor options are + **reparented** into slide-over pages. +- `SourcePanel{id, title, widget}` is unchanged, so the E-1608 and Flat UDP panels need no change. +- Only the events *list and actions* of the old pane move; the left `GtkPaned`/`GtkRevealer` and its + divider sync are deleted. + +## Invariants kept + +- **Layout must not depend on content** (`invariants-plot-and-layout.md`): the dock has a fixed width, + the badge bar a fixed height, and the slide-over overlays instead of resizing the plot. Rows that + appear by trigger condition appear inside the slide-over's scroller. +- **A `GtkOverlay` child is picked wherever its allocation is**: the slide-over is one opaque widget + aligned to the end edge by margin and width, never a full-area transparent child, so pan, drop and + hover on the rest of the plot are untouched. +- The header's Trigger/Cursors/Pause toggles keep their two-way mirroring with the panels. + +## State and session file + +- The session file drops `side_tab` and gains the dock's shown state and width. An old file's + `side_tab` is ignored (an unknown name is already harmless to `select_side_tab`). +- The open slide-over page is not saved. +- The tab ids `measure`, `signals` and `source-` stop being a session format; they remain page ids. + +## What must be rewritten around it + +- **Tour and context help:** steps and help-figure surfaces that point at `side_switcher`, + `side_stack` and `side_box` (`ui_tour.cpp`, `ui_context_help.cpp`) are re-pointed at the badge bar, + slide-over and dock, and the tour text is rewritten. +- **Probes:** the `switcher: policy=... pills=... labels=... images=... pages=...` line and + `side_tab_probe` become probes that count badges, dock rows and the open slide-over page; the events + divider probe goes away with the divider. +- **Keyboard:** `SideTabMeasure` / `SideTabSignals` become "open the measure / signals page". +- **Docs:** `README.md`, `USER-GUIDE.md`, `docs/claude/*` (architecture, verification, + probe-readings, the plot-and-layout and gtk-and-chrome invariants), and the Mallard manual's + figures. + +## Rollout + +Four steps, each its own plan, each leaving the app building, passing the probes and usable. + +1. **Shell.** The plot overlay, the badge bar with Trigger and Source, the slide-over holding the + existing trigger form and source pages. The sidebar tabs are removed; the existing Measure and + Signals panels move into a temporary dock so nothing is lost. +2. **Signal badges.** Per-signal badges, the `+` and "N signals" overflow, drag to reorder and to + move to a row. +3. **Compact readouts.** Read-only cursors and measurements in the dock; their editors in the + slide-over. +4. **Events.** The compact events section in the dock, the left pane and divider deleted, then the + tour / help / docs sweep. + +## Risks + +- **Overlay above the GL plot.** The panel must composite and take input correctly above the + plotter's GL area. The layout editor already places overlay children over the plot, so this is + expected to work; step 1 confirms it before anything else is built on it. +- **Reparenting the trigger form.** Its two-way mirroring with the header toggle, `sync_trigger_rows` + and the locked summary view must keep working from a hidden-by-default page. Step 1 verifies it with + the existing trigger probes. +- **Many signals.** The badge bar's six-badge limit and horizontal behaviour are the one place the + E-1608 (up to about 17 signals) stresses the design; step 2 includes a run on it. + +## Out of scope + +User-arranged or undockable panels, saved layouts of the panels themselves, a touch-specific mode, +and any change to the plot, trigger, measurement or source engines. Per-source tab names do not +change. From 73f74ca495f4f9b7b5dcab976607b2c99efacf9d Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 2 Nov 2026 19:50:12 +0100 Subject: [PATCH 056/127] docs: plan for step 1 of the badge bar / slide-over / dock redesign Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-11-02-ui-badges-dock-shell.md | 950 ++++++++++++++++++ 1 file changed, 950 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-02-ui-badges-dock-shell.md diff --git a/docs/superpowers/plans/2026-11-02-ui-badges-dock-shell.md b/docs/superpowers/plans/2026-11-02-ui-badges-dock-shell.md new file mode 100644 index 0000000..168ce8d --- /dev/null +++ b/docs/superpowers/plans/2026-11-02-ui-badges-dock-shell.md @@ -0,0 +1,950 @@ +# Badge bar, slide-over and dock -- step 1 (the shell) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the sidebar's tab switcher with a plot-edge slide-over panel reached from a badge bar under the plot (Trigger and Source badges), keeping Measure and Signals reachable in a temporary always-on dock, so the app is usable after every task. + +**Architecture:** Two new units, `SlideOver` (`src/ui_slideover.cpp`, a `GtkRevealer` + `GtkStack` placed by margins as one opaque `GtkOverlay` child above the plot) and `BadgeBar` (`src/ui_badges.cpp`, a fixed-height row between the plot and the status line). The existing trigger form and the source kind's `SourcePanel` widgets are *reparented* into slide-over pages. The right `AdwOverlaySplitView` sidebar stays, but its `AdwViewSwitcher`/`AdwViewStack` become one scroller holding the signal list, the cursors and the measurements. The left events pane and everything else is untouched (spec step 4). + +**Tech Stack:** C++20, GTK4, libadwaita >= 1.8 (`AdwToggleGroup` is 1.7+), CMake. No unit-test suite: verification is the app's own headless self-check (`xvfb-run`, mock sources) -- extend `src/ui.cpp` / `src/ui_probe.cpp` probes, never a separate harness. + +Spec: `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md` (read it first). Branch: `feature/ui-badges-dock`. + +## Global Constraints + +Copied from the spec and the repository's rules; every task includes them. + +- **Layout must not depend on content** (`docs/claude/invariants-plot-and-layout.md`): the dock has a fixed width, the badge bar a fixed height, the slide-over overlays the plot and never resizes it. Rows that appear by trigger condition appear inside the slide-over's scroller. +- **A `GtkOverlay` child is picked wherever its allocation is**: the slide-over is one opaque widget aligned to the end edge by `halign=END` and a 360 px width, never a full-area transparent child, so pan, drop and hover on the rest of the plot are untouched. +- The header's Trigger / Cursors / Pause toggles keep their two-way mirroring with the panels. +- `SourcePanel{id, title, widget}` is unchanged: the E-1608 and Flat UDP panels need no change. +- Page ids: `trigger`, `signal:`, `signals`, `measure`, `cursors`, `events`, `source-`. (Step 1 implements `trigger` and `source-` only.) +- The open slide-over page is **not** saved. The session file drops `side_tab`; an old file's `side_tab` is ignored. +- Esc or a click outside closes the slide-over; one page at a time; each page has its own scroller. +- "Disable, do not hide": the dock is shown but insensitive until the stream has signals. +- Text buttons only for new controls (no icon-only buttons, no icons in badges). +- Every new source file carries the SPDX MIT header (`// SPDX-License-Identifier: MIT` / `// SPDX-FileCopyrightText: 2026 Martino Ferrari`). +- Baselines: a clean build emits exactly **one** warning (the icons' `-Woverlength-strings`), an incremental build **zero**; the dump's `gtk warnings:` reads `0`; no `Gtk-CRITICAL`/`Gtk-WARNING`. +- Verification runs are short and serial; kill every mock you start; do not re-run to chase cosmetics. Headless needs `GDK_BACKEND=x11` (the host has `WAYLAND_DISPLAY`) and, for screenshots, `GSK_RENDERER=cairo` + `import -window root`. +- Do not push, merge or amend. Commit per task on `feature/ui-badges-dock`. + +## Deliberate step-1 deviations from the spec (state them in the final report) + +- The dock keeps the sidebar's **320 px** (spec: ~280). The panels it holds were built for 320; the width change belongs to step 3 when they become compact readouts. +- The session file gains the dock's *shown* state only, not a width: the dock's width is fixed, so there is nothing to persist. +- The Trigger badge shows `off / armed / triggered` plus `capturing` and `disarmed`, so the label never claims a state the engine is not in. + +## File Structure + +| File | Create/Modify | Responsibility | +| --- | --- | --- | +| `src/ui_slideover.cpp` | Create | The slide-over: build, page registry, open/close/toggle, Esc, click-away, `slideover` probe. | +| `src/ui_badges.cpp` | Create | The badge bar: Trigger and Source badges, per-tick state, pure `trigger_badge_state()`, `badges` probe. | +| `src/ui_app.hpp` | Modify | `SlideOver`/`BadgeBar` structs on `App`, declarations, removal of `side_stack`/`side_switcher`/`side_tab`/`keep_tabs_icon_free`. | +| `src/ui/main-window.ui` | Modify | `plot_overlay` around `plot_box`; `badge_bar` row; the sidebar tab switcher/stack replaced by one dock scroller. | +| `src/ui_widgets.cpp` | Modify | CSS for badges and the slide-over (`install_css`). | +| `src/ui.cpp` | Modify | Build/wire both units in `on_activate`; tick sync; Esc; dock visibility; remove tab code. | +| `src/ui_source.cpp` | Modify | Source panels become slide-over pages; `keep_tabs_icon_free` deleted. | +| `src/ui_tour.cpp`, `src/ui_context_help.cpp` | Modify | Tour/context-help re-pointed (task 5). | +| `src/ui_layout.cpp`, `src/layout_store.{hpp,cpp}` | Modify | `side_tab` out, `dock_shown` in. | +| `src/shortcuts.{hpp,cpp}`, `src/ui/shortcuts.ui` | Modify | `SideTabMeasure/Signals` -> `ShowMeasure/ShowSignals`. | +| `src/ui_probe.cpp` | Modify | New probes; every probe that touched the tab stack re-pointed. | +| `CMakeLists.txt` | Modify | Add the two new `.cpp` files to the `udpscope` sources (next to `src/ui_source.cpp`). | +| `README.md`, `docs/claude/*.md` | Modify | Task 6. (`USER-GUIDE.md` and the Mallard manual's figures are the spec's step 4.) | + +--- + +### Task 1: SlideOver unit and the plot overlay (confirms the compositing risk) + +Spec step 1 says: confirm the overlay-above-GL risk *before* anything else is built on it. This task builds the slide-over with a throw-away probe page and proves composite, geometry and input. + +**Files:** +- Create: `src/ui_slideover.cpp` +- Modify: `src/ui/main-window.ui`, `src/ui_app.hpp`, `src/ui.cpp`, `src/ui_widgets.cpp`, `src/ui_probe.cpp` (or put the probe in `ui_slideover.cpp`; `ui_app.hpp` declares it either way), `CMakeLists.txt`, `docs/claude/verification.md` (probe env var line) + +**Interfaces:** +- Produces (all in `namespace udpscope::ui_impl`, declared in `ui_app.hpp`): + +```cpp +/// The slide-over panel's widgets and state (src/ui_slideover.cpp). `page` is the open page's id, empty when closed. +struct SlideOver { + GtkWidget* overlay = nullptr; ///< the GtkOverlay around the plot (main-window.ui `plot_overlay`) + GtkWidget* revealer = nullptr; ///< the one overlay child; halign END, valign FILL, 360 px + GtkWidget* title = nullptr; ///< GtkLabel in the panel header + GtkWidget* stack = nullptr; ///< GtkStack: one scroller per page + GtkWidget* source_switch = nullptr; ///< AdwToggleGroup, shown on a source page when there are >= 2 + std::vector source_pages; + std::string page; + bool syncing = false; ///< true while we write the toggle group ourselves +}; +constexpr int kSlideOverWidth = 360; +void build_slideover(App& app); // once, after the plot_overlay exists +bool slideover_add_page(App& app, const std::string& id, const std::string& title, GtkWidget* content); +bool slideover_has_page(const App& app, const std::string& id); +bool slideover_open(App& app, const std::string& id); // false when the id is unknown +void slideover_close(App& app); +bool slideover_toggle(App& app, const std::string& id); // open if closed or on another page, else close; false if unknown +const std::string& slideover_page(const App& app); // "" when closed +bool slideover_escape(App& app); // closes if open; true when it did +bool slideover_click_away(App& app, double x, double y); // x,y in overlay coords; closes unless inside the panel; true when it closed +gboolean slideover_probe(gpointer user); // UDPSCOPE_PROBE_SLIDEOVER_MS +``` + `App` gains `SlideOver slideover;` and `GtkWidget* plot_overlay = nullptr;`. Task 2 consumes `slideover_add_page`, `slideover_toggle`, `slideover_page`; task 3 consumes `source_pages`. + +- [ ] **Step 1: Capture the UDPS baseline from the current HEAD** + +The tabs are about to go, so freeze today's self-check first. Build `HEAD` (`cmake --build build -j`), then: + +```sh +python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$! +sleep 1.5 +xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --server 127.0.0.1 --port 8114 > /tmp/ui-badges-base.log 2>&1 +kill $MOCK +sed 's/[0-9.]\+/N/g' /tmp/ui-badges-base.log > .superpowers/sdd/ui-badges-baseline-udps.txt +grep -c 'Gtk-CRITICAL' /tmp/ui-badges-base.log; grep 'gtk warnings' /tmp/ui-badges-base.log +``` +Expected: `0` Gtk-CRITICAL, `gtk warnings: 0`. Also keep `/tmp/ui-badges-base.log` un-normalised. Every later task diffs its own normalised run against this file and must **explain every differing line** from that task's "allowed baseline changes" list; any other difference is a defect. + +- [ ] **Step 2: Wrap the plot in `plot_overlay` in `src/ui/main-window.ui`** + +In `left_column`, replace the first `` (the bare `plot_box`) with: + +```xml + + + + 1 + 1 + + + ... (the existing plot_box, unchanged) ... + + + + +``` +Update the file's header comment: the plot column is `plot_overlay` > `plot_box`. + +- [ ] **Step 3: Add the CSS to `install_css()` (`src/ui_widgets.cpp`)** + +Append to `kCss` before the final `menubutton.udpscope-hint` rules (keep the string a single literal, each rule on its own `"...\n"` line): + +```cpp + // The slide-over panel over the plot's right edge: opaque (it must hide the plot under it), + // with an edge and a shadow so it reads as above the plot rather than beside it. + ".udpscope-slideover { background-color: @window_bg_color; border-left: 1px solid @borders;" + " box-shadow: -3px 0 10px rgba(0, 0, 0, 0.35); }\n" + ".udpscope-slideover-title { font-weight: bold; }\n" +``` + +- [ ] **Step 4: Write `src/ui_slideover.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +// The slide-over panel: one panel at a time over the plot's right edge, a page per setup surface. +// See docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md. + +#include "ui_app.hpp" + +#include + +#include +#include + +namespace udpscope { +namespace ui_impl { + +namespace { + +void on_close_clicked(GtkButton*, gpointer user) { slideover_close(*static_cast(user)); } + +void on_source_switch(GObject* o, GParamSpec*, gpointer user) { + App* app = static_cast(user); + if (app->slideover.syncing) return; + const char* name = adw_toggle_group_get_active_name(ADW_TOGGLE_GROUP(o)); + if (name != nullptr && app->slideover.page != name) slideover_open(*app, name); +} + +/// Observes every press on the plot overlay, in the capture phase, and never claims the sequence: the +/// plot's own pan/zoom/drop gestures see the press exactly as they did before. +void on_press_capture(GtkGestureClick*, int, double x, double y, gpointer user) { + slideover_click_away(*static_cast(user), x, y); +} + +bool is_source_page(const std::string& id) { return id.rfind("source-", 0) == 0; } + +} // namespace + +void build_slideover(App& app) { + SlideOver& s = app.slideover; + s.overlay = app.plot_overlay; + if (s.overlay == nullptr) return; + + GtkWidget* panel = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_widget_add_css_class(panel, "udpscope-slideover"); + gtk_widget_set_size_request(panel, kSlideOverWidth, -1); + gtk_widget_set_hexpand(panel, FALSE); + + GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_widget_set_margin_start(head, 12); + gtk_widget_set_margin_end(head, 6); + gtk_widget_set_margin_top(head, 6); + gtk_widget_set_margin_bottom(head, 6); + s.title = gtk_label_new(""); + gtk_widget_add_css_class(s.title, "udpscope-slideover-title"); + gtk_label_set_xalign(GTK_LABEL(s.title), 0.0f); + gtk_widget_set_hexpand(s.title, TRUE); + gtk_label_set_ellipsize(GTK_LABEL(s.title), PANGO_ELLIPSIZE_END); + GtkWidget* close = gtk_button_new_with_label("Close"); + gtk_widget_add_css_class(close, "flat"); + g_signal_connect(close, "clicked", G_CALLBACK(on_close_clicked), &app); + gtk_box_append(GTK_BOX(head), s.title); + gtk_box_append(GTK_BOX(head), close); + gtk_box_append(GTK_BOX(panel), head); + + // A source with several panels (the E-1608: Analog out, Digital I/O) gets a segmented switch under + // the header; it is hidden unless the open page is a source page and there are two or more. + s.source_switch = adw_toggle_group_new(); + gtk_widget_set_margin_start(s.source_switch, 12); + gtk_widget_set_margin_end(s.source_switch, 12); + gtk_widget_set_margin_bottom(s.source_switch, 6); + gtk_widget_set_visible(s.source_switch, FALSE); + g_signal_connect(s.source_switch, "notify::active-name", G_CALLBACK(on_source_switch), &app); + gtk_box_append(GTK_BOX(panel), s.source_switch); + gtk_box_append(GTK_BOX(panel), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + + s.stack = gtk_stack_new(); + gtk_widget_set_vexpand(s.stack, TRUE); + gtk_box_append(GTK_BOX(panel), s.stack); + + s.revealer = gtk_revealer_new(); + gtk_revealer_set_transition_type(GTK_REVEALER(s.revealer), GTK_REVEALER_TRANSITION_TYPE_SLIDE_LEFT); + gtk_revealer_set_transition_duration(GTK_REVEALER(s.revealer), 120); + gtk_revealer_set_child(GTK_REVEALER(s.revealer), panel); + gtk_widget_set_halign(s.revealer, GTK_ALIGN_END); + gtk_widget_set_valign(s.revealer, GTK_ALIGN_FILL); + // Closed, it must not be a pick target for the frames of its slide-out. + gtk_widget_set_can_target(s.revealer, FALSE); + gtk_overlay_add_overlay(GTK_OVERLAY(s.overlay), s.revealer); + gtk_overlay_set_clip_overlay(GTK_OVERLAY(s.overlay), s.revealer, TRUE); + + GtkGesture* click = gtk_gesture_click_new(); + gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(click), GTK_PHASE_CAPTURE); + g_signal_connect(click, "pressed", G_CALLBACK(on_press_capture), &app); + gtk_widget_add_controller(s.overlay, GTK_EVENT_CONTROLLER(click)); +} + +bool slideover_add_page(App& app, const std::string& id, const std::string& title, GtkWidget* content) { + SlideOver& s = app.slideover; + if (s.stack == nullptr || content == nullptr || id.empty() || slideover_has_page(app, id)) { + return false; + } + // A scroller per page: pages differ in height, and one shared scroller would leave a page scrolled + // to somewhere it never asked for. It does not propagate the content's height, so the panel's + // height is the plot's and never the page's. + GtkWidget* scroller = gtk_scrolled_window_new(); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), content); + gtk_stack_add_titled(GTK_STACK(s.stack), scroller, id.c_str(), title.c_str()); + if (is_source_page(id)) { + s.source_pages.push_back(id); + AdwToggle* t = adw_toggle_new(); + adw_toggle_set_name(t, id.c_str()); + adw_toggle_set_label(t, title.c_str()); + adw_toggle_group_add(ADW_TOGGLE_GROUP(s.source_switch), t); // takes the reference + } + return true; +} + +bool slideover_has_page(const App& app, const std::string& id) { + return app.slideover.stack != nullptr && + gtk_stack_get_child_by_name(GTK_STACK(app.slideover.stack), id.c_str()) != nullptr; +} + +bool slideover_open(App& app, const std::string& id) { + SlideOver& s = app.slideover; + if (!slideover_has_page(app, id)) return false; + GtkWidget* child = gtk_stack_get_child_by_name(GTK_STACK(s.stack), id.c_str()); + gtk_stack_set_visible_child(GTK_STACK(s.stack), child); + s.page = id; + GtkStackPage* sp = gtk_stack_get_page(GTK_STACK(s.stack), child); + const char* title = gtk_stack_page_get_title(sp); + gtk_label_set_text(GTK_LABEL(s.title), title != nullptr ? title : ""); + const bool several = is_source_page(id) && s.source_pages.size() >= 2; + gtk_widget_set_visible(s.source_switch, several); + if (several) { + s.syncing = true; + adw_toggle_group_set_active_name(ADW_TOGGLE_GROUP(s.source_switch), id.c_str()); + s.syncing = false; + } + gtk_widget_set_can_target(s.revealer, TRUE); + gtk_revealer_set_reveal_child(GTK_REVEALER(s.revealer), TRUE); + sync_badge_bar(app); + return true; +} + +void slideover_close(App& app) { + SlideOver& s = app.slideover; + if (s.revealer == nullptr || s.page.empty()) return; + s.page.clear(); + gtk_widget_set_can_target(s.revealer, FALSE); + gtk_revealer_set_reveal_child(GTK_REVEALER(s.revealer), FALSE); + sync_badge_bar(app); +} + +bool slideover_toggle(App& app, const std::string& id) { + if (!slideover_has_page(app, id)) return false; + if (app.slideover.page == id) { + slideover_close(app); + } else { + slideover_open(app, id); + } + return true; +} + +const std::string& slideover_page(const App& app) { return app.slideover.page; } + +bool slideover_escape(App& app) { + if (app.slideover.page.empty()) return false; + slideover_close(app); + return true; +} + +bool slideover_click_away(App& app, double x, double y) { + SlideOver& s = app.slideover; + if (s.page.empty() || s.overlay == nullptr || s.revealer == nullptr) return false; + graphene_point_t in = GRAPHENE_POINT_INIT(static_cast(x), static_cast(y)); + graphene_point_t out; + if (gtk_widget_compute_point(s.overlay, s.revealer, &in, &out) && + gtk_widget_contains(s.revealer, out.x, out.y)) { + return false; + } + slideover_close(app); + return true; +} + +// --- the probe --------------------------------------------------------------------------------- + +namespace { + +struct ProbeState { + App* app; + int plot_w_before = 0; +}; + +bool under(GtkWidget* w, GtkWidget* ancestor) { + return w != nullptr && ancestor != nullptr && (w == ancestor || gtk_widget_is_ancestor(w, ancestor)); +} + +gboolean slideover_probe_read(gpointer user) { + ProbeState* ps = static_cast(user); + App& a = *ps->app; + SlideOver& s = a.slideover; + const int ow = gtk_widget_get_width(s.overlay); + const int oh = gtk_widget_get_height(s.overlay); + graphene_rect_t r; + const bool have = gtk_widget_compute_bounds(s.revealer, s.overlay, &r); + const int rw = have ? static_cast(r.size.width + 0.5f) : -1; + const int rx = have ? static_cast(r.origin.x + 0.5f) : -1; + const int rh = have ? static_cast(r.size.height + 0.5f) : -1; + // Outside the panel, a quarter in: the plot must be what answers. Inside it, the panel. + GtkWidget* outside = gtk_widget_pick(s.overlay, ow / 4.0, oh / 2.0, GTK_PICK_DEFAULT); + GtkWidget* inside = gtk_widget_pick(s.overlay, ow - kSlideOverWidth / 2.0, oh / 2.0, GTK_PICK_DEFAULT); + const int plot_w_open = gtk_widget_get_width(a.plot_box); + std::fprintf(stderr, + "[slideover] open page=%s panel=%dx%d at x=%d overlay=%dx%d edge_flush=%d full_height=%d " + "outside_hits_plot=%d inside_hits_panel=%d plot_w before=%d open=%d plot_unchanged=%d\n", + s.page.c_str(), rw, rh, rx, ow, oh, rx + rw == ow ? 1 : 0, rh == oh ? 1 : 0, + under(outside, a.plot_box) ? 1 : 0, under(inside, s.revealer) ? 1 : 0, ps->plot_w_before, + plot_w_open, ps->plot_w_before == plot_w_open ? 1 : 0); + // Click-away from outside the panel closes; a press inside does not. + const bool inside_closed = slideover_click_away(a, ow - kSlideOverWidth / 2.0, oh / 2.0); + const bool open_after_inside = !s.page.empty(); + const bool outside_closed = slideover_click_away(a, ow / 4.0, oh / 2.0); + slideover_open(a, "probe"); + const bool esc_closed = slideover_escape(a); + std::fprintf(stderr, + "[slideover] click_inside_closes=%d still_open=%d click_outside_closes=%d esc_closes=%d " + "closed=%d\n", + inside_closed ? 1 : 0, open_after_inside ? 1 : 0, outside_closed ? 1 : 0, esc_closed ? 1 : 0, + s.page.empty() ? 1 : 0); + delete ps; + return G_SOURCE_REMOVE; +} + +} // namespace + +gboolean slideover_probe(gpointer user) { + App* a = static_cast(user); + if (a->slideover.stack == nullptr) return G_SOURCE_REMOVE; + // A page of the probe's own: the real pages arrive in later tasks, and this one has an entry so the + // panel has something that takes focus and keys. + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_box_append(GTK_BOX(box), gtk_label_new("slide-over probe page")); + gtk_box_append(GTK_BOX(box), gtk_entry_new()); + slideover_add_page(*a, "probe", "Probe", box); + ProbeState* ps = new ProbeState{a, gtk_widget_get_width(a->plot_box)}; + slideover_open(*a, "probe"); + // The revealer's slide is 120 ms; the reading is taken well after it has settled. + g_timeout_add(500, slideover_probe_read, ps); + return G_SOURCE_REMOVE; +} + +} // namespace ui_impl +} // namespace udpscope +``` +`sync_badge_bar(App&)` does not exist until task 2: in this task add to `ui_app.hpp` a declaration and, in `ui_slideover.cpp`'s neighbour, nothing -- instead **temporarily omit** the two `sync_badge_bar(app);` calls and add them in task 2 step 4 (they are marked `// task 2` if you prefer a comment). Check every GTK/libadwaita signature against the installed headers (`/usr/include/gtk-4.0`, `/usr/include/libadwaita-1`); fix names rather than guessing. + +- [ ] **Step 5: Declare, wire, register** + + - `ui_app.hpp`: add the `SlideOver` struct, `kSlideOverWidth`, the function declarations from **Interfaces**, and on `App`: `SlideOver slideover; GtkWidget* plot_overlay = nullptr;`. + - `ui.cpp` `on_activate`: next to the other `gtk_builder_get_object` lookups add `app->plot_overlay = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_overlay"));`, add it to the null guard, and call `build_slideover(*app);` right after `app->plot_split` is resolved and the tour overlay is built (the plot overlay exists from the builder, so any point before `make_area` works). + - `ui.cpp` probe scheduling (next to line ~5906 `side_tab_probe`): `if (const char* env = std::getenv("UDPSCOPE_PROBE_SLIDEOVER_MS")) g_timeout_add(static_cast(std::atoi(env)), slideover_probe, &app);` + - `CMakeLists.txt`: add `src/ui_slideover.cpp` after `src/ui_source.cpp`. + - `docs/claude/verification.md`: add an entry for `UDPSCOPE_PROBE_SLIDEOVER_MS=2000` (with `UDPSCOPE_EXIT_AFTER_MS=5000`): the two `[slideover]` lines and what each field must read (below). + +- [ ] **Step 6: Build and run the probe** + +```sh +cmake --build build -j 2>&1 | grep -E 'warning|error' ; echo "build rc=$?" +python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$! +sleep 1.5 +xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=5000 UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SLIDEOVER_MS=1500 \ + ./build/udpscope --server 127.0.0.1 --port 8114 2>&1 | grep -E '\[slideover\]|Gtk-|gtk warnings' +kill $MOCK +``` +Expected: the build has zero warnings (incremental) and `[slideover]` line 1 reads `panel=360x ... edge_flush=1 full_height=1 outside_hits_plot=1 inside_hits_panel=1 ... plot_unchanged=1`; line 2 reads `click_inside_closes=0 still_open=1 click_outside_closes=1 esc_closes=1 closed=1`; `gtk warnings: 0`. If `outside_hits_plot=0` the panel has an allocation wider than 360 or a transparent parent -- stop and fix, do not go on. + +- [ ] **Step 7: Confirm the compositing by eye (the risk the spec names)** + +```sh +python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$! +sleep 1.5 +xvfb-run -a -s "-screen 0 1400x900x24" sh -c 'GDK_BACKEND=x11 GSK_RENDERER=cairo UDPSCOPE_EXIT_AFTER_MS=7000 \ + UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SLIDEOVER_MS=1500 ./build/udpscope --server 127.0.0.1 --port 8114 \ + >/tmp/so.log 2>&1 & sleep 2.4; import -window root /tmp/slideover.png; wait' +kill $MOCK +``` +The probe closes the page at ~2.0 s, so for the screenshot only: temporarily leave it open by setting `UDPSCOPE_PROBE_SLIDEOVER_HOLD=1`, which makes `slideover_probe_read` return before the click/esc part (add that early `if (std::getenv("UDPSCOPE_PROBE_SLIDEOVER_HOLD")) { delete ps; return G_SOURCE_REMOVE; }` after the first `fprintf`). Read `/tmp/slideover.png` with the Read tool and confirm: the panel is drawn **over** the traces on the plot's right edge, opaque, with its header, "Close" button, entry; the plot to its left is intact. Record the result in the task report. If the GL surface draws over the panel or the panel is transparent, stop: the design's premise failed, report BLOCKED. + +- [ ] **Step 8: Baseline diff and commit** + +Re-run Step 1's command into `/tmp/ui-badges-t1.log`, normalise, `diff` against `.superpowers/sdd/ui-badges-baseline-udps.txt`. +Allowed baseline changes: none (a layout line that prints the plot area's size may differ by the panel not being open -- it must not). + +```bash +git add src/ui_slideover.cpp src/ui/main-window.ui src/ui_app.hpp src/ui.cpp src/ui_widgets.cpp CMakeLists.txt docs/claude/verification.md +git commit -m "ui: a slide-over panel over the plot's right edge, with its probe" +``` + +--- + +### Task 2: BadgeBar with the Trigger badge, and the trigger form into the slide-over + +**Files:** +- Create: `src/ui_badges.cpp` +- Modify: `src/ui/main-window.ui`, `src/ui_app.hpp`, `src/ui.cpp`, `src/ui_widgets.cpp`, `src/ui_slideover.cpp`, `src/ui_probe.cpp`, `CMakeLists.txt`, `docs/claude/verification.md` + +**Interfaces:** +- Consumes: Task 1's `slideover_add_page`, `slideover_toggle`, `slideover_page`, `slideover_open`. +- Produces: + +```cpp +/// One badge: a button with a name and a state line. `page` is the slide-over page it opens. +struct Badge { + GtkWidget* button = nullptr; + GtkWidget* name = nullptr; + GtkWidget* state = nullptr; + std::string page; +}; +struct BadgeBar { + GtkWidget* bar = nullptr; ///< main-window.ui `badge_bar`, fixed height + GtkWidget* signals_slot = nullptr; ///< left; empty in step 1, step 2 fills it + GtkWidget* system_slot = nullptr; ///< right: Trigger, Source + Badge trigger; + Badge source; ///< task 3 +}; +/// The Trigger badge's state word. Pure, so the probe can assert the whole table. +const char* trigger_badge_state(bool enabled, bool armed, bool pending, bool has_event); +void build_badge_bar(App& app); +void sync_badge_bar(App& app); // per tick and on every slide-over open/close; only writes what changed +gboolean badges_probe(gpointer user); // UDPSCOPE_PROBE_BADGES_MS +``` + `App` gains `BadgeBar badges;` and, via the builder, `badge_bar`. `trigger_badge_state` truth table (enabled, armed, pending, has_event) -> word: `!enabled` -> `off`; `has_event && !pending` -> `triggered`; `pending` -> `capturing`; `armed` -> `armed`; else `disarmed`. + +- [ ] **Step 1: Add the badge bar row to `main-window.ui`** + +In `left_column`, between `plot_overlay` and `status_row`: + +```xml + + + + horizontal + 6 + 36 + 6 + 6 + + + horizontal + 6 + 1 + + + + + horizontal + 6 + end + + + + +``` +Add the three ids to the guard in `on_activate`. + +- [ ] **Step 2: CSS (`install_css`)** + +```cpp + // The badge bar: a badge is a pill carrying a name and a state; the one whose page is open is + // marked, as a bench scope marks the open menu's button. + ".udpscope-badge { padding: 2px 12px; min-height: 0; border-radius: 14px; }\n" + ".udpscope-badge-open { background-color: alpha(@accent_bg_color, 0.28); }\n" + ".udpscope-badge-state { opacity: 0.7; }\n" +``` + +- [ ] **Step 3: Write `src/ui_badges.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +// The badge bar under the plot: setup is reached by the object it configures. + +#include "ui_app.hpp" + +#include +#include + +namespace udpscope { +namespace ui_impl { + +const char* trigger_badge_state(bool enabled, bool armed, bool pending, bool has_event) { + if (!enabled) return "off"; + if (has_event && !pending) return "triggered"; + if (pending) return "capturing"; + return armed ? "armed" : "disarmed"; +} + +namespace { + +void set_text_if_changed(GtkWidget* label, const char* text) { + if (std::strcmp(gtk_label_get_text(GTK_LABEL(label)), text) != 0) gtk_label_set_text(GTK_LABEL(label), text); +} + +void set_open(GtkWidget* button, bool open) { + if ((gtk_widget_has_css_class(button, "udpscope-badge-open") != FALSE) == open) return; + if (open) { + gtk_widget_add_css_class(button, "udpscope-badge-open"); + } else { + gtk_widget_remove_css_class(button, "udpscope-badge-open"); + } +} + +void on_badge_clicked(GtkButton*, gpointer user) { + Badge* b = static_cast(user); + // The App is reached through the badge's button: the badges live inside App, so the pointer is stable. + App* app = static_cast(g_object_get_data(G_OBJECT(b->button), "udpscope-app")); + if (app != nullptr) slideover_toggle(*app, b->page); +} + +/// A badge: a flat pill, name on the left, state dimmed beside it. Words only. +void make_badge(App& app, Badge& b, GtkWidget* parent, const char* name, const std::string& page) { + b.page = page; + b.button = gtk_button_new(); + gtk_widget_add_css_class(b.button, "flat"); + gtk_widget_add_css_class(b.button, "udpscope-badge"); + GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + b.name = gtk_label_new(name); + b.state = gtk_label_new(""); + gtk_widget_add_css_class(b.state, "udpscope-badge-state"); + gtk_box_append(GTK_BOX(row), b.name); + gtk_box_append(GTK_BOX(row), b.state); + gtk_button_set_child(GTK_BUTTON(b.button), row); + g_object_set_data(G_OBJECT(b.button), "udpscope-app", &app); + g_signal_connect(b.button, "clicked", G_CALLBACK(on_badge_clicked), &b); + gtk_box_append(GTK_BOX(parent), b.button); +} + +} // namespace + +void build_badge_bar(App& app) { + BadgeBar& bb = app.badges; + bb.bar = app.badge_bar; + bb.signals_slot = app.badge_signals; + bb.system_slot = app.badge_system; + make_badge(app, bb.trigger, bb.system_slot, "Trigger", "trigger"); + sync_badge_bar(app); +} + +void sync_badge_bar(App& app) { + BadgeBar& bb = app.badges; + if (bb.trigger.button == nullptr) return; + const bool enabled = app.trigger_toggle != nullptr && + gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.trigger_toggle)) != FALSE; + const bool armed = app.model->trigger_armed().load(std::memory_order_relaxed); + const bool pending = app.model->trigger_pending(); + const bool has_event = app.model->event() != nullptr; + set_text_if_changed(bb.trigger.state, trigger_badge_state(enabled, armed, pending, has_event)); + set_open(bb.trigger.button, slideover_page(app) == bb.trigger.page); +} + +gboolean badges_probe(gpointer user) { + App* a = static_cast(user); + // The whole table of the pure function: 16 inputs, 5 words. + struct Row { bool e, a, p, v; const char* want; }; + static const Row kRows[] = { + {false, false, false, false, "off"}, {false, true, true, true, "off"}, + {true, false, false, true, "triggered"}, {true, true, false, true, "triggered"}, + {true, true, true, false, "capturing"}, {true, false, true, true, "capturing"}, + {true, true, false, false, "armed"}, {true, false, false, false, "disarmed"}, + }; + int bad = 0; + for (const Row& r : kRows) { + if (std::strcmp(trigger_badge_state(r.e, r.a, r.p, r.v), r.want) != 0) ++bad; + } + // The geometry: fixed height whatever the state, and the open mark follows the slide-over. + const int h_closed = gtk_widget_get_height(a->badges.bar); + slideover_toggle(*a, "trigger"); + const bool marked_open = gtk_widget_has_css_class(a->badges.trigger.button, "udpscope-badge-open") != FALSE; + const bool page_open = slideover_page(*a) == "trigger"; + slideover_toggle(*a, "trigger"); + const bool marked_closed = gtk_widget_has_css_class(a->badges.trigger.button, "udpscope-badge-open") == FALSE; + const int h_after = gtk_widget_get_height(a->badges.bar); + std::fprintf(stderr, + "[badges] table_bad=%d trigger_state=%s bar_h=%d bar_h_after=%d fixed=%d open_marked=%d " + "page_open=%d closed_unmarked=%d\n", + bad, gtk_label_get_text(GTK_LABEL(a->badges.trigger.state)), h_closed, h_after, + h_closed == h_after && h_closed >= 36 ? 1 : 0, marked_open ? 1 : 0, page_open ? 1 : 0, + marked_closed ? 1 : 0); + return G_SOURCE_REMOVE; +} + +} // namespace ui_impl +} // namespace udpscope +``` +(The probe checks `trigger_badge_state` against the full table the declaration gives. Add the 8 remaining combinations if you want all 16; the 8 above cover every word and every branch.) + +- [ ] **Step 4: Wire it** + + - `ui_app.hpp`: `Badge`, `BadgeBar`, declarations; on `App`: `BadgeBar badges; GtkWidget* badge_bar = nullptr; GtkWidget* badge_signals = nullptr; GtkWidget* badge_system = nullptr;`. + - `ui_slideover.cpp`: restore the two `sync_badge_bar(app);` calls from task 1 (end of `slideover_open` and `slideover_close`). + - `ui.cpp` `on_activate`: resolve the three ids, then `build_badge_bar(*app);` **after** `app->trigger_toggle` exists (it is created in `build_toolbar`, which runs before) and after `build_slideover`. + - `ui.cpp` `tick()`: call `sync_badge_bar(*app);` next to `sync_source_panels(*app);`. + - `ui.cpp`: probe scheduling `UDPSCOPE_PROBE_BADGES_MS` -> `badges_probe`. + - `CMakeLists.txt`: add `src/ui_badges.cpp`. + +- [ ] **Step 5: Move the trigger form into the slide-over's `trigger` page** + +In `on_activate` (currently ~ui.cpp:5187): + +```cpp + // Signals: the trigger picks a channel from the list beside it. + app->trigger_panel = build_trigger_panel(*app); + gtk_box_append(GTK_BOX(signals_page), app->trigger_panel); + gtk_box_append(GTK_BOX(signals_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + app->signal_panel = build_signal_panel(*app); + gtk_box_append(GTK_BOX(signals_page), app->signal_panel); +``` +becomes +```cpp + // The trigger form lives in the slide-over (reached by the Trigger badge); the signal list stays in the + // sidebar until the dock replaces it. + app->trigger_panel = build_trigger_panel(*app); + slideover_add_page(*app, "trigger", "Trigger", app->trigger_panel); + app->signal_panel = build_signal_panel(*app); + gtk_box_append(GTK_BOX(signals_page), app->signal_panel); +``` +Then deal with everything that assumed the trigger was on the Signals page **and mapped**: `grep -n "trigger_panel\|trigger_body\|trigger_head\|trigger_summary\|dbg_trigger_\|dbg_signal_page_current" src/*.cpp`. Specifically `exit_probe_step3` (`ui_probe.cpp` ~100-206) reads `gtk_widget_get_width/height(a->trigger_panel)`, `on_signals && gtk_widget_get_sensitive(a->trigger_body)`; any probe that reads a trigger widget's *allocation* must call `slideover_open(*a, "trigger")` and wait one settled frame (the reveal is 120 ms) before reading, and close it after. Reads of sensitivity/visibility/value need no open page. `dbg_signal_page_current` / `dbg_trigger_sensitive` keep their meaning but `on_signals` is now about the sidebar tab only for the signal list: `dbg_trigger_sensitive = gtk_widget_get_sensitive(a->trigger_body)`. + The trigger form's header toggle mirroring (`trigger_switch` <-> `trigger_toggle`), `sync_trigger_rows` and the locked summary view are all widget-state, not page-state; they must be unaffected -- the existing trigger probes are the proof (Step 6). + +- [ ] **Step 6: Verify** + +Build (0 warnings). Then, serially, with a mock each: + 1. `[badges]` probe: `UDPSCOPE_PROBE_BADGES_MS=2000 UDPSCOPE_EXIT_AFTER_MS=5000 UDPSCOPE_ENABLE_ALL_MS=500` against the UDPS mock -> `table_bad=0 fixed=1 open_marked=1 page_open=1 closed_unmarked=1`, `gtk warnings: 0`. + 2. Every existing trigger probe in `docs/claude/verification.md` (grep `UDPSCOPE_PROBE_.*TRIGGER\|FORCE\|ARM\|PERSIST\|DENSITY\|CONDITION\|LOCK\|SUMMARY`; run those whose names mention the trigger panel, arming, locking, the summary or the panel switch) and compare against the reading each entry in `docs/claude/probe-readings.md` states. A probe that now reads zero because its widget is unallocated is the fix described in Step 5, not a regression to hide. + 3. Screenshot with `UDPSCOPE_PROBE_BADGES_HOLD`-style early exit is not needed: add `slideover_open(*a,"trigger")` to nothing; instead take one screenshot (Task 1 step 7's command with `UDPSCOPE_PROBE_BADGES_MS` replaced by a one-off env `UDPSCOPE_OPEN_PAGE=trigger` read in `on_activate`'s tail -- remove it after) and view it: badge bar under the plot, Trigger badge pressed, the trigger form in the panel. Do not commit the temporary env. + 4. Baseline diff (normalised) vs `.superpowers/sdd/ui-badges-baseline-udps.txt`. +Allowed baseline changes: the `side`/`trigger` size lines (`trigger_panel` is read through the slide-over), plus the new `[badges]` lines. + +- [ ] **Step 7: Commit** + +```bash +git add src/ui_badges.cpp src/ui/main-window.ui src/ui_app.hpp src/ui.cpp src/ui_widgets.cpp src/ui_slideover.cpp src/ui_probe.cpp CMakeLists.txt docs/claude/verification.md +git commit -m "ui: a badge bar with a Trigger badge, and the trigger form in the slide-over" +``` + +--- + +### Task 3: The Source badge and the source panels in the slide-over + +**Files:** +- Modify: `src/ui_source.cpp`, `src/ui_badges.cpp`, `src/ui_app.hpp`, `src/ui.cpp`, `src/ui_probe.cpp`, `docs/claude/verification.md` + +**Interfaces:** +- Consumes: `slideover_add_page`, `slideover.source_pages`, `Badge`/`BadgeBar::source`. +- Produces: `build_source_panels(App&)` now registers `source-` slide-over pages (id and `SourcePanelPage::name` unchanged); `sync_badge_bar` also writes the Source badge. `SourcePanelPage` is unchanged. + +- [ ] **Step 1: `build_source_panels` registers slide-over pages** + +In `src/ui_source.cpp`, replace the scroller + `adw_view_stack_add_titled` block with: + +```cpp + const std::string name = "source-" + p.id; + // The panel is a slide-over page now: the slide-over wraps it in its own scroller, and with two or + // more source pages it draws the segmented switch (the E-1608: Analog out, Digital I/O). + if (!slideover_add_page(app, name, p.title, p.widget)) continue; + app.source_panels.push_back({name, p.title, p.widget}); +``` +and change the guard at the top of the function from `app.side_stack == nullptr` to `app.slideover.stack == nullptr`. `apply_source_panels`/`sync_source_panels` (sensitivity from the connection flag) are unchanged. + Leave `keep_tabs_icon_free` in place for now (the sidebar tabs still exist until task 4); it must not be called with source pages any more -- it only hides images, so it is harmless. + +- [ ] **Step 2: The Source badge** + +In `build_badge_bar`, after the Trigger badge: + +```cpp + // The first source page is where the badge goes; a source with none (UDPS) has nothing to open, so its + // badge is a read-out: not a target, not focusable, but still on screen. + const std::string first = app.source_panels.empty() ? std::string() : app.source_panels.front().name; + make_badge(app, bb.source, bb.system_slot, "Source", first); + if (first.empty()) { + gtk_widget_set_can_target(bb.source.button, FALSE); + gtk_widget_set_focusable(bb.source.button, FALSE); + } +``` +`build_badge_bar` must therefore run **after** `build_source_panels(*app)` -- move its call there. (Order in `on_activate`: `build_slideover`, trigger page, `build_source_panels`, `build_badge_bar`.) For a source's open mark, the Source badge is "open" for any `source-*` page: in `sync_badge_bar`, `set_open(bb.source.button, slideover_page(app).rfind("source-", 0) == 0)`. + State text (written in `sync_badge_bar`): the name label is the source kind's `display_name()` (`find_kind(app.source_kind)`, include `source/registry.hpp`; fall back to `"Source"`), the state is `connected` / `disconnected` from `app.model->stats().connected.load(std::memory_order_relaxed)`; in file mode (`app.replaying` or `!app.model->config().open_waveform.empty()`) the name is `"File"` and the state is empty. Set the name label with `set_text_if_changed` too. + The badge click on a multi-page source opens the **first** page; the segmented switch inside the panel changes page (Task 1's `on_source_switch`). If the open page is already a source page, a click closes it (that is `slideover_toggle`'s same-page rule, which the badge's `page` = first page gives for the first page; for any other open source page make `on_badge_clicked` close the panel when `b == &app->badges.source` and the open page starts with `source-`). + +- [ ] **Step 3: Re-point the probes that read the source pages** + + - `ui_probe.cpp` ~1958 and ~2400: `adw_view_stack_get_child_by_name(side_stack, name) != nullptr` -> `slideover_has_page(*a, name)`. + - `ui_probe.cpp` ~2495: `adw_view_stack_set_visible_child_name(side_stack, "source-ao")` -> `slideover_open(*a, "source-ao")`; the probe reads panel widgets' values and sensitivity, and any read of an allocation needs the page open and one settled frame (>= 150 ms) -- keep the page open for the probe's reads and close it at the end. + - Add to the dump (or a `UDPSCOPE_PROBE_BADGES_MS` extension) a `[badges] source name=<...> state=<...> pages= switch_visible=<0|1>` line: after `slideover_open(*a, )`, `switch_visible` must read `1` for the E-1608 (two pages) and `0` for the probe source (one page); then select the second page via `adw_toggle_group_set_active_name` and read back `slideover_page(*a)`. + +- [ ] **Step 4: Verify (serial; kill each mock)** + + 1. Probe source: `UDPSCOPE_PROBE_SOURCE_KINDS=3000 UDPSCOPE_EXIT_AFTER_MS=7000 UDPSCOPE_PROBE_BADGES_MS=2500 ./build/udpscope --source probe --source-opt rate=500` -> `[badges] source name=... pages=1 switch_visible=0`; `[source-kinds]` lines unchanged. + 2. E-1608: `python3 tools/mock_e1608.py` (default port 54211), run the app with `--source e1608` the way `docs/claude/verification.md` states, with the E-1608 panel probes (`UDPSCOPE_PROBE_E1608_*`) and `UDPSCOPE_PROBE_BADGES_MS` -> `pages=2 switch_visible=1`, the panel probes' readings unchanged, `gtk warnings: 0`. + 3. UDPS mock baseline diff. Allowed baseline changes: none beyond Task 2's; the Source badge on UDPS is the not-a-target read-out. + 4. Screenshot of the E-1608 run with the Source page open (cairo, as Task 1 step 7), view it: the segmented switch under the header, the Analog out page below it. + +- [ ] **Step 5: Commit** + +```bash +git add src/ui_source.cpp src/ui_badges.cpp src/ui_app.hpp src/ui.cpp src/ui_probe.cpp docs/claude/verification.md +git commit -m "ui: the Source badge, and the source panels as slide-over pages" +``` + +--- + +### Task 4: Replace the sidebar tabs with the temporary dock; session and keyboard + +**Files:** +- Modify: `src/ui/main-window.ui`, `src/ui.cpp`, `src/ui_source.cpp`, `src/ui_app.hpp`, `src/ui_layout.cpp`, `src/layout_store.hpp`, `src/layout_store.cpp`, `src/shortcuts.hpp`, `src/shortcuts.cpp`, `src/ui/shortcuts.ui` (if it lists the two actions), `src/ui_probe.cpp`, `docs/claude/*` entries touched by their probes +- Read first: `docs/claude/invariants-session-and-export.md`, `docs/claude/invariants-plot-and-layout.md` + +**Interfaces:** +- Produces: `App::dock_page` (the dock's `GtkBox`), `void dock_show_section(App&, const char* section)` (`"signals"` or `"measure"`; scrolls the dock to that section; unknown name is a no-op), `AppAction::ShowMeasure` / `AppAction::ShowSignals` (renamed from `SideTabMeasure` / `SideTabSignals`; same keys `m` and `g`, labels "Go to Measure" / "Go to Signals"), `SessionState::dock_shown` (bool, default true). **Removed:** `App::side_stack`, `App::side_switcher`, `App::side_tab`, `select_side_tab`, `on_side_tab_changed`, `keep_tabs_icon_free`, `SessionState::side_tab`, `App::dbg_switcher_*`, `dbg_signal_page_current`'s tab meaning. + +- [ ] **Step 1: The dock in `main-window.ui`** + +Replace the `side_column` subtree (switcher + stack + the two page scrollers) with: + +```xml + + + vertical + 300 + + + 1 + never + automatic + + + vertical + + + + + +``` +Update the file's header comment (the sidebar is the dock; the panels appended to `dock_page`). + +- [ ] **Step 2: Fill the dock in `on_activate` and delete the tab code** + + - Resolve `dock_page`, `dock_scroller` instead of `side_stack`, `side_switcher`, `measure_page`, `signals_page`; guard them; `app->side_box = side;` stays (the tour and the layout probes measure it). Add `GtkWidget* dock_page; GtkWidget* dock_scroller;` to `App`. + - Order in the dock, top to bottom: `signal_panel`, separator, `cursors_panel`, separator, `build_measures_panel(*app)`. The signal list is first because it is what is dragged onto the plot. + - Delete: the `adw_view_stack_set_visible_child_name(side_stack, "signals")` block and its `notify::visible-child-name` connection, `keep_tabs_icon_free(*app)` and its definition/declaration in `ui_source.cpp`/`ui_app.hpp` (with `hide_tab_icons`, `on_tab_pages_changed`), `on_side_tab_changed`, `select_side_tab` (`ui.cpp` ~2867-2882), `App::side_stack/side_switcher/side_tab`, and the `dbg_switcher_*` members. + - Add in `ui.cpp`: + +```cpp +void dock_show_section(App& app, const char* section) { + if (app.dock_scroller == nullptr || app.dock_page == nullptr || section == nullptr) return; + GtkWidget* target = std::strcmp(section, "measure") == 0 ? app.cursors_panel + : std::strcmp(section, "signals") == 0 ? app.signal_panel + : nullptr; + if (target == nullptr) return; + graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f); + graphene_point_t at; + if (!gtk_widget_compute_point(target, app.dock_page, &zero, &at)) return; + GtkAdjustment* adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(app.dock_scroller)); + gtk_adjustment_set_value(adj, at.y); +} +``` + - `run_app_action`: `case AppAction::ShowMeasure: dock_show_section(app, "measure"); break;` / `ShowSignals` likewise. Rename in `shortcuts.hpp`, `shortcuts.cpp` (labels "Go to Measure" / "Go to Signals", same group "Panes and editors"), `shortcuts.ui` if present, and the keyboard probe (`ui_probe.cpp` ~6278-6305: it compared `side_tab` before/after; compare the dock scroller's `vadjustment` value instead -- `m` must move it to the cursors panel's y, `g` back to 0). + - Add `case Escape`: in `on_app_key_pressed`, directly after the tour's Escape branch and before the editable check: `if (keyval == GDK_KEY_Escape && slideover_escape(*app)) return TRUE;`. + +- [ ] **Step 3: Dock visibility -- shown, insensitive until signals** + +`update_side_visibility` (`ui.cpp` ~2887) and the per-frame block in `tick()` (~4029-4047) both compute `want = have_signals && sidebar_wanted`. Change both to `want = sidebar_wanted`, and state the dock's availability instead: + +```cpp + // "Disable, do not hide": the dock is on screen from the start and does nothing until the stream has signals. + gtk_widget_set_sensitive(app.side_box, have_signals); + gtk_widget_set_sensitive(app.sidebar_toggle, TRUE); +``` +(`trigger_toggle`'s visibility from `have_signals` is unchanged; `update_side_visibility` keeps the early-return guard on `app.side_box == nullptr` instead of `side_stack`.) Update the comments above both blocks. + +- [ ] **Step 4: Session -- `side_tab` out, `dock_shown` in** + + - `layout_store.hpp`: remove `side_tab`; add `bool dock_shown = true;` documented as "the dock's shown state (the user's last word on the toolbar's Dock button)". + - `layout_store.cpp`: writer (~743) emits `dock = true|false` in the same top section where `side_tab` was written (keep key order per the session invariants); parser: replace the `key == "side_tab"` branch with `key == "dock"` -> `st.dock_shown = value == "true"`; an old file's `side_tab = ...` falls through to the existing unknown-key handling (check the final `else` of that chain ignores it -- read it; do not add a branch to swallow it if it already does). Bump nothing else. + - `ui_layout.cpp:67` -> `st.dock_shown = app.sidebar_wanted;`; `:430` -> `app.sidebar_wanted = st.dock_shown;` then `update_side_visibility(app);`. + - A change of the user's wish must schedule a save: in `on_sidebar_toggled`, after recording `sidebar_wanted` when `!sidebar_syncing`, `if (!app->updating_panels) schedule_layout_save(*app);`. + - If there are session round-trip probes (`grep -n "side_tab" src/ui_probe.cpp docs/claude/*.md`), extend them: save with the dock hidden, reload, read `sidebar_wanted == false`; and a file containing `side_tab = "measure"` loads with no error and `dock_shown` default. + +- [ ] **Step 5: Re-point the layout/sidebar probes** + +`ui_probe.cpp`: `exit_probe_step3` (~100-206), `side_tab_probe*` (~1800-1875), `sidebar_probe` (~3532-3567), the `select_side_tab(*a, "measure")` calls at ~4519 and ~4678, and the tour-surface probes (done in Task 5). Rules: + - `dbg_switcher_*` and the `switcher:` dump line (ui.cpp ~2998, 3055-3090, and the printing at ~6224-6436) are deleted; replace with one `dock: shown=<0|1> sensitive=<0|1> w= scroller_pages=1` line and document it. + - `side_tab_probe` keeps its job (measurements keep accumulating while the Measure section is scrolled out of view) with `dock_show_section(*a, "measure")` / `"signals"` in place of `select_side_tab`; rename it `dock_section_probe`, env `UDPSCOPE_PROBE_SIDE_TAB_MS` is kept as is (do not break scripts); its `advanced=4` assertion is unchanged. + - `dbg_signal_page_current` becomes "the signal list has rows" (no tab), `dbg_trigger_sensitive` is read directly. + +- [ ] **Step 6: Verify** + +Build (0 warnings incremental; clean build has exactly one). Serially: UDPS mock baseline diff; `UDPSCOPE_PROBE_SIDE_TAB_MS=3000 UDPSCOPE_EXIT_AFTER_MS=11000` (`advanced=4`); the sidebar/collapse probe; the keyboard probe; the session round-trip probe; the pane-drag probe (`dbg_drag_hid_sidebar`/`restored` both 1: dragging a signal from an overlaid dock still hides it for the drag); one run with **no signals** (UDPS mock with `--source` that has none, or a run before CONFIG) reading `dock: shown=1 sensitive=0`; E-1608 and probe-source runs from Task 3 again. Screenshot at 1280x800: plot, badge bar, dock with the list, cursors, measurements. +Allowed baseline changes: the `switcher:` line goes; a `dock:` line appears; `tab`/`page` readings that named `signals`/`measure` become the section readings; `[sidebar]` lines may differ only by `shown` when no signals existed. + +- [ ] **Step 7: Commit** + +```bash +git add -A src docs/claude +git commit -m "ui: the sidebar's tabs become one dock; Measure and Signals by keyboard section; session keeps the dock's state" +``` + +--- + +### Task 5: Tour, contextual help and the help figures + +**Files:** +- Modify: `src/ui_tour.cpp`, `src/ui_context_help.cpp`, `src/ui_app.hpp`, `src/ui_probe.cpp`; read `tools/check_help.sh`, `tools/make-help-shots.sh` + +**Interfaces:** +- Produces: `TourView::on_page` -- `void (*on_page)(void* user, const char* page) = nullptr; void* page_user = nullptr;`. When set it replaces the `page_stack` mechanism for that view: `select_view_page` calls `on_page(page_user, stop->page)` (page may be `nullptr`, meaning "no page needed"). The chooser keeps `page_stack`. `TourStep::page` for the main window now names a slide-over page (`"trigger"`) or is `nullptr`. + +- [ ] **Step 1: The page hook in the shared tour machinery (`ui_tour.cpp` / `ui_app.hpp`)** + + - Add the two members to `TourView`. + - `select_view_page`: first line `if (view.on_page != nullptr) { view.on_page(view.page_user, page); return; }`. + - `stop_is_walkable`: `if (stop.page != nullptr && view.on_page != nullptr) return true;` before the `page_stack` rule -- a slide-over page can always be opened, whatever the dock is doing. + - `start_tour`: replace `app.tour.page_stack = app.side_stack;` with `app.tour.on_page = +[](void* u, const char* page) { App& a = *static_cast(u); if (page != nullptr) slideover_open(a, page); else slideover_close(a); }; app.tour.page_user = &app;`. A stop with `page == nullptr` closes the panel, so the card never walks to the status line over an open panel. On `tour_end`, close the panel too (find where the tour tears down and call `slideover_close` through the same hook with `nullptr`). + +- [ ] **Step 2: Rewrite the main window's stops** + +`kTourSteps` becomes (titles/bodies in plain words; keep the existing style): + +| id | page | target | text (replace with your own wording of this meaning) | +| --- | --- | --- | --- | +| `plot` | nullptr | `&App::area_overlay` | unchanged | +| `badges` | nullptr | `&App::badge_bar` | "Setup is reached by the thing it configures. The Trigger badge opens the trigger form; the Source badge opens the source's own controls. Click it again, press Esc or click the plot to close the panel." | +| `trigger` | `"trigger"` | `&App::trigger_body` | existing trigger text (describe an event, arm it) | +| `side` | nullptr | `&App::side_box` | "The dock beside the plot: the list of signals, then the cursors and the measurements. The buttons in the bar above show and hide it." | +| `signals` | nullptr | `&App::signal_list` | unchanged | +| `status` | nullptr | `&App::status_row` | unchanged | + +Add `GtkWidget* App::badge_bar` to the table (it is already an `App` member from Task 2). Order matters for the walk: the trigger stop opens the panel, the `side` stop closes it. Update `kFigureSurfaces` (`ui_tour.cpp` ~484): drop `side_switcher` and `side_stack`; add `{"badge_bar", &App::badge_bar}` and `{"dock", &App::side_box}`. If `tools/check_help.sh`, `tools/make-help-shots.sh` or any `docs/help/**` figure data names `side_switcher`/`side_stack`, grep for them (`grep -rn "side_switcher\|side_stack" tools docs src data 2>/dev/null`): keep `side_stack` as a **second name for the dock** in `kFigureSurfaces` until the manual's figures are redone in step 4 (record that in the ledger), and drop `side_switcher` only if nothing names it. + +- [ ] **Step 3: Contextual help (`ui_context_help.cpp`)** + +The hint table (~line 92 `{"signals", "The signals", ...}`) anchors "(i)" marks to panel hosts. Read the whole table. Hints whose host is now in the slide-over (the trigger's) are on an unmapped widget while the panel is closed: confirm the code that places them tolerates an unmapped host (hints are overlay children of the host; they should appear with the panel) and that `context_help_count` / the `hints=N ids={...}` reading is the same set of ids as before. Add a hint for the badge bar only if the table's pattern is "one hint per panel"; otherwise do not. + +- [ ] **Step 4: Re-point the tour probes** + +`ui_probe.cpp` ~6565-6875 (`[tour]`, `[toursurface]`): `side_tab=` becomes `slideover=` printed from `slideover_page(a)`; `select_side_tab(*a, "measure")` + `mapped(a->side_stack)` become `slideover_open(*a, "trigger")` and `mapped(a->side_box)`; the "collapsed" leg (dock collapsed) must show that the trigger stop is still walkable (it is the new behaviour: the slide-over does not depend on the dock) and the signals stop is dropped. Read `docs/claude/probe-readings.md` for the `[toursurface]` entry and update it in Task 6. + +- [ ] **Step 5: Verify** + +Tour probes (`grep -n "TOUR\|HELPUI\|CONTEXT" docs/claude/verification.md` for their env names) -> the walk lists the stops above in order, `slideover=trigger` at the trigger stop and `slideover=none` at the next, spotlight rectangles contained in the overlay, `hints=` ids as before; `tools/check_help.sh` passes. UDPS baseline diff; allowed changes: tour step count and stop ids/titles, `side_tab=` fields. + +- [ ] **Step 6: Commit** + +```bash +git add src/ui_tour.cpp src/ui_context_help.cpp src/ui_app.hpp src/ui_probe.cpp +git commit -m "ui: the tour and the contextual help follow the badge bar, the slide-over and the dock" +``` + +--- + +### Task 6: Documentation for step 1, and the final check + +**Files:** +- Modify: `README.md`, `docs/claude/architecture.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md`, `docs/claude/invariants-plot-and-layout.md`, `docs/claude/invariants-gtk-and-chrome.md`, `docs/claude/build-and-run.md` (only if it names a changed file), `CLAUDE.md` (only its table if a row's text is now wrong) +- Not in this plan (spec step 4): `USER-GUIDE.md`, the Mallard manual's text and figures. + +- [ ] **Step 1: Sweep the references** + +`grep -rn "side_switcher\|side_stack\|side_tab\|switcher:\|keep_tabs_icon_free\|SideTab\|Measure tab\|Signals tab\|tab switcher\|sidebar tab" README.md CLAUDE.md docs/claude src tools` -- every hit outside `docs/superpowers/` is rewritten or deleted. Specifically: + - `README.md`: the layout description (badge bar, slide-over, dock), the keyboard table (`m`/`g` are "Go to Measure / Signals"), the session file's keys, the deviations list (the three step-1 deviations above, as known limitations of the in-progress redesign). + - `docs/claude/architecture.md`: the three new units and the rule that the slide-over/badge bar are built in `on_activate`, per-tick state in `sync_badge_bar`. + - `docs/claude/invariants-plot-and-layout.md`: the slide-over as the second thing placed over the plot (after the layout editor's buttons), with the pick rule; the badge bar's fixed height. + - `docs/claude/invariants-gtk-and-chrome.md`: delete the tab-icon paragraph and `keep_tabs_icon_free` (the switcher is gone); add: a capture-phase observer gesture that never claims the sequence is how click-away coexists with the plot's gestures; `AdwToggleGroup` is the segmented switch. + - `docs/claude/probe-readings.md` and `verification.md`: the new lines `[slideover]`, `[badges]`, `dock:`, the removal of `switcher:`; the new baseline statement for the UDPS diff ("unchanged except ..." now relative to the commit before this series, with the allowed list); the renamed keyboard actions. + - `docs/claude/verification.md` header sentence about the UDPS baseline: say a self-check taken **before** `c572ec2` is no longer the baseline for these lines. + +- [ ] **Step 2: The final run, serially** + +Clean build count: `touch src/icons/udpscope.gresource.xml && cmake --build build -j 2>&1 | grep -c warning` -> `1`; incremental -> `0`. UDPS mock full self-check: `gtk warnings: 0`, no `Gtk-CRITICAL`, `buffer allocated:` lines 2-3. Each probe touched in Tasks 1-5 once more, on the final tree. A last screenshot at 1280x800 with the trigger page open and one with a source page open (E-1608 mock) for the report. Kill every mock. + +- [ ] **Step 3: Commit** + +```bash +git add README.md CLAUDE.md docs/claude +git commit -m "docs: the badge bar, the slide-over and the dock (step 1 of the redesign)" +``` + +--- + +## Self-review (against the spec) + +- **Spec step 1 -- "plot overlay, badge bar with Trigger and Source, slide-over holding the existing trigger form and source pages, sidebar tabs removed, existing Measure and Signals in a temporary dock":** Task 1 (overlay + slide-over), Task 2 (badge bar, Trigger, trigger form), Task 3 (Source, source pages), Task 4 (tabs removed, temporary dock). +- **Risks the spec assigns to step 1:** overlay above GL -> Task 1 steps 6-7 before anything else; reparenting the trigger form -> Task 2 step 6 (existing trigger probes). "Many signals" is step 2. +- **Header:** the Events toggle removal and the Dock toggle rename are *not* step 1 (the events pane stays until step 4); the sidebar toggle already is the dock toggle in behaviour -- its tooltip/label text, if it says "sidebar", is renamed "Dock" in Task 4 step 3 (look at `header-bar.ui`). +- **Keyboard / session / probes / tour / docs:** Tasks 4, 4, 4-5, 5, 6. +- **Left to later steps (by the spec's rollout):** signal badges and drag (2), compact readouts and the 280 px dock (3), events section and deleting the left pane (4), USER-GUIDE and manual figures (4). +- **Names used across tasks:** `slideover_add_page/has_page/open/close/toggle/page/escape/click_away`, `SlideOver::source_pages`, `build_badge_bar`, `sync_badge_bar`, `trigger_badge_state`, `BadgeBar::{trigger,source}`, `dock_show_section`, `ShowMeasure/ShowSignals`, `SessionState::dock_shown`, `TourView::on_page/page_user` are used with one spelling throughout. From a40863fa459d439fb57826e91c03c1404b9a9dbe Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 2 Nov 2026 19:57:17 +0100 Subject: [PATCH 057/127] ui: a slide-over panel over the plot's right edge, with its probe Co-Authored-By: Claude Opus 4.6 --- CMakeLists.txt | 1 + docs/claude/verification.md | 11 ++ src/ui.cpp | 9 +- src/ui/main-window.ui | 28 ++-- src/ui_app.hpp | 29 ++++ src/ui_slideover.cpp | 260 ++++++++++++++++++++++++++++++++++++ src/ui_widgets.cpp | 5 + 7 files changed, 334 insertions(+), 9 deletions(-) create mode 100644 src/ui_slideover.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f3a9785..a65da52 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -272,6 +272,7 @@ add_executable(udpscope src/ui_math.cpp src/ui_layout.cpp src/ui_source.cpp + src/ui_slideover.cpp src/ui_trigger.cpp src/ui_signals.cpp src/ui_context_help.cpp diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 92c7393..1b5fe08 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -418,6 +418,17 @@ precedent, not an exception. 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. It enables *and plots* its own signal, so it needs no other probe ahead of it. The reading's own entry is in `probe-readings.md`. +- `UDPSCOPE_PROBE_SLIDEOVER_MS=1500` with `UDPSCOPE_EXIT_AFTER_MS=5000` (and `UDPSCOPE_ENABLE_ALL_MS=500`) -- the + slide-over panel over the plot's right edge, opened on a throw-away `probe` page, read 0.5 s later. Two + `[slideover]` lines. Line 1 must read `panel=360x ... edge_flush=1 full_height=1 + outside_hits_plot=1 inside_hits_panel=1 ... plot_unchanged=1`: a 360 px opaque panel flush to the overlay's + right edge and as tall as it, a pick outside it landing in the plot and one inside it in the panel, and the + plot's width unchanged by the panel being open (`plot_w before=` equal to `open=`). + `outside_hits_plot=0` means the panel has an allocation wider than 360 or a transparent parent that + swallows the plot's pan, drop and hover; `plot_unchanged=0` means the panel resized the plot. Line 2 must + read `click_inside_closes=0 still_open=1 click_outside_closes=1 esc_closes=1 closed=1`. For a screenshot + add `UDPSCOPE_PROBE_SLIDEOVER_HOLD=1`, which leaves the page open after line 1 (run with `GSK_RENDERER=cairo` + and `import -window root`): the panel must be drawn over the traces, opaque. - `UDPSCOPE_PROBE_SOURCE_KINDS=2000` with `UDPSCOPE_EXIT_AFTER_MS=5000`, run as `--source probe --source-opt rate=500` -- the source-plugin path end to end. The variable's presence also registers the test-only `probe` kind (a synthetic sine with one panel), so it is the only way a run sees that kind; its value is the delay in ms. diff --git a/src/ui.cpp b/src/ui.cpp index c6c715d..a3dcaf5 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -5009,6 +5009,7 @@ void on_activate(GtkApplication* application, gpointer user) { GtkWidget* events_divider = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "events_divider")); app->plot_box = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_box")); app->plot_split = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_split")); + app->plot_overlay = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_overlay")); app->status_badge = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_badge")); app->status_label = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_label")); app->status_info_button = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_info")); @@ -5023,7 +5024,8 @@ void on_activate(GtkApplication* application, gpointer user) { // A missing id is the one mistake neither the build nor `GtkBuilder` sees, and every use below would // be a null pointer. if (app->window == nullptr || side_view == nullptr || events_divider == nullptr || - app->plot_box == nullptr || app->plot_split == nullptr || app->status_badge == nullptr || + app->plot_box == nullptr || app->plot_overlay == nullptr || app->plot_split == nullptr || + app->status_badge == nullptr || app->status_label == nullptr || app->status_info_button == nullptr || app->status_row == nullptr || side == nullptr || side_stack == nullptr || side_switcher == nullptr || @@ -5063,6 +5065,8 @@ void on_activate(GtkApplication* application, gpointer user) { app->tour.overlay = overlay; build_tour(*app); } + // The slide-over panel: an overlay child of the plot, above the GL area. + build_slideover(*app); // The size the window opens at, stated in code as well as in the `.ui`: `default-width` there is the // size it *asks* for, and a window whose content has a larger minimum opens at that minimum instead -- @@ -5905,6 +5909,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_PROBE_SIDE_TAB_MS")) { g_timeout_add(static_cast(std::atoi(env)), side_tab_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_SLIDEOVER_MS")) { + g_timeout_add(static_cast(std::atoi(env)), slideover_probe, &app); + } if (const char* env = g_getenv("UDPSCOPE_PROBE_SEARCH_MS")) { g_timeout_add(static_cast(std::atoi(env)), search_probe, &app); } diff --git a/src/ui/main-window.ui b/src/ui/main-window.ui index a6bf93f..444a0d5 100644 --- a/src/ui/main-window.ui +++ b/src/ui/main-window.ui @@ -3,7 +3,8 @@ + 1 1 - - horizontal - 1 + + vertical + 1 + 4 + 1 + 1 + + + horizontal + 1 + + diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 8437d53..f63da4d 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -117,6 +117,19 @@ struct SourcePanelPage { GtkWidget* content = nullptr; }; +/// The slide-over panel's widgets and state (src/ui_slideover.cpp). `page` is the open page's id, empty when closed. +struct SlideOver { + GtkWidget* overlay = nullptr; ///< the GtkOverlay around the plot (main-window.ui `plot_overlay`) + GtkWidget* revealer = nullptr; ///< the one overlay child; halign END, valign FILL, 360 px + GtkWidget* title = nullptr; ///< GtkLabel in the panel header + GtkWidget* stack = nullptr; ///< GtkStack: one scroller per page + GtkWidget* source_switch = nullptr; ///< AdwToggleGroup, shown on a source page when there are >= 2 + std::vector source_pages; + std::string page; + bool syncing = false; ///< true while we write the toggle group ourselves +}; +constexpr int kSlideOverWidth = 360; + struct App { ScopeModel* model = nullptr; Acquisition* acq = nullptr; @@ -124,6 +137,9 @@ struct App { GtkWidget* window = nullptr; GtkWidget* plot_box = nullptr; + /// The `GtkOverlay` around `plot_box` (`main-window.ui`), which the slide-over panel is a child of. + GtkWidget* plot_overlay = nullptr; + SlideOver slideover; /// The one GL area, drawing the `RowSet`'s figure: every row is a row of that /// figure, so there is one widget, one context and one `::render` whatever the row /// count is. @@ -1872,6 +1888,19 @@ gboolean stats_probe_report(gpointer user); gboolean stats_probe(gpointer user); gboolean side_tab_probe_report(gpointer user); gboolean side_tab_probe(gpointer user); +/// The slide-over panel (`src/ui_slideover.cpp`): one panel at a time over the plot's right edge, a page +/// per setup surface, each page in a scroller of its own. +void build_slideover(App& app); // once, after the plot_overlay exists +bool slideover_add_page(App& app, const std::string& id, const std::string& title, GtkWidget* content); +bool slideover_has_page(const App& app, const std::string& id); +bool slideover_open(App& app, const std::string& id); // false when the id is unknown +void slideover_close(App& app); +bool slideover_toggle(App& app, const std::string& id); // open if closed or on another page, else close +const std::string& slideover_page(const App& app); // "" when closed +bool slideover_escape(App& app); // closes if open; true when it did +/// x, y in the overlay's coordinates; closes unless the point is inside the panel; true when it closed. +bool slideover_click_away(App& app, double x, double y); +gboolean slideover_probe(gpointer user); // UDPSCOPE_PROBE_SLIDEOVER_MS gboolean source_kinds_probe_report(gpointer user); gboolean source_kinds_probe(gpointer user); /// MCC E-1608 end to end against `tools/mock_e1608.py`: the stream, the calibration, the panels and diff --git a/src/ui_slideover.cpp b/src/ui_slideover.cpp new file mode 100644 index 0000000..cb3fe2b --- /dev/null +++ b/src/ui_slideover.cpp @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +// The slide-over panel: one panel at a time over the plot's right edge, a page per setup surface. +// See docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md. + +#include "ui_app.hpp" + +#include + +#include +#include +#include + +namespace udpscope { +namespace ui_impl { + +namespace { + +void on_close_clicked(GtkButton*, gpointer user) { slideover_close(*static_cast(user)); } + +void on_source_switch(GObject* o, GParamSpec*, gpointer user) { + App* app = static_cast(user); + if (app->slideover.syncing) return; + const char* name = adw_toggle_group_get_active_name(ADW_TOGGLE_GROUP(o)); + if (name != nullptr && app->slideover.page != name) slideover_open(*app, name); +} + +/// Observes every press on the plot overlay, in the capture phase, and never claims the sequence: the +/// plot's own pan/zoom/drop gestures see the press exactly as they did before. +void on_press_capture(GtkGestureClick*, int, double x, double y, gpointer user) { + slideover_click_away(*static_cast(user), x, y); +} + +bool is_source_page(const std::string& id) { return id.rfind("source-", 0) == 0; } + +} // namespace + +void build_slideover(App& app) { + SlideOver& s = app.slideover; + s.overlay = app.plot_overlay; + if (s.overlay == nullptr) return; + + GtkWidget* panel = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_widget_add_css_class(panel, "udpscope-slideover"); + gtk_widget_set_size_request(panel, kSlideOverWidth, -1); + gtk_widget_set_hexpand(panel, FALSE); + + GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_widget_set_margin_start(head, 12); + gtk_widget_set_margin_end(head, 6); + gtk_widget_set_margin_top(head, 6); + gtk_widget_set_margin_bottom(head, 6); + s.title = gtk_label_new(""); + gtk_widget_add_css_class(s.title, "udpscope-slideover-title"); + gtk_label_set_xalign(GTK_LABEL(s.title), 0.0f); + gtk_widget_set_hexpand(s.title, TRUE); + gtk_label_set_ellipsize(GTK_LABEL(s.title), PANGO_ELLIPSIZE_END); + GtkWidget* close = gtk_button_new_with_label("Close"); + gtk_widget_add_css_class(close, "flat"); + g_signal_connect(close, "clicked", G_CALLBACK(on_close_clicked), &app); + gtk_box_append(GTK_BOX(head), s.title); + gtk_box_append(GTK_BOX(head), close); + gtk_box_append(GTK_BOX(panel), head); + + // A source with several panels (the E-1608: Analog out, Digital I/O) gets a segmented switch under + // the header; it is hidden unless the open page is a source page and there are two or more. + s.source_switch = adw_toggle_group_new(); + gtk_widget_set_margin_start(s.source_switch, 12); + gtk_widget_set_margin_end(s.source_switch, 12); + gtk_widget_set_margin_bottom(s.source_switch, 6); + gtk_widget_set_visible(s.source_switch, FALSE); + g_signal_connect(s.source_switch, "notify::active-name", G_CALLBACK(on_source_switch), &app); + gtk_box_append(GTK_BOX(panel), s.source_switch); + gtk_box_append(GTK_BOX(panel), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + + s.stack = gtk_stack_new(); + gtk_widget_set_vexpand(s.stack, TRUE); + gtk_box_append(GTK_BOX(panel), s.stack); + + s.revealer = gtk_revealer_new(); + gtk_revealer_set_transition_type(GTK_REVEALER(s.revealer), GTK_REVEALER_TRANSITION_TYPE_SLIDE_LEFT); + gtk_revealer_set_transition_duration(GTK_REVEALER(s.revealer), 120); + gtk_revealer_set_child(GTK_REVEALER(s.revealer), panel); + gtk_widget_set_halign(s.revealer, GTK_ALIGN_END); + gtk_widget_set_valign(s.revealer, GTK_ALIGN_FILL); + // Closed, it must not be a pick target for the frames of its slide-out. + gtk_widget_set_can_target(s.revealer, FALSE); + gtk_overlay_add_overlay(GTK_OVERLAY(s.overlay), s.revealer); + gtk_overlay_set_clip_overlay(GTK_OVERLAY(s.overlay), s.revealer, TRUE); + + GtkGesture* click = gtk_gesture_click_new(); + gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(click), GTK_PHASE_CAPTURE); + g_signal_connect(click, "pressed", G_CALLBACK(on_press_capture), &app); + gtk_widget_add_controller(s.overlay, GTK_EVENT_CONTROLLER(click)); +} + +bool slideover_add_page(App& app, const std::string& id, const std::string& title, GtkWidget* content) { + SlideOver& s = app.slideover; + if (s.stack == nullptr || content == nullptr || id.empty() || slideover_has_page(app, id)) { + return false; + } + // A scroller per page: pages differ in height, and one shared scroller would leave a page scrolled + // to somewhere it never asked for. It does not propagate the content's height, so the panel's + // height is the plot's and never the page's. + GtkWidget* scroller = gtk_scrolled_window_new(); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), content); + gtk_stack_add_titled(GTK_STACK(s.stack), scroller, id.c_str(), title.c_str()); + if (is_source_page(id)) { + s.source_pages.push_back(id); + AdwToggle* t = adw_toggle_new(); + adw_toggle_set_name(t, id.c_str()); + adw_toggle_set_label(t, title.c_str()); + adw_toggle_group_add(ADW_TOGGLE_GROUP(s.source_switch), t); // takes the reference + } + return true; +} + +bool slideover_has_page(const App& app, const std::string& id) { + return app.slideover.stack != nullptr && + gtk_stack_get_child_by_name(GTK_STACK(app.slideover.stack), id.c_str()) != nullptr; +} + +bool slideover_open(App& app, const std::string& id) { + SlideOver& s = app.slideover; + if (!slideover_has_page(app, id)) return false; + GtkWidget* child = gtk_stack_get_child_by_name(GTK_STACK(s.stack), id.c_str()); + gtk_stack_set_visible_child(GTK_STACK(s.stack), child); + s.page = id; + GtkStackPage* sp = gtk_stack_get_page(GTK_STACK(s.stack), child); + const char* title = gtk_stack_page_get_title(sp); + gtk_label_set_text(GTK_LABEL(s.title), title != nullptr ? title : ""); + const bool several = is_source_page(id) && s.source_pages.size() >= 2; + gtk_widget_set_visible(s.source_switch, several); + if (several) { + s.syncing = true; + adw_toggle_group_set_active_name(ADW_TOGGLE_GROUP(s.source_switch), id.c_str()); + s.syncing = false; + } + gtk_widget_set_can_target(s.revealer, TRUE); + gtk_revealer_set_reveal_child(GTK_REVEALER(s.revealer), TRUE); + // task 2: sync_badge_bar(app); + return true; +} + +void slideover_close(App& app) { + SlideOver& s = app.slideover; + if (s.revealer == nullptr || s.page.empty()) return; + s.page.clear(); + gtk_widget_set_can_target(s.revealer, FALSE); + gtk_revealer_set_reveal_child(GTK_REVEALER(s.revealer), FALSE); + // task 2: sync_badge_bar(app); +} + +bool slideover_toggle(App& app, const std::string& id) { + if (!slideover_has_page(app, id)) return false; + if (app.slideover.page == id) { + slideover_close(app); + } else { + slideover_open(app, id); + } + return true; +} + +const std::string& slideover_page(const App& app) { return app.slideover.page; } + +bool slideover_escape(App& app) { + if (app.slideover.page.empty()) return false; + slideover_close(app); + return true; +} + +bool slideover_click_away(App& app, double x, double y) { + SlideOver& s = app.slideover; + if (s.page.empty() || s.overlay == nullptr || s.revealer == nullptr) return false; + graphene_point_t in = GRAPHENE_POINT_INIT(static_cast(x), static_cast(y)); + graphene_point_t out; + if (gtk_widget_compute_point(s.overlay, s.revealer, &in, &out) && + gtk_widget_contains(s.revealer, out.x, out.y)) { + return false; + } + slideover_close(app); + return true; +} + +// --- the probe --------------------------------------------------------------------------------- + +namespace { + +struct ProbeState { + App* app; + int plot_w_before = 0; +}; + +bool under(GtkWidget* w, GtkWidget* ancestor) { + return w != nullptr && ancestor != nullptr && (w == ancestor || gtk_widget_is_ancestor(w, ancestor)); +} + +gboolean slideover_probe_read(gpointer user) { + ProbeState* ps = static_cast(user); + App& a = *ps->app; + SlideOver& s = a.slideover; + const int ow = gtk_widget_get_width(s.overlay); + const int oh = gtk_widget_get_height(s.overlay); + graphene_rect_t r; + const bool have = gtk_widget_compute_bounds(s.revealer, s.overlay, &r); + const int rw = have ? static_cast(r.size.width + 0.5f) : -1; + const int rx = have ? static_cast(r.origin.x + 0.5f) : -1; + const int rh = have ? static_cast(r.size.height + 0.5f) : -1; + // Outside the panel, a quarter in: the plot must be what answers. Inside it, the panel. + GtkWidget* outside = gtk_widget_pick(s.overlay, ow / 4.0, oh / 2.0, GTK_PICK_DEFAULT); + GtkWidget* inside = gtk_widget_pick(s.overlay, ow - kSlideOverWidth / 2.0, oh / 2.0, GTK_PICK_DEFAULT); + const int plot_w_open = gtk_widget_get_width(a.plot_box); + std::fprintf(stderr, + "[slideover] open page=%s panel=%dx%d at x=%d overlay=%dx%d edge_flush=%d full_height=%d " + "outside_hits_plot=%d inside_hits_panel=%d plot_w before=%d open=%d plot_unchanged=%d\n", + s.page.c_str(), rw, rh, rx, ow, oh, rx + rw == ow ? 1 : 0, rh == oh ? 1 : 0, + under(outside, a.plot_box) ? 1 : 0, under(inside, s.revealer) ? 1 : 0, ps->plot_w_before, + plot_w_open, ps->plot_w_before == plot_w_open ? 1 : 0); + // For a screenshot only: leave the page open instead of exercising click-away and Esc. + if (std::getenv("UDPSCOPE_PROBE_SLIDEOVER_HOLD") != nullptr) { + delete ps; + return G_SOURCE_REMOVE; + } + // Click-away from outside the panel closes; a press inside does not. + const bool inside_closed = slideover_click_away(a, ow - kSlideOverWidth / 2.0, oh / 2.0); + const bool open_after_inside = !s.page.empty(); + const bool outside_closed = slideover_click_away(a, ow / 4.0, oh / 2.0); + slideover_open(a, "probe"); + const bool esc_closed = slideover_escape(a); + std::fprintf(stderr, + "[slideover] click_inside_closes=%d still_open=%d click_outside_closes=%d esc_closes=%d " + "closed=%d\n", + inside_closed ? 1 : 0, open_after_inside ? 1 : 0, outside_closed ? 1 : 0, esc_closed ? 1 : 0, + s.page.empty() ? 1 : 0); + delete ps; + return G_SOURCE_REMOVE; +} + +} // namespace + +gboolean slideover_probe(gpointer user) { + App* a = static_cast(user); + if (a->slideover.stack == nullptr) return G_SOURCE_REMOVE; + // A page of the probe's own: the real pages arrive in later tasks, and this one has an entry so the + // panel has something that takes focus and keys. + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_box_append(GTK_BOX(box), gtk_label_new("slide-over probe page")); + gtk_box_append(GTK_BOX(box), gtk_entry_new()); + slideover_add_page(*a, "probe", "Probe", box); + ProbeState* ps = new ProbeState{a, gtk_widget_get_width(a->plot_box)}; + slideover_open(*a, "probe"); + // The revealer's slide is 120 ms; the reading is taken well after it has settled. + g_timeout_add(500, slideover_probe_read, ps); + return G_SOURCE_REMOVE; +} + +} // namespace ui_impl +} // namespace udpscope diff --git a/src/ui_widgets.cpp b/src/ui_widgets.cpp index 1eeb979..1bf2aba 100644 --- a/src/ui_widgets.cpp +++ b/src/ui_widgets.cpp @@ -69,6 +69,11 @@ void install_css() { ".udpscope-tour-spot { border: 2px solid @accent_bg_color; border-radius: 8px; }\n" ".udpscope-tour-card { background-color: @window_bg_color; border: 1px solid @borders;" " border-radius: 12px; padding: 12px; box-shadow: 0 3px 12px rgba(0, 0, 0, 0.45); }\n" + // The slide-over panel over the plot's right edge: opaque (it must hide the plot under it), + // with an edge and a shadow so it reads as above the plot rather than beside it. + ".udpscope-slideover { background-color: @window_bg_color; border-left: 1px solid @borders;" + " box-shadow: -3px 0 10px rgba(0, 0, 0, 0.35); }\n" + ".udpscope-slideover-title { font-weight: bold; }\n" // The contextual-help "(i)": a *mark*, not a button. Adwaita's menu button is a 34x34 control // (a 24 px floor plus 5 px of padding) with a 16 px glyph, which beside a heading reads as a // second toolbar button. Measured on libadwaita 1.10: the outer `menubutton` node *and* its From 5fb479bd6974f6e8052ab0006e94200033e00159 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 2 Nov 2026 20:12:16 +0100 Subject: [PATCH 058/127] ui: a badge bar with a Trigger badge, and the trigger form in the slide-over Co-Authored-By: Claude Opus 4.6 --- CMakeLists.txt | 1 + docs/claude/verification.md | 10 +++ src/ui.cpp | 18 ++++-- src/ui/main-window.ui | 26 ++++++++ src/ui_app.hpp | 27 ++++++++ src/ui_badges.cpp | 120 ++++++++++++++++++++++++++++++++++++ src/ui_probe.cpp | 11 +++- src/ui_slideover.cpp | 4 +- src/ui_widgets.cpp | 5 ++ 9 files changed, 215 insertions(+), 7 deletions(-) create mode 100644 src/ui_badges.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a65da52..db2b8f6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -273,6 +273,7 @@ add_executable(udpscope src/ui_layout.cpp src/ui_source.cpp src/ui_slideover.cpp + src/ui_badges.cpp src/ui_trigger.cpp src/ui_signals.cpp src/ui_context_help.cpp diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 1b5fe08..502315c 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -429,6 +429,16 @@ precedent, not an exception. read `click_inside_closes=0 still_open=1 click_outside_closes=1 esc_closes=1 closed=1`. For a screenshot add `UDPSCOPE_PROBE_SLIDEOVER_HOLD=1`, which leaves the page open after line 1 (run with `GSK_RENDERER=cairo` and `import -window root`): the panel must be drawn over the traces, opaque. +- `UDPSCOPE_PROBE_BADGES_MS=2000` with `UDPSCOPE_EXIT_AFTER_MS=5000` and `UDPSCOPE_ENABLE_ALL_MS=500` -- the badge + bar under the plot. One `[badges]` line, which must read `table_bad=0 fixed=1 open_marked=1 page_open=1 + closed_unmarked=1`: `table_bad` counts the rows of the 16-input `trigger_badge_state()` table the probe + gets wrong; `fixed=1` is the bar's height being the same (and at least 36 px) before and after the + `trigger` page was opened and closed -- `0` means the bar's height depends on content and the plot above it + moves; `open_marked`/`closed_unmarked` are the `udpscope-badge-open` class following the slide-over's page. + `trigger_state=` is the state word as read then (`off` with the trigger toggle off). **The trigger form is + a slide-over page**, so a probe that reads one of its widgets' *allocation* (a width, a height) must + `slideover_open(*a, "trigger")` and read after the 120 ms reveal, as `exit_probe_step1` and `force_probe` do; + a sensitivity, a visibility or a value needs no open page. - `UDPSCOPE_PROBE_SOURCE_KINDS=2000` with `UDPSCOPE_EXIT_AFTER_MS=5000`, run as `--source probe --source-opt rate=500` -- the source-plugin path end to end. The variable's presence also registers the test-only `probe` kind (a synthetic sine with one panel), so it is the only way a run sees that kind; its value is the delay in ms. diff --git a/src/ui.cpp b/src/ui.cpp index a3dcaf5..e28d78d 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -3380,6 +3380,7 @@ gboolean tick(gpointer user) { if (app->window == nullptr) return G_SOURCE_REMOVE; note_panel_controls(*app); sync_source_panels(*app); + sync_badge_bar(*app); // The tour, re-measured every frame: the target's allocation is 0 on the frame it is first shown // (see the allocation bullets in `docs/claude/invariants-trigger-and-pinning.md`) and the widget // moves under the spotlight on a window resize, so a one-shot measurement is wrong both ways. @@ -5021,13 +5022,17 @@ void on_activate(GtkApplication* application, gpointer user) { GtkWidget* side_switcher = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_switcher")); GtkWidget* measure_page = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "measure_page")); GtkWidget* signals_page = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "signals_page")); + app->badge_bar = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "badge_bar")); + app->badge_signals = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "badge_signals")); + app->badge_system = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "badge_system")); // A missing id is the one mistake neither the build nor `GtkBuilder` sees, and every use below would // be a null pointer. if (app->window == nullptr || side_view == nullptr || events_divider == nullptr || app->plot_box == nullptr || app->plot_overlay == nullptr || app->plot_split == nullptr || app->status_badge == nullptr || app->status_label == nullptr || app->status_info_button == nullptr || - app->status_row == nullptr || + app->status_row == nullptr || app->badge_bar == nullptr || app->badge_signals == nullptr || + app->badge_system == nullptr || side == nullptr || side_stack == nullptr || side_switcher == nullptr || measure_page == nullptr || signals_page == nullptr) { return; @@ -5067,6 +5072,8 @@ void on_activate(GtkApplication* application, gpointer user) { } // The slide-over panel: an overlay child of the plot, above the GL area. build_slideover(*app); + // The badge bar reads `trigger_toggle`, which `build_toolbar()` made above. + build_badge_bar(*app); // The size the window opens at, stated in code as well as in the `.ui`: `default-width` there is the // size it *asks* for, and a window whose content has a larger minimum opens at that minimum instead -- @@ -5188,10 +5195,10 @@ void on_activate(GtkApplication* application, gpointer user) { gtk_box_append(GTK_BOX(measure_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); gtk_box_append(GTK_BOX(measure_page), build_measures_panel(*app)); - // Signals: the trigger picks a channel from the list beside it. + // The trigger form lives in the slide-over (reached by the Trigger badge); the signal list stays in the + // sidebar until the dock replaces it. app->trigger_panel = build_trigger_panel(*app); - gtk_box_append(GTK_BOX(signals_page), app->trigger_panel); - gtk_box_append(GTK_BOX(signals_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + slideover_add_page(*app, "trigger", "Trigger", app->trigger_panel); app->signal_panel = build_signal_panel(*app); gtk_box_append(GTK_BOX(signals_page), app->signal_panel); @@ -5912,6 +5919,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_PROBE_SLIDEOVER_MS")) { g_timeout_add(static_cast(std::atoi(env)), slideover_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_BADGES_MS")) { + g_timeout_add(static_cast(std::atoi(env)), badges_probe, &app); + } if (const char* env = g_getenv("UDPSCOPE_PROBE_SEARCH_MS")) { g_timeout_add(static_cast(std::atoi(env)), search_probe, &app); } diff --git a/src/ui/main-window.ui b/src/ui/main-window.ui index 444a0d5..a2d16b1 100644 --- a/src/ui/main-window.ui +++ b/src/ui/main-window.ui @@ -150,6 +150,32 @@ + + + + horizontal + 6 + 36 + 6 + 6 + + + horizontal + 6 + 1 + + + + + horizontal + 6 + end + + + + horizontal diff --git a/src/ui_app.hpp b/src/ui_app.hpp index f63da4d..8a58a80 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -130,6 +130,22 @@ struct SlideOver { }; constexpr int kSlideOverWidth = 360; +/// One badge: a button with a name and a state line. `page` is the slide-over page it opens. +struct Badge { + GtkWidget* button = nullptr; + GtkWidget* name = nullptr; + GtkWidget* state = nullptr; + std::string page; +}; +/// The badge bar under the plot (src/ui_badges.cpp). +struct BadgeBar { + GtkWidget* bar = nullptr; ///< main-window.ui `badge_bar`, fixed height + GtkWidget* signals_slot = nullptr; ///< left; empty in step 1, step 2 fills it + GtkWidget* system_slot = nullptr; ///< right: Trigger, Source + Badge trigger; + Badge source; ///< task 3 +}; + struct App { ScopeModel* model = nullptr; Acquisition* acq = nullptr; @@ -140,6 +156,11 @@ struct App { /// The `GtkOverlay` around `plot_box` (`main-window.ui`), which the slide-over panel is a child of. GtkWidget* plot_overlay = nullptr; SlideOver slideover; + /// The badge bar under the plot (`main-window.ui` `badge_bar`, with its two slots). + BadgeBar badges; + GtkWidget* badge_bar = nullptr; + GtkWidget* badge_signals = nullptr; + GtkWidget* badge_system = nullptr; /// The one GL area, drawing the `RowSet`'s figure: every row is a row of that /// figure, so there is one widget, one context and one `::render` whatever the row /// count is. @@ -1901,6 +1922,12 @@ bool slideover_escape(App& app); // closes if open; true /// x, y in the overlay's coordinates; closes unless the point is inside the panel; true when it closed. bool slideover_click_away(App& app, double x, double y); gboolean slideover_probe(gpointer user); // UDPSCOPE_PROBE_SLIDEOVER_MS +/// The badge bar (`src/ui_badges.cpp`): setup is reached by the object it configures. +/// The Trigger badge's state word. Pure, so the probe can assert the whole table. +const char* trigger_badge_state(bool enabled, bool armed, bool pending, bool has_event); +void build_badge_bar(App& app); // after build_slideover and after the toolbar (it reads trigger_toggle) +void sync_badge_bar(App& app); // per tick and on every slide-over open/close; only writes what changed +gboolean badges_probe(gpointer user); // UDPSCOPE_PROBE_BADGES_MS gboolean source_kinds_probe_report(gpointer user); gboolean source_kinds_probe(gpointer user); /// MCC E-1608 end to end against `tools/mock_e1608.py`: the stream, the calibration, the panels and diff --git a/src/ui_badges.cpp b/src/ui_badges.cpp new file mode 100644 index 0000000..1ac2830 --- /dev/null +++ b/src/ui_badges.cpp @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +// The badge bar under the plot: setup is reached by the object it configures. + +#include "ui_app.hpp" + +#include +#include + +namespace udpscope { +namespace ui_impl { + +const char* trigger_badge_state(bool enabled, bool armed, bool pending, bool has_event) { + if (!enabled) return "off"; + if (has_event && !pending) return "triggered"; + if (pending) return "capturing"; + return armed ? "armed" : "disarmed"; +} + +namespace { + +void set_text_if_changed(GtkWidget* label, const char* text) { + if (std::strcmp(gtk_label_get_text(GTK_LABEL(label)), text) != 0) gtk_label_set_text(GTK_LABEL(label), text); +} + +void set_open(GtkWidget* button, bool open) { + if ((gtk_widget_has_css_class(button, "udpscope-badge-open") != FALSE) == open) return; + if (open) { + gtk_widget_add_css_class(button, "udpscope-badge-open"); + } else { + gtk_widget_remove_css_class(button, "udpscope-badge-open"); + } +} + +void on_badge_clicked(GtkButton*, gpointer user) { + Badge* b = static_cast(user); + // The App is reached through the badge's button: the badges live inside App, so the pointer is stable. + App* app = static_cast(g_object_get_data(G_OBJECT(b->button), "udpscope-app")); + if (app != nullptr) slideover_toggle(*app, b->page); +} + +/// A badge: a flat pill, name on the left, state dimmed beside it. Words only. +void make_badge(App& app, Badge& b, GtkWidget* parent, const char* name, const std::string& page) { + b.page = page; + b.button = gtk_button_new(); + gtk_widget_add_css_class(b.button, "flat"); + gtk_widget_add_css_class(b.button, "udpscope-badge"); + GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + b.name = gtk_label_new(name); + b.state = gtk_label_new(""); + gtk_widget_add_css_class(b.state, "udpscope-badge-state"); + gtk_box_append(GTK_BOX(row), b.name); + gtk_box_append(GTK_BOX(row), b.state); + gtk_button_set_child(GTK_BUTTON(b.button), row); + g_object_set_data(G_OBJECT(b.button), "udpscope-app", &app); + g_signal_connect(b.button, "clicked", G_CALLBACK(on_badge_clicked), &b); + gtk_box_append(GTK_BOX(parent), b.button); +} + +} // namespace + +void build_badge_bar(App& app) { + BadgeBar& bb = app.badges; + bb.bar = app.badge_bar; + bb.signals_slot = app.badge_signals; + bb.system_slot = app.badge_system; + make_badge(app, bb.trigger, bb.system_slot, "Trigger", "trigger"); + sync_badge_bar(app); +} + +void sync_badge_bar(App& app) { + BadgeBar& bb = app.badges; + if (bb.trigger.button == nullptr) return; + const bool enabled = app.trigger_toggle != nullptr && + gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.trigger_toggle)) != FALSE; + const bool armed = app.model->trigger_armed().load(std::memory_order_relaxed); + const bool pending = app.model->trigger_pending(); + const bool has_event = app.model->event() != nullptr; + set_text_if_changed(bb.trigger.state, trigger_badge_state(enabled, armed, pending, has_event)); + set_open(bb.trigger.button, slideover_page(app) == bb.trigger.page); +} + +gboolean badges_probe(gpointer user) { + App* a = static_cast(user); + // The whole table of the pure function: 16 inputs, 5 words. + struct Row { bool e, a, p, v; const char* want; }; + static const Row kRows[] = { + {false, false, false, false, "off"}, {false, false, false, true, "off"}, + {false, false, true, false, "off"}, {false, false, true, true, "off"}, + {false, true, false, false, "off"}, {false, true, false, true, "off"}, + {false, true, true, false, "off"}, {false, true, true, true, "off"}, + {true, false, false, false, "disarmed"}, {true, false, false, true, "triggered"}, + {true, false, true, false, "capturing"}, {true, false, true, true, "capturing"}, + {true, true, false, false, "armed"}, {true, true, false, true, "triggered"}, + {true, true, true, false, "capturing"}, {true, true, true, true, "capturing"}, + }; + int bad = 0; + for (const Row& r : kRows) { + if (std::strcmp(trigger_badge_state(r.e, r.a, r.p, r.v), r.want) != 0) ++bad; + } + // The geometry: fixed height whatever the state, and the open mark follows the slide-over. + const int h_closed = gtk_widget_get_height(a->badges.bar); + slideover_toggle(*a, "trigger"); + const bool marked_open = gtk_widget_has_css_class(a->badges.trigger.button, "udpscope-badge-open") != FALSE; + const bool page_open = slideover_page(*a) == "trigger"; + slideover_toggle(*a, "trigger"); + const bool marked_closed = gtk_widget_has_css_class(a->badges.trigger.button, "udpscope-badge-open") == FALSE; + const int h_after = gtk_widget_get_height(a->badges.bar); + std::fprintf(stderr, + "[badges] table_bad=%d trigger_state=%s bar_h=%d bar_h_after=%d fixed=%d open_marked=%d " + "page_open=%d closed_unmarked=%d\n", + bad, gtk_label_get_text(GTK_LABEL(a->badges.trigger.state)), h_closed, h_after, + h_closed == h_after && h_closed >= 36 ? 1 : 0, marked_open ? 1 : 0, page_open ? 1 : 0, + marked_closed ? 1 : 0); + return G_SOURCE_REMOVE; +} + +} // namespace ui_impl +} // namespace udpscope diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index fe5dc8c..1d9df6e 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -195,7 +195,8 @@ gboolean exit_probe_step3(gpointer user) { const int on_signals = page != nullptr && std::strcmp(page, "signals") == 0 ? 1 : 0; a->dbg_signal_page_current = on_signals && gtk_widget_get_first_child(a->signal_list) != nullptr ? 1 : 0; - a->dbg_trigger_sensitive = on_signals && gtk_widget_get_sensitive(a->trigger_body) ? 1 : 0; + // The trigger form is no longer on a sidebar tab, so the reading is the body's own sensitivity. + a->dbg_trigger_sensitive = gtk_widget_get_sensitive(a->trigger_body) ? 1 : 0; // `gtk_widget_get_visible` on the sidebar's GtkBox is always 1: AdwOverlaySplitView hides its // sidebar child via `gtk_widget_set_child_visible()`, not `gtk_widget_set_visible()`. The useful // reading is whether the split view is currently showing the sidebar panel. @@ -212,6 +213,7 @@ gboolean exit_probe_step3(gpointer user) { } // `dbg_window_text` is kept by `sync_window_display()`, the only place that knows the span the // window control is showing: there is no label to read it back from any more. + slideover_close(*a); // opened by step 1 for the allocations read above if (window != nullptr) gtk_window_destroy(GTK_WINDOW(window)); return G_SOURCE_REMOVE; } @@ -4030,6 +4032,9 @@ gboolean force_probe(gpointer user) { // trigger, so a second run against the same source would otherwise start armed and never read the // off case at all. gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), FALSE); + // The buttons' widths are allocations, and the trigger form is a slide-over page: open it, and step 2 + // reads 300 ms later, past the 120 ms reveal. + slideover_open(*a, "trigger"); g_timeout_add(300, force_probe_step2, a); return G_SOURCE_REMOVE; } @@ -4178,6 +4183,10 @@ gboolean export_probe(gpointer user) { gboolean exit_probe_step1(gpointer user) { App* a = static_cast(user); + // The trigger form is a slide-over page now, and a page that is not the open one has no allocation: + // open it here, and the chain below reads its widgets 2.1 s later, long after the 120 ms reveal. Step 3 + // closes it again. Sensitivity, visibility and values need no open page; widths and heights do. + slideover_open(*a, "trigger"); // Record the panel states before the toggles are exercised. a->dbg_trigger_sensitive_default = gtk_widget_get_sensitive(a->trigger_body) ? 1 : 0; // The two [edit] buttons, in each mode: the icon is the only thing that says which mode the pane is diff --git a/src/ui_slideover.cpp b/src/ui_slideover.cpp index cb3fe2b..40beeae 100644 --- a/src/ui_slideover.cpp +++ b/src/ui_slideover.cpp @@ -140,7 +140,7 @@ bool slideover_open(App& app, const std::string& id) { } gtk_widget_set_can_target(s.revealer, TRUE); gtk_revealer_set_reveal_child(GTK_REVEALER(s.revealer), TRUE); - // task 2: sync_badge_bar(app); + sync_badge_bar(app); return true; } @@ -150,7 +150,7 @@ void slideover_close(App& app) { s.page.clear(); gtk_widget_set_can_target(s.revealer, FALSE); gtk_revealer_set_reveal_child(GTK_REVEALER(s.revealer), FALSE); - // task 2: sync_badge_bar(app); + sync_badge_bar(app); } bool slideover_toggle(App& app, const std::string& id) { diff --git a/src/ui_widgets.cpp b/src/ui_widgets.cpp index 1bf2aba..cb5931a 100644 --- a/src/ui_widgets.cpp +++ b/src/ui_widgets.cpp @@ -74,6 +74,11 @@ void install_css() { ".udpscope-slideover { background-color: @window_bg_color; border-left: 1px solid @borders;" " box-shadow: -3px 0 10px rgba(0, 0, 0, 0.35); }\n" ".udpscope-slideover-title { font-weight: bold; }\n" + // The badge bar: a badge is a pill carrying a name and a state; the one whose page is open is + // marked, as a bench scope marks the open menu's button. + ".udpscope-badge { padding: 2px 12px; min-height: 0; border-radius: 14px; }\n" + ".udpscope-badge-open { background-color: alpha(@accent_bg_color, 0.28); }\n" + ".udpscope-badge-state { opacity: 0.7; }\n" // The contextual-help "(i)": a *mark*, not a button. Adwaita's menu button is a 34x34 control // (a 24 px floor plus 5 px of padding) with a 16 px glyph, which beside a heading reads as a // second toolbar button. Measured on libadwaita 1.10: the outer `menubutton` node *and* its From 31ff778e07fb130b6e6a27811dec86c5f681697f Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 2 Nov 2026 20:19:47 +0100 Subject: [PATCH 059/127] ui: the Source badge, and the source panels as slide-over pages --- docs/claude/verification.md | 13 +++++-- src/ui.cpp | 5 +-- src/ui_app.hpp | 14 ++++---- src/ui_badges.cpp | 71 ++++++++++++++++++++++++++++++++++++- src/ui_probe.cpp | 13 ++++--- src/ui_source.cpp | 13 +++---- 6 files changed, 101 insertions(+), 28 deletions(-) diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 502315c..d5b3dd6 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -439,6 +439,15 @@ precedent, not an exception. a slide-over page**, so a probe that reads one of its widgets' *allocation* (a width, a height) must `slideover_open(*a, "trigger")` and read after the 120 ms reveal, as `exit_probe_step1` and `force_probe` do; a sensitivity, a visibility or a value needs no open page. + **The source kind's panels are slide-over pages too** (`source-`: the E-1608's `source-ao` and + `source-dio`, Flat UDP's, the probe kind's), so the same rule holds for them. The probe appends a second + `[badges]` line for the Source badge: `[badges] source name= state= pages= + switch_visible=<0|1> can_target=<0|1> second_page= second_ok=<0|1> open_marked=<0|1|-1> + closed_unmarked=1`. `pages` is the number of source pages, `switch_visible` is the segmented switch + being on screen after the first source page was opened (`1` for the E-1608's two pages, `0` for one page + or none), `second_page` is what `slideover_page()` read after the switch selected the second page + (`second_ok=1` when it is that page), `can_target=0` with `pages=0` is the UDPS badge being a read-out and + `open_marked=-1` is that no-page case (there is nothing to open). - `UDPSCOPE_PROBE_SOURCE_KINDS=2000` with `UDPSCOPE_EXIT_AFTER_MS=5000`, run as `--source probe --source-opt rate=500` -- the source-plugin path end to end. The variable's presence also registers the test-only `probe` kind (a synthetic sine with one panel), so it is the only way a run sees that kind; its value is the delay in ms. @@ -461,8 +470,8 @@ precedent, not an exception. - `UDPSCOPE_PROBE_E1608_GEN=1500` with `UDPSCOPE_EXIT_AFTER_MS=5500`, run as `--source e1608 --source-opt address=127.0.0.1 --source-opt port=54711 --source-opt ao0_init=0 --source-opt gen_rate=100` against `tools/mock_e1608.py --port 54711 --log /tmp/e1608-gen100.log` -- the generator's update rate: it starts a Sine on - DAC 0 through the Analog out panel (and switches the side stack to that page, since the panel refreshes on its - frame clock, which does not tick for a page that is off screen), waits 2.5 s, reads the achieved rate and the + DAC 0 through the Analog out panel (and opens the `source-ao` slide-over page, since the panel refreshes on its + frame clock, which does not tick for a page that is off screen; the page is closed again at the end), waits 2.5 s, reads the achieved rate and the panel's "Update rate" row, stops the generator and reads both again 400 ms later. Two lines, in `probe-readings.md`. Then count the mock log's `AOUT` lines: about 250 with `gen_rate=100` (the generator ran for about 2.5 s) and about 125 for a run without the option (default 50 Hz). Short and serial, the mock killed diff --git a/src/ui.cpp b/src/ui.cpp index e28d78d..b38a491 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -5072,8 +5072,6 @@ void on_activate(GtkApplication* application, gpointer user) { } // The slide-over panel: an overlay child of the plot, above the GL area. build_slideover(*app); - // The badge bar reads `trigger_toggle`, which `build_toolbar()` made above. - build_badge_bar(*app); // The size the window opens at, stated in code as well as in the `.ui`: `default-width` there is the // size it *asks* for, and a window whose content has a larger minimum opens at that minimum instead -- @@ -5222,6 +5220,9 @@ void on_activate(GtkApplication* application, gpointer user) { // are hidden first and kept hidden for the pages this call adds. keep_tabs_icon_free(*app); build_source_panels(*app); + // The badge bar reads `trigger_toggle` (made by `build_toolbar()` above) and the source's pages, which + // `build_source_panels()` has just registered: the Source badge opens the first of them. + build_badge_bar(*app); // The left side is the events pane *and* the plot, in one row: the pane is a column of its own // (the item's "collapsable pane on the left"), revealed by trigger mode. diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 8a58a80..11d0a75 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -109,11 +109,11 @@ struct TourView { void* done_user = nullptr; }; -/// One capability panel a source kind contributed to the side stack. `content` is the kind's own widget +/// One capability panel a source kind contributed to the slide-over. `content` is the kind's own widget /// (made insensitive while the source is disconnected); the page wrapping it is named `name`. struct SourcePanelPage { - std::string name; ///< stack page name: "source-" + the kind's panel id - std::string title; ///< the pill's tooltip and accessible name; the page's own title stays empty + std::string name; ///< slide-over page id: "source-" + the kind's panel id + std::string title; ///< the slide-over page's title, and the segmented switch's label GtkWidget* content = nullptr; }; @@ -143,7 +143,7 @@ struct BadgeBar { GtkWidget* signals_slot = nullptr; ///< left; empty in step 1, step 2 fills it GtkWidget* system_slot = nullptr; ///< right: Trigger, Source Badge trigger; - Badge source; ///< task 3 + Badge source; ///< opens the first source page; a read-out when the source has none }; struct App { @@ -1606,8 +1606,8 @@ void save_layout_now(App& app); void keep_tabs_icon_free(App& app); /// Capability panels (`src/ui_source.cpp`). `build_source_panels` adds the source kind's panels as -/// pages of `side_stack`, once, before the first tick. `apply_source_panels` sets every panel's -/// sensitivity from `connected` -- disabled, never hidden: the tab switcher owns visibility. +/// pages of the slide-over (`source-`), once, before the first tick. `apply_source_panels` sets every panel's +/// sensitivity from `connected` -- disabled, never hidden. /// `sync_source_panels` is the per-tick call: it applies the model's own connection flag. void build_source_panels(App& app); void apply_source_panels(App& app, bool connected); @@ -1925,7 +1925,7 @@ gboolean slideover_probe(gpointer user); // UDPSCOPE_PROBE_SLIDEOVER_MS /// The badge bar (`src/ui_badges.cpp`): setup is reached by the object it configures. /// The Trigger badge's state word. Pure, so the probe can assert the whole table. const char* trigger_badge_state(bool enabled, bool armed, bool pending, bool has_event); -void build_badge_bar(App& app); // after build_slideover and after the toolbar (it reads trigger_toggle) +void build_badge_bar(App& app); // after build_slideover, the toolbar (it reads trigger_toggle) and build_source_panels (the Source badge's page) void sync_badge_bar(App& app); // per tick and on every slide-over open/close; only writes what changed gboolean badges_probe(gpointer user); // UDPSCOPE_PROBE_BADGES_MS gboolean source_kinds_probe_report(gpointer user); diff --git a/src/ui_badges.cpp b/src/ui_badges.cpp index 1ac2830..fd560f1 100644 --- a/src/ui_badges.cpp +++ b/src/ui_badges.cpp @@ -3,8 +3,12 @@ // The badge bar under the plot: setup is reached by the object it configures. +#include "source/registry.hpp" #include "ui_app.hpp" +#include + +#include #include #include @@ -37,7 +41,15 @@ void on_badge_clicked(GtkButton*, gpointer user) { Badge* b = static_cast(user); // The App is reached through the badge's button: the badges live inside App, so the pointer is stable. App* app = static_cast(g_object_get_data(G_OBJECT(b->button), "udpscope-app")); - if (app != nullptr) slideover_toggle(*app, b->page); + if (app == nullptr) return; + // The Source badge opens the first source page; with another source page already open, a click on it + // closes the panel rather than jumping back to the first page (the switch inside the panel moves between + // them). + if (b == &app->badges.source && slideover_page(*app).rfind("source-", 0) == 0) { + slideover_close(*app); + return; + } + slideover_toggle(*app, b->page); } /// A badge: a flat pill, name on the left, state dimmed beside it. Words only. @@ -66,6 +78,14 @@ void build_badge_bar(App& app) { bb.signals_slot = app.badge_signals; bb.system_slot = app.badge_system; make_badge(app, bb.trigger, bb.system_slot, "Trigger", "trigger"); + // The first source page is where the badge goes; a source with none (UDPS) has nothing to open, so its + // badge is a read-out: not a target, not focusable, but still on screen. + const std::string first = app.source_panels.empty() ? std::string() : app.source_panels.front().name; + make_badge(app, bb.source, bb.system_slot, "Source", first); + if (first.empty()) { + gtk_widget_set_can_target(bb.source.button, FALSE); + gtk_widget_set_focusable(bb.source.button, FALSE); + } sync_badge_bar(app); } @@ -79,6 +99,21 @@ void sync_badge_bar(App& app) { const bool has_event = app.model->event() != nullptr; set_text_if_changed(bb.trigger.state, trigger_badge_state(enabled, armed, pending, has_event)); set_open(bb.trigger.button, slideover_page(app) == bb.trigger.page); + + if (bb.source.button == nullptr) return; + // The source's name and link state. A file has neither a link nor a kind worth naming. + const bool file_mode = app.replaying || !app.model->config().open_waveform.empty(); + std::string name = "Source"; + const char* state = ""; + if (file_mode) { + name = "File"; + } else { + if (SourceKind* kind = find_kind(app.source_kind)) name = kind->display_name(); + state = app.model->stats().connected.load(std::memory_order_relaxed) ? "connected" : "disconnected"; + } + set_text_if_changed(bb.source.name, name.c_str()); + set_text_if_changed(bb.source.state, state); + set_open(bb.source.button, slideover_page(app).rfind("source-", 0) == 0); } gboolean badges_probe(gpointer user) { @@ -113,6 +148,40 @@ gboolean badges_probe(gpointer user) { bad, gtk_label_get_text(GTK_LABEL(a->badges.trigger.state)), h_closed, h_after, h_closed == h_after && h_closed >= 36 ? 1 : 0, marked_open ? 1 : 0, page_open ? 1 : 0, marked_closed ? 1 : 0); + // The Source badge: its read-out, and the segmented switch of a source with several panels. + const std::string first = a->badges.source.page; + bool switch_visible = false; + bool second_ok = true; + std::string second_page; + if (!first.empty()) { + slideover_open(*a, first); + switch_visible = gtk_widget_get_visible(a->slideover.source_switch) != FALSE; + if (a->slideover.source_pages.size() >= 2) { + adw_toggle_group_set_active_name(ADW_TOGGLE_GROUP(a->slideover.source_switch), + a->slideover.source_pages[1].c_str()); + second_page = slideover_page(*a); + second_ok = second_page == a->slideover.source_pages[1]; + } + } + const bool marked = gtk_widget_has_css_class(a->badges.source.button, "udpscope-badge-open") != FALSE; + // The widest minimum width of any source panel: the slide-over's scroller never scrolls horizontally, so + // a panel whose minimum exceeds the page (360 px less the scroller's own chrome) would be clipped. + int widest = 0; + for (const SourcePanelPage& p : a->source_panels) { + int min_w = 0; + gtk_widget_measure(p.content, GTK_ORIENTATION_HORIZONTAL, -1, &min_w, nullptr, nullptr, nullptr); + widest = std::max(widest, min_w); + } + slideover_close(*a); + std::fprintf(stderr, + "[badges] source name=%s state=%s pages=%zu switch_visible=%d can_target=%d second_page=%s " + "second_ok=%d open_marked=%d closed_unmarked=%d panel_min_w=%d\n", + gtk_label_get_text(GTK_LABEL(a->badges.source.name)), + gtk_label_get_text(GTK_LABEL(a->badges.source.state)), a->slideover.source_pages.size(), + switch_visible ? 1 : 0, gtk_widget_get_can_target(a->badges.source.button) ? 1 : 0, + second_page.empty() ? "-" : second_page.c_str(), second_ok ? 1 : 0, + first.empty() ? -1 : marked ? 1 : 0, + gtk_widget_has_css_class(a->badges.source.button, "udpscope-badge-open") ? 0 : 1, widest); return G_SOURCE_REMOVE; } diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 1d9df6e..14cf6c7 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -1957,9 +1957,7 @@ gboolean source_kinds_probe(gpointer user) { const SourcePanelPage* page = a->source_panels.empty() ? nullptr : &a->source_panels.front(); const auto report = [&](const char* state) { const bool present = - page != nullptr && a->side_stack != nullptr && - adw_view_stack_get_child_by_name(ADW_VIEW_STACK(a->side_stack), page->name.c_str()) != - nullptr; + page != nullptr && slideover_has_page(*a, page->name); std::fprintf(stderr, "[source-kinds] panel %s: panels=%zu present=%d sensitive=%d\n", state, a->source_panels.size(), present ? 1 : 0, page != nullptr && gtk_widget_get_sensitive(page->content) ? 1 : 0); @@ -2399,8 +2397,7 @@ gboolean e1608_probe(gpointer user) { const std::string page = std::string("source-") + id; GtkWidget* content = source_panel_content(a, page.c_str()); const bool present = - content != nullptr && a->side_stack != nullptr && - adw_view_stack_get_child_by_name(ADW_VIEW_STACK(a->side_stack), page.c_str()) != nullptr; + content != nullptr && slideover_has_page(*a, page); std::fprintf(stderr, "[e1608] panel %s: present=%d sensitive=%d connected=%d\n", id, present ? 1 : 0, content != nullptr && gtk_widget_get_sensitive(content) ? 1 : 0, up ? 1 : 0); @@ -2445,6 +2442,7 @@ gboolean e1608_gen_probe_stopped(gpointer user) { std::fprintf(stderr, "[e1608] generator stopped: achieved=%.1f panel='%s' ok=%d\n", hz, sub != nullptr ? sub : "", hz == 0.0 && off ? 1 : 0); std::fflush(stderr); + slideover_close(*a); return G_SOURCE_REMOVE; } @@ -2493,8 +2491,9 @@ gboolean e1608_gen_probe(gpointer user) { std::fflush(stderr); return G_SOURCE_REMOVE; } - // The panel refreshes on its frame clock, which only ticks while the page is on screen. - if (a->side_stack != nullptr) adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(a->side_stack), "source-ao"); + // The panel refreshes on its frame clock, which only ticks while the page is on screen; the page stays + // open for the probe's reads and `e1608_gen_probe_stopped` closes it. + slideover_open(*a, "source-ao"); adw_combo_row_set_selected(ADW_COMBO_ROW(mode), 1); // Sine adw_switch_row_set_active(ADW_SWITCH_ROW(run), TRUE); g_timeout_add(2500, e1608_gen_probe_report, a); diff --git a/src/ui_source.cpp b/src/ui_source.cpp index d880485..1337e7c 100644 --- a/src/ui_source.cpp +++ b/src/ui_source.cpp @@ -54,7 +54,7 @@ void keep_tabs_icon_free(App& app) { } void build_source_panels(App& app) { - if (app.side_stack == nullptr || app.acq == nullptr || app.acq->source() == nullptr) return; + if (app.slideover.stack == nullptr || app.acq == nullptr || app.acq->source() == nullptr) return; SourceKind* kind = find_kind(app.source_kind); if (kind == nullptr) return; for (const SourcePanel& p : kind->make_panels(*app.acq->source())) { @@ -63,15 +63,10 @@ void build_source_panels(App& app) { p.widget == nullptr ? "no widget" : "an empty id"); continue; } - // Each page scrolls on its own, like Measure and Signals: panels differ in height. - GtkWidget* scroller = gtk_scrolled_window_new(); - gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER, - GTK_POLICY_AUTOMATIC); - gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), p.widget); const std::string name = "source-" + p.id; - // The tab is its title: the sidebar's pills are words, not glyphs. - adw_view_stack_add_titled(ADW_VIEW_STACK(app.side_stack), scroller, name.c_str(), - p.title.c_str()); + // The panel is a slide-over page now: the slide-over wraps it in its own scroller, and with two or + // more source pages it draws the segmented switch (the E-1608: Analog out, Digital I/O). + if (!slideover_add_page(app, name, p.title, p.widget)) continue; app.source_panels.push_back({name, p.title, p.widget}); } // Disconnected until the first Connected event says otherwise. From 4b65197f56ecbca93e5e476c2c08e0826eef3971 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 2 Nov 2026 20:37:55 +0100 Subject: [PATCH 060/127] ui: the sidebar's tabs become one dock; Measure and Signals by keyboard section; session keeps the dock's state Co-Authored-By: Claude Opus 4.6 --- README.md | 6 +- docs/claude/architecture.md | 2 +- docs/claude/invariants-gtk-and-chrome.md | 46 ++-- docs/claude/invariants-plot-and-layout.md | 5 +- docs/claude/invariants-session-and-export.md | 5 +- docs/claude/probe-readings.md | 116 ++++----- docs/claude/verification.md | 23 +- src/layout_store.cpp | 6 +- src/layout_store.hpp | 7 +- src/shortcuts.cpp | 4 +- src/shortcuts.hpp | 4 +- src/ui.cpp | 188 ++++++--------- src/ui/main-window.ui | 69 +----- src/ui/shortcuts.ui | 4 +- src/ui_app.hpp | 56 ++--- src/ui_layout.cpp | 7 +- src/ui_probe.cpp | 239 ++++++++++++++----- src/ui_source.cpp | 42 ---- src/ui_tour.cpp | 11 +- 19 files changed, 421 insertions(+), 419 deletions(-) diff --git a/README.md b/README.md index ded6c36..bf3e487 100644 --- a/README.md +++ b/README.md @@ -2915,9 +2915,11 @@ now — they are listed so they are not mistaken for anything else: unlocked, because configuring the host is what a first run is for. Each pane's heading also carries its **own switch**, the same control as the header bar's button — clicking either moves both — because a pane that is *disabled* while its feature is off is a pane whose controls you would otherwise have to leave to turn it back on. The - switcher's policy is `wide` and each page has a title and no icon (the switcher's own placeholder icon is hidden). With the math + sidebar is one **dock** now, with no tab switcher: the signal list, the cursors and the measurements + in one scroller, shown from the start and insensitive until the stream has signals; `m` and `g` + scroll it to the cursors and back to the signal list. With the math list folded into the signal list under a `MATH` divider whose `+` creates a channel — a derived channel already *is* a channel in - the model, so the two lists were the same list drawn twice. A tab is one-of-*N*, so no + the model, so the two lists were the same list drawn twice. No toggle hides a pane any more: cursors-off **disables** its readout and leaves it in place, and trigger-off leaves the trigger panel fully editable — a trigger is configured *before* it is switched on, and the locked view says `stream (trigger off)` instead of the settings — and the measurements' and math panes' toolbar toggles are gone with the panes. diff --git a/docs/claude/architecture.md b/docs/claude/architecture.md index b295015..40c8568 100644 --- a/docs/claude/architecture.md +++ b/docs/claude/architecture.md @@ -36,7 +36,7 @@ What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*. `src/source/udps_*.cpp` (`grep -rn "udps_" src` outside those files is a leak of the old coupling). - The frame is **single-timebase**. A multi-timebase source (ABScope) will change the interface and the data path together; do not bend one frame to carry two clocks. -- A kind's capability panels are pages of `side_stack`, named `source-`. A disconnected source +- A kind's capability panels are pages of the slide-over (`slideover_add_page`), named `source-`. A disconnected source leaves them **insensitive, not hidden** (`apply_source_panels` in `src/ui_source.cpp`). - Session file naming for a non-UDPS kind: the pseudo-host is `-` (characters outside `[A-Za-z0-9._-]` become `_`) on port **1** (0 is rejected by the session-file parser), the kind is diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md index c594499..cc7d9fc 100644 --- a/docs/claude/invariants-gtk-and-chrome.md +++ b/docs/claude/invariants-gtk-and-chrome.md @@ -50,7 +50,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear 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* (Fit, Export, Configurations) and the one editor that is not a pane (the layout editor): the signal list - is a *tab* of the sidebar, so there is nothing left to reveal or hide about it. The Arm/Disarm button in the trigger panel still has text. + is a section of the sidebar's dock, so there is nothing left to reveal or hide about it. The Arm/Disarm button in the trigger panel still has text. - **Five 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 measurement ruler, and the stock names those buttons used to carry said something else @@ -61,9 +61,9 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear an icon with a live user is not an orphan, whatever the toggle that introduced it did -- and it is the Preferences window's icon. - The sidebar's **tabs have no icons any more**: two glyphs (`udpscope-tab-measure-symbolic`, + The sidebar has **no tabs any more** (it is one dock): two tab glyphs (`udpscope-tab-measure-symbolic`, `udpscope-tab-signals-symbolic`, each a 32x16 pair) were drawn for them and judged not understandable - enough, so the tabs are text and both SVGs were deleted. The *trigger's* own icon was redrawn to the + enough, so the tabs were text, and both SVGs were deleted. The *trigger's* own icon was redrawn to the Signals glyph's picture at that time and kept it. - **A shortcut table row's `accel` is GTK accelerator syntax, and a pointer gesture has none.** `src/shortcuts.cpp`'s strings are handed to `AdwShortcutsItem`, which parses them @@ -229,11 +229,13 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear instead of across. `gtk_widget_set_visible()` is what a layout honours, so anything that must not take room has to be hidden and not merely unrevealed; the revealer keeps the slide while the pane *is* open. The allocated width is how this was found -- the revealer's own measure said 0. - **The sidebar is a tab stack now, and has no `GtkRevealer` left in it**: an `AdwViewSwitcher` - above an `AdwViewStack`, each page a `GtkScrolledWindow` of its own (the two pages are different - heights, so one shared scroller would leave a switch scrolled where the new page never asked to - be). The reason is the one this trap teaches: five sections competing for one 320 px column left - every one of them short of room. A *feature's* on/off is no longer a visibility at all -- the + **The sidebar is one dock now, and has no `GtkRevealer` left in it**: a single `GtkScrolledWindow` + (`dock_scroller`) holding the signal list, the cursors and the measurements one under another. It + replaced an `AdwViewSwitcher` over an `AdwViewStack`, whose four text tabs did not fit 320 px (and a source + can add more); a source kind's own panels are slide-over pages, not dock sections. The reason is the one + this trap teaches: sections competing for one 320 px column each left every one of them short of room, and + a scroller gives each its natural height. `m` and `g` scroll the dock to the cursors and to the signal list + (`dock_show_section()`). A *feature's* on/off is no longer a visibility at all -- the **cursors'** toggle sets its pane's **body** insensitive -- the readout has nothing to show with the instruments off -- and the *body* and not the pane, because the pane's heading carries its own switch (see the next bullet). The **trigger's body is deliberately never disabled**: a trigger is configured @@ -241,23 +243,15 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear ordinary way to work. It used to go insensitive with the mode, which left the settings unreachable exactly when a user wanted to change them; `trigger panel sensitive …=1` is the regression guard, and a `0` there means the configuration has been locked out again. The - trap itself does not go away with the widget: a stack's hidden page measures 0x0 too and is still - given the leftover width, so **ask about state, not about size** carries over unchanged. Every - allocation read in the sidebar is a debug capture, and each reads 0 on the page that is *not* - current -- a change in what the reading means, not a defect, and no functional code may depend on - those numbers. -- **The sidebar's tabs are text: `AdwViewSwitcherPolicy.wide`, a title on every page, no icon.** The - built-in pages get their titles in the `.ui` and a source kind's pages from `SourcePanel::title` - (`adw_view_stack_add_titled`); the page's *name* still identifies it to `select_side_tab()` and the - session file. An icon-only version existed (policy `narrow`, empty titles, the pills named in code by - `name_side_tabs()`, because `narrow` still draws a label beside an icon); it was reversed because the - glyphs were not understandable. A page added with an empty title is a blank pill, and - `switcher: … pills=N labels=N images=0 pages=N` reads it as `labels` below `pills`. **No icon at all** - is not what "no `icon-name`" gives you: `AdwViewSwitcherButton` always builds a `GtkImage` and falls - back to `image-missing` (the `[x]` placeholder, which also squeezed the title to "Meas..."), and no - property turns it off. `keep_tabs_icon_free()` (`src/ui_source.cpp`) hides every image in the switcher, - re-hides one that is shown again, and re-runs on the stack's `items-changed` (connected *after* the - switcher's own, so the new button exists) and on the switcher's `map`; `images=0` is the reading. + trap itself does not go away with the widget: a section scrolled out of view is still laid out, but + **ask about state, not about size** still holds for anything a tick reads. +- **The dock is shown but insensitive until the stream has signals ("disable, do not hide").** + `update_side_visibility()` and the tick's per-frame block set the column's visibility from the user's wish + alone (`sidebar_wanted`) and the dock's *sensitivity* from `have_signals`; the toolbar's Dock button stays + sensitive throughout. The wish is persisted (`dock = true|false` in the session file) and a change of it + schedules a save. There is no tab icon to keep clear any more: the `keep_tabs_icon_free()` workaround for + `AdwViewSwitcherButton`'s placeholder `[x]` image went with the switcher, and `dock:` in the dump + replaces the `switcher:` line. - **An *overlaid* sidebar covers the plot, so a drag out of it has nowhere to land.** `collapsed` in an `AdwOverlaySplitView` means the sidebar sits *on* the plot rather than beside it: the pointer is over the list, never over a drop target, and a signal can be dragged but not dropped. `row_drag_sidebar()` hides @@ -278,7 +272,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear `sync_panel_switches()` re-reads each pair from the toolbar's state after any change — so clicking either end moves both, and `panel switches: trigger=1/1 cursors=1/1` is the reading. Two traps: a `GtkSwitch` emits `notify::active` for a *programmatic* write as well, so the sync holds a guard - (`App::panel_switch_sync`) or the program's own write comes back as a click; and the switcher must + (`App::panel_switch_sync`) or the program's own write comes back as a click; and the switch must stay *out* of what the pane disables, because a control inside an insensitive parent cannot be clicked — a switch that greyed out with its own pane could never turn it back on. That is why the sensitive target is the pane's **body** (`trigger_body`, `cursors_body`) and not the pane. diff --git a/docs/claude/invariants-plot-and-layout.md b/docs/claude/invariants-plot-and-layout.md index a82221b..897d782 100644 --- a/docs/claude/invariants-plot-and-layout.md +++ b/docs/claude/invariants-plot-and-layout.md @@ -23,10 +23,11 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear toggle is the same mistake in one line, and it cannot be told from a real change by a "is this our own write" flag either, since the toggle's signal may fire after the write returns. Instead `App::sidebar_wanted` holds the wish (written only by `on_sidebar_toggled`, guarded by `App::sidebar_syncing` around our own - `set_active`), `update_side_visibility()` is the single place that turns that wish plus "are there signals" - into the panel's *and* the button's state together, and `tick()` re-asserts them when the widget disagrees — + `set_active`), `update_side_visibility()` is the single place that turns that wish into the panel's visibility + and "are there signals" into the dock's *sensitivity* (the button stays sensitive), and `tick()` re-asserts both when the widget disagrees — which is also why a hide *gesture* does not stick: the panel stays where the button says it is. The no-signals case must leave the wish alone, or a source connecting later finds a sidebar the user never closed. + The wish is the session's `dock` key: a change of it (not a program's own write, and not a restore) schedules a save. - **`AdwOverlaySplitView:sidebar-width-fraction` is a 0..1 fraction of the window, whatever `sidebar-width-unit` says.** Setting a pixel count there (320) is a `GObject` critical, and it silently falls back to 0.25 — which reads as "the sidebar is mysteriously 560 px wide" on a wide window with no diff --git a/docs/claude/invariants-session-and-export.md b/docs/claude/invariants-session-and-export.md index 550e0ea..169ebdb 100644 --- a/docs/claude/invariants-session-and-export.md +++ b/docs/claude/invariants-session-and-export.md @@ -30,7 +30,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear not freeze a colour that is meant to follow the signal list), **the math channels with their graphs**, **the measurements pane**, and **`measure_stats`** — the measurements statistics toggle; written as a bare top-level boolean (`measure_stats = true/false`), defaults to `false` - when absent. Signal identity + when absent, and **`dock`** -- the Dock button's state (the user's last word on it, `sidebar_wanted`), + a bare top-level boolean written where the sidebar's `side_tab` used to be, default `true`. The parser + ignores unknown keys, so a file written before the tabs became a dock (`side_tab = "measure"`) still loads + and simply opens with the dock shown; nothing needs to read the old key. Signal identity is the *name* throughout (a CONFIG renumbers channels), 'enabled' is read off the channels the model built -- an enabled signal is exactly one that produced a channel -- and `apply_layout()` installs the graphs **before** assigning the rows, since a row may hold a math channel's name and diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 36faa0d..48d9243 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -479,22 +479,18 @@ 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 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. -- `switcher: policy=wide pills=4 labels=4 images=0 pages=4 size=308x34` (in the exit dump, beside `panels:`) — the - sidebar tabs as the switcher actually draws them, and they are **words**: `labels` counts *mapped labels - with text*, so `pills=labels=pages` says every page has a visible title. **`images` must be `0`**: it - counts *mapped* `GtkImage`s inside the switcher. `AdwViewSwitcherButton` builds an icon in every tab, and - a page with a title and no `icon-name` gets `image-missing` -- the `[x]` placeholder; the tabs hide it - (`keep_tabs_icon_free()`, which also covers pages `build_source_panels()` adds). A nonzero `images` - names a tab still drawing an icon or its placeholder (`images=pages` is the unfixed state), and - `pills`/`labels` never saw it. Read it on a source with extra pages (`--source probe` under - `UDPSCOPE_PROBE_SOURCE_KINDS`) as well as on UDPS. `pages` is read from the stack - (2 for UDPS, 3 for the probe source, 4 for the E-1608 with its Analog out and Digital I/O tabs). A - `labels` below `pills` is a page added with an empty title; `pills=0` means nothing is mapped, which - makes the rest of the line meaningless rather than a pass. `policy=wide` is read rather than assumed. - `size=` is the bar's own size: a text tab costs about 34 px of height, and a width of 308 shows the - four tabs fit the 320 px column. +- `dock: shown=1 sensitive=1 w=320 scroller_pages=1` (in the exit dump, beside `panels:`) -- the sidebar's dock + as the last tick found it. It replaces the old `switcher:` line: there is no tab switcher, the sidebar is + one scroller (`dock_scroller`) holding the signal list, the cursors and the measurements. **`shown=1` + always** (the dock is on screen from the start; `shown=0` only when the user hid it with the Dock button, + which a probe does deliberately); **`sensitive=0` only before the stream has signals** ("disable, do not + hide") -- a run against a port with no sender reads `shown=1 sensitive=0`, a normal run `sensitive=1`; + `w=` is the sidebar's own width (320); **`scroller_pages=1`** counts the scrollers directly under the dock + column and says there is one scroller and no stack. These are captured on the tick or at the exit probe: + the dump runs after the window is destroyed. A source with extra panels adds *no* dock section: its panels + are slide-over pages (`source-`), so the line reads the same on the E-1608 and the probe kind. - `help layer: contextual=on hints=12 ids={events plot status cursors measures trigger trigger_advanced events_record trigger_search signals signals_config window} icon=dialog-information-symbolic(ok) hint=12x12; tour=idle steps=5 step=0 auto=1 requested=0` (in the exit - dump, beside `switcher:`) — the help affordance layer as the last tick found it. **`hints=` must equal + dump, beside `dock:`) — the help affordance layer as the last tick found it. **`hints=` must equal the number of ids**, and the ids are the surfaces the contextual help is on: eleven of the window itself, and `math` only while the math editor is open (its heading lives in a builder that is rebuilt on every opening, so it is not one of the window's own). Four of the eleven are not a pane's *heading* @@ -538,7 +534,7 @@ Two lines of the self-check output are assertions in disguise: [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). The writer chains to `g_log_writer_default()`, so every message still reaches stderr exactly as before and the documented baselines are read the same way. -- `help figures: trigger_head=X,Y,W,H trigger_summary=… trigger_panel=… signal_list=… side_switcher=… side_stack=… window_button=… status_label=… header_bar=… plot=…` (in the exit +- `help figures: trigger_head=X,Y,W,H trigger_summary=… trigger_panel=… signal_list=… side_stack=… window_button=… status_label=… header_bar=… plot=…` (in the exit dump, beside `help layer:`) — the **window-coordinate** rectangle of each surface the help's annotated figures label, `x,y,width,height`, or `-1,-1,0,0` for a surface that is **not on screen**. It is measured on the tick while the window is alive (nothing in the dump may call `gtk_widget_*` — see @@ -919,9 +915,9 @@ two also catch a row whose accelerator went missing. `settings=` must be a real selectable into it (`gtk_widget_get_mapped()` -- a wrong *page* is kept, because selecting its page reveals it, while a *collapsed pane* is still dropped; see the surface bullets in [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). - - `[helpui] step=1/5 visible=1 side_tab=signals spot=[x,y WxH] target=[…] contains=1 card=[…] clear=C` - — **in window coordinates**, the frame a screenshot is cropped in, with the sidebar's page beside - it (a stop the walk had to change the page for is readable as such; see `[toursurface]` below). + - `[helpui] step=1/5 visible=1 slideover=none spot=[x,y WxH] target=[…] contains=1 card=[…] clear=C` + — **in window coordinates**, the frame a screenshot is cropped in, with the slide-over's open page + (`none`, or a page id) beside it; see `[toursurface]` below. **`contains` must be 1** on every step: the spotlight is the target's own rectangle grown three pixels. `clear` is the card missing the target, and it is 1 wherever the window has room for the card beside or above/below the hole — at the documented run's window (see `help figures:` below) @@ -949,34 +945,30 @@ two also catch a row whose accelerator went missing. `settings=` must be a real read `mapped=0` (and `0x0`), which is exactly why the header box exists (`search_head` in `src/ui/trigger-panel.ui`) and why the hint joins it as its last child. `mapped_header=` is the header box beside it, and `hint=WxH` is the same 12x12 the layer's own line reads. -- `[toursurface] …` (stderr, `UDPSCOPE_PROBE_TOUR_SURFACE_MS`) — a stop that stands on a page it is - not on, entered from the page it is not on. It puts the sidebar on **Measure**, starts the window's - tour, and walks it a stop per tick (400 ms), because a stack allocates the new page's widgets on - the frame *after* the switch — so each reading is of the page the step left standing, not of the - one the switch has just left behind. The lines: +- `[toursurface] …` (stderr, `UDPSCOPE_PROBE_TOUR_SURFACE_MS`) -- a stop that stands on a surface it is + not on, entered from the state it is not in. Since the sidebar's tabs became the dock the probe opens the + `trigger` slide-over page instead of switching a tab, starts the window's tour and walks it a stop per tick + (400 ms). **The tour's own rework for the slide-over is Task 5's**: until its page hook exists the tour + has no page stack, so a stop whose target is on a slide-over page is dropped, and the lines below read the + dock only. The lines: - - `[toursurface] before: side_tab=signals signal_list_mapped=1 running=1` — the state a first run - starts in (a fresh config's sidebar is on Signals), then the auto-started walk is dismissed. - - `[toursurface] on measure: side_tab=measure signal_list_mapped=0 side_stack_mapped=1` — a frame - after the switch: **`signal_list_mapped=0` beside `side_stack_mapped=1`** is the whole premise, - the list is genuinely off screen while the stack that holds it is on screen. - - `[toursurface] tour: visible=1 steps=5 step=0 signals_stop=1` — **`signals_stop=1` is the filter's - reading**: a stop whose target is *not mapped* is still in the plan because it names a page the - stack can select. `steps=5` is the full table. - - `[toursurface] step=N/5 side_tab= target_mapped=D[ target=signal_list]` — one per stop. The - page moves from `measure` to `signals` at the `trigger` stop (the first stop whose target is on - the Signals page) and stays there; **the `signals` stop reads `target_mapped=1`**. - - `[toursurface] signals stop=4/5 visible=1 side_tab=signals spot=[…] target=[…] contains=1 - card=[…] clear=1` — the `tour_geometry_line()` the helpui probe uses, read on the frame *after* - the switch: **`side_tab=signals` with `contains=1` is the assertion** that the spotlight and the - card are on the list, not on the page the walk came from. - - `[toursurface] collapsed: side_stack_mapped=0 steps=2 signals_stop=0 side_stop=0` — the other half - of the filter, read: with `sidebar_wanted` off (`update_side_visibility()` re-asserts it, so this - is how a probe puts the sidebar away for good) the split view unmaps the column, the stack with - it, and the two stops that need the sidebar are dropped while the page-less ones stay. This is - what says a *wrong page* and a *collapsed pane* are not the same case. -- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | c: cursors: 0->1 | m: tab signals->measure | g: tab measure->signals | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. - The `m`/`g` pairs are the *tab name* before and after, which is what says those two keys select a tab rather than toggling a pane; `t`/`c` read the toolbar toggle's own state, because those two enable a feature and the button is where that state is visible. 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 `: : ->` — 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). + - `[toursurface] before: slideover=none signal_list_mapped=1 running=1` -- the state a first run starts in + (the dock is on screen), then the auto-started walk is dismissed. + - `[toursurface] on trigger: slideover=trigger signal_list_mapped=1 side_box_mapped=1` -- a frame after the + page was opened: the dock is still on screen under the slide-over (they are not alternatives). + - `[toursurface] tour: visible=1 steps=N step=0 signals_stop=1` -- **`signals_stop=1` is the filter's + reading**: the signal list is in the dock, which is mapped, so its stop is in the plan. + - `[toursurface] step=N/M slideover= target_mapped=D[ target=signal_list]` -- one per stop; + **the `signals` stop reads `target_mapped=1`**. + - `[toursurface] signals stop=N/M visible=1 slideover=… spot=[…] target=[…] contains=1 card=[…] clear=1` -- + the `tour_geometry_line()` the helpui probe uses: **`contains=1` is the assertion** that the spotlight + and the card are on the list. + - `[toursurface] collapsed: side_box_mapped=0 steps=2 signals_stop=0 side_stop=0` -- the other half of + the filter, read: with `sidebar_wanted` off (`update_side_visibility()` re-asserts it, so this is how a + probe puts the sidebar away for good) the split view unmaps the column and the stops that need the + dock are dropped while the page-less ones stay. This is what says a *collapsed pane* is dropped. +- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | c: cursors: 0->1 | m: dock 0->243 (cursors_y=243 reach=2256 at_cursors=1) | g: dock 243->0 at_top=1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** (`g` returns, so its pair reads `243->0`) and **`refused` must be 1**. + The `m`/`g` readings are the dock scroller's vertical adjustment before and after (the probe pads the dock to 3000 px so there is something to scroll, and undoes it at the end): **`m` must land on `cursors_y`, the cursors panel's y in the dock (`at_cursors=1`), and `g` must return to 0 (`at_top=1`)**, which is what says those two keys scroll to a section rather than toggling a pane; a `reach` of 0 would mean the dock fit its viewport and the probe's padding was lost; `t`/`c` read the toolbar toggle's own state, because those two enable a feature and the button is where that state is visible. 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 `: : ->` — 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). - `[color] '' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1)`, then `[color] override: trace=#123456 chip_agrees=1`, `[color] session: entries=1 rgb=#123456` and `[color] default: trace=#RRGGBB chip_agrees=1` (stderr, `UDPSCOPE_PROBE_COLOR_MS`) — **all three `chip_agrees` must be 1**, the override line's `trace` must be `#123456`, the session line must read `entries=1 rgb=#123456`, and the default line's `trace` must be back to the first line's. Run it with `UDPSCOPE_ENABLE_ALL_MS` ahead of it, or there is no bound trace and the probe prints `no bound trace to recolour` instead of asserting anything; on the standard mock the first line reads `'sine' at row 0 slot 0: trace=#4FA3FF`. Two things make this an assertion rather than a tautology. The colours are read out of **`plot::Plot::trace().color`** — what the renderer will stroke, reached through `Row::plot` *after* `relayout_areas()`, since a new colour is a new figure — and out of the chip's own `` markup, not by asking `RowSet::signal_color()` twice, which would pass against a chip that never reached the trace. And the override is `#123456`, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips `collect_layout()` → `save_layout()` → `load_layout()` on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit. - `[reseq-probe] reseq=N late=N stale=N overflows=N holes=N hole_slots=N backfilled=N gaps_refused=N open_holes=N bands=N bands_repaired=N nan_slots=N scanned=N trigger_in_hole=D` @@ -1796,18 +1788,16 @@ and `regained` reads 400 ms later. ## `[side-tab-probe]` ``` -[side-tab-probe] page=signals accums=4 baseline recorded -[side-tab-probe] page=signals rows=4 accums=4 advanced=4 min_n=18 +[side-tab-probe] section=measure accums=4 baseline recorded (measure at scroll 243 of 2256) +[side-tab-probe] section=signals rows=4 accums=4 advanced=4 min_n=18 ``` -`UDPSCOPE_PROBE_SIDE_TAB_MS` is the one reading for the rule that **the statistics accumulator keeps running -on the hidden page**. It switches *to* the Measure tab, forces one pass so there is a baseline, then switches -away to Signals and reads again two seconds later -- with the measurements' page not current, so its widgets -are unrealized and measure 0x0. +`UDPSCOPE_PROBE_SIDE_TAB_MS` (the variable and the tag keep their pre-dock names; the function is `dock_section_probe`) is the one reading for the rule that **the statistics accumulator keeps running +while the Measure section is out of view**. It scrolls the dock *to* the Measure section (`dock_show_section("measure")`), forces one pass so there is a baseline, then scrolls back to Signals and reads again two seconds later. To make the scroll observable on a short window the probe pads `dock_page` to 3000 px (probe only), and it re-arms once after 400 ms so the first reading has the layout. -- **`advanced=4` is the assertion**: every accumulator gained passes while its page was off screen, and +- **`advanced=4` is the assertion**: every accumulator gained passes while its section was out of view, and `min_n` (the smallest `n` seen) must be non-zero too. **`advanced=0` is the defect**: something gated the - accumulation on the visible tab, so the readout would gain a gap in its min/max/avg for exactly as long as + accumulation on the visible section, so the readout would gain a gap in its min/max/avg for exactly as long as the trigger was on screen. A `Gtk-CRITICAL` here is the other half of the same rule -- a label or an allocation read on the unrealized page. - The probe enables a signal **and assigns it to a plot** itself, both deliberately. A signal starts disabled @@ -1849,7 +1839,7 @@ kind, so a run without it never shows that kind. The `form` line appears once pe `drained=0` is a queue that `poll()` does not drain on some path; `after` greater than `before + 1` is a command run twice. -The UDPS run has no `source-` page in `side_stack`, because the UDPS kind has no panels. +The UDPS run has no `source-` slide-over page, because the UDPS kind has no panels. ## `[e1608]` @@ -2591,3 +2581,19 @@ The same probe then puts five measurements on the same spiked `sine` as **two wi - **`measure windows=2`** -- the five legs ask for corners `0, 50, 0, 0, 50` on one channel, which is two distinct windows. **`windows=1` means the corner is not part of the key**: the five collapsed onto one walk and two of them read a signal they did not ask for. + +## `[dock-session]` + +``` +[dock-session] hidden: wish=0 save_scheduled=1 collected=0 saved=1 loaded=1 read=0 applied=1 split_follows=1 ok=1 +[dock-session] shown: wish=1 save_scheduled=1 collected=1 saved=1 loaded=1 read=1 applied=1 split_follows=1 ok=1 +[dock-session] old file (side_tab only): loaded=1 dock_shown=1 default=1 ok=1 +``` + +`UDPSCOPE_PROBE_DOCK_SESSION_MS` reads the one thing the dock persists, the Dock button's state, as `dock = true|false` in the +session file (it replaced `side_tab`). Each of the first two lines drives the toggle, then follows the wish through: `save_scheduled=1` is +`on_sidebar_toggled` having scheduled a save (a user's wish that was not saved is lost at the next start), `collected` is +`collect_layout()`'s `dock_shown`, `saved`/`loaded` the file round trip on a temp path, `read` the loaded value, `applied` +the wish after `apply_layout()`, and `split_follows=1` the split view following it. **`ok=1` on all three lines is the assertion.** The third line +loads a file as the previous version wrote it (`side_tab = "measure"`): it must load with no error and leave `dock_shown` at its default +(`true`) -- the parser ignores unknown keys, and this is what says the old key still does not break a session. The probe writes only to a temp path. diff --git a/docs/claude/verification.md b/docs/claude/verification.md index d5b3dd6..090b15d 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -412,12 +412,19 @@ precedent, not an exception. pauses, reports again with `dn=` per measurement, then resets and reports a third time. Three phases: `phase=0`, `phase=1` (after pause) and `phase=2` (after Reset). Needs `UDPSCOPE_ENABLE_ALL_MS` ahead of it and `UDPSCOPE_PROBE_MEASURE_MS` to add measurements first. -- `UDPSCOPE_PROBE_SIDE_TAB_MS=3000` with `UDPSCOPE_EXIT_AFTER_MS=11000` — the measurements on the - Measure tab, a forced pass, then the tab switched away and read again two seconds later. **`advanced=4` - is the assertion** (every accumulator gained passes while its page was off screen); `advanced=0` is the - defect where the accumulation is gated on the visible tab, which would leave a gap in min/max/avg for +- `UDPSCOPE_PROBE_SIDE_TAB_MS=3000` with `UDPSCOPE_EXIT_AFTER_MS=11000` — the measurements in the + dock's Measure section, a forced pass, then the dock scrolled back to the signal list (the Measure + section out of view) and read again two seconds later. The probe pads the dock to 3000 px so a scroll is + observable on a short window. **`advanced=4` + is the assertion** (every accumulator gained passes while its section was out of view); `advanced=0` is the + defect where the accumulation is gated on the visible section, which would leave a gap in min/max/avg for exactly as long as the trigger was on screen. It enables *and plots* its own signal, so it needs no other - probe ahead of it. The reading's own entry is in `probe-readings.md`. + probe ahead of it. The reading's own entry is in `probe-readings.md`. (Probe name `dock_section_probe`; the + variable and the `[side-tab-probe]` tag keep their old names so existing scripts keep working.) +- `UDPSCOPE_PROBE_DOCK_SESSION_MS=2500` with `UDPSCOPE_EXIT_AFTER_MS=8000` -- the session file's `dock` key, + end to end: the dock hidden, saved and reloaded; shown again; and a file from the previous version + (`side_tab = "measure"`, no `dock`) loaded without an error. Three `[dock-session]` lines, each `ok=1`; + entries in `probe-readings.md`. - `UDPSCOPE_PROBE_SLIDEOVER_MS=1500` with `UDPSCOPE_EXIT_AFTER_MS=5000` (and `UDPSCOPE_ENABLE_ALL_MS=500`) -- the slide-over panel over the plot's right edge, opened on a throw-away `probe` page, read 0.5 s later. Two `[slideover]` lines. Line 1 must read `panel=360x ... edge_flush=1 full_height=1 @@ -631,12 +638,12 @@ as distinct from the fault injection below, which exercises ingest: bullet) — so a probe run against a fresh config directory will have it on screen for the first second and a half, and that start *writes* `tour_seen` to whichever config directory the run is using, which is why the second run against the same one reads `auto=0`. -- `UDPSCOPE_PROBE_TOUR_SURFACE_MS` — the tour's *surface* switch: it puts the sidebar on **Measure**, +- `UDPSCOPE_PROBE_TOUR_SURFACE_MS` — the tour's *surface* switch: it opens a slide-over page (`trigger`), starts the window's tour, and walks it a stop per tick to the signals stop, reading the sidebar's page and the spotlight/card where the walk arrives. It then puts the sidebar *away* and starts the tour again, which is the same filter's other case (a collapsed pane drops the stops a page switch - could have revealed). `[toursurface] signals stop=4/5 … side_tab=signals … contains=1` and - `[toursurface] collapsed: side_stack_mapped=0 steps=2 signals_stop=0` are the two assertions; its + could have revealed). `[toursurface] signals stop=4/5 … slideover=none … contains=1` and + `[toursurface] collapsed: side_box_mapped=0 steps=2 signals_stop=0` are the two assertions; its lines are in [probe-readings.md](probe-readings.md). Run it alone with `UDPSCOPE_EXIT_AFTER_MS=9000 UDPSCOPE_EXIT_DUMP=1` against a live source. - `UDPSCOPE_PROBE_DEFAULTS_MS` — opens **Calibrate** and reads every spin button in it and in the diff --git a/src/layout_store.cpp b/src/layout_store.cpp index d6454b8..502f9aa 100644 --- a/src/layout_store.cpp +++ b/src/layout_store.cpp @@ -568,8 +568,8 @@ bool load_layout(const std::string& path, LayoutState& out) { } } else if (key == "measure_stats") { st.measure_stats = value == "true"; - } else if (key == "side_tab") { - st.side_tab = strip_quote(value); + } else if (key == "dock") { + st.dock_shown = value == "true"; } else if (key.rfind("trigger_", 0) == 0) { trigger_key(st.trigger, key.substr(8), strip_quote(value)); } else if (key == "multicast") { @@ -740,7 +740,7 @@ bool save_layout(const std::string& path, const LayoutState& state) { o << "]\n"; } o << "measure_stats = " << (state.measure_stats ? "true" : "false") << "\n"; - o << "side_tab = \"" << state.side_tab << "\"\n"; + o << "dock = " << (state.dock_shown ? "true" : "false") << "\n"; // The trigger, settings and state both. Written even when it is off: that is the point of it. o << "trigger_enabled = " << (state.trigger.enabled ? "true" : "false") << "\n"; if (!state.trigger.channel.empty()) { diff --git a/src/layout_store.hpp b/src/layout_store.hpp index eb7db18..38264d7 100644 --- a/src/layout_store.hpp +++ b/src/layout_store.hpp @@ -73,9 +73,10 @@ struct LayoutState { /// stream and mean nothing restored against another one. bool measure_stats = false; - /// Which sidebar tab was showing: "measure" or "signals". The one thing the panel persists -- - /// no revealer states remain, and the 320 px width is the `.ui`'s. - std::string side_tab = "signals"; + /// The dock's shown state (the user's last word on the toolbar's Dock button). The one thing the dock + /// persists -- its 320 px width is the `.ui`'s. An older file's `side_tab` key is not read: the parser + /// ignores keys it does not know. + bool dock_shown = true; /// The measurements pane, by signal *name* and with the type and options a row was built with. std::vector measures; diff --git a/src/shortcuts.cpp b/src/shortcuts.cpp index 504f7f6..1471949 100644 --- a/src/shortcuts.cpp +++ b/src/shortcuts.cpp @@ -14,8 +14,8 @@ constexpr Accel kAccels[] = { {GDK_KEY_a, 0, AppAction::TriggerArm, "a", "Arm or disarm the trigger", "Acquisition"}, {GDK_KEY_t, 0, AppAction::TriggerPanel, "t", "Enable or disable trigger mode", "Acquisition"}, {GDK_KEY_c, 0, AppAction::CursorsPane, "c", "Enable or disable the cursors", "Panes and editors"}, - {GDK_KEY_m, 0, AppAction::SideTabMeasure, "m", "Show the Measure tab", "Panes and editors"}, - {GDK_KEY_g, 0, AppAction::SideTabSignals, "g", "Show the Signals tab", "Panes and editors"}, + {GDK_KEY_m, 0, AppAction::ShowMeasure, "m", "Go to Measure", "Panes and editors"}, + {GDK_KEY_g, 0, AppAction::ShowSignals, "g", "Go to Signals", "Panes and editors"}, {GDK_KEY_l, 0, AppAction::LayoutEditor, "l", "Show or hide the layout editor", "Panes and editors"}, {GDK_KEY_s, 0, AppAction::SidePanel, "s", "Show or hide the side panel", diff --git a/src/shortcuts.hpp b/src/shortcuts.hpp index d9c16dd..3cc6def 100644 --- a/src/shortcuts.hpp +++ b/src/shortcuts.hpp @@ -10,8 +10,8 @@ enum class AppAction { TriggerArm, TriggerPanel, CursorsPane, - SideTabMeasure, - SideTabSignals, + ShowMeasure, + ShowSignals, LayoutEditor, SidePanel, Export, diff --git a/src/ui.cpp b/src/ui.cpp index b38a491..095f553 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -2864,35 +2864,38 @@ void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer open_row_menu(*app, row, x, y, widget); } -void on_side_tab_changed(GObject* o, GParamSpec*, gpointer user) { - App* app = static_cast(user); - const char* name = adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(o)); - app->side_tab = name != nullptr ? name : "signals"; - // Not while the layout is being applied: the restore would schedule a save of what it just read. - if (!app->updating_panels) schedule_layout_save(*app); +/// Scrolls the dock to a section. The dock is one scroller (`App::dock_scroller`) holding the signal list, +/// the cursors and the measurements top to bottom, so "measure" is the cursors panel's offset in the +/// page, and "signals" the top. Nothing is hidden or removed: both stay in the page. An unknown name is a +/// no-op, so a key bound to a section a later version drops cannot blank anything. +void dock_show_section(App& app, const char* section) { + if (app.dock_scroller == nullptr || app.dock_page == nullptr || section == nullptr) return; + GtkAdjustment* adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(app.dock_scroller)); + if (std::strcmp(section, "signals") == 0) { + // The list is the first section: its own offset in the page is the page's margin, and "go to Signals" + // means the top of the dock. + gtk_adjustment_set_value(adj, 0.0); + return; + } + if (std::strcmp(section, "measure") != 0 || app.cursors_panel == nullptr) return; + graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f); + graphene_point_t at; + if (!gtk_widget_compute_point(app.cursors_panel, app.dock_page, &zero, &at)) return; + gtk_adjustment_set_value(adj, at.y); } -void select_side_tab(App& app, const char* name) { - if (app.side_stack == nullptr || name == nullptr) return; - // An unknown name leaves the stack where it is -- a session file from a later version must not - // be able to blank the sidebar. - if (adw_view_stack_get_child_by_name(ADW_VIEW_STACK(app.side_stack), name) == nullptr) return; - adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(app.side_stack), name); - app.side_tab = name; -} - -/// The side column is on when it has something to show *and* the user's sidebar -/// button is in; the two panel buttons appear as soon as a CONFIG describes any -/// signal at all. +/// The dock is on when the user's sidebar button is in -- from the start, with or without signals; the +/// two panel buttons appear as soon as a CONFIG describes any signal at all. void update_side_visibility(App& app) { - if (app.side_stack == nullptr) return; + if (app.side_box == nullptr) return; const bool have_signals = !app.model->signals().empty(); - // `have_signals` decides whether the sidebar *can* be on; `sidebar_wanted` is the user's last word, - // which is why it is kept apart from the button's own state -- that state follows the panel now, and - // the panel is off while there is nothing to show. A swipe that hides the sidebar fires the button's - // handler (the binding is bidirectional), so a gesture is a choice too. - gtk_widget_set_sensitive(app.sidebar_toggle, have_signals); - const bool want_column = have_signals && app.sidebar_wanted; + // `sidebar_wanted` is the user's last word, which is why it is kept apart from the button's own state: + // a swipe that hides the sidebar fires the button's handler (the binding is bidirectional), so a gesture + // is a choice too. "Disable, do not hide": the dock is on screen from the start and does nothing until + // the stream has signals, so the signals decide its *sensitivity* and never whether it is there. + gtk_widget_set_sensitive(app.side_box, have_signals); + gtk_widget_set_sensitive(app.sidebar_toggle, TRUE); + const bool want_column = app.sidebar_wanted; // The split view owns the sidebar's visibility: it animates it, and when the window is too narrow it // shows it as an overlay rather than letting the column be squeezed to nothing. if (app.side_view != nullptr) { @@ -2908,7 +2911,12 @@ void on_sidebar_toggled(GtkToggleButton* button, gpointer user) { App* app = static_cast(user); // Only a state we did not write is a wish: `update_side_visibility` sets this button (and the panel) // from `sidebar_wanted`, so an unguarded change here is the user's. - if (!app->sidebar_syncing) app->sidebar_wanted = gtk_toggle_button_get_active(button) != FALSE; + if (!app->sidebar_syncing) { + app->sidebar_wanted = gtk_toggle_button_get_active(button) != FALSE; + // The wish is persisted (`SessionState::dock_shown`), so a change of it is a change of the session -- + // but not one made while a layout is being applied, which would save what it just read. + if (!app->updating_panels) schedule_layout_save(*app); + } update_side_visibility(*app); } @@ -2994,8 +3002,8 @@ void note_panel_controls(App& app) { // `HeaderBar::pack_end()` parents into an internal box, so the bar is a level up, not the parent. char text[256]; std::snprintf(text, sizeof(text), - "cursors=%d in the toolbar, switcher=%d, reset=%d in the cursors pane", - in_bar(app.cursors_toggle), app.side_switcher != nullptr ? 1 : 0, + "cursors=%d in the toolbar, dock=%d, reset=%d in the cursors pane", + in_bar(app.cursors_toggle), app.dock_page != nullptr ? 1 : 0, in_cursors_pane(app.cursors_reset)); // ... and the order the bar actually shows, which is what "the menu should be last" is about. A // widget tree walked in child order *is* the layout order, so this is the reading order of the @@ -3051,42 +3059,6 @@ void note_panel_controls(App& app) { " " + icon_report(theme, "org.udpscope.app-symbolic", "/org/udpscope/icons/scalable/apps/org.udpscope.app-symbolic.svg"); - - // What the switcher is drawing: the sidebar's tabs are words (`wide` policy, a title per page, no - // icon). The pills are `AdwViewSwitcher`'s own buttons, built when the stack is mapped, so this - // reads on the first tick. `mapped`, not `visible`: visibility is per widget, so a label whose - // *parent* is hidden still reads visible; `mapped` requires every ancestor, which is what "on - // screen" means -- and it is why the pill count is read the same way: if nothing is mapped the - // pill count reads 0 and the label count means nothing, rather than passing vacuously. - int pills = 0; - int drawn_labels = 0; - int drawn_images = 0; - const std::function count_drawn = [&](GtkWidget* w) { - if (GTK_IS_TOGGLE_BUTTON(w) && gtk_widget_get_mapped(w)) ++pills; - // A *mapped* image is an icon (or its missing-icon placeholder) on a tab: tabs are words only. - if (GTK_IS_IMAGE(w) && gtk_widget_get_mapped(w)) ++drawn_images; - // A *mapped* label with text: an empty label would be counted by "mapped" alone. - if (GTK_IS_LABEL(w) && gtk_widget_get_mapped(w) && gtk_label_get_text(GTK_LABEL(w))[0] != '\0') { - ++drawn_labels; - } - for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr; - c = gtk_widget_get_next_sibling(c)) { - count_drawn(c); - } - }; - if (app.side_switcher != nullptr) count_drawn(app.side_switcher); - app.dbg_switcher_pills = pills; - app.dbg_switcher_labels = drawn_labels; - app.dbg_switcher_images = drawn_images; - // Walked through `get_pages()`, not the widget tree: an `AdwViewStackPage` is a plain `GObject`. - app.dbg_switcher_pages = - app.side_stack != nullptr - ? static_cast(g_list_model_get_n_items( - G_LIST_MODEL(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack))))) - : 0; - if (app.side_switcher != nullptr) { - g_object_get(G_OBJECT(app.side_switcher), "policy", &app.dbg_switcher_policy, nullptr); - } } /// The event-zoom sweep's two halves: a gesture zoom, then the same walk through the window @@ -4022,7 +3994,7 @@ gboolean tick(gpointer user) { // follow a resize and the gutter settling without being told. if (app->layout_editing) sync_layout_editor(*app); - // The one thing that has to be decided *per frame* about the sidebar: whether there is room for it + // The one thing that has to be decided *per frame* about the dock: whether there is room for it // beside the plot. Collapsed, the split view puts it *over* the plot rather than giving it a share of // a width that is not there -- so a small window shows fewer pixels of plot, never a panel squeezed // to nothing. `kSidebarRoom` is the sidebar's 320 plus the width below which the plot stops being @@ -4030,8 +4002,12 @@ gboolean tick(gpointer user) { if (app->side_view != nullptr && app->window != nullptr) { // The split view writes `show-sidebar` itself (at startup and when it collapses), so the wish is // re-asserted here -- but only when they disagree, so a settled frame touches nothing. This is also - // why a hide *gesture* does not stick: the panel is where the button says it is. - const bool want = !app->model->signals().empty() && app->sidebar_wanted; + // why a hide *gesture* does not stick: the panel is where the button says it is. The wish alone decides + // whether the dock is there; having signals decides only whether it does anything ("disable, do not + // hide"): a dock that is on screen from the start does not jump in when the first CONFIG arrives. + const bool want = app->sidebar_wanted; + gtk_widget_set_sensitive(app->side_box, !app->model->signals().empty()); + gtk_widget_set_sensitive(app->sidebar_toggle, TRUE); if (adw_overlay_split_view_get_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(app->side_view)) != want) { adw_overlay_split_view_set_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(app->side_view), want); if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app->sidebar_toggle)) != want) { @@ -4702,8 +4678,8 @@ void run_app_action(App& app, AppAction action) { case AppAction::CursorsPane: toggle(app.cursors_toggle); break; - case AppAction::SideTabMeasure: select_side_tab(app, "measure"); break; - case AppAction::SideTabSignals: select_side_tab(app, "signals"); break; + case AppAction::ShowMeasure: dock_show_section(app, "measure"); break; + case AppAction::ShowSignals: dock_show_section(app, "signals"); break; case AppAction::LayoutEditor: toggle(app.layout_toggle); break; @@ -4745,6 +4721,9 @@ gboolean on_app_key_pressed(GtkEventControllerKey*, guint keyval, guint, GdkModi tour_dismiss(*app); return TRUE; } + // ... and then the slide-over: Escape closes it, ahead of the editable check for the same reason -- the + // key that dismisses a panel must work from inside one of its own spin buttons. + if (keyval == GDK_KEY_Escape && slideover_escape(*app)) return TRUE; GtkWidget* focus = gtk_window_get_focus(GTK_WINDOW(app->window)); if (focus != nullptr && GTK_IS_EDITABLE(focus)) return FALSE; @@ -4998,8 +4977,8 @@ void on_activate(GtkApplication* application, gpointer user) { install_css(); // The window's skeleton is a `.ui` file: the window, its two panes, the plot column with the status - // line under it, and the sidebar's tab switcher and two-page stack. Everything that depends on the - // stream -- the events pane, the two plot areas, the sidebar's panels -- is built below and put + // line under it, and the dock's scroller. Everything that depends on the + // stream -- the events pane, the two plot areas, the dock's panels -- is built below and put // into the slots by id. g_clear_object(&app->window_ui); app->window_ui = gtk_builder_new_from_resource("/org/udpscope/ui/main-window.ui"); @@ -5018,10 +4997,8 @@ void on_activate(GtkApplication* application, gpointer user) { // help's annotated figures label. app->status_row = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_row")); GtkWidget* side = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_column")); - GtkWidget* side_stack = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_stack")); - GtkWidget* side_switcher = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_switcher")); - GtkWidget* measure_page = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "measure_page")); - GtkWidget* signals_page = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "signals_page")); + app->dock_scroller = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "dock_scroller")); + app->dock_page = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "dock_page")); app->badge_bar = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "badge_bar")); app->badge_signals = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "badge_signals")); app->badge_system = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "badge_system")); @@ -5033,8 +5010,7 @@ void on_activate(GtkApplication* application, gpointer user) { app->status_label == nullptr || app->status_info_button == nullptr || app->status_row == nullptr || app->badge_bar == nullptr || app->badge_signals == nullptr || app->badge_system == nullptr || - side == nullptr || side_stack == nullptr || side_switcher == nullptr || - measure_page == nullptr || signals_page == nullptr) { + side == nullptr || app->dock_scroller == nullptr || app->dock_page == nullptr) { return; } // The window's actions live on the application, so it has to know which one it belongs to. @@ -5177,28 +5153,28 @@ void on_activate(GtkApplication* application, gpointer user) { gtk_popover_set_has_arrow(GTK_POPOVER(app->status_popover), TRUE); } - // The right-hand side is two tabs, not five stacked panels: one tab is on screen at a time, so - // no section competes with four others for height. Each page scrolls on its own -- the two are - // different heights, and one shared scroller would leave a switch scrolled where the new page - // never asked to be. - // `side` and `side_stack`/`side_switcher`/`measure_page`/`signals_page` are resolved above with - // the rest of the skeleton and checked by the null guard; assign the members here. + // The right-hand side is the dock: one scroller, the signal list first (it is what is dragged onto the + // plot), then the cursors and the measurements. Every section is always there -- "go to Measure" scrolls + // to it instead of switching a tab, so the measurements are as live as the list above them. + // `side` and `dock_page`/`dock_scroller` are resolved above with the rest of the skeleton and checked + // by the null guard; assign the member here. app->side_box = side; - app->side_stack = side_stack; - app->side_switcher = side_switcher; - // Measure: what the cursors bracket, and the figures derived from it. + // Built in the order the panels have always been (cursors, measurements, trigger, signals) and placed in + // the dock's: the trigger form goes to the slide-over (reached by the Trigger badge). app->cursors_panel = build_cursors_panel(*app); - gtk_box_append(GTK_BOX(measure_page), app->cursors_panel); - gtk_box_append(GTK_BOX(measure_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); - gtk_box_append(GTK_BOX(measure_page), build_measures_panel(*app)); - - // The trigger form lives in the slide-over (reached by the Trigger badge); the signal list stays in the - // sidebar until the dock replaces it. + GtkWidget* measures_panel = build_measures_panel(*app); app->trigger_panel = build_trigger_panel(*app); + // Top-aligned: the page's scroller gives the child the page's height, and a form that stretched to it + // would spread its rows (or draw its frame) over empty space under the last one. + gtk_widget_set_valign(app->trigger_panel, GTK_ALIGN_START); slideover_add_page(*app, "trigger", "Trigger", app->trigger_panel); app->signal_panel = build_signal_panel(*app); - gtk_box_append(GTK_BOX(signals_page), app->signal_panel); + gtk_box_append(GTK_BOX(app->dock_page), app->signal_panel); + gtk_box_append(GTK_BOX(app->dock_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + gtk_box_append(GTK_BOX(app->dock_page), app->cursors_panel); + gtk_box_append(GTK_BOX(app->dock_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + gtk_box_append(GTK_BOX(app->dock_page), measures_panel); // The cursors start off, so their readout starts insensitive -- stated here by the one place that // knows the default, and the *body* and not the panel, because the panel's heading carries the @@ -5210,15 +5186,8 @@ void on_activate(GtkApplication* application, gpointer user) { sync_arm_buttons(*app); gtk_widget_set_sensitive(app->cursors_reset, FALSE); - if (app->side_stack != nullptr) { - adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(app->side_stack), "signals"); - g_signal_connect(app->side_stack, "notify::visible-child-name", - G_CALLBACK(on_side_tab_changed), app); - } - // The source kind's own tabs (a generator, digital I/O), added before the first tick so the - // pages-versus-pills reading below counts them too. The tabs are words, so the switcher's icons - // are hidden first and kept hidden for the pages this call adds. - keep_tabs_icon_free(*app); + // The source kind's own panels (a generator, digital I/O), pages of the slide-over, added before the + // first tick. build_source_panels(*app); // The badge bar reads `trigger_toggle` (made by `build_toolbar()` above) and the source's pages, which // `build_source_panels()` has just registered: the Source badge opens the first of them. @@ -5915,7 +5884,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit g_timeout_add(static_cast(std::atoi(env)), stats_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_SIDE_TAB_MS")) { - g_timeout_add(static_cast(std::atoi(env)), side_tab_probe, &app); + g_timeout_add(static_cast(std::atoi(env)), dock_section_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_SLIDEOVER_MS")) { g_timeout_add(static_cast(std::atoi(env)), slideover_probe, &app); @@ -6028,6 +5997,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_PROBE_MENUS_MS")) { menus_probe_next(app, 0, static_cast(std::atoi(env))); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_DOCK_SESSION_MS")) { + g_timeout_add(static_cast(std::atoi(env)), dock_session_probe, &app); + } if (const char* env = g_getenv("UDPSCOPE_PROBE_KEYS_MS")) { g_timeout_add(static_cast(std::atoi(env)), keys_probe, &app); } @@ -6237,12 +6209,12 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit inside(app.events_list, app.events_revealer)); } std::printf("[udpscope] panels: side=%dx%d (visible=%d) signals=%dx%d row_h=%d " - "(page_current=%d) trigger=%dx%d (sensitive=%d) switcher=%d " + "(page_current=%d) trigger=%dx%d (sensitive=%d) " "cursors=%d window=%.3fs window_cap=%.1fs\n", app.dbg_side_w, app.dbg_side_h, app.dbg_side_column_after_hide, app.dbg_signal_w, app.dbg_signal_h, app.dbg_signal_row_h, app.dbg_signal_page_current, app.dbg_trigger_w, app.dbg_trigger_h, - app.dbg_trigger_sensitive, app.dbg_switcher_visible, + app.dbg_trigger_sensitive, rows.cursors_visible() ? 1 : 0, rows.time_window(), app.window_limit_s); { @@ -6267,10 +6239,8 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit app.dbg_panel_switch_trigger, app.dbg_toolbar_switch_trigger, app.dbg_panel_switch_cursors, app.dbg_toolbar_switch_cursors, app.dbg_panel_drove_toolbar); - std::printf("[udpscope] switcher: policy=%s pills=%d labels=%d images=%d pages=%d size=%dx%d\n", - app.dbg_switcher_policy == 0 ? "narrow" : (app.dbg_switcher_policy == 1 ? "wide" : "?"), - app.dbg_switcher_pills, app.dbg_switcher_labels, app.dbg_switcher_images, - app.dbg_switcher_pages, app.dbg_switcher_w, app.dbg_switcher_h); + std::printf("[udpscope] dock: shown=%d sensitive=%d w=%d scroller_pages=%d\n", app.dbg_dock_shown, + app.dbg_dock_sensitive, app.dbg_dock_w, app.dbg_dock_scrollers); // The help affordance layer: what the contextual-help toggle left on the surfaces, and where the // guided tour stood when the last capture ran. Both are captured on the tick (`capture_help_state`), // so neither asks a destroyed widget anything. diff --git a/src/ui/main-window.ui b/src/ui/main-window.ui index a2d16b1..1ecc6aa 100644 --- a/src/ui/main-window.ui +++ b/src/ui/main-window.ui @@ -4,7 +4,7 @@ + vertical 300 - - side_stack - wide - 4 - 4 - 6 - 6 - - - - + 1 - - - measure - Measure - - - never - automatic - - - vertical - - - - + never + automatic + + + vertical - - - - signals - Signals - - - never - automatic - - - vertical - - - - - - + diff --git a/src/ui/shortcuts.ui b/src/ui/shortcuts.ui index dd6e334..bab4fe6 100644 --- a/src/ui/shortcuts.ui +++ b/src/ui/shortcuts.ui @@ -73,14 +73,14 @@ 1 m - Show the Measure tab + Go to Measure 1 g - Show the Signals tab + Go to Signals diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 11d0a75..5949134 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -241,13 +241,11 @@ struct App { GtkWidget* trigger_panel = nullptr; GtkWidget* signal_panel = nullptr; GtkWidget* side_box = nullptr; - /// The sidebar's tab stack and its switcher. The stack is what chooses which panels are on - /// screen; a feature's own on/off never does (it sets its panel's sensitivity instead). - GtkWidget* side_stack = nullptr; - GtkWidget* side_switcher = nullptr; - /// The page name the stack is showing, and the one value the session file persists about the - /// sidebar. "signals" on a first run. - std::string side_tab = "signals"; + /// The dock: the sidebar's one scroller and the box inside it, which holds the signal list, the + /// cursors and the measurements (`dock_show_section()` scrolls it). A feature's own on/off never + /// removes a section (it sets its panel's sensitivity instead). + GtkWidget* dock_page = nullptr; + GtkWidget* dock_scroller = nullptr; GtkWidget* arm_button = nullptr; /// The Force button, beside the Arm/Disarm one: one acquisition now, with the configured pre/post /// window. It carries no state of its own -- `sync_arm_buttons()` states the one it has @@ -477,18 +475,6 @@ struct App { /// works, and every other line of the dump still passes. std::string dbg_toolbar_icons; std::string dbg_app_icons; - /// What the switcher is: how many pills it holds, and how many labels it is *drawing*. The tabs - /// are words (`wide` policy, a title per page), so the label count must equal the page count. - int dbg_switcher_pills = -1; - int dbg_switcher_labels = -1; - /// How many `GtkImage`s the switcher has *mapped*: a tab is its title and nothing else, so this - /// must be 0 -- an icon, or the missing-icon placeholder GTK draws for a page with no `icon-name`. - int dbg_switcher_images = -1; - /// How many pages the sidebar stack holds: `pills` and `labels` must both equal it. - int dbg_switcher_pages = -1; - /// The switcher's own size, beside it: what the tab bar costs the column. - int dbg_switcher_w = -1; - int dbg_switcher_h = -1; /// The panes' own switches and the header bar's buttons, read together by the exit probe: the two /// must agree, and `dbg_panel_drove_toolbar` is the pane's switch having moved the button. int dbg_panel_switch_trigger = -1; @@ -504,7 +490,6 @@ struct App { /// The Arm button, read with the trigger off: it is the pane's one runtime control, so it is the one /// thing in the body that *is* disabled with the mode. int dbg_arm_sensitive = -1; - int dbg_switcher_policy = -1; /// The header bar, so the dump can report the order the controls are in: a header bar is a /// *titlebar*, not a child of the window, so walking down from the window does not reach it. GtkWidget* toolbar = nullptr; @@ -881,7 +866,7 @@ struct App { std::string source_kind = "udps"; SourceConfig source_opts; std::string source_title; - /// The panels the source kind added to `side_stack`, in tab order. Empty for UDPS and in file mode. + /// The panels the source kind added to the slide-over, in page order. Empty for UDPS and in file mode. std::vector source_panels; bool source_has_datagrams = true; ///< the kind reports raw datagrams; false leaves the capture button disabled guint layout_save_source = 0; @@ -1139,7 +1124,14 @@ struct App { int dbg_signal_row_h = 0; int dbg_trigger_w = 0; int dbg_trigger_h = 0; + /// The signal list has rows (the dock has no tab to be "current" on any more). int dbg_signal_page_current = 0; + /// The dock as the exit probe found it: the split view showing the sidebar, the column sensitive (it is + /// not until the stream has signals), its width, and how many `GtkScrolledWindow`s it holds directly. + int dbg_dock_shown = -1; + int dbg_dock_sensitive = -1; + int dbg_dock_w = -1; + int dbg_dock_scrollers = -1; int dbg_trigger_sensitive = 0; int dbg_trigger_items = 0; int dbg_trigger_selected = -1; @@ -1161,7 +1153,6 @@ struct App { double dbg_trigger_time = 0.0; int dbg_trigger_time_valid = -1; std::string dbg_mode_live; - int dbg_switcher_visible = 0; int dbg_trigger_sensitive_default = 0; /// The detail string the popover and the exit dump read. The dump used to read the label, @@ -1572,9 +1563,9 @@ constexpr int kEventThumbH = 46; constexpr int kEventThumbMaxH = 104; inline int g_stats_phase = 0; inline std::vector g_stats_n_before; -/// The same for the tab-switch probe: the accumulator counts recorded while the Measure page was -/// still current, so the report can say whether any of them advanced afterwards. -inline std::vector g_side_tab_n_before; +/// The same for the dock-section probe: the accumulator counts recorded while the Measure section was +/// still scrolled into view, so the report can say whether any of them advanced after it scrolled out. +inline std::vector g_dock_section_n_before; inline double g_trigrenum_edge = 0.0; inline std::uint64_t g_trigrenum_latches = 0; inline ConditionProbe g_condition; @@ -1600,11 +1591,6 @@ void config_dialog_refresh(ConfigDialog& d); void open_configurations(App& app); void save_layout_now(App& app); -/// The sidebar's tabs are words: hide the icon every `AdwViewSwitcherButton` draws (the -/// missing-icon placeholder, for a page with a title and no `icon-name`), for the buttons that exist -/// and for any page added later. Call once, before `build_source_panels()`. -void keep_tabs_icon_free(App& app); - /// Capability panels (`src/ui_source.cpp`). `build_source_panels` adds the source kind's panels as /// pages of the slide-over (`source-`), once, before the first tick. `apply_source_panels` sets every panel's /// sensitivity from `connected` -- disabled, never hidden. @@ -1803,8 +1789,9 @@ void row_menu_fill(RowMenuState& st); GtkWidget* open_row_menu(App& app, int row, double x, double y, GtkWidget* parent, bool autohide = true); void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer user); gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, gpointer user); -void on_side_tab_changed(GObject*, GParamSpec*, gpointer user); -void select_side_tab(App& app, const char* name); +/// Scrolls the dock to a section: "signals" (the top) or "measure" (the cursors panel, which the +/// measurements follow). An unknown name is a no-op. +void dock_show_section(App& app, const char* section); /// Reads each pane's own switch back off the header bar's button, and the other way round: the two are /// one control in two places, and `panel_switch_sync` keeps the write from looking like a click. void sync_panel_switches(App& app); @@ -1907,8 +1894,9 @@ gboolean measurepin_live_wait(gpointer user); gboolean measure_trace(gpointer user); gboolean stats_probe_report(gpointer user); gboolean stats_probe(gpointer user); -gboolean side_tab_probe_report(gpointer user); -gboolean side_tab_probe(gpointer user); +gboolean dock_section_probe_report(gpointer user); +gboolean dock_section_probe(gpointer user); +gboolean dock_session_probe(gpointer user); // UDPSCOPE_PROBE_DOCK_SESSION_MS /// The slide-over panel (`src/ui_slideover.cpp`): one panel at a time over the plot's right edge, a page /// per setup surface, each page in a scroller of its own. void build_slideover(App& app); // once, after the plot_overlay exists diff --git a/src/ui_layout.cpp b/src/ui_layout.cpp index 329dc57..db631af 100644 --- a/src/ui_layout.cpp +++ b/src/ui_layout.cpp @@ -64,7 +64,7 @@ LayoutState collect_layout(App& app) { st.math = app.math; st.measures = app.measures; st.measure_stats = app.measure_stats_on; - st.side_tab = app.side_tab; + st.dock_shown = app.sidebar_wanted; // The trigger, its settings *and* whether it was on. The channel goes by name, like every identity // in this file: every rebuild renumbers the channels. { @@ -427,7 +427,10 @@ void apply_layout(App& app, const LayoutState& st) { app.measures = st.measures; app.measure_stats_on = st.measure_stats; app.updating_panels = true; - select_side_tab(app, st.side_tab.c_str()); + // The dock's shown state is the wish `update_side_visibility()` re-asserts every frame; setting the + // toggle goes through `sidebar_syncing`, so the restore does not schedule a save of what it just read. + app.sidebar_wanted = st.dock_shown; + update_side_visibility(app); if (app.measures_stats_check != nullptr) { gtk_check_button_set_active(GTK_CHECK_BUTTON(app.measures_stats_check), app.measure_stats_on); diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 14cf6c7..bd6af31 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -104,12 +104,6 @@ gboolean exit_probe_step3(gpointer user) { a->dbg_signal_h = gtk_widget_get_height(a->signal_panel); a->dbg_trigger_w = gtk_widget_get_width(a->trigger_panel); a->dbg_trigger_h = gtk_widget_get_height(a->trigger_panel); - // The switcher's size as allocated: read here and not on the first tick, where the window has not - // been allocated yet and every widget still measures 0x0. - if (a->side_switcher != nullptr) { - a->dbg_switcher_w = gtk_widget_get_width(a->side_switcher); - a->dbg_switcher_h = gtk_widget_get_height(a->side_switcher); - } // How many rows of the signal list carry a drag source: dragging a signal onto a plot is how a plot is // filled, so it has to survive both pane modes -- the locked list drops the tick, the menu and the `+`, // and this is the reading that says it did not drop the drag with them. One row is the `MATH` divider, @@ -187,14 +181,9 @@ gboolean exit_probe_step3(gpointer user) { } if (a->area != nullptr) a->dbg_frames_painted = static_cast(a->area->frames_painted()); a->dbg_reset_sensitive = a->cursors_reset != nullptr && gtk_widget_get_sensitive(a->cursors_reset) ? 1 : 0; - // A page, not a revealer: a stack's hidden page measures 0x0 the way an unrevealed revealer - // did, so the reading asks about *state* -- is this page the current one -- and never about size. - const char* page = a->side_stack != nullptr - ? adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(a->side_stack)) - : nullptr; - const int on_signals = page != nullptr && std::strcmp(page, "signals") == 0 ? 1 : 0; - a->dbg_signal_page_current = - on_signals && gtk_widget_get_first_child(a->signal_list) != nullptr ? 1 : 0; + // The signal list has rows: a reading about *state* (the dock has no page to be current on), never + // about size. + a->dbg_signal_page_current = gtk_widget_get_first_child(a->signal_list) != nullptr ? 1 : 0; // The trigger form is no longer on a sidebar tab, so the reading is the body's own sensitivity. a->dbg_trigger_sensitive = gtk_widget_get_sensitive(a->trigger_body) ? 1 : 0; // `gtk_widget_get_visible` on the sidebar's GtkBox is always 1: AdwOverlaySplitView hides its @@ -203,8 +192,17 @@ gboolean exit_probe_step3(gpointer user) { const int sidebar_shown = a->side_view != nullptr && adw_overlay_split_view_get_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(a->side_view)) ? 1 : 0; a->dbg_side_column_after_hide = sidebar_shown; - a->dbg_switcher_visible = - a->side_switcher != nullptr && gtk_widget_get_visible(a->side_switcher) ? 1 : 0; + // The dock, read here with the other sizes (the dump runs after the window is gone). `sensitive` is the + // availability: the column is on screen whether or not the stream has signals, and does nothing until + // it does. + a->dbg_dock_shown = sidebar_shown; + a->dbg_dock_sensitive = gtk_widget_get_sensitive(a->side_box) ? 1 : 0; + a->dbg_dock_w = a->dbg_side_w; + a->dbg_dock_scrollers = 0; + for (GtkWidget* c = gtk_widget_get_first_child(a->side_box); c != nullptr; + c = gtk_widget_get_next_sibling(c)) { + if (GTK_IS_SCROLLED_WINDOW(c)) ++a->dbg_dock_scrollers; + } if (a->trigger_signal_drop != nullptr) { GListModel* model = gtk_drop_down_get_model(GTK_DROP_DOWN(a->trigger_signal_drop)); a->dbg_trigger_items = model != nullptr ? static_cast(g_list_model_get_n_items(model)) : 0; @@ -1795,11 +1793,24 @@ gboolean stats_probe(gpointer user) { return G_SOURCE_REMOVE; } -/// Phase two of the tab-switch probe: the hidden page's accumulator must have advanced. A statistics -/// readout is *history*, so min/max/avg would gain a gap for exactly as long as the trigger was on -/// screen -- which is the one defect the design's "what runs on a hidden page" rule is there to -/// prevent, and the reason no frame-rate code may be gated on which tab is current. -gboolean side_tab_probe_report(gpointer user) { +/// Which section the dock's scroller is showing at its top: "measure" once it has been scrolled to the +/// cursors panel or past it, "signals" above that. Read from the adjustment, so it is the state the user +/// would see and not the last call made. +static const char* dock_section_in_view(App& a) { + if (a.dock_scroller == nullptr || a.cursors_panel == nullptr || a.dock_page == nullptr) return "none"; + graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f); + graphene_point_t at; + if (!gtk_widget_compute_point(a.cursors_panel, a.dock_page, &zero, &at)) return "none"; + GtkAdjustment* adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(a.dock_scroller)); + return gtk_adjustment_get_value(adj) > 0.5 * at.y ? "measure" : "signals"; +} + +/// Phase two of the dock-section probe: the accumulators must have advanced while the Measure section was +/// scrolled out of view. A statistics readout is *history*, so min/max/avg would gain a gap for exactly as +/// long as the section was off screen -- which is the one defect the design's "what runs on a hidden +/// page" rule is there to prevent, and the reason no frame-rate code may be gated on which section is in +/// view. (It was a tab once; the dock is one scroller, and the rule outlived the tabs.) +gboolean dock_section_probe_report(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; std::uint64_t advanced = 0; @@ -1807,25 +1818,37 @@ gboolean side_tab_probe_report(gpointer user) { bool first = true; for (std::size_t i = 0; i < a->measure_stats.size(); ++i) { const std::uint64_t n = a->measure_stats[i].n; - if (i < g_side_tab_n_before.size() && n > g_side_tab_n_before[i]) ++advanced; + if (i < g_dock_section_n_before.size() && n > g_dock_section_n_before[i]) ++advanced; if (first || n < min_n) { min_n = n; first = false; } } + if (a->dock_page != nullptr) gtk_widget_set_size_request(a->dock_page, -1, -1); std::fprintf(stderr, - "[side-tab-probe] page=%s rows=%zu accums=%zu advanced=%" G_GUINT64_FORMAT + "[side-tab-probe] section=%s rows=%zu accums=%zu advanced=%" G_GUINT64_FORMAT " min_n=%" G_GUINT64_FORMAT "\n", - a->side_tab.c_str(), a->measures.size(), a->measure_stats.size(), advanced, min_n); + dock_section_in_view(*a), a->measures.size(), a->measure_stats.size(), advanced, min_n); std::fflush(stderr); return G_SOURCE_REMOVE; } -/// Phase one: four measurements on the first channel, statistics on, then switch *away* from the -/// Measure tab and come back in two seconds to read what ran while it was not on screen. -gboolean side_tab_probe(gpointer user) { +/// Phase one: four measurements on the first channel, statistics on, then scroll the dock *away* from the +/// Measure section and come back in two seconds to read what ran while it was not in view. (The probe +/// keeps its old environment variable, `UDPSCOPE_PROBE_SIDE_TAB_MS`, and its `[side-tab-probe]` tag.) +gboolean dock_section_probe(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; + // A dock whose content fits has nothing to scroll away from, and a tall window does that. The first + // pass makes the page taller than any window (a probe-only minimum height, undone in the report) and + // comes back once it has been laid out, so "scrolled out of view" is a state this run really reaches. + static bool padded = false; + if (!padded && a->dock_page != nullptr) { + padded = true; + gtk_widget_set_size_request(a->dock_page, -1, 3000); + g_timeout_add(400, dock_section_probe, a); + return G_SOURCE_REMOVE; + } // One signal on, and on a plot, the way `enable_all_probe` does it and a restored session arrives. // Both halves matter here: a signal starts disabled (enabling is what starts acquisition), and a // channel with no trace leaves the live window *still* -- and a still window is exactly the state @@ -1863,18 +1886,23 @@ gboolean side_tab_probe(gpointer user) { if (a->measures_stats_check != nullptr) { gtk_check_button_set_active(GTK_CHECK_BUTTON(a->measures_stats_check), TRUE); } - select_side_tab(*a, "measure"); + dock_show_section(*a, "measure"); + // Where the dock went: `scrolled` is its offset and `reach` the most it can scroll. A reach of 0 would + // mean the content fits and "away from the section" is not a state this run can reach. + GtkAdjustment* dock_adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(a->dock_scroller)); + const double scrolled = gtk_adjustment_get_value(dock_adj); + const double reach = gtk_adjustment_get_upper(dock_adj) - gtk_adjustment_get_page_size(dock_adj); update_measures(*a, /*force=*/true, /*accumulate=*/true); - g_side_tab_n_before.clear(); - for (const MeasureStatsAccum& s : a->measure_stats) g_side_tab_n_before.push_back(s.n); - // Away from the page the measurements are on: its widgets are now unrealized and measure 0x0, and - // the tick keeps calling `update_measures()` regardless. - select_side_tab(*a, "signals"); - std::fprintf(stderr, "[side-tab-probe] page=%s accums=%zu baseline recorded\n", - a->side_tab.c_str(), g_side_tab_n_before.size()); + g_dock_section_n_before.clear(); + for (const MeasureStatsAccum& s : a->measure_stats) g_dock_section_n_before.push_back(s.n); + // Away from the section the measurements are in: they are scrolled out of the viewport (the widgets + // stay mapped, only clipped), and the tick keeps calling `update_measures()` regardless. + dock_show_section(*a, "signals"); + std::fprintf(stderr, "[side-tab-probe] section=%s accums=%zu baseline recorded (measure at scroll %.0f of %.0f)\n", + dock_section_in_view(*a), g_dock_section_n_before.size(), scrolled, reach); std::fflush(stderr); // Long enough for several throttled recomputes (one per 100 ms while the window moves). - g_timeout_add(2000, side_tab_probe_report, a); + g_timeout_add(2000, dock_section_probe_report, a); return G_SOURCE_REMOVE; } @@ -3573,6 +3601,63 @@ gboolean sidebar_probe(gpointer user) { return G_SOURCE_REMOVE; } +/// `UDPSCOPE_PROBE_DOCK_SESSION_MS`: the dock's shown state through the session file, both ways. +/// +/// The Dock button's state is the one thing the dock persists (`dock = true|false`, replacing `side_tab`). +/// Leg 1 hides the dock through the button (a wish, so it must schedule a save), collects the session and +/// writes it, reads the file back and applies it; leg 2 does the same shown. Leg 3 loads a file written by +/// the previous version (`side_tab = "measure"`, no `dock`): it must load without a complaint and leave +/// `dock_shown` at its default, true. Read as `ok=1` on every line. +gboolean dock_session_probe(gpointer user) { + App& a = *static_cast(user); + if (a.window == nullptr || a.sidebar_toggle == nullptr) return G_SOURCE_REMOVE; + const std::string dir = g_get_tmp_dir(); + const std::string path = dir + "/udpscope-dock-session-probe.toml"; + const auto leg = [&](const char* name, bool shown) { + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a.sidebar_toggle), shown); + const bool wish = a.sidebar_wanted; + const bool save_pending = a.layout_save_source != 0; + LayoutState st = collect_layout(a); + const bool collected = st.dock_shown; + const bool saved = save_layout(path, st); + LayoutState back; + const bool loaded = load_layout(path, back); + // Apply the *opposite* first so the restore has something to undo, then the loaded state. + a.sidebar_wanted = !shown; + apply_layout(a, back); + const bool applied = a.sidebar_wanted == shown; + const bool split_follows = + (adw_overlay_split_view_get_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(a.side_view)) != FALSE) == shown; + std::fprintf(stderr, + "[dock-session] %s: wish=%d save_scheduled=%d collected=%d saved=%d loaded=%d read=%d " + "applied=%d split_follows=%d ok=%d\n", + name, wish ? 1 : 0, save_pending ? 1 : 0, collected ? 1 : 0, saved ? 1 : 0, + loaded ? 1 : 0, back.dock_shown ? 1 : 0, applied ? 1 : 0, split_follows ? 1 : 0, + (wish == shown && save_pending && collected == shown && saved && loaded && + back.dock_shown == shown && applied) + ? 1 : 0); + }; + leg("hidden", false); + leg("shown", true); + { + std::FILE* f = std::fopen(path.c_str(), "w"); + if (f != nullptr) { + std::fputs("side_tab = \"measure\"\nmeasure_stats = false\n", f); + std::fclose(f); + } + LayoutState old_file; + old_file.dock_shown = false; // anything but the default, so "left at the default" is a reading + const bool loaded = load_layout(path, old_file); + LayoutState fresh; + std::fprintf(stderr, "[dock-session] old file (side_tab only): loaded=%d dock_shown=%d default=%d ok=%d\n", + loaded ? 1 : 0, old_file.dock_shown ? 1 : 0, fresh.dock_shown ? 1 : 0, + loaded && fresh.dock_shown ? 1 : 0); + } + std::remove(path.c_str()); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} + gboolean prefs_probe(gpointer user) { App& a = *static_cast(user); open_preferences(a); @@ -4524,7 +4609,7 @@ gboolean measures_probe_step(gpointer user) { m.options.show_on_plot = measure_can_show_on_plot(m.type); a->measures.push_back(std::move(m)); } - select_side_tab(*a, "measure"); + dock_show_section(*a, "measure"); rebuild_measures_pane(*a); std::fprintf(stderr, "[measures-probe] added %zu measurements on '%s'\n", a->measures.size(), signal.c_str()); @@ -4683,7 +4768,7 @@ gboolean bandavg_probe_step(gpointer user) { m.options.show_on_plot = measure_can_show_on_plot(m.type); a->measures.push_back(std::move(m)); } - select_side_tab(*a, "measure"); + dock_show_section(*a, "measure"); rebuild_measures_pane(*a); g_timeout_add(250, measure_trace, a); bandavg_probe_next(*a, 1, 1500); @@ -6247,6 +6332,16 @@ gboolean defaults_probe(gpointer user) { gboolean keys_probe(gpointer user) { App* a = static_cast(user); + // `m` scrolls the dock, and a dock whose content fits has nothing to scroll: a run on a tall window + // would read 0->0 and prove nothing. So the first pass only makes the page taller than any window + // (a probe-only minimum height, undone below) and comes back once it has been laid out. + static bool padded = false; + if (!padded && a->dock_page != nullptr) { + padded = true; + gtk_widget_set_size_request(a->dock_page, -1, 3000); + g_timeout_add(400, keys_probe, a); + return G_SOURCE_REMOVE; + } std::string line = "[keys]"; const auto before_after = [&](const char* name, GtkWidget* button, AppAction action) { @@ -6282,38 +6377,56 @@ gboolean keys_probe(gpointer user) { before_after("t: trigger panel", a->trigger_toggle, AppAction::TriggerPanel); before_after("c: cursors", a->cursors_toggle, AppAction::CursorsPane); + // `m` and `g` scroll the dock: the reading is the scroller's offset before and after, and `m` must land + // on the cursors panel's own y (clamped to what the page can scroll, which is why the reachable maximum + // is printed beside it), `g` back on 0. + const auto dock_adjustment = [a]() { + return gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(a->dock_scroller)); + }; + const auto dock_value = [&]() { return gtk_adjustment_get_value(dock_adjustment()); }; { - const std::string before_tab = a->side_tab; - run_app_action(*a, AppAction::SideTabMeasure); - const std::string after_tab = a->side_tab; - char buf[96]; - std::snprintf(buf, sizeof(buf), " m: tab %s->%s |", before_tab.c_str(), after_tab.c_str()); + const double before = dock_value(); + run_app_action(*a, AppAction::ShowMeasure); + const double after = dock_value(); + graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f); + graphene_point_t at = GRAPHENE_POINT_INIT(-1.f, -1.f); + if (!gtk_widget_compute_point(a->cursors_panel, a->dock_page, &zero, &at)) at.y = -1.f; + GtkAdjustment* adj = dock_adjustment(); + const double reach = gtk_adjustment_get_upper(adj) - gtk_adjustment_get_page_size(adj); + char buf[160]; + std::snprintf(buf, sizeof(buf), " m: dock %.0f->%.0f (cursors_y=%.0f reach=%.0f at_cursors=%d) |", before, + after, static_cast(at.y), reach, + std::fabs(after - std::min(static_cast(at.y), reach)) < 1.0 ? 1 : 0); line += buf; } { - const std::string before_tab = a->side_tab; - run_app_action(*a, AppAction::SideTabSignals); - const std::string after_tab = a->side_tab; + const double before = dock_value(); + run_app_action(*a, AppAction::ShowSignals); + const double after = dock_value(); char buf[96]; - std::snprintf(buf, sizeof(buf), " g: tab %s->%s |", before_tab.c_str(), after_tab.c_str()); + std::snprintf(buf, sizeof(buf), " g: dock %.0f->%.0f at_top=%d |", before, after, + std::fabs(after) < 1.0 ? 1 : 0); line += buf; } before_after("l: layout", a->layout_toggle, AppAction::LayoutEditor); before_after("s: side panel", a->sidebar_toggle, AppAction::SidePanel); // An accel must be refused while text is being typed. Focus the window entry, send 'm' - // through the real handler, and require the side tab not to change. + // through the real handler, and require the dock not to scroll. int refused = -1; if (a->window_entry != nullptr) { gtk_menu_button_popup(GTK_MENU_BUTTON(a->window_button)); gtk_widget_grab_focus(a->window_entry); - const std::string before_tab = a->side_tab; + // The dock has to be somewhere other than where `m` would take it, or "did not scroll" proves nothing. + gtk_adjustment_set_value(dock_adjustment(), 0.0); + const double before_dock = dock_value(); const gboolean handled = on_app_key_pressed(nullptr, GDK_KEY_m, 0, static_cast(0), a); - const std::string after_tab = a->side_tab; - refused = (handled == FALSE && before_tab == after_tab) ? 1 : 0; + const double after_dock = dock_value(); + refused = (handled == FALSE && before_dock == after_dock) ? 1 : 0; gtk_menu_button_popdown(GTK_MENU_BUTTON(a->window_button)); } + if (a->dock_page != nullptr) gtk_widget_set_size_request(a->dock_page, -1, -1); char tail[64]; std::snprintf(tail, sizeof(tail), " editable focused: 'm' refused=%d\n", refused); line += tail; @@ -6570,10 +6683,10 @@ void tour_geometry_line(App& a, const char* probe, const char* what) { p.origin.y < q.origin.y + q.size.height && q.origin.y < p.origin.y + p.size.height; }; std::fprintf(stderr, - "[%s] %s=%d/%d visible=%d side_tab=%s spot=[%.0f,%.0f %.0fx%.0f] " + "[%s] %s=%d/%d visible=%d slideover=%s spot=[%.0f,%.0f %.0fx%.0f] " "target=[%.0f,%.0f %.0fx%.0f] contains=%d card=[%.0f,%.0f %.0fx%.0f] clear=%d\n", probe, what, tour_step_index(a) + 1, tour_step_count(a), tour_active(a) ? 1 : 0, - a.side_tab.c_str(), spot.origin.x, spot.origin.y, spot.size.width, spot.size.height, + (slideover_page(a).empty() ? "none" : slideover_page(a).c_str()), spot.origin.x, spot.origin.y, spot.size.width, spot.size.height, target.origin.x, target.origin.y, target.size.width, target.size.height, have_spot && have_target && contains(spot, target) ? 1 : 0, card.origin.x, card.origin.y, card.size.width, card.size.height, @@ -6818,10 +6931,10 @@ gboolean tour_surface_probe_step(gpointer user) { case 0: { // Where the run starts: a fresh config's sidebar is on Signals, so this is a *first* run's // state. The auto-start's walk (if one began) would only confuse the reading; it goes first. - std::fprintf(stderr, "[toursurface] before: side_tab=%s signal_list_mapped=%d running=%d\n", - a->side_tab.c_str(), mapped(a->signal_list), tour_active(*a) ? 1 : 0); + std::fprintf(stderr, "[toursurface] before: slideover=%s signal_list_mapped=%d running=%d\n", + slideover_page(*a).empty() ? "none" : slideover_page(*a).c_str(), mapped(a->signal_list), tour_active(*a) ? 1 : 0); tour_dismiss(*a); - select_side_tab(*a, "measure"); + slideover_open(*a, "trigger"); a->dbg_tour_surface_step = 1; tour_surface_probe_next(*a, 500); return G_SOURCE_REMOVE; @@ -6830,8 +6943,9 @@ gboolean tour_surface_probe_step(gpointer user) { // A frame after the switch, so the Measure page has been laid out and the list really is off // screen. The tour is started *here*, from this state. std::fprintf(stderr, - "[toursurface] on measure: side_tab=%s signal_list_mapped=%d side_stack_mapped=%d\n", - a->side_tab.c_str(), mapped(a->signal_list), mapped(a->side_stack)); + "[toursurface] on trigger: slideover=%s signal_list_mapped=%d side_box_mapped=%d\n", + slideover_page(*a).empty() ? "none" : slideover_page(*a).c_str(), + mapped(a->signal_list), mapped(a->side_box)); start_tour(*a); std::fprintf(stderr, "[toursurface] tour: visible=%d steps=%d step=%d signals_stop=%d\n", tour_active(*a) ? 1 : 0, tour_step_count(*a), tour_step_index(*a), @@ -6848,8 +6962,9 @@ gboolean tour_surface_probe_step(gpointer user) { } GtkWidget* target = tour_target(*a); const bool is_list = target != nullptr && target == a->signal_list; - std::fprintf(stderr, "[toursurface] step=%d/%d side_tab=%s target_mapped=%d%s\n", - tour_step_index(*a) + 1, tour_step_count(*a), a->side_tab.c_str(), mapped(target), + std::fprintf(stderr, "[toursurface] step=%d/%d slideover=%s target_mapped=%d%s\n", + tour_step_index(*a) + 1, tour_step_count(*a), + slideover_page(*a).empty() ? "none" : slideover_page(*a).c_str(), mapped(target), is_list ? " target=signal_list" : ""); if (is_list) { // The frame after the switch: the page has been laid out and `update_tour()` has placed the @@ -6876,9 +6991,9 @@ gboolean tour_surface_probe_step(gpointer user) { default: { start_tour(*a); std::fprintf(stderr, - "[toursurface] collapsed: side_stack_mapped=%d steps=%d signals_stop=%d " + "[toursurface] collapsed: side_box_mapped=%d steps=%d signals_stop=%d " "side_stop=%d\n", - mapped(a->side_stack), tour_step_count(*a), + mapped(a->side_box), tour_step_count(*a), tour_plan_holds(*a, a->signal_list) ? 1 : 0, tour_plan_holds(*a, a->side_box) ? 1 : 0); tour_dismiss(*a); diff --git a/src/ui_source.cpp b/src/ui_source.cpp index 1337e7c..b9be3d0 100644 --- a/src/ui_source.cpp +++ b/src/ui_source.cpp @@ -11,48 +11,6 @@ namespace udpscope { namespace ui_impl { -namespace { - -/// Hide every `GtkImage` inside `w`, and keep it hidden. `AdwViewSwitcherButton` always builds an -/// icon -- in both of its layouts -- and a page with no `icon-name` gets `image-missing`, which is -/// the placeholder the sidebar's text tabs used to draw. There is no property or policy that turns -/// the icon off, so the image is hidden (a hidden child takes no space: the label gets the pill) and -/// a `notify::visible` handler puts it back to hidden if the button ever shows it again. -void hide_tab_icons(GtkWidget* w) { - if (GTK_IS_IMAGE(w)) { - if (g_object_get_data(G_OBJECT(w), "udpscope-icon-hidden") == nullptr) { - g_object_set_data(G_OBJECT(w), "udpscope-icon-hidden", GINT_TO_POINTER(1)); - g_signal_connect(w, "notify::visible", G_CALLBACK(+[](GObject* o, GParamSpec*, gpointer) { - GtkWidget* img = GTK_WIDGET(o); - if (gtk_widget_get_visible(img)) gtk_widget_set_visible(img, FALSE); - }), - nullptr); - } - gtk_widget_set_visible(w, FALSE); - return; - } - for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr; c = gtk_widget_get_next_sibling(c)) { - hide_tab_icons(c); - } -} - -void on_tab_pages_changed(GListModel*, guint, guint, guint, gpointer sw) { - hide_tab_icons(GTK_WIDGET(sw)); -} - -} // namespace - -void keep_tabs_icon_free(App& app) { - if (app.side_switcher == nullptr || app.side_stack == nullptr) return; - hide_tab_icons(app.side_switcher); - // A page added later gets a button when the model reports it; this handler is connected *after* - // the switcher's own, so the button already exists when it runs. - g_signal_connect_after(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack)), "items-changed", - G_CALLBACK(on_tab_pages_changed), app.side_switcher); - // And once more when the switcher is (re)mapped, which also covers a rebuild of the buttons. - g_signal_connect_swapped(app.side_switcher, "map", G_CALLBACK(hide_tab_icons), app.side_switcher); -} - void build_source_panels(App& app) { if (app.slideover.stack == nullptr || app.acq == nullptr || app.acq->source() == nullptr) return; SourceKind* kind = find_kind(app.source_kind); diff --git a/src/ui_tour.cpp b/src/ui_tour.cpp index 43a1816..4aea78b 100644 --- a/src/ui_tour.cpp +++ b/src/ui_tour.cpp @@ -422,11 +422,9 @@ void start_tour(App& app) { app.settings.tour_seen = true; (void)save_settings(app.settings); } - // The sidebar's own stack, so a stop can name the page it stands on. Set here and not in - // `build_tour()`: the overlay is inserted above the window's content *before* `App::side_stack` is - // assigned, so the member does not exist yet at build time -- and the walk cannot start before the - // window is up anyway. - app.tour.page_stack = app.side_stack; + // The main window has no page stack any more (the dock is one scroller): `page_stack` stays null, so + // a stop's `page` selects nothing and a stop whose target is unmapped is dropped. The slide-over hook + // that lets a stop open the trigger page is the next step's. // The whole table, in walk order: `tour_begin()` drops the stops whose surface is not on screen. std::vector stops; stops.reserve(kTourStepCount); @@ -486,8 +484,7 @@ constexpr FigureSurface kFigureSurfaces[] = { {"trigger_summary", &App::trigger_summary}, {"trigger_panel", &App::trigger_panel}, {"signal_list", &App::signal_list}, - {"side_switcher", &App::side_switcher}, - {"side_stack", &App::side_stack}, + {"side_stack", &App::side_box}, {"window_button", &App::window_button}, {"status_label", &App::status_label}, {"header_bar", &App::toolbar}, From 2826042156062ab30fb4f1b4d9a199482155a0e9 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 2 Nov 2026 20:46:59 +0100 Subject: [PATCH 061/127] ui: the tour and the contextual help follow the badge bar, the slide-over and the dock Co-Authored-By: Claude Opus 4.6 --- docs/claude/probe-readings.md | 98 ++++++++++++++++---------- docs/claude/verification.md | 12 ++-- src/ui_app.hpp | 22 +++++- src/ui_probe.cpp | 112 +++++++++++++++++------------ src/ui_tour.cpp | 129 ++++++++++++++++++++++++++-------- 5 files changed, 254 insertions(+), 119 deletions(-) diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 48d9243..659ff6e 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -489,7 +489,7 @@ Two lines of the self-check output are assertions in disguise: column and says there is one scroller and no stack. These are captured on the tick or at the exit probe: the dump runs after the window is destroyed. A source with extra panels adds *no* dock section: its panels are slide-over pages (`source-`), so the line reads the same on the E-1608 and the probe kind. -- `help layer: contextual=on hints=12 ids={events plot status cursors measures trigger trigger_advanced events_record trigger_search signals signals_config window} icon=dialog-information-symbolic(ok) hint=12x12; tour=idle steps=5 step=0 auto=1 requested=0` (in the exit +- `help layer: contextual=on hints=12 ids={events plot status cursors measures trigger trigger_advanced events_record trigger_search signals signals_config window} icon=dialog-information-symbolic(ok) hint=12x12; tour=idle steps=6 step=0 auto=1 requested=0` (in the exit dump, beside `dock:`) — the help affordance layer as the last tick found it. **`hints=` must equal the number of ids**, and the ids are the surfaces the contextual help is on: eleven of the window itself, and `math` only while the math editor is open (its heading lives in a builder that is rebuilt @@ -534,14 +534,18 @@ Two lines of the self-check output are assertions in disguise: [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). The writer chains to `g_log_writer_default()`, so every message still reaches stderr exactly as before and the documented baselines are read the same way. -- `help figures: trigger_head=X,Y,W,H trigger_summary=… trigger_panel=… signal_list=… side_stack=… window_button=… status_label=… header_bar=… plot=…` (in the exit +- `help figures: trigger_head=X,Y,W,H trigger_summary=… trigger_panel=… signal_list=… badge_bar=… dock=… window_button=… status_label=… header_bar=… plot=…` (in the exit dump, beside `help layer:`) — the **window-coordinate** rectangle of each surface the help's annotated figures label, `x,y,width,height`, or `-1,-1,0,0` for a surface that is **not on screen**. It is measured on the tick while the window is alive (nothing in the dump may call `gtk_widget_*` — see - [verification.md](verification.md)) and with `gtk_widget_is_visible()`, not `get_visible()`: a widget - under a hidden parent — `trigger_summary` while the pane is in *configuration* mode, the Measure page - while the Signals tab is showing — keeps its own flag true and reads `0x0`, and a crop box drawn from - that is a box over the wrong thing. The figures are produced by cropping a real screenshot, so these + [verification.md](verification.md)) and with `gtk_widget_is_visible()` **and** `gtk_widget_get_mapped()`, + not `get_visible()`: a widget under a hidden parent — `trigger_summary` while the pane is in + *configuration* mode, and **every `trigger_*` surface while the slide-over is closed** (a revealer + unmaps its child without touching its visible flag) — keeps its own flag true and reads `0x0`, and a crop + box drawn from that is a box over the wrong thing. So the three `trigger_*` rectangles read real numbers + only while the `trigger` page is open (the exit probe has it open; `UDPSCOPE_PROBE_HELPUI_MS` opens it for + its figures legs), and `-1,-1,0,0` otherwise. `badge_bar` is the badge row under the plot and `dock` the + right-hand column (these replaced `side_stack`, the old sidebar stack). The figures are produced by cropping a real screenshot, so these numbers **are** the crop boxes; their values depend on the window's size, which under the documented `xvfb-run` is the window's 756x894 minimum (the 640x480 screen is smaller than the content), so a run on a larger screen reads different numbers for the same panels. `trigger_summary` reading @@ -878,7 +882,7 @@ two also catch a row whose accelerator went missing. `settings=` must be a real 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. -- `[helpui] auto_started=1 visible=1 steps=5 step=0 (first run)` (stderr, `UDPSCOPE_PROBE_HELPUI_MS`) — +- `[helpui] auto_started=1 visible=1 steps=6 step=0 (first run)` (stderr, `UDPSCOPE_PROBE_HELPUI_MS`) — the help affordance layer end to end, in ten steps 300-400 ms apart (every reading is an *allocation*, and a step that read its own predecessor's geometry would read a rectangle from before the frame that placed it). The line above is the **first-run auto-start**, read before the probe @@ -910,21 +914,25 @@ two also catch a row whose accelerator went missing. `settings=` must be a real affordances are *gone*, not greyed, so anything else is a row that hid its buttons instead of removing them. The count comes back to the same twelve, which is the other half — a toggle that removed and could not restore is a one-way switch. - - `[helpui] tour started from the probe: visible=1 steps=5 step=0` — the tour started the way - `Ctrl+T` starts it. `steps=5` is the plan: the table's five steps whose surface is on screen or - selectable into it (`gtk_widget_get_mapped()` -- a wrong *page* is kept, because selecting its - page reveals it, while a *collapsed pane* is still dropped; see the surface bullets in - [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). - - `[helpui] step=1/5 visible=1 slideover=none spot=[x,y WxH] target=[…] contains=1 card=[…] clear=C` + - `[helpui] tour started from the probe: visible=1 steps=6 step=0` — the tour started the way + `Ctrl+T` starts it. `steps=6` is the plan: the table's six stops (plot, badges, trigger, dock, + signals, status) whose surface is on screen or can be opened into view. A stop that names a + slide-over page is always kept (the `on_page` hook opens it whatever the dock is doing), a target + that is mapped is kept, and a stop in a *collapsed dock* is dropped (`gtk_widget_get_mapped()`; see + the surface bullets in [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). + - `[helpui] step=1/6 visible=1 slideover=none spot=[x,y WxH] target=[…] contains=1 card=[…] clear=C` — **in window coordinates**, the frame a screenshot is cropped in, with the slide-over's open page - (`none`, or a page id) beside it; see `[toursurface]` below. + (`none`, or a page id) beside it; see `[toursurface]` below. The `target=` rectangle is the part of the + target that is *on screen* (intersected with the scrolled windows above it, `tour_target_bounds()`): a + form taller than the panel's scroller on a short window is spotlit down to the scroller's edge and no + further. **`contains` must be 1** on every step: the spotlight is the target's own rectangle grown three pixels. `clear` is the card missing the target, and it is 1 wherever the window has room for the card beside or above/below the hole — at the documented run's window (see `help figures:` below) the plot's step is *the whole content*, because at 756 px the split view overlays the side panel rather than putting it beside the plot, so `clear=0` there is the geometry and not a placement - bug. The side panel's step reads `clear=1`. - - `[helpui] after Next: step=2/5 visible=1`, `[helpui] after Back: step=1/5 visible=1`, + bug. The badges step reads `clear=1`. + - `[helpui] after Next: step=2/6 visible=1`, `[helpui] after Back: step=1/6 visible=1`, `[helpui] after Skip: visible=0 dims_hidden=1 spot_hidden=1 card_hidden=1 controllers=12 (was 12) click_gestures=2` — the three controls. **`controllers=` equal to `(was N)` is the "no lingering input grab" reading**: a tour of ordinary overlay widgets takes no grab at all, and a count that @@ -935,7 +943,9 @@ two also catch a row whose accelerator went missing. `settings=` must be a real trigger pane's modes, by clicking its [edit] button. This exists because one of the help's figures is the *locked* pane's summary grid (`trigger_summary`), which in configuration mode is not on screen at all and reads `-1,-1,0,0`; the two lines give the geometry agent both states' numbers - rather than leaving them to find the mode. `trigger_panel`'s height differs between them (508 px in + rather than leaving them to find the mode. The probe opens the slide-over's `trigger` page for these + legs (and for the collapsed-search leg after them) and closes it at the end: with the panel shut + every `trigger_*` reads `-1,-1,0,0`. `trigger_panel`'s height differs between them (532 px in configuration mode against 72 locked — the body is replaced by the summary), which is the check that the mode really moved. - `[helpui] collapsed search: expanded=0 mapped=1 mapped_header=1 hint=12x12` (the last step, in @@ -945,28 +955,40 @@ two also catch a row whose accelerator went missing. `settings=` must be a real read `mapped=0` (and `0x0`), which is exactly why the header box exists (`search_head` in `src/ui/trigger-panel.ui`) and why the hint joins it as its last child. `mapped_header=` is the header box beside it, and `hint=WxH` is the same 12x12 the layer's own line reads. -- `[toursurface] …` (stderr, `UDPSCOPE_PROBE_TOUR_SURFACE_MS`) -- a stop that stands on a surface it is - not on, entered from the state it is not in. Since the sidebar's tabs became the dock the probe opens the - `trigger` slide-over page instead of switching a tab, starts the window's tour and walks it a stop per tick - (400 ms). **The tour's own rework for the slide-over is Task 5's**: until its page hook exists the tour - has no page stack, so a stop whose target is on a slide-over page is dropped, and the lines below read the - dock only. The lines: +- `[toursurface] …` (stderr, `UDPSCOPE_PROBE_TOUR_SURFACE_MS`) -- a stop that opens a surface it is not + on, entered from the state it is not in. The probe opens the `trigger` slide-over page, starts the + window's tour from there and walks the whole table a stop per tick (400 ms, past the panel's 120 ms + slide); the `on_page` hook is what opens the page for the trigger stop and closes the panel for every + stop that names none. The lines: - - `[toursurface] before: slideover=none signal_list_mapped=1 running=1` -- the state a first run starts in - (the dock is on screen), then the auto-started walk is dismissed. - - `[toursurface] on trigger: slideover=trigger signal_list_mapped=1 side_box_mapped=1` -- a frame after the - page was opened: the dock is still on screen under the slide-over (they are not alternatives). - - `[toursurface] tour: visible=1 steps=N step=0 signals_stop=1` -- **`signals_stop=1` is the filter's - reading**: the signal list is in the dock, which is mapped, so its stop is in the plan. - - `[toursurface] step=N/M slideover= target_mapped=D[ target=signal_list]` -- one per stop; - **the `signals` stop reads `target_mapped=1`**. - - `[toursurface] signals stop=N/M visible=1 slideover=… spot=[…] target=[…] contains=1 card=[…] clear=1` -- - the `tour_geometry_line()` the helpui probe uses: **`contains=1` is the assertion** that the spotlight - and the card are on the list. - - `[toursurface] collapsed: side_box_mapped=0 steps=2 signals_stop=0 side_stop=0` -- the other half of - the filter, read: with `sidebar_wanted` off (`update_side_visibility()` re-asserts it, so this is how a - probe puts the sidebar away for good) the split view unmaps the column and the stops that need the - dock are dropped while the page-less ones stay. This is what says a *collapsed pane* is dropped. + - `[toursurface] before: slideover=none signal_list_mapped=1 running=1` -- the state a first run starts + in (panel closed, dock on screen); the auto-started walk is dismissed. + - `[toursurface] on trigger: slideover=trigger trigger_body_mapped=1 side_box_mapped=1` -- a frame after + the page was opened: the dock is still on screen under the panel (they are not alternatives). + - `[toursurface] tour: visible=1 steps=6 step=0 trigger_stop=1 signals_stop=1` -- the plan: **six + stops, the trigger stop and the signals stop both in it**. + - `[toursurface] step=N/6 title='…' slideover= target_mapped=D` -- one per stop, in order: *The + plot*, *The badges*, *The trigger*, *The dock*, *The signals*, *The status line*. **Step 1 reads + `slideover=none` although the probe started with the panel open** (a stop with no page closes it), + **step 3 reads `slideover=trigger`, and step 4 reads `slideover=none` again**; every `target_mapped` + is 1. + - `[toursurface] stop=N/6 visible=1 slideover=… spot=[…] target=[…] contains=1 card=[…] clear=C` -- the + `tour_geometry_line()` the helpui probe uses, one per stop, read a frame after the stop was entered + (the spotlight is withheld until the panel has finished sliding in, `page_settled`): **`contains=1` on + every stop is the assertion** that the spotlight is on the target. `clear=0` is read as geometry, not + as a placement bug, on the plot stop (the target is the whole content) and on the trigger stop at the + documented 756 px window (the panel is 360 px of it, so the card has no side wide enough and goes flush + left over the plot, overlapping the panel's edge by a few pixels). + - `[toursurface] finished: running=0 slideover=none` -- the last Next ended the walk and **the panel is + closed with it**. + - `[toursurface] restarted at trigger: running=1 slideover=trigger` then + `[toursurface] skipped: running=0 slideover=none` -- the walk parked on the trigger stop and dismissed + (the Skip/Esc route): **`slideover=none` after the dismissal** is the tour not leaving a panel behind. + - `[toursurface] collapsed: side_box_mapped=0 steps=4 trigger_stop=1 signals_stop=0 side_stop=0` -- the + filter with the dock put away (`sidebar_wanted` off; `update_side_visibility()` re-asserts it, so this + is how a probe puts the dock away for good): the dock's two stops are dropped, **and the trigger stop is + kept** because the slide-over does not depend on the dock -- four stops remain (plot, badges, trigger, + status). - `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | c: cursors: 0->1 | m: dock 0->243 (cursors_y=243 reach=2256 at_cursors=1) | g: dock 243->0 at_top=1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** (`g` returns, so its pair reads `243->0`) and **`refused` must be 1**. The `m`/`g` readings are the dock scroller's vertical adjustment before and after (the probe pads the dock to 3000 px so there is something to scroll, and undoes it at the end): **`m` must land on `cursors_y`, the cursors panel's y in the dock (`at_cursors=1`), and `g` must return to 0 (`at_top=1`)**, which is what says those two keys scroll to a section rather than toggling a pane; a `reach` of 0 would mean the dock fit its viewport and the probe's padding was lost; `t`/`c` read the toolbar toggle's own state, because those two enable a feature and the button is where that state is visible. 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 `: : ->` — 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). - `[color] '' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1)`, then `[color] override: trace=#123456 chip_agrees=1`, `[color] session: entries=1 rgb=#123456` and `[color] default: trace=#RRGGBB chip_agrees=1` (stderr, `UDPSCOPE_PROBE_COLOR_MS`) — **all three `chip_agrees` must be 1**, the override line's `trace` must be `#123456`, the session line must read `entries=1 rgb=#123456`, and the default line's `trace` must be back to the first line's. Run it with `UDPSCOPE_ENABLE_ALL_MS` ahead of it, or there is no bound trace and the probe prints `no bound trace to recolour` instead of asserting anything; on the standard mock the first line reads `'sine' at row 0 slot 0: trace=#4FA3FF`. Two things make this an assertion rather than a tautology. The colours are read out of **`plot::Plot::trace().color`** — what the renderer will stroke, reached through `Row::plot` *after* `relayout_areas()`, since a new colour is a new figure — and out of the chip's own `` markup, not by asking `RowSet::signal_color()` twice, which would pass against a chip that never reached the trace. And the override is `#123456`, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips `collect_layout()` → `save_layout()` → `load_layout()` on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit. diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 090b15d..e995b36 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -639,11 +639,13 @@ as distinct from the fault injection below, which exercises ingest: second and a half, and that start *writes* `tour_seen` to whichever config directory the run is using, which is why the second run against the same one reads `auto=0`. - `UDPSCOPE_PROBE_TOUR_SURFACE_MS` — the tour's *surface* switch: it opens a slide-over page (`trigger`), - starts the window's tour, and walks it a stop per tick to the signals stop, reading the sidebar's - page and the spotlight/card where the walk arrives. It then puts the sidebar *away* and starts the - tour again, which is the same filter's other case (a collapsed pane drops the stops a page switch - could have revealed). `[toursurface] signals stop=4/5 … slideover=none … contains=1` and - `[toursurface] collapsed: side_box_mapped=0 steps=2 signals_stop=0` are the two assertions; its + starts the window's tour from that state and walks all six stops a stop per tick, reading the + slide-over's open page and the spotlight/card at each. The trigger stop must read `slideover=trigger` and + the next `slideover=none`; the first stop closes the panel the probe left open, and the end of the walk + (and a dismissal on the trigger stop) leave none. It then puts the dock *away* and starts the tour again: + the dock's stops are dropped while the trigger stop, which does not depend on the dock, stays. + `[toursurface] stop=3/6 … slideover=trigger … contains=1` and + `[toursurface] collapsed: side_box_mapped=0 steps=4 trigger_stop=1 signals_stop=0` are the assertions; its lines are in [probe-readings.md](probe-readings.md). Run it alone with `UDPSCOPE_EXIT_AFTER_MS=9000 UDPSCOPE_EXIT_DUMP=1` against a live source. - `UDPSCOPE_PROBE_DEFAULTS_MS` — opens **Calibrate** and reads every spin button in it and in the diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 5949134..2e17cff 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -73,9 +73,11 @@ enum class EventGroupKind { /// do not exist yet. struct TourStop { GtkWidget* target; - /// The named page of the view's `page_stack` the target lives on, or nullptr when the stop needs no - /// page. The tour selects it as the stop is entered, so a target on a page the user has left is - /// made visible instead of being walked over or spotlighted where it is not. + /// The page the target lives on, or nullptr when the stop needs none. What a page *is* depends on + /// the view: a named page of its `page_stack` (the chooser), or whatever its `on_page` hook opens + /// (the main window: a slide-over page id). The tour selects it as the stop is entered, so a target + /// on a page the user has left is made visible instead of being walked over or spotlighted where it + /// is not. A stop with no page *closes* what an `on_page` hook opened. const char* page; const char* title; const char* body; @@ -103,6 +105,16 @@ struct TourView { /// sidebar; the chooser's two pages), so it is the view's and not each stop's. Set where the walk /// begins, when the widgets exist; null leaves every stop on the page it finds. GtkWidget* page_stack = nullptr; + /// The alternative to `page_stack` for a surface that is not a stack: when set it *replaces* the + /// stack mechanism for this view, and is called with each stop's `page` as the stop is entered -- + /// `nullptr` meaning "no page needed" -- and with `nullptr` as the walk ends. The main window's opens + /// and closes the slide-over through it; the chooser keeps `page_stack`. + void (*on_page)(void* user, const char* page) = nullptr; + void* page_user = nullptr; + /// Optional, with `on_page`: whether the surface `on_page` opened has finished appearing. A stop that + /// names a page is not spotlit while this answers false -- a panel that is still sliding in is a + /// target that moves, and a spotlight that follows it for 120 ms reads as a glitch. Null means "ready". + bool (*page_settled)(void* user) = nullptr; /// Runs when the walk passes its last stop (Next/Finish, never Skip): the chooser's hands the tour /// on to the application through `Config::tour_pending`. void (*on_done)(void* user) = nullptr; @@ -2114,6 +2126,10 @@ void tour_end(TourView& view); void tour_update(TourView& view); /// The widget the current stop points at, or nullptr when the walk is not on a stop. GtkWidget* tour_target(TourView& view); +/// The part of `target` that is on screen, in `overlay`'s coordinates: its bounds intersected with every +/// scrolled window above it. False when it cannot be measured yet or is scrolled out of sight entirely. +/// The spotlight is drawn from it, and the probe reads the spotlight against it. +bool tour_target_bounds(GtkWidget* target, GtkWidget* overlay, graphene_rect_t* out); /// Builds the *window's* tour inside `app.tour.overlay`. Called once, as the overlay is inserted. void build_tour(App& app); diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index bd6af31..4930420 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -6657,8 +6657,8 @@ GtkWidget* helpui_menu_view(App* a) { } /// One line: the spotlight, the widget it spotlights, and the card, all in **window coordinates** -- -/// the frame a screenshot is cropped in -- with the window's own record of the sidebar page beside -/// them, so a stop that had to change the page to find its target is readable as such. `contains` is +/// the frame a screenshot is cropped in -- with the slide-over's open page beside them (`none` when it +/// is closed), so a stop that had to open a page to find its target is readable as such. `contains` is /// the spotlight covering its target (it is the target's own rectangle, grown three pixels) and /// `clear` is the card missing the target. `probe` is the tag it is printed under, so a window with /// more than one tour probe can tell its lines apart. @@ -6670,7 +6670,7 @@ void tour_geometry_line(App& a, const char* probe, const char* what) { const bool have_spot = a.tour.spot != nullptr && gtk_widget_compute_bounds(a.tour.spot, a.window, &spot); const bool have_target = t != nullptr && gtk_widget_get_mapped(t) && - gtk_widget_compute_bounds(t, a.window, &target); + tour_target_bounds(t, a.window, &target); const bool have_card = a.tour.card != nullptr && gtk_widget_compute_bounds(a.tour.card, a.window, &card); const auto contains = [](const graphene_rect_t& outer, const graphene_rect_t& inner) { @@ -6859,6 +6859,10 @@ gboolean helpui_probe_step(gpointer user) { // The trigger pane's summary grid is on screen only in *locked* mode, and one of the help's // figures is that content: the probe reads `help figures:` in both states, so the geometry agent // has the locked pane's numbers without having to guess how to get there. + // The trigger form is a slide-over page: it is open for both readings (and for the collapsed-search + // one after them), because a closed panel leaves every one of its surfaces unmapped and reads + // `-1,-1,0,0`. The 400 ms of the step is past the 120 ms reveal. + if (step == 6) slideover_open(*a, "trigger"); if (a->trigger_edit != nullptr) g_signal_emit_by_name(a->trigger_edit, "clicked"); helpui_probe_next(*a, step == 6 ? 7 : 9, 400); return G_SOURCE_REMOVE; @@ -6891,6 +6895,7 @@ gboolean helpui_probe_step(gpointer user) { hint != nullptr && gtk_widget_get_mapped(hint) ? 1 : 0, gtk_widget_get_mapped(a->trigger_search) ? 1 : 0, sw, sh); } + slideover_close(*a); // opened by step 6 std::fflush(stderr); return G_SOURCE_REMOVE; } @@ -6898,7 +6903,7 @@ gboolean helpui_probe_step(gpointer user) { // --- the tour's surface switch: a stop that stands on another stack page ------- /// Whether the walk's plan holds `target`. `steps=` alone cannot say a stop was *filtered out* rather -/// than merely reordered, so the two cases the filter tells apart are read against the plan itself. +/// than merely reordered, so the cases the filter tells apart are read against the plan itself. bool tour_plan_holds(const App& a, GtkWidget* target) { for (const TourStop& stop : a.tour.plan) { if (stop.target == target) return true; @@ -6912,27 +6917,34 @@ void tour_surface_probe_next(App& a, guint delay_ms) { g_timeout_add(delay_ms, tour_surface_probe_step, &a); } -/// `UDPSCOPE_PROBE_TOUR_SURFACE_MS`: the stop that stands on a page it is not on. It puts the sidebar -/// on **Measure**, starts the window's tour, and walks it a stop per tick -- a tick apart on purpose, -/// because a stack's new page is allocated on the frame *after* the switch, so a reading taken in the -/// switch's own tick would be of the page the walk has just left (and `update_tour()` places the hole -/// from the frame that *has* been laid out). Every step prints the sidebar's page and whether the -/// stop's target is mapped, and the stop whose target is the signal list prints the same geometry -/// `[helpui]` does: **`side_tab=signals` with `target=signal_list` and `contains=1`** is the reading -/// that the stop was not merely kept but *made* visible. It then puts the sidebar away -- -/// `sidebar_wanted` is the wish the tick re-asserts -- and starts the tour again: a *collapsed* pane -/// leaves the stack unmapped, so the stops that need it are dropped and `steps=` falls. That pair is -/// the filter's two cases, read rather than assumed. +/// The slide-over's open page for a reading: its id, or `none`. +const char* slideover_name(const App& a) { + return slideover_page(a).empty() ? "none" : slideover_page(a).c_str(); +} + +/// `UDPSCOPE_PROBE_TOUR_SURFACE_MS`: the stop that opens a surface it is not on. It opens the +/// slide-over's `trigger` page, starts the window's tour from that state, and walks the whole table a +/// stop per tick -- a tick apart on purpose (400 ms, longer than the panel's 120 ms slide), because a +/// reading taken in the switch's own tick would be of the surface the walk has just left (and +/// `update_tour()` places the hole from the frame that *has* been laid out). Every stop prints its +/// title, the slide-over's open page and whether the target is mapped, with the spotlight geometry +/// `[helpui]` prints: **`slideover=trigger` at the trigger stop and `slideover=none` at the one after +/// it, with `contains=1`**, is the reading that the walk opened the panel and then closed it. The walk +/// then runs again and is dismissed on the trigger stop (the Skip/Esc route), which must close the +/// panel too. It finally puts the dock *away* -- `sidebar_wanted` is the wish the tick re-asserts -- +/// and starts the tour once more: the dock's stops are dropped (a collapsed pane is not walked over) +/// while the trigger stop, which does not depend on the dock, is kept. gboolean tour_surface_probe_step(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; const auto mapped = [](GtkWidget* w) { return w != nullptr && gtk_widget_get_mapped(w) ? 1 : 0; }; switch (a->dbg_tour_surface_step) { case 0: { - // Where the run starts: a fresh config's sidebar is on Signals, so this is a *first* run's - // state. The auto-start's walk (if one began) would only confuse the reading; it goes first. + // Where the run starts: the panel closed and the dock on screen. The auto-start's walk (if one + // began) would only confuse the reading; it goes first. The panel is then opened, so the walk's + // first stop (which needs no page) has something to close. std::fprintf(stderr, "[toursurface] before: slideover=%s signal_list_mapped=%d running=%d\n", - slideover_page(*a).empty() ? "none" : slideover_page(*a).c_str(), mapped(a->signal_list), tour_active(*a) ? 1 : 0); + slideover_name(*a), mapped(a->signal_list), tour_active(*a) ? 1 : 0); tour_dismiss(*a); slideover_open(*a, "trigger"); a->dbg_tour_surface_step = 1; @@ -6940,15 +6952,16 @@ gboolean tour_surface_probe_step(gpointer user) { return G_SOURCE_REMOVE; } case 1: { - // A frame after the switch, so the Measure page has been laid out and the list really is off - // screen. The tour is started *here*, from this state. + // A frame after the page opened, so the panel has slid in. The tour is started *here*, from this + // state. std::fprintf(stderr, - "[toursurface] on trigger: slideover=%s signal_list_mapped=%d side_box_mapped=%d\n", - slideover_page(*a).empty() ? "none" : slideover_page(*a).c_str(), - mapped(a->signal_list), mapped(a->side_box)); + "[toursurface] on trigger: slideover=%s trigger_body_mapped=%d side_box_mapped=%d\n", + slideover_name(*a), mapped(a->trigger_body), mapped(a->side_box)); start_tour(*a); - std::fprintf(stderr, "[toursurface] tour: visible=%d steps=%d step=%d signals_stop=%d\n", + std::fprintf(stderr, + "[toursurface] tour: visible=%d steps=%d step=%d trigger_stop=%d signals_stop=%d\n", tour_active(*a) ? 1 : 0, tour_step_count(*a), tour_step_index(*a), + tour_plan_holds(*a, a->trigger_body) ? 1 : 0, tour_plan_holds(*a, a->signal_list) ? 1 : 0); a->dbg_tour_surface_step = 2; tour_surface_probe_next(*a, 400); @@ -6956,44 +6969,53 @@ gboolean tour_surface_probe_step(gpointer user) { } case 2: { if (!tour_active(*a)) { - std::fprintf(stderr, "[toursurface] walk ended before the signals stop\n"); - std::fflush(stderr); - return G_SOURCE_REMOVE; - } - GtkWidget* target = tour_target(*a); - const bool is_list = target != nullptr && target == a->signal_list; - std::fprintf(stderr, "[toursurface] step=%d/%d slideover=%s target_mapped=%d%s\n", - tour_step_index(*a) + 1, tour_step_count(*a), - slideover_page(*a).empty() ? "none" : slideover_page(*a).c_str(), mapped(target), - is_list ? " target=signal_list" : ""); - if (is_list) { - // The frame after the switch: the page has been laid out and `update_tour()` has placed the - // hole from it, so this is the reading that the spotlight is on the list. - tour_geometry_line(*a, "toursurface", "signals stop"); - tour_dismiss(*a); + // The last Next ended the walk: the panel must have gone with it. + std::fprintf(stderr, "[toursurface] finished: running=0 slideover=%s\n", slideover_name(*a)); + start_tour(*a); + while (tour_active(*a) && tour_target(*a) != a->trigger_body) tour_step(*a, 1); a->dbg_tour_surface_step = 3; tour_surface_probe_next(*a, 400); return G_SOURCE_REMOVE; } + GtkWidget* target = tour_target(*a); + const TourStop& stop = a->tour.plan[static_cast(tour_step_index(*a))]; + std::fprintf(stderr, "[toursurface] step=%d/%d title='%s' slideover=%s target_mapped=%d\n", + tour_step_index(*a) + 1, tour_step_count(*a), stop.title, slideover_name(*a), + mapped(target)); + // The frame after the switch: the page has been laid out and `update_tour()` has placed the + // hole from it, so this is the reading that the spotlight is on the stop's target. + tour_geometry_line(*a, "toursurface", "stop"); tour_step(*a, 1); tour_surface_probe_next(*a, 400); return G_SOURCE_REMOVE; } case 3: { - // The other half of the filter. `sidebar_wanted` is the user's wish that the tick re-asserts - // (`update_side_visibility`), so setting it here is how a probe puts the sidebar away for good; - // the delay leaves the split view time to animate the column shut and unmap it. - a->sidebar_wanted = false; + // The walk again, parked on the trigger stop with the panel open: Skip (or Esc) closes it. + std::fprintf(stderr, "[toursurface] restarted at trigger: running=%d slideover=%s\n", + tour_active(*a) ? 1 : 0, slideover_name(*a)); + tour_dismiss(*a); + std::fprintf(stderr, "[toursurface] skipped: running=%d slideover=%s\n", + tour_active(*a) ? 1 : 0, slideover_name(*a)); a->dbg_tour_surface_step = 4; + tour_surface_probe_next(*a, 100); + return G_SOURCE_REMOVE; + } + case 4: { + // The other half of the filter. `sidebar_wanted` is the user's wish that the tick re-asserts + // (`update_side_visibility`), so setting it here is how a probe puts the dock away for good; the + // delay leaves the split view time to animate the column shut and unmap it. + a->sidebar_wanted = false; + a->dbg_tour_surface_step = 5; tour_surface_probe_next(*a, 700); return G_SOURCE_REMOVE; } default: { start_tour(*a); std::fprintf(stderr, - "[toursurface] collapsed: side_box_mapped=%d steps=%d signals_stop=%d " - "side_stop=%d\n", + "[toursurface] collapsed: side_box_mapped=%d steps=%d trigger_stop=%d " + "signals_stop=%d side_stop=%d\n", mapped(a->side_box), tour_step_count(*a), + tour_plan_holds(*a, a->trigger_body) ? 1 : 0, tour_plan_holds(*a, a->signal_list) ? 1 : 0, tour_plan_holds(*a, a->side_box) ? 1 : 0); tour_dismiss(*a); diff --git a/src/ui_tour.cpp b/src/ui_tour.cpp index 4aea78b..21db85b 100644 --- a/src/ui_tour.cpp +++ b/src/ui_tour.cpp @@ -16,9 +16,9 @@ // (see the allocation bullets in `docs/claude/invariants-trigger-and-pinning.md`), and a window // resize moves it under a spotlight that a one-shot measurement would leave behind. // -// There are *two* tours and one implementation. The main window's walks the plot, the side panel, -// the trigger and the like; the source chooser's -- which appears *before* `App` exists -- walks the -// chooser's own widgets and then hands the tour on to the window through `Config::tour_pending`. The +// There are *two* tours and one implementation. The main window's walks the plot, the badge bar, the +// trigger (in the slide-over), the dock and the like; the source chooser's -- which appears *before* +// `App` exists -- walks the chooser's own widgets and then hands the tour on to the window through `Config::tour_pending`. The // machinery below is written against a `TourView` and a list of `TourStop`s, so the difference // between the two is only which widgets they name: the chooser keeps its table in // `source_dialog.cpp`, where its widgets live. @@ -76,12 +76,18 @@ const TourStop* current_stop(const TourView& view) { return &view.plan[static_cast(view.index)]; } -/// Selects a stop's page on the view's stack, before anything is measured against the target. Both -/// windows' stacks are `AdwViewStack`s and their page names are the ones the same tables name their -/// `.ui` pages with, so the machinery can do this itself; the `get_child_by_name()` guard keeps a -/// stop that names a page a later `.ui` dropped from blanking the stack. The stack's own -/// `notify::visible-child-name` is what tells the window which page it is now on. +/// Selects a stop's page before anything is measured against the target. Two mechanisms, one per +/// kind of view: a view with an `on_page` hook (the main window: its pages are slide-over pages, and +/// a stop with no page *closes* the panel) hands the page to the hook; a view with a `page_stack` (the +/// source chooser: an `AdwViewStack` whose page names are the ones its `.ui` names its pages with) +/// has the machinery select it. The `get_child_by_name()` guard keeps a stop that names a page a +/// later `.ui` dropped from blanking the stack. The stack's own `notify::visible-child-name` is what +/// tells the window which page it is now on. void select_view_page(TourView& view, const char* page) { + if (view.on_page != nullptr) { + view.on_page(view.page_user, page); + return; + } if (view.page_stack == nullptr || page == nullptr) return; if (adw_view_stack_get_child_by_name(ADW_VIEW_STACK(view.page_stack), page) == nullptr) return; adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(view.page_stack), page); @@ -91,13 +97,16 @@ void select_view_page(TourView& view, const char* page) { /// pages, and the split view its sidebar, with `gtk_widget_set_child_visible()`, which leaves the /// widget's own visible flag -- the one `is_visible()` walks up the parents -- **true**. So the only /// honest "is this on screen" test is whether it is mapped, and it is the one that keeps the filter's -/// two cases apart: +/// cases apart: +/// * a stop that names a page of a view with an `on_page` hook is always walkable: the hook opens +/// the page whatever the rest of the window is doing (the slide-over does not depend on the dock); /// * a target not mapped because it is on *another page* is walked over when the stop names that /// page and the stack itself is on screen -- selecting the page is what puts the target there; /// * a target not mapped because its *pane is collapsed* leaves the stack unmapped too, so no page /// can reveal it and the stop is still dropped. bool stop_is_walkable(const TourView& view, const TourStop& stop) { if (stop.target == nullptr) return false; + if (stop.page != nullptr && view.on_page != nullptr) return true; if (gtk_widget_get_mapped(stop.target)) return true; return stop.page != nullptr && view.page_stack != nullptr && gtk_widget_get_mapped(view.page_stack); @@ -130,6 +139,25 @@ void show_step(TourView& view) { } // namespace +bool tour_target_bounds(GtkWidget* target, GtkWidget* overlay, graphene_rect_t* out) { + if (target == nullptr || overlay == nullptr || out == nullptr) return false; + if (!gtk_widget_compute_bounds(target, overlay, out)) return false; + // A target taller than the scroller it stands in (a form on a short window) is clipped by that + // scroller: the part below its edge is not on screen, and a hole that kept going would spotlight + // whatever is under the scroller instead -- the badge bar, the status line. So the hole is the + // *visible* part, intersected with every scrolled window above the target. + for (GtkWidget* parent = gtk_widget_get_parent(target); parent != nullptr; + parent = gtk_widget_get_parent(parent)) { + if (!GTK_IS_SCROLLED_WINDOW(parent)) continue; + graphene_rect_t clip; + graphene_rect_t cut; + if (!gtk_widget_compute_bounds(parent, overlay, &clip)) continue; + if (!graphene_rect_intersection(out, &clip, &cut)) return false; + *out = cut; + } + return true; +} + // --- the machinery, shared by both windows ---------------------------------- void tour_build(TourView& view, GtkWidget* overlay) { @@ -241,6 +269,10 @@ void tour_advance(TourView& view, int delta) { } void tour_end(TourView& view) { + // A page the walk opened is the walk's to close: the panel must not be left standing over a window + // whose tour has ended (Skip, Esc, or the last Next). Only when a walk was actually running -- + // dismissing an idle tour must not close a panel the user opened. + if (view.running && view.on_page != nullptr) view.on_page(view.page_user, nullptr); view.running = false; view.index = 0; view.plan.clear(); @@ -271,8 +303,15 @@ void tour_update(TourView& view) { // Mapped, not `is_visible()`: a widget on another page of a stack, or in a collapsed pane, keeps // its own visible flag -- a stack hides its pages with `set_child_visible()` -- and a widget that // is not on screen is not one to leave the spotlight standing on. - const bool target_present = target != nullptr && gtk_widget_get_mapped(target); - if (target_present && gtk_widget_compute_bounds(target, view.overlay, &bounds)) { + bool target_present = target != nullptr && gtk_widget_get_mapped(target); + // A stop on a page waits for the page to finish appearing: until then the target is moving, and + // the frame is drawn as a stop with nothing to point at (everything dim, the hole empty). + const TourStop* stop = current_stop(view); + if (target_present && stop != nullptr && stop->page != nullptr && view.page_settled != nullptr && + !view.page_settled(view.page_user)) { + target_present = false; + } + if (target_present && tour_target_bounds(target, view.overlay, &bounds)) { // The hole: the target, grown by three pixels so the outline sits *around* the widget rather than // on it, and clamped to the overlay -- a target partly off screen is an ordinary case (a sidebar // dragged wide), and a negative rectangle is not something a margin can express. @@ -354,6 +393,13 @@ void tour_update(TourView& view) { } else if (hx >= cw + 2 * kGap) { cx = hx - cw - kGap; cy = std::clamp(hy, kGap, std::max(kGap, h - ch - kGap)); + } else if (hx > w - (hx + hw) + kGap) { + // No side wide enough for the card, but the hole is at the right edge (the slide-over's trigger + // form on a narrow window: 276 px to its left against a 289 px card): the card goes flush left, + // overlapping the plot and as little of the target as there is no way to avoid, instead of + // sitting on the target's own corner. + cx = kGap; + cy = std::clamp(hy, kGap, std::max(kGap, h - ch - kGap)); } else { // No clear side at all -- the target is nearly the whole window, which is the plot's step in a // window narrow enough for the split view to *overlay* the side panel rather than put it beside @@ -374,9 +420,9 @@ namespace { /// point of re-measuring every frame is that nothing here knows where the widget is. struct TourStep { const char* id; - /// The sidebar page the target lives on, or nullptr when the stop is not on either page. The - /// trigger and the channel list are both on the Signals page, so a stop about either has to put - /// the sidebar there before it can spotlight one. + /// The slide-over page the target lives on, or nullptr when the stop needs none. A stop with a page + /// opens it; a stop without one closes the panel, so the card never points at the status line over + /// an open panel. The page ids are the slide-over's (`trigger`, `source-`, ...). const char* page; const char* title; const char* body; @@ -388,15 +434,20 @@ constexpr TourStep kTourSteps[] = { "Every signal you have switched on is drawn here, all rows sharing one time axis. The wheel " "zooms, a drag pans, and a right-click on a row opens that plot's own menu.", &App::area_overlay}, - {"side", nullptr, "The side panel", - "The working surface beside the plot. Measure holds the cursors and the measurements; Signals " - "holds the trigger and the list of signals. The buttons in the bar above show and hide it.", - &App::side_box}, - {"trigger", "signals", "The trigger", + {"badges", nullptr, "The badges", + "Setup is reached by the thing it configures. The Trigger badge opens the trigger form; the " + "Source badge opens the source's own controls. Click it again, press Esc or click the plot to " + "close the panel.", + &App::badge_bar}, + {"trigger", "trigger", "The trigger", "Describe an event -- a signal, a condition, a level and the window around it -- and arm it: " "the view then freezes on the first event that matches, however rare it is.", &App::trigger_body}, - {"signals", "signals", "The signals", + {"side", nullptr, "The dock", + "The dock beside the plot: the list of signals, then the cursors and the measurements. The " + "buttons in the bar above show and hide it.", + &App::side_box}, + {"signals", nullptr, "The signals", "Every signal the source declares, each with the switch that acquires it. A signal has to be " "switched on before it can be plotted, measured or searched.", &App::signal_list}, @@ -407,6 +458,23 @@ constexpr TourStep kTourSteps[] = { }; constexpr int kTourStepCount = static_cast(sizeof(kTourSteps) / sizeof(kTourSteps[0])); +/// The tour's page hook: a page is a slide-over page, and no page closes the panel. +void tour_open_page(void* user, const char* page) { + App& app = *static_cast(user); + if (page != nullptr) { + slideover_open(app, page); + } else { + slideover_close(app); + } +} + +/// Whether the slide-over has finished sliding in. A closed panel has nothing to wait for. +bool tour_page_settled(void* user) { + const App& app = *static_cast(user); + if (app.slideover.revealer == nullptr || app.slideover.page.empty()) return true; + return gtk_revealer_get_child_revealed(GTK_REVEALER(app.slideover.revealer)) != FALSE; +} + } // namespace void build_tour(App& app) { @@ -422,9 +490,12 @@ void start_tour(App& app) { app.settings.tour_seen = true; (void)save_settings(app.settings); } - // The main window has no page stack any more (the dock is one scroller): `page_stack` stays null, so - // a stop's `page` selects nothing and a stop whose target is unmapped is dropped. The slide-over hook - // that lets a stop open the trigger page is the next step's. + // The main window has no page stack (the dock is one scroller): its pages are the slide-over's, and + // the view's `on_page` hook opens one (or closes the panel for a stop with no page), replacing the + // `page_stack` mechanism the chooser keeps. + app.tour.on_page = tour_open_page; + app.tour.page_settled = tour_page_settled; + app.tour.page_user = &app; // The whole table, in walk order: `tour_begin()` drops the stops whose surface is not on screen. std::vector stops; stops.reserve(kTourStepCount); @@ -484,7 +555,8 @@ constexpr FigureSurface kFigureSurfaces[] = { {"trigger_summary", &App::trigger_summary}, {"trigger_panel", &App::trigger_panel}, {"signal_list", &App::signal_list}, - {"side_stack", &App::side_box}, + {"badge_bar", &App::badge_bar}, + {"dock", &App::side_box}, {"window_button", &App::window_button}, {"status_label", &App::status_label}, {"header_bar", &App::toolbar}, @@ -526,10 +598,11 @@ void capture_help_state(App& app) { GtkWidget* widget = app.*(surface.widget); graphene_rect_t r = GRAPHENE_RECT_INIT(0.0f, 0.0f, 0.0f, 0.0f); int x = -1, y = -1, width = 0, height = 0; - // `is_visible()`, not `get_visible()`: a widget under a hidden parent -- the trigger summary - // while the pane is in configuration mode, the Measure page while the Signals tab is showing -- - // keeps its own flag true and reads 0x0, which a crop box would take for a real rectangle. - if (widget != nullptr && gtk_widget_is_visible(widget) && + // `is_visible()` and `get_mapped()`, not `get_visible()`: a widget under a hidden parent -- the + // trigger summary while the pane is in configuration mode -- keeps its own flag true and reads + // 0x0, and so does everything on the slide-over while the panel is closed (a revealer unmaps its + // child without touching its visible flag); a crop box would take either for a real rectangle. + if (widget != nullptr && gtk_widget_is_visible(widget) && gtk_widget_get_mapped(widget) && gtk_widget_compute_bounds(widget, app.window, &r)) { x = static_cast(std::lround(r.origin.x)); y = static_cast(std::lround(r.origin.y)); From a1672acc70aa2ec70a47a15dbf72f98be7de28da Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 2 Nov 2026 20:56:09 +0100 Subject: [PATCH 062/127] ui: tighten the badge/dock/tour probes after review and fix the tour's stale hole The session old-file probe and save_scheduled now discriminate, Esc is read through the real key path, the dock-section probe proves the Measure section starts out of view, and the tour's spotlight is measured independently of the helper it is drawn from. A target scrolled out of its scroller no longer leaves the previous hole standing. Co-Authored-By: Claude Opus 4.6 --- docs/claude/probe-readings.md | 93 ++++++++++++++++++++++----- docs/claude/verification.md | 18 +++--- src/source_dialog.cpp | 4 +- src/ui_app.hpp | 3 +- src/ui_badges.cpp | 6 +- src/ui_probe.cpp | 114 ++++++++++++++++++++++++++++------ src/ui_tour.cpp | 34 ++++++---- 7 files changed, 210 insertions(+), 62 deletions(-) diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 659ff6e..bbcf7ec 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -920,16 +920,24 @@ two also catch a row whose accelerator went missing. `settings=` must be a real slide-over page is always kept (the `on_page` hook opens it whatever the dock is doing), a target that is mapped is kept, and a stop in a *collapsed dock* is dropped (`gtk_widget_get_mapped()`; see the surface bullets in [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). - - `[helpui] step=1/6 visible=1 slideover=none spot=[x,y WxH] target=[…] contains=1 card=[…] clear=C` + - `[helpui] step=1/6 visible=1 slideover=none spot=[x,y WxH] raw=[…] target=[…] contains=1 inside=I card=[…] clear=C` — **in window coordinates**, the frame a screenshot is cropped in, with the slide-over's open page - (`none`, or a page id) beside it; see `[toursurface]` below. The `target=` rectangle is the part of the - target that is *on screen* (intersected with the scrolled windows above it, `tour_target_bounds()`): a - form taller than the panel's scroller on a short window is spotlit down to the scroller's edge and no - further. - **`contains` must be 1** on every step: the spotlight is the target's own rectangle grown three - pixels. `clear` is the card missing the target, and it is 1 wherever the window has room for the + (`none`, or a page id) beside it; see `[toursurface]` below. `raw=` is the target's own rectangle, + unclipped; `target=` is the part of it that is *on screen*, which the probe computes **itself** by + intersecting `raw` with the bounds of every scrolled window above the target (not through + `tour_target_bounds()`, which the spotlight is drawn from -- reading the spotlight against its own helper + would only ever agree with itself). A form taller than the panel's scroller on a short window reads + `raw` taller than `target`, and is spotlit down to the scroller's edge and no further; the trigger stop at + the documented window reads `raw=[279,143 343x474] target=[279,143 343x253]`. + **`contains` must be 1** on every step: the spotlight covers the visible part (the part grown three + pixels), or is empty when none of the target is visible (a target scrolled out of its scroller gets no + hole at all, and not the hole of the frame before). **`inside` must not be 0**: it is the spotlight + staying within the nearest scrolled window above the target (grown by the same three pixels); `-1` is a + target with no scroller above it (the plot, the badge bar, the dock, the status line), `1` the trigger + form and the signal list. `inside=0` is a spotlight that spills out of its scroller over whatever is + under it. `clear` is the card missing the target, and it is 1 wherever the window has room for the card beside or above/below the hole — at the documented run's window (see `help figures:` below) - the plot's step is *the whole content*, because at 756 px the split view overlays the side panel + the plot's step is *the whole content*, because at 756 px the split view overlays the dock rather than putting it beside the plot, so `clear=0` there is the geometry and not a placement bug. The badges step reads `clear=1`. - `[helpui] after Next: step=2/6 visible=1`, `[helpui] after Back: step=1/6 visible=1`, @@ -972,10 +980,11 @@ two also catch a row whose accelerator went missing. `settings=` must be a real `slideover=none` although the probe started with the panel open** (a stop with no page closes it), **step 3 reads `slideover=trigger`, and step 4 reads `slideover=none` again**; every `target_mapped` is 1. - - `[toursurface] stop=N/6 visible=1 slideover=… spot=[…] target=[…] contains=1 card=[…] clear=C` -- the + - `[toursurface] stop=N/6 visible=1 slideover=… spot=[…] raw=[…] target=[…] contains=1 inside=I card=[…] clear=C` -- the `tour_geometry_line()` the helpui probe uses, one per stop, read a frame after the stop was entered (the spotlight is withheld until the panel has finished sliding in, `page_settled`): **`contains=1` on - every stop is the assertion** that the spotlight is on the target. `clear=0` is read as geometry, not + every stop is the assertion** that the spotlight is on the target, and `inside` must not be 0 (both as + in the `[helpui]` entry above, where `raw`, `target` and `inside` are explained). `clear=0` is read as geometry, not as a placement bug, on the plot stop (the target is the whole content) and on the trigger stop at the documented 756 px window (the panel is 360 px of it, so the card has no side wide enough and goes flush left over the plot, overlapping the panel's edge by a few pixels). @@ -989,8 +998,8 @@ two also catch a row whose accelerator went missing. `settings=` must be a real is how a probe puts the dock away for good): the dock's two stops are dropped, **and the trigger stop is kept** because the slide-over does not depend on the dock -- four stops remain (plot, badges, trigger, status). -- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | c: cursors: 0->1 | m: dock 0->243 (cursors_y=243 reach=2256 at_cursors=1) | g: dock 243->0 at_top=1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** (`g` returns, so its pair reads `243->0`) and **`refused` must be 1**. - The `m`/`g` readings are the dock scroller's vertical adjustment before and after (the probe pads the dock to 3000 px so there is something to scroll, and undoes it at the end): **`m` must land on `cursors_y`, the cursors panel's y in the dock (`at_cursors=1`), and `g` must return to 0 (`at_top=1`)**, which is what says those two keys scroll to a section rather than toggling a pane; a `reach` of 0 would mean the dock fit its viewport and the probe's padding was lost; `t`/`c` read the toolbar toggle's own state, because those two enable a feature and the button is where that state is visible. 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 `: : ->` — 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 | c: cursors: 0->1 | m: dock 0->243 (cursors_y=243 reach=2256 at_cursors=1) | g: dock 243->0 at_top=1 | … | esc: opened=1 handled=1 closed=1 idle_handled=0 | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** (`g` returns, so its pair reads `243->0`), **`refused` must be 1**, and the `esc:` reading must be `opened=1 handled=1 closed=1 idle_handled=0`. + The `m`/`g` readings are the dock scroller's vertical adjustment before and after (the probe pads the dock to 3000 px so there is something to scroll, and undoes it at the end): **`m` must land on `cursors_y`, the cursors panel's y in the dock (`at_cursors=1`), and `g` must return to 0 (`at_top=1`)**, which is what says those two keys scroll to a section rather than toggling a pane; a `reach` of 0 would mean the dock fit its viewport and the probe's padding was lost; `t`/`c` read the toolbar toggle's own state, because those two enable a feature and the button is where that state is visible. **The `esc:` reading goes through `on_app_key_pressed()` with `GDK_KEY_Escape`**, the handler the window's key controller calls, and not through `slideover_escape()`: the probe opens the `trigger` slide-over page (`opened=1`), sends the key (`handled=1`: it was consumed, and `closed=1`: the page is gone), then sends it again with nothing open (`idle_handled=0`: Escape is left alone when there is nothing for it to close, so a dialog or popover under it still gets it). `handled=0` or `closed=0` is the slide-over's Escape never reaching the panel from the window's key path -- a handler that returns before the slide-over test, or a controller in the wrong phase -- which the `[slideover] esc_closes=1` reading (a direct call to `slideover_escape()`) cannot see. **Limit of the `m`/`g` readings:** the padding is 3000 px at the end of the page, so `reach` is non-zero on any window shorter than that and the key has somewhere to scroll, but it does not push the cursors panel out of the viewport -- on a window whose dock viewport is taller than `cursors_y` (a tall window) the section is already in view at offset 0 and `m` still scrolls to it; `at_cursors=1` is the key landing the section's top at the viewport's top, which is the reading, not "the section was hidden before". 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 `: : ->` — 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). - `[color] '' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1)`, then `[color] override: trace=#123456 chip_agrees=1`, `[color] session: entries=1 rgb=#123456` and `[color] default: trace=#RRGGBB chip_agrees=1` (stderr, `UDPSCOPE_PROBE_COLOR_MS`) — **all three `chip_agrees` must be 1**, the override line's `trace` must be `#123456`, the session line must read `entries=1 rgb=#123456`, and the default line's `trace` must be back to the first line's. Run it with `UDPSCOPE_ENABLE_ALL_MS` ahead of it, or there is no bound trace and the probe prints `no bound trace to recolour` instead of asserting anything; on the standard mock the first line reads `'sine' at row 0 slot 0: trace=#4FA3FF`. Two things make this an assertion rather than a tautology. The colours are read out of **`plot::Plot::trace().color`** — what the renderer will stroke, reached through `Row::plot` *after* `relayout_areas()`, since a new colour is a new figure — and out of the chip's own `` markup, not by asking `RowSet::signal_color()` twice, which would pass against a chip that never reached the trace. And the override is `#123456`, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips `collect_layout()` → `save_layout()` → `load_layout()` on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit. - `[reseq-probe] reseq=N late=N stale=N overflows=N holes=N hole_slots=N backfilled=N gaps_refused=N open_holes=N bands=N bands_repaired=N nan_slots=N scanned=N trigger_in_hole=D` @@ -1810,12 +1819,14 @@ and `regained` reads 400 ms later. ## `[side-tab-probe]` ``` -[side-tab-probe] section=measure accums=4 baseline recorded (measure at scroll 243 of 2256) +[side-tab-probe] section=signals accums=4 baseline recorded (measure at scroll 2732 of 2732, outside_at_top=1) [side-tab-probe] section=signals rows=4 accums=4 advanced=4 min_n=18 ``` `UDPSCOPE_PROBE_SIDE_TAB_MS` (the variable and the tag keep their pre-dock names; the function is `dock_section_probe`) is the one reading for the rule that **the statistics accumulator keeps running -while the Measure section is out of view**. It scrolls the dock *to* the Measure section (`dock_show_section("measure")`), forces one pass so there is a baseline, then scrolls back to Signals and reads again two seconds later. To make the scroll observable on a short window the probe pads `dock_page` to 3000 px (probe only), and it re-arms once after 400 ms so the first reading has the layout. +while the Measure section is out of view**. It scrolls the dock *to* the Measure section (`dock_show_section("measure")`), forces one pass so there is a baseline, then scrolls back to Signals and reads again two seconds later. To make the scroll observable the probe pads `dock_page` **and the signal list** to 3000 px each (probe only, both undone in the report), and it re-arms once after 400 ms so the first reading has the layout. + +- **`outside_at_top=1` is the precondition**: with the dock at offset 0, the Measure section (the cursors panel, where `dock_show_section("measure")` lands) starts at or below the viewport's bottom edge, so the "away from the section" state really has the measurements out of view and `advanced=4` says something. `outside_at_top=0` means the probe's own set-up failed and the run proves nothing -- before the signal list was padded it read `0` on the documented window, where the section's top is 243 px down and the dock's viewport is taller than that. **The limit is the 3000 px:** the guarantee holds for a dock viewport shorter than the padding (any real window); a viewport taller than the signal list's padding, about 3000 logical px, would read `0` again. - **`advanced=4` is the assertion**: every accumulator gained passes while its section was out of view, and `min_n` (the smallest `n` seen) must be non-zero too. **`advanced=0` is the defect**: something gated the @@ -2616,6 +2627,54 @@ The same probe then puts five measurements on the same spiked `sine` as **two wi session file (it replaced `side_tab`). Each of the first two lines drives the toggle, then follows the wish through: `save_scheduled=1` is `on_sidebar_toggled` having scheduled a save (a user's wish that was not saved is lost at the next start), `collected` is `collect_layout()`'s `dock_shown`, `saved`/`loaded` the file round trip on a temp path, `read` the loaded value, `applied` -the wish after `apply_layout()`, and `split_follows=1` the split view following it. **`ok=1` on all three lines is the assertion.** The third line -loads a file as the previous version wrote it (`side_tab = "measure"`): it must load with no error and leave `dock_shown` at its default -(`true`) -- the parser ignores unknown keys, and this is what says the old key still does not break a session. The probe writes only to a temp path. +the wish after `apply_layout()`, and `split_follows=1` the split view following it. **`ok=1` on all three lines is the assertion.** Each leg first cancels any save timer still pending +(`schedule_layout_save()` is an idempotent 400 ms debounce, so a leg would otherwise inherit the previous one's), which is what makes `save_scheduled` a reading +of *that* leg's toggle; with no source port (a recording opened from a file) nothing can be scheduled and the leg expects `save_scheduled=0`. The third line +loads a file as the previous version wrote it (`side_tab = "measure"`) into a `LayoutState` the probe has set to `dock_shown=false`, the opposite of the default: it must load with no error and +leave `dock_shown` at the default (`true`), so `ok=1` requires `dock_shown == default` -- the parser ignores unknown keys, and this is what says the old key still does not break a session. A load that +left the caller's `false` alone would read `dock_shown=0 default=1 ok=0`. The probe writes only to a temp path. + +## `[badges]` + +``` +[badges] table_bad=0 trigger_state=off bar_h=36 bar_h_after=36 fixed=1 open_marked=1 page_open=1 closed_unmarked=1 +[badges] source name=UDPStreamer state=connected pages=0 switch_visible=0 can_target=0 second_page=- second_ok=-1 open_marked=-1 closed_unmarked=1 panel_min_w=0 +``` + +`UDPSCOPE_PROBE_BADGES_MS` reads the badge bar under the plot. **Line 1** is the Trigger badge: `table_bad=0` (the rows of the 16-input +`trigger_badge_state()` table the probe gets wrong -- anything else is a state word that misreports the engine), `fixed=1` (the bar's height is +the same, and at least 36 px, before and after the `trigger` page was opened and closed; `0` means the bar's height depends on content and the plot above it +moves), `open_marked=1` / `page_open=1` / `closed_unmarked=1` (the `udpscope-badge-open` class follows the slide-over's page, both ways; `0` on +any is a badge that does not say its panel is open, or one that stays marked after it closed). + +**Line 2** is the Source badge, and every field has a value it must read: + +- `name=` the source kind's name and `state=` `connected`/`disconnected`, as the badge shows them. `connected` against a live mock; a badge stuck on + `disconnected` is the status not reaching the badge. +- `pages=` the number of slide-over pages the source kind contributes (`source-`): `0` for UDPS, `2` for the E-1608 (`source-ao`, `source-dio`). + `can_target=0` with `pages=0` is the UDPS badge being a read-out rather than a button; `can_target=1` with `pages>0` is the button being live. + `can_target=1` with `pages=0` is a dead button that opens nothing. +- `switch_visible=` the segmented switch being on screen after the first source page was opened: `1` with two or more pages, `0` with one or none. + A switch shown for a single page is a control that cannot change anything; hidden with two, the second panel is unreachable. +- `second_page=` / `second_ok=` what `slideover_page()` read after the switch selected the second page, and whether it is that page. **`second_ok=1` with + two or more pages, `-1` with fewer** (there is no second page to select, so the reading is "not applicable" and not a pass). `second_ok=0` is + the switch changing its own state without the slide-over following it. +- `open_marked=` the Source badge carrying the open mark while its first page is open: `1`; **`-1` with `pages=0`** (nothing to open); + `0` with pages is a badge that does not follow the panel. `closed_unmarked=1` is the mark gone once the panel is closed. +- `panel_min_w=` the widest minimum width of any source panel, measured in the slide-over's page. The page is 360 px less the scroller's own chrome and the + scroller never scrolls horizontally, so a value above that is a panel clipped at its right edge. `0` is no panel. + +## `[slideover]` + +``` +[slideover] open page=probe panel=360x350 at x=270 overlay=630x350 edge_flush=1 full_height=1 outside_hits_plot=1 inside_hits_panel=1 plot_w before=630 open=630 plot_unchanged=1 +[slideover] click_inside_closes=0 still_open=1 click_outside_closes=1 esc_closes=1 closed=1 +``` + +`UDPSCOPE_PROBE_SLIDEOVER_MS` opens a throw-away `probe` page and reads the panel 0.5 s later (add `UDPSCOPE_ENABLE_ALL_MS` so there are traces to hit). +**Line 1:** `panel=360x` (a 360 px panel as tall as the overlay), `edge_flush=1` (its right edge on the overlay's) and `full_height=1`; +`outside_hits_plot=1` / `inside_hits_panel=1` (a pick outside the panel lands in the plot, one inside it in the panel); `plot_unchanged=1` (`before=` equal to +`open=`: the panel is an overlay and does not resize the plot). `outside_hits_plot=0` is a panel allocation wider than 360 or a transparent parent that swallows +the plot's pan, drop and hover; `plot_unchanged=0` is the panel taking room from the plot. **Line 2:** `click_inside_closes=0` with `still_open=1` (a click +inside the panel does not close it), `click_outside_closes=1` (a click on the plot does), `esc_closes=1` (`slideover_escape()` closes it) and `closed=1`. +`esc_closes=1` is the function called directly; the key *through the window's handler* is the `esc:` reading of the `[keys]` line. diff --git a/docs/claude/verification.md b/docs/claude/verification.md index e995b36..01b9a71 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -414,8 +414,8 @@ precedent, not an exception. `UDPSCOPE_ENABLE_ALL_MS` ahead of it and `UDPSCOPE_PROBE_MEASURE_MS` to add measurements first. - `UDPSCOPE_PROBE_SIDE_TAB_MS=3000` with `UDPSCOPE_EXIT_AFTER_MS=11000` — the measurements in the dock's Measure section, a forced pass, then the dock scrolled back to the signal list (the Measure - section out of view) and read again two seconds later. The probe pads the dock to 3000 px so a scroll is - observable on a short window. **`advanced=4` + section out of view) and read again two seconds later. The probe pads the dock page and the signal list to + 3000 px each so the Measure section really starts below the viewport (`outside_at_top=1`) on any real window. **`advanced=4` is the assertion** (every accumulator gained passes while its section was out of view); `advanced=0` is the defect where the accumulation is gated on the visible section, which would leave a gap in min/max/avg for exactly as long as the trigger was on screen. It enables *and plots* its own signal, so it needs no other @@ -476,13 +476,13 @@ precedent, not an exception. - **a rejected code:** mock `--code 1234`, app `code=7`: one error line, the retries stop. - `UDPSCOPE_PROBE_E1608_GEN=1500` with `UDPSCOPE_EXIT_AFTER_MS=5500`, run as `--source e1608 --source-opt address=127.0.0.1 --source-opt port=54711 --source-opt ao0_init=0 --source-opt gen_rate=100` against - `tools/mock_e1608.py --port 54711 --log /tmp/e1608-gen100.log` -- the generator's update rate: it starts a Sine on - DAC 0 through the Analog out panel (and opens the `source-ao` slide-over page, since the panel refreshes on its - frame clock, which does not tick for a page that is off screen; the page is closed again at the end), waits 2.5 s, reads the achieved rate and the - panel's "Update rate" row, stops the generator and reads both again 400 ms later. Two lines, in - `probe-readings.md`. Then count the mock log's `AOUT` lines: about 250 with `gen_rate=100` (the generator ran - for about 2.5 s) and about 125 for a run without the option (default 50 Hz). Short and serial, the mock killed - afterwards. It enables nothing else, so it can run alone. + `tools/mock_e1608.py --port 54711 --log /tmp/e1608-gen100.log` -- the generator's update rate: it starts a Sine + on DAC 0 through the Analog out panel (and opens the `source-ao` slide-over page, since the panel refreshes on + its frame clock, which does not tick for a page that is off screen; the page is closed again at the end), + waits 2.5 s, reads the achieved rate and the panel's "Update rate" row, stops the generator and reads both + again 400 ms later. Two lines, in `probe-readings.md`. Then count the mock log's `AOUT` lines: about 250 with + `gen_rate=100` (the generator ran for about 2.5 s) and about 125 for a run without the option (default + 50 Hz). Short and serial, the mock killed afterwards. It enables nothing else, so it can run alone. - `UDPSCOPE_PROBE_E1608_RECONNECT=8000` -- reads the source after the mock dropped the link and 500 ms later checks the frame count moved on. The reading's own entry is in `probe-readings.md`. - `UDPSCOPE_PROBE_E1608_SCAN=1000` with `UDPSCOPE_EXIT_AFTER_MS=6500`, run as `--source e1608 --source-opt diff --git a/src/source_dialog.cpp b/src/source_dialog.cpp index 7889003..70a1c71 100644 --- a/src/source_dialog.cpp +++ b/src/source_dialog.cpp @@ -922,8 +922,8 @@ void chooser_tour_mark_seen() { void chooser_tour_begin(Chooser& c) { if (c.tour.card == nullptr) return; chooser_tour_mark_seen(); - // The chooser's own stack, so a stop can name the page it stands on (the window does the same with - // its sidebar in `start_tour()`). + // The chooser's own stack, so a stop can name the page it stands on (the window has no stack: + // `start_tour()` hooks its slide-over pages in through `on_page`). c.tour.page_stack = c.page_stack; // The whole table, in walk order: `tour_begin()` drops the stops whose surface is not on screen. std::vector stops; diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 2e17cff..9fff5bb 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -2127,7 +2127,8 @@ void tour_update(TourView& view); /// The widget the current stop points at, or nullptr when the walk is not on a stop. GtkWidget* tour_target(TourView& view); /// The part of `target` that is on screen, in `overlay`'s coordinates: its bounds intersected with every -/// scrolled window above it. False when it cannot be measured yet or is scrolled out of sight entirely. +/// scrolled window above it. A target scrolled out of sight entirely is measured and *empty*: true with a +/// zero-size rectangle. False only when it cannot be measured yet (no allocation, not in the overlay). /// The spotlight is drawn from it, and the probe reads the spotlight against it. bool tour_target_bounds(GtkWidget* target, GtkWidget* overlay, graphene_rect_t* out); diff --git a/src/ui_badges.cpp b/src/ui_badges.cpp index fd560f1..024379a 100644 --- a/src/ui_badges.cpp +++ b/src/ui_badges.cpp @@ -151,7 +151,7 @@ gboolean badges_probe(gpointer user) { // The Source badge: its read-out, and the segmented switch of a source with several panels. const std::string first = a->badges.source.page; bool switch_visible = false; - bool second_ok = true; + int second_ok = -1; // -1: there is no second page to select (one source page, or none) std::string second_page; if (!first.empty()) { slideover_open(*a, first); @@ -160,7 +160,7 @@ gboolean badges_probe(gpointer user) { adw_toggle_group_set_active_name(ADW_TOGGLE_GROUP(a->slideover.source_switch), a->slideover.source_pages[1].c_str()); second_page = slideover_page(*a); - second_ok = second_page == a->slideover.source_pages[1]; + second_ok = second_page == a->slideover.source_pages[1] ? 1 : 0; } } const bool marked = gtk_widget_has_css_class(a->badges.source.button, "udpscope-badge-open") != FALSE; @@ -179,7 +179,7 @@ gboolean badges_probe(gpointer user) { gtk_label_get_text(GTK_LABEL(a->badges.source.name)), gtk_label_get_text(GTK_LABEL(a->badges.source.state)), a->slideover.source_pages.size(), switch_visible ? 1 : 0, gtk_widget_get_can_target(a->badges.source.button) ? 1 : 0, - second_page.empty() ? "-" : second_page.c_str(), second_ok ? 1 : 0, + second_page.empty() ? "-" : second_page.c_str(), second_ok, first.empty() ? -1 : marked ? 1 : 0, gtk_widget_has_css_class(a->badges.source.button, "udpscope-badge-open") ? 0 : 1, widest); return G_SOURCE_REMOVE; diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 4930420..5223de3 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -1825,6 +1825,7 @@ gboolean dock_section_probe_report(gpointer user) { } } if (a->dock_page != nullptr) gtk_widget_set_size_request(a->dock_page, -1, -1); + if (a->signal_panel != nullptr) gtk_widget_set_size_request(a->signal_panel, -1, -1); std::fprintf(stderr, "[side-tab-probe] section=%s rows=%zu accums=%zu advanced=%" G_GUINT64_FORMAT " min_n=%" G_GUINT64_FORMAT "\n", @@ -1846,6 +1847,10 @@ gboolean dock_section_probe(gpointer user) { if (!padded && a->dock_page != nullptr) { padded = true; gtk_widget_set_size_request(a->dock_page, -1, 3000); + // ... and the signal list taller than any window too, which is what pushes the Measure section below + // the viewport at offset 0 (padding the page's end only makes it scrollable, and on a window taller than + // the section's y the section would stay in view). Undone in the report. + if (a->signal_panel != nullptr) gtk_widget_set_size_request(a->signal_panel, -1, 3000); g_timeout_add(400, dock_section_probe, a); return G_SOURCE_REMOVE; } @@ -1886,10 +1891,19 @@ gboolean dock_section_probe(gpointer user) { if (a->measures_stats_check != nullptr) { gtk_check_button_set_active(GTK_CHECK_BUTTON(a->measures_stats_check), TRUE); } + GtkAdjustment* dock_adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(a->dock_scroller)); + // From the top, is the measure section (the cursors panel, where `dock_show_section("measure")` lands) + // below the viewport? Only then is scrolling to it a move *into* view and back out a move *away*; a + // section already in view at offset 0 would make the whole probe a reading of nothing. + gtk_adjustment_set_value(dock_adj, 0.0); + graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f); + graphene_point_t measure_at = GRAPHENE_POINT_INIT(-1.f, -1.f); + const bool located = + a->cursors_panel != nullptr && gtk_widget_compute_point(a->cursors_panel, a->dock_page, &zero, &measure_at); + const bool outside_at_top = located && static_cast(measure_at.y) >= gtk_adjustment_get_page_size(dock_adj); dock_show_section(*a, "measure"); // Where the dock went: `scrolled` is its offset and `reach` the most it can scroll. A reach of 0 would // mean the content fits and "away from the section" is not a state this run can reach. - GtkAdjustment* dock_adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(a->dock_scroller)); const double scrolled = gtk_adjustment_get_value(dock_adj); const double reach = gtk_adjustment_get_upper(dock_adj) - gtk_adjustment_get_page_size(dock_adj); update_measures(*a, /*force=*/true, /*accumulate=*/true); @@ -1898,8 +1912,11 @@ gboolean dock_section_probe(gpointer user) { // Away from the section the measurements are in: they are scrolled out of the viewport (the widgets // stay mapped, only clipped), and the tick keeps calling `update_measures()` regardless. dock_show_section(*a, "signals"); - std::fprintf(stderr, "[side-tab-probe] section=%s accums=%zu baseline recorded (measure at scroll %.0f of %.0f)\n", - dock_section_in_view(*a), g_dock_section_n_before.size(), scrolled, reach); + std::fprintf(stderr, + "[side-tab-probe] section=%s accums=%zu baseline recorded (measure at scroll %.0f of %.0f, " + "outside_at_top=%d)\n", + dock_section_in_view(*a), g_dock_section_n_before.size(), scrolled, reach, + outside_at_top ? 1 : 0); std::fflush(stderr); // Long enough for several throttled recomputes (one per 100 ms while the window moves). g_timeout_add(2000, dock_section_probe_report, a); @@ -3607,13 +3624,23 @@ gboolean sidebar_probe(gpointer user) { /// Leg 1 hides the dock through the button (a wish, so it must schedule a save), collects the session and /// writes it, reads the file back and applies it; leg 2 does the same shown. Leg 3 loads a file written by /// the previous version (`side_tab = "measure"`, no `dock`): it must load without a complaint and leave -/// `dock_shown` at its default, true. Read as `ok=1` on every line. +/// `dock_shown` at its default, true (the probe presets it to false, so only a load that resets it passes). +/// Each leg first cancels any pending save timer and expects `save_scheduled=1` only with a source port. +/// Read as `ok=1` on every line. gboolean dock_session_probe(gpointer user) { App& a = *static_cast(user); if (a.window == nullptr || a.sidebar_toggle == nullptr) return G_SOURCE_REMOVE; const std::string dir = g_get_tmp_dir(); const std::string path = dir + "/udpscope-dock-session-probe.toml"; + // `schedule_layout_save()` is an idempotent 400 ms debounce and does nothing without a source port + // (a recording opened from a file has none), so a leg that did not cancel the previous leg's timer + // would read it as its own. Cancel first, and expect a save only where one can be scheduled at all. + const bool can_save = a.source_port != 0; const auto leg = [&](const char* name, bool shown) { + if (a.layout_save_source != 0) { + g_source_remove(a.layout_save_source); + a.layout_save_source = 0; + } gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a.sidebar_toggle), shown); const bool wish = a.sidebar_wanted; const bool save_pending = a.layout_save_source != 0; @@ -3633,8 +3660,8 @@ gboolean dock_session_probe(gpointer user) { "applied=%d split_follows=%d ok=%d\n", name, wish ? 1 : 0, save_pending ? 1 : 0, collected ? 1 : 0, saved ? 1 : 0, loaded ? 1 : 0, back.dock_shown ? 1 : 0, applied ? 1 : 0, split_follows ? 1 : 0, - (wish == shown && save_pending && collected == shown && saved && loaded && - back.dock_shown == shown && applied) + (wish == shown && collected == shown && saved && loaded && + back.dock_shown == shown && applied && save_pending == can_save) ? 1 : 0); }; leg("hidden", false); @@ -3648,10 +3675,10 @@ gboolean dock_session_probe(gpointer user) { LayoutState old_file; old_file.dock_shown = false; // anything but the default, so "left at the default" is a reading const bool loaded = load_layout(path, old_file); - LayoutState fresh; + const LayoutState fresh; std::fprintf(stderr, "[dock-session] old file (side_tab only): loaded=%d dock_shown=%d default=%d ok=%d\n", loaded ? 1 : 0, old_file.dock_shown ? 1 : 0, fresh.dock_shown ? 1 : 0, - loaded && fresh.dock_shown ? 1 : 0); + loaded && old_file.dock_shown == fresh.dock_shown ? 1 : 0); } std::remove(path.c_str()); std::fflush(stderr); @@ -6411,6 +6438,19 @@ gboolean keys_probe(gpointer user) { before_after("l: layout", a->layout_toggle, AppAction::LayoutEditor); before_after("s: side panel", a->sidebar_toggle, AppAction::SidePanel); + // Escape through the real key path: with a slide-over page open it must be consumed and close the page + // (the handler runs the slide-over ahead of the editable check), and with none open it must be left alone. + { + const bool opened = slideover_open(*a, "trigger") && slideover_page(*a) == "trigger"; + const gboolean handled = on_app_key_pressed(nullptr, GDK_KEY_Escape, 0, static_cast(0), a); + const bool closed = slideover_page(*a).empty(); + const gboolean idle = on_app_key_pressed(nullptr, GDK_KEY_Escape, 0, static_cast(0), a); + char buf[96]; + std::snprintf(buf, sizeof(buf), " esc: opened=%d handled=%d closed=%d idle_handled=%d |", opened ? 1 : 0, + handled != FALSE ? 1 : 0, closed ? 1 : 0, idle != FALSE ? 1 : 0); + line += buf; + } + // An accel must be refused while text is being typed. Focus the window entry, send 'm' // through the real handler, and require the dock not to scroll. int refused = -1; @@ -6658,19 +6698,46 @@ GtkWidget* helpui_menu_view(App* a) { /// One line: the spotlight, the widget it spotlights, and the card, all in **window coordinates** -- /// the frame a screenshot is cropped in -- with the slide-over's open page beside them (`none` when it -/// is closed), so a stop that had to open a page to find its target is readable as such. `contains` is -/// the spotlight covering its target (it is the target's own rectangle, grown three pixels) and +/// is closed), so a stop that had to open a page to find its target is readable as such. +/// +/// `raw` is the target's own rectangle, unclipped, and `target` the part of it a scrolled window above +/// it leaves on screen -- measured *here*, by intersecting `raw` with each scroller's bounds, and not by +/// `tour_target_bounds()` (which the spotlight is drawn from, so reading the spotlight against it would +/// only agree with itself). `contains` is the spotlight covering that visible part (the part, grown three +/// pixels), or being empty when none of the target is visible; `inside` is the spotlight staying within +/// the nearest scroller above the target (grown by the same three pixels), `-1` when there is none; /// `clear` is the card missing the target. `probe` is the tag it is printed under, so a window with /// more than one tour probe can tell its lines apart. void tour_geometry_line(App& a, const char* probe, const char* what) { graphene_rect_t spot = GRAPHENE_RECT_INIT(0, 0, 0, 0); + graphene_rect_t raw = GRAPHENE_RECT_INIT(0, 0, 0, 0); graphene_rect_t target = GRAPHENE_RECT_INIT(0, 0, 0, 0); + graphene_rect_t scroller = GRAPHENE_RECT_INIT(0, 0, 0, 0); graphene_rect_t card = GRAPHENE_RECT_INIT(0, 0, 0, 0); GtkWidget* t = tour_target(a); const bool have_spot = a.tour.spot != nullptr && gtk_widget_compute_bounds(a.tour.spot, a.window, &spot); - const bool have_target = t != nullptr && gtk_widget_get_mapped(t) && - tour_target_bounds(t, a.window, &target); + const bool have_raw = t != nullptr && gtk_widget_get_mapped(t) && + gtk_widget_compute_bounds(t, a.window, &raw); + target = raw; + bool visible_part = have_raw; + bool have_scroller = false; + for (GtkWidget* p = have_raw ? gtk_widget_get_parent(t) : nullptr; p != nullptr; + p = gtk_widget_get_parent(p)) { + graphene_rect_t clip; + if (!GTK_IS_SCROLLED_WINDOW(p) || !gtk_widget_compute_bounds(p, a.window, &clip)) continue; + if (!have_scroller) { + scroller = clip; + have_scroller = true; + } + graphene_rect_t cut; + if (!graphene_rect_intersection(&target, &clip, &cut)) { + visible_part = false; + target = GRAPHENE_RECT_INIT(0, 0, 0, 0); + break; + } + target = cut; + } const bool have_card = a.tour.card != nullptr && gtk_widget_compute_bounds(a.tour.card, a.window, &card); const auto contains = [](const graphene_rect_t& outer, const graphene_rect_t& inner) { @@ -6682,15 +6749,26 @@ void tour_geometry_line(App& a, const char* probe, const char* what) { return p.origin.x < q.origin.x + q.size.width && q.origin.x < p.origin.x + p.size.width && p.origin.y < q.origin.y + q.size.height && q.origin.y < p.origin.y + p.size.height; }; + // Nothing of the target on screen: the right spotlight is no spotlight at all. + const bool spot_empty = have_spot && spot.size.width <= 0.5f && spot.size.height <= 0.5f; + const bool covers = have_spot && have_raw && (visible_part ? contains(spot, target) : spot_empty); + int inside = -1; + if (have_scroller && have_spot) { + graphene_rect_t grown = GRAPHENE_RECT_INIT(scroller.origin.x - 3.0f, scroller.origin.y - 3.0f, + scroller.size.width + 6.0f, scroller.size.height + 6.0f); + inside = (spot_empty || contains(grown, spot)) ? 1 : 0; + } std::fprintf(stderr, "[%s] %s=%d/%d visible=%d slideover=%s spot=[%.0f,%.0f %.0fx%.0f] " - "target=[%.0f,%.0f %.0fx%.0f] contains=%d card=[%.0f,%.0f %.0fx%.0f] clear=%d\n", + "raw=[%.0f,%.0f %.0fx%.0f] target=[%.0f,%.0f %.0fx%.0f] contains=%d inside=%d " + "card=[%.0f,%.0f %.0fx%.0f] clear=%d\n", probe, what, tour_step_index(a) + 1, tour_step_count(a), tour_active(a) ? 1 : 0, - (slideover_page(a).empty() ? "none" : slideover_page(a).c_str()), spot.origin.x, spot.origin.y, spot.size.width, spot.size.height, - target.origin.x, target.origin.y, target.size.width, target.size.height, - have_spot && have_target && contains(spot, target) ? 1 : 0, card.origin.x, - card.origin.y, card.size.width, card.size.height, - have_card && have_target && !overlaps(card, target) ? 1 : 0); + (slideover_page(a).empty() ? "none" : slideover_page(a).c_str()), spot.origin.x, + spot.origin.y, spot.size.width, spot.size.height, raw.origin.x, raw.origin.y, + raw.size.width, raw.size.height, target.origin.x, target.origin.y, target.size.width, + target.size.height, covers ? 1 : 0, inside, card.origin.x, card.origin.y, + card.size.width, card.size.height, + have_card && visible_part && !overlaps(card, target) ? 1 : 0); } gboolean helpui_probe_step(gpointer user) { diff --git a/src/ui_tour.cpp b/src/ui_tour.cpp index 21db85b..21f8a49 100644 --- a/src/ui_tour.cpp +++ b/src/ui_tour.cpp @@ -18,10 +18,10 @@ // // There are *two* tours and one implementation. The main window's walks the plot, the badge bar, the // trigger (in the slide-over), the dock and the like; the source chooser's -- which appears *before* -// `App` exists -- walks the chooser's own widgets and then hands the tour on to the window through `Config::tour_pending`. The -// machinery below is written against a `TourView` and a list of `TourStop`s, so the difference -// between the two is only which widgets they name: the chooser keeps its table in -// `source_dialog.cpp`, where its widgets live. +// `App` exists -- walks the chooser's own widgets and then hands the tour on to the window through +// `Config::tour_pending`. The machinery below is written against a `TourView` and a list of +// `TourStop`s, so the difference between the two is only which widgets they name: the chooser keeps +// its table in `source_dialog.cpp`, where its widgets live. #include "ui_app.hpp" @@ -39,11 +39,11 @@ namespace ui_impl { namespace { /// The card's width. Wide enough for a comfortable line of the paragraph, narrow enough to fit -/// beside the plot on the documented window -- at 756 px the side panel is 320 and a card wider than +/// beside the plot on the documented window -- at 756 px the dock is 320 and a card wider than /// that is a card that has to cover its own target. It is a *floor* on the box, not a cap on the /// paragraph: the wrapped label is capped by `max_width_chars` so the natural width never exceeds it. constexpr int kCardWidth = 280; -/// The gap between the hole and whatever is placed beside it. Eight and not twelve: the side panel +/// The gap between the hole and whatever is placed beside it. Eight and not twelve: the dock /// is 320 px, and it has to hold the card and both gaps. constexpr int kGap = 8; @@ -152,7 +152,12 @@ bool tour_target_bounds(GtkWidget* target, GtkWidget* overlay, graphene_rect_t* graphene_rect_t clip; graphene_rect_t cut; if (!gtk_widget_compute_bounds(parent, overlay, &clip)) continue; - if (!graphene_rect_intersection(out, &clip, &cut)) return false; + if (!graphene_rect_intersection(out, &clip, &cut)) { + // Scrolled out of sight entirely: measured, and nothing of it is on screen. An *empty* rectangle, + // not a failure -- a caller that took "false" for "not measured yet" would keep the previous hole. + *out = GRAPHENE_RECT_INIT(0.0f, 0.0f, 0.0f, 0.0f); + return true; + } *out = cut; } return true; @@ -311,7 +316,12 @@ void tour_update(TourView& view) { !view.page_settled(view.page_user)) { target_present = false; } - if (target_present && tour_target_bounds(target, view.overlay, &bounds)) { + const bool measured = target_present && tour_target_bounds(target, view.overlay, &bounds); + if (measured && (bounds.size.width <= 0.0f || bounds.size.height <= 0.0f)) { + // Measured, but scrolled out of the scroller it stands in: nothing is on screen to point at, and the + // hole from the last frame (the target before it was scrolled away) must not stay standing. + view.hole[0] = view.hole[1] = view.hole[2] = view.hole[3] = 0; + } else if (measured) { // The hole: the target, grown by three pixels so the outline sits *around* the widget rather than // on it, and clamped to the overlay -- a target partly off screen is an ordinary case (a sidebar // dragged wide), and a negative rectangle is not something a margin can express. @@ -328,7 +338,7 @@ void tour_update(TourView& view) { // target went away mid-tour is not a step to be skipped silently. view.hole[0] = view.hole[1] = view.hole[2] = view.hole[3] = 0; } - // ... and when the target *is* there but could not be measured, the previous hole stands: a frame + // ... and when the target *is* there but could not be measured yet, the previous hole stands: a frame // can arrive between two layout passes, and emptying the hole there is the spotlight and the card // jumping to the corner and back. const int hx = view.hole[0]; @@ -344,8 +354,8 @@ void tour_update(TourView& view) { // The card, placed clear of the hole whenever the window has room for it. The strip order is the // order a card is easiest to read in: under the thing it describes, then over it, then in - // whichever column is beside it -- which is, on this window, the side panel for the plot's step and - // the plot for every step in the side panel. + // whichever column is beside it -- which is, on this window, the dock for the plot's stop and the + // plot for the dock's stops. // // The card's size is the *rectangle it was allocated*, read the same way the probe reads it and // the help's figures are drawn from: `gtk_widget_compute_bounds()`. Not `gtk_widget_get_width()`, @@ -402,7 +412,7 @@ void tour_update(TourView& view) { cy = std::clamp(hy, kGap, std::max(kGap, h - ch - kGap)); } else { // No clear side at all -- the target is nearly the whole window, which is the plot's step in a - // window narrow enough for the split view to *overlay* the side panel rather than put it beside + // window narrow enough for the split view to *overlay* the dock rather than put it beside // the plot. The card goes to the bottom-right corner, which is where the least of the target is, // and the two readings the probe prints (`spot=`, `card=`) are what show how little choice there // was rather than the card having been placed badly. From 0c94caf866aae2b18c62576e94e6dc96d42c4f2c Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 2 Nov 2026 21:03:03 +0100 Subject: [PATCH 063/127] docs: the badge bar, the slide-over and the dock (step 1 of the redesign) Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 6 +- README.md | 115 ++++++++++++------- docs/claude/architecture.md | 33 ++++++ docs/claude/invariants-gtk-and-chrome.md | 69 +++++++---- docs/claude/invariants-plot-and-layout.md | 27 ++++- docs/claude/invariants-session-and-export.md | 12 +- docs/claude/verification.md | 11 ++ 7 files changed, 203 insertions(+), 70 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 76d822c..17e9a5d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,13 +46,13 @@ The detail lives in `docs/claude/`. Read the files that touch the area you are e | File | What it covers | | --- | --- | -| [architecture.md](docs/claude/architecture.md) | The two threads and what they share; the one `BufferStore` and the four display modes (`Live`, `Capturing`, `Held`, `Waiting`); the display clock and `stream_at_x0()`; the layers to keep straight (`SignalInfo` → `ChannelBuffer` → `SlotSeries` → `RowSet`); the source plugins. | +| [architecture.md](docs/claude/architecture.md) | The two threads and what they share; the one `BufferStore` and the four display modes (`Live`, `Capturing`, `Held`, `Waiting`); the display clock and `stream_at_x0()`; the layers to keep straight (`SignalInfo` → `ChannelBuffer` → `SlotSeries` → `RowSet`); the source plugins; the window's chrome (slide-over, badge bar, dock) and when each is built. | ### Invariants worth knowing before editing | File | What it covers | | --- | --- | -| [invariants-plot-and-layout.md](docs/claude/invariants-plot-and-layout.md) | What `plot::Figure`/`plot::Plot` can and cannot be asked to do, what a row's contents belong to, the layout editor's overlay children, and the rule that **layout must not depend on content**. | +| [invariants-plot-and-layout.md](docs/claude/invariants-plot-and-layout.md) | What `plot::Figure`/`plot::Plot` can and cannot be asked to do, what a row's contents belong to, the layout editor's and the slide-over's overlay children (with the pick rule), the badge bar's fixed height, and the rule that **layout must not depend on content**. | | [invariants-readouts-and-cursors.md](docs/claude/invariants-readouts-and-cursors.md) | A readout quotes a stored sample and never a value between two; how the cursors are read back from the plots; the one formatter both panes share. | | [invariants-data-path.md](docs/claude/invariants-data-path.md) | What rebuilds the buffer and drops history, why decimation at ingest is an **average** and never a stride, and the two rules the `f64` time ring and its cached first stamp impose. | | [invariants-rendering.md](docs/claude/invariants-rendering.md) | How the renderer maps a visible range onto samples (never the `dx()` grid), what `gather()` emits and relative to which origin, why every series needs a real `MinMaxPyramid`, and why a dense trace is drawn one pixel column at a time. | @@ -61,4 +61,4 @@ The detail lives in `docs/claude/`. Read the files that touch the area you are e | [invariants-measurements.md](docs/claude/invariants-measurements.md) | Why the pane is refreshed on a cadence, why neither pass may read its window raw, how a crossing is counted with hysteresis, and which mean over a split is which. | | [invariants-math-channels.md](docs/claude/invariants-math-channels.md) | A math channel *is* a channel; the engine runs on the producer thread, so a regression there costs packets and not frames; the node editor's own rules. | | [invariants-session-and-export.md](docs/claude/invariants-session-and-export.md) | What a session file persists and in what order, when a save is refused, and when an export is collected. | -| [invariants-gtk-and-chrome.md](docs/claude/invariants-gtk-and-chrome.md) | The GTK signal-signature and dialog-lifetime traps this app has already fallen into, the status bar's two tiers, the bundled icons and their authoring rules, and how the Mallard manual is opened. | +| [invariants-gtk-and-chrome.md](docs/claude/invariants-gtk-and-chrome.md) | The GTK signal-signature and dialog-lifetime traps this app has already fallen into, the status bar's two tiers, the bundled icons and their authoring rules, how the slide-over's click-away coexists with the plot's gestures, how the guided tour opens a page, and how the Mallard manual is opened. | diff --git a/README.md b/README.md index bf3e487..e5fb6e8 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ switches trigger mode on, waits for an event, then leaves trigger mode the way t "Go live" button does, closes the window, and prints on stdout the status line in 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 -state of both side panels. +state of the dock and the events pane. 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 @@ -96,9 +96,24 @@ src/plot_host.* the one plot::Figure, its rows' traces and cursors, the x lin src/export.* the visible range as a table, and the .npy / .csv / .mf3 writers src/layout_store.* the per-source session layout file (TOML), written and read back src/event_series.hpp an immutable captured event as a plot::data::Series -src/ui.* the GTK4 window: toolbar, trigger panel, signal panel, plot box +src/ui.* the GTK4 window: toolbar, trigger form, signal list, plot box, the dock +src/ui_slideover.cpp the slide-over panel over the plot's right edge, one page at a time +src/ui_badges.cpp the badge bar under the plot: the Trigger and Source badges ``` +**The window.** The plot is the window. Under it sits a fixed-height **badge bar** (36 px) whose +right-hand end carries two words-only badges: **Trigger** (`off`, `armed`, `capturing`, `triggered` +or `disarmed`) and **Source** (the kind's name and `connected`/`disconnected`; a read-out, not a button, +for a source with no controls of its own, and for a file). A badge opens the **slide-over**: a 360 px +panel laid over the plot's right edge, opaque, with its own title, a *Close* button and one scroller per +page. The Trigger badge opens the trigger form; the Source badge opens the source kind's own panels +(the E-1608's *Analog out* and *Digital I/O*, behind a segmented switch at the top of the panel). It +closes with a second click on its badge, with `Esc`, with *Close*, or with a click on the plot; it never +resizes the plot. The right-hand **dock** (the existing sidebar, 320 px) holds what is left of the old +tabs in one scroller: the signal list, the cursors and the measurements, shown from the start and +insensitive until the stream has signals. It is temporary -- see "Review findings not (yet) +implemented" for what the redesign still has to do with it. + Threading: one producer thread owns the data source (the UDPStreamer client is the built-in one) and calls `poll()`; the GTK main thread owns every widget and the GL contexts. They share `ScopeModel` under one mutex, which the producer takes once per DATA @@ -1006,7 +1021,9 @@ offset = 0.5 ``` It also stores `measure_stats` (the measurements statistics toggle, a boolean, absent means -`false`). It is written (400 ms after the last change, and flushed on close) whenever the +`false`) and `dock` (whether the dock is shown, a boolean, absent means `true`; it replaced the +sidebar's `side_tab`, and a file that still carries `side_tab` loads, the key being ignored). The +open slide-over page is not stored: a panel that opens by itself at start-up would be a surprise. It is written (400 ms after the last change, and flushed on close) whenever the window, the layout, a drop or a calibration changes, and applied once, when the first CONFIG names the signals -- signal *names* are the identity, because a CONFIG renumbers and an enable/disable rebuilds the channel list. Signals the @@ -1151,18 +1168,23 @@ up, the same delay the first-run auto-start uses, the pending request starts the chooser's rows show **no accelerators** -- the window prints `F1`/`Ctrl+T` because the window binds them, and this dialog binds none. -**The guided tour is five steps on a `GtkOverlay`** inserted above the window's content: the plot, -the side panel, the trigger, the signals and the status line. There is no popover anywhere in it and +**The guided tour is six steps on a `GtkOverlay`** inserted above the window's content: the plot, +the badge bar, the trigger form, the dock, the signals and the status line. There is no popover anywhere in it and therefore no pointer grab to lose: what makes the tour modal is four dim rectangles -- above, below and either side of the target -- that swallow the clicks that land on them. A stop declares the -**page it stands on** (the trigger and the signals steps live on the sidebar's *Signals* page, named -in `TourStop::page`), and the tour selects that page as the step is entered -- on the frame *after* -the switch, since a stack allocates a new page's widgets a frame late -- so the spotlight lands on a -widget the user had left behind on the other tab instead of on nothing. `gtk_widget_get_mapped()` -decides which steps exist at all: a stop on the *wrong page* is kept, because selecting its page -reveals it, while one whose *pane is collapsed* is dropped, because the stack is unmapped with it -(a hidden page keeps its own *visible* flag true -- the widget's own flag, which is all -`is_visible()` walks -- so the old test walked over surfaces that were not on screen). The chooser's +**slide-over page it stands on** (the trigger step names `trigger`, in `TourStop::page`): the tour opens +that page as the step is entered, and a stop with no page *closes* the panel, so the card never points +at the status line over an open panel. It does not spotlight the form until the panel has finished +sliding in (about 120 ms, during which the surface is dimmed and the card waits in the corner), and the +hole is the target's bounds clipped by every scroller above it -- on the documented 756x470 window the +trigger form is taller than the panel, and the spotlight covers the part that is on screen, not the +badge bar under it. Skip, Escape and the last step close a panel the tour opened (an idle dismissal +leaves one the user opened alone). `gtk_widget_get_mapped()` +decides which steps exist at all: a stop on a slide-over page is always kept, because opening the page +reveals it, while one whose *pane is collapsed* (the dock, in a window too narrow to put it beside the +plot) is dropped, because it is unmapped with its pane (a hidden page keeps its own *visible* flag +true -- the widget's own flag, which is all `is_visible()` walks -- so the old test walked over +surfaces that were not on screen); a narrow window therefore walks four steps. The chooser's tour works the same way over its own two pages: its tabs step shows the *Waveforms* page and stepping off it -- in either direction -- brings the *Sources* page back. @@ -2780,12 +2802,13 @@ reaches every widget). The full key table: |---|---| | `a` | arm / disarm the trigger | | `Space` | pause / resume | -| `t` | show / hide the trigger panel | -| `c` | show / hide cursors | -| `m` | show / hide measurements | -| `g` | show / hide math channels | +| `t` | enable / disable trigger mode | +| `c` | enable / disable the cursors | +| `m` | go to Measure (scroll the dock to the cursors and the measurements) | +| `g` | go to Signals (scroll the dock back to the signal list) | | `l` | show / hide the layout editor | -| `s` | show / hide the side panel | +| `s` | show / hide the dock | +| `Esc` | close the slide-over panel (the guided tour takes it first) | | `f` | fit to data (the plot's own key, not an application accelerator) | | `Ctrl+E` | export visible range | | `F1` | open the manual (`help:org.udpscope.app`) in the desktop help viewer | @@ -2897,32 +2920,42 @@ now — they are listed so they are not mistaken for anything else: progress. What the spec was protecting still holds — a *held* event is bound to its snapshot alone, and the axis is the event's own window from the edge onwards rather than a sliding live view. -- **The sidebar is two tabs, and a math channel is a row of the signal list.** `SPECS.md` - names the panes as separate collapsible sections — trigger, cursors, measurements, math, - signal list — in one scrolling column; with four of them open in a 320 px sidebar every - section was short of room, and finding one meant scrolling past the others. The built - sidebar is a **Measure** tab (the cursors readout and the measurements) and a **Signals** - tab (the trigger and the channels), **each a text tab** (the sidebar was icon-only for a while; glyphs for "cursors and ruler" and - "trigger and channel list" were not understandable enough, so the words won). A source kind's own tabs - (the E-1608's **Analog out** and **Digital I/O**) sit beside them as text too. Both panes also have a **locked** mode, which is what a returning user gets: the +- **The sidebar is one dock, setup is a slide-over, and a math channel is a row of the signal list.** + `SPECS.md` names the panes as separate collapsible sections -- trigger, cursors, measurements, math, + signal list -- in one scrolling column; with four of them open in a 320 px sidebar every + section was short of room, and finding one meant scrolling past the others. The sidebar was then two + text tabs (a **Measure** tab and a **Signals** tab, with a source kind's own tabs beside them), and four + tabs did not fit 320 px. It is **one dock** now, with no tab switcher: the signal list, the cursors and the + measurements in one scroller, shown from the start and insensitive until the stream has signals; `m` and + `g` scroll it to the cursors and back to the signal list. The **trigger form** and a **source's own + controls** are pages of the slide-over, reached from the badge bar. Both panes also have a **locked** mode, which 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 - to switch to the full pane, and back. Dragging a signal onto a plot still works while locked — filling - plots is what the list is for, not configuration — and a locked row can be dragged from anywhere on it, - the colour chip included. In a window too narrow to show both, where the sidebar is *overlaid on* the - plot, the sidebar steps aside for the length of the drag so there is something under the pointer to drop + on, and neither offers a control that changes the configuration -- each heading has an `[edit]` button + to switch to the full pane, and back. Dragging a signal onto a plot still works while locked -- filling + plots is what the list is for, not configuration -- and a locked row can be dragged from anywhere on it, + the colour chip included. In a window too narrow to show both, where the dock is *overlaid on* the + plot, the dock steps aside for the length of the drag so there is something under the pointer to drop onto. A **first run** (no session file for that source) comes up unlocked, because configuring the host is what a first run is for. Each pane's heading also carries its **own switch**, the same control as the - header bar's button — clicking either moves both — because a pane that is *disabled* while its - feature is off is a pane whose controls you would otherwise have to leave to turn it back on. The - sidebar is one **dock** now, with no tab switcher: the signal list, the cursors and the measurements - in one scroller, shown from the start and insensitive until the stream has signals; `m` and `g` - scroll it to the cursors and back to the signal list. With the math - list folded into the signal list under a `MATH` divider whose `+` creates a channel — a derived channel already *is* a channel in + header bar's button -- clicking either moves both -- because a pane that is *disabled* while its + feature is off is a pane whose controls you would otherwise have to leave to turn it back on. With the math + list folded into the signal list under a `MATH` divider whose `+` creates a channel -- a derived channel already *is* a channel in the model, so the two lists were the same list drawn twice. No toggle hides a pane any more: cursors-off **disables** its readout and leaves it in place, and - trigger-off leaves the trigger panel fully editable — a trigger is configured *before* it is - switched on, and the locked view says `stream (trigger off)` instead of the settings — and the measurements' and math panes' toolbar toggles are gone with the panes. + trigger-off leaves the trigger form fully editable -- a trigger is configured *before* it is + switched on, and the locked view says `stream (trigger off)` instead of the settings -- and the measurements' and math panes' toolbar toggles are gone with the panes. +- **The badge bar / slide-over / dock redesign is in progress; this is its first step, and it deviates + from `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md` in three known ways.** + (1) The dock keeps the sidebar's **320 px**, not the spec's ~280: the panels it holds were built for + 320, and the width changes when they become compact readouts (step 3). (2) The session file gains the + dock's *shown* state only, not a width -- the dock's width is fixed, so there is nothing to persist + -- and does not store the open slide-over page. (3) The **Trigger badge** shows `off`, `armed` and + `triggered` as the spec says, and two more words, `capturing` (a post-trigger window is filling) and + `disarmed` (trigger on, not armed), so the label never claims a state the engine is not in. Still to + come, in the spec's order: the signal badges and drag (2), the compact readouts (3), the events + section in the dock, the deletion of the left pane and the Events toggle, and `USER-GUIDE.md` and the + manual's figures (4). Until then the events pane, the header's Events toggle and the manual still + describe the previous layout. - **A plot holds at most eight signals** (`RowSet::kSlotsPerRow`), because a `Plot` cannot give up a trace and the slots are therefore created up front. - **One toggle drives both cursor orientations.** The vertical markers and the @@ -3016,6 +3049,10 @@ silence timeout and then resumes, which is the EMI case end to end. rather than rebuilds, and why the frame tick reads the markers back: a parked cursor's snapped position is a real number, so nothing may be inferred from it. The trigger marker is separate: its own cursor, independent of the cursor toggle. +- **The slide-over shows a form taller than the plot by scrolling it.** The panel is as tall as the + plot and never taller, and each page has a scroller of its own, so on the documented 756x470 window + the trigger form (about 474 px) shows 253 px of itself. The tour's spotlight is clipped to that, and at + that size the card of the trigger step overlaps the panel's edge by about 18 px (`clear=0`). - **Verified headless only.** Acquisition, partitioning, the trigger state machine and its integration with the buffers, and rendering under Xvfb (llvmpipe) were exercised; the interactive paths (drag and drop, the dialogs, diff --git a/docs/claude/architecture.md b/docs/claude/architecture.md index 40c8568..67454b7 100644 --- a/docs/claude/architecture.md +++ b/docs/claude/architecture.md @@ -91,6 +91,39 @@ What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*. every handler tests `Form::alive`, which the groups' `destroy` clears, because the chooser may have destroyed the sibling widgets the handlers reach by raw pointer. +### The window's chrome: plot, slide-over, badge bar, dock + +The main window is a plot with three things around it, each a unit of `src/ui_*.cpp` and none of them +touching the data path. + +- **`SlideOver`** (`src/ui_slideover.cpp`) is one `GtkRevealer` holding a `GtkStack`, added as the second + child placed *over* the plot (after the layout editor's buttons) in `GtkOverlay#plot_overlay`. A page is + a `slideover_add_page(app, id, title, widget)`: the widget is wrapped in a `GtkScrolledWindow` of its + own and registered under its id (`trigger`, `source-`; `signal:`, `signals`, `measure`, + `cursors` and `events` are reserved for later steps). One page is open at a time and `App::slideover.page` + is its id, empty when closed. The open page is **not** a session value. Esc, *Close*, a second click on the + badge and a click on the plot close it; the click-away is a capture-phase observer (see + [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). With two or more `source-*` pages, an + `AdwToggleGroup` under the header is the segmented switch between them. +- **`BadgeBar`** (`src/ui_badges.cpp`) is the fixed-height row between the plot and the status line + (`main-window.ui` `badge_bar`, two slots: signals, left, empty until step 2; system, right, holding the + **Trigger** and **Source** badges). A badge is a flat button whose `page` is the slide-over page it + toggles; the Source badge's page is the first `source-*` page, and a kind with none (UDPS, Flat UDP, a + file) leaves it a read-out with `can_target` off. +- **The dock** is the `AdwOverlaySplitView` sidebar (`side_box` > `dock_scroller` > `dock_page`): the + signal list, the cursors and the measurements in one scroller. It holds no tab switcher and no + revealer, and it does not hold a source kind's panels (those are slide-over pages). + +**When each is built and when it is read.** `build_slideover()` runs in `on_activate` right after the tour +overlay is built (the tour's overlay wraps the whole window, so its dim rectangles cover the slide-over +too); the trigger form is registered as +a page by `build_trigger_panel`; `build_source_panels()` registers the source pages; and +`build_badge_bar()` runs *after* all of them, because the Source badge needs to know the first page. +Per-tick state (the Trigger word, the Source name and `connected`, the open marks) is written by +`sync_badge_bar()`, called from `tick()` and from `slideover_open`/`slideover_close`; it writes a label +only when its text changed. Nothing in the badge bar or the slide-over may be read from the producer +thread. + ### Data path `ScopeModel` holds **one `BufferStore`** (`live()`): one shared time-base ring plus one diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md index cc7d9fc..0fb5b0a 100644 --- a/docs/claude/invariants-gtk-and-chrome.md +++ b/docs/claude/invariants-gtk-and-chrome.md @@ -60,11 +60,6 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear user; `udpscope-measure-symbolic` **stayed**, because the Preferences window's Plot page carries it -- an icon with a live user is not an orphan, whatever the toggle that introduced it did -- and it is the Preferences window's icon. - - The sidebar has **no tabs any more** (it is one dock): two tab glyphs (`udpscope-tab-measure-symbolic`, - `udpscope-tab-signals-symbolic`, each a 32x16 pair) were drawn for them and judged not understandable - enough, so the tabs were text, and both SVGs were deleted. The *trigger's* own icon was redrawn to the - Signals glyph's picture at that time and kept it. - **A shortcut table row's `accel` is GTK accelerator syntax, and a pointer gesture has none.** `src/shortcuts.cpp`'s strings are handed to `AdwShortcutsItem`, which parses them with `gtk_accelerator_parse()` — so they are `"space"`, `"e"`, `"slash"`, @@ -249,9 +244,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear `update_side_visibility()` and the tick's per-frame block set the column's visibility from the user's wish alone (`sidebar_wanted`) and the dock's *sensitivity* from `have_signals`; the toolbar's Dock button stays sensitive throughout. The wish is persisted (`dock = true|false` in the session file) and a change of it - schedules a save. There is no tab icon to keep clear any more: the `keep_tabs_icon_free()` workaround for - `AdwViewSwitcherButton`'s placeholder `[x]` image went with the switcher, and `dock:` in the dump - replaces the `switcher:` line. + schedules a save. `dock:` in the dump reads the result. - **An *overlaid* sidebar covers the plot, so a drag out of it has nowhere to land.** `collapsed` in an `AdwOverlaySplitView` means the sidebar sits *on* the plot rather than beside it: the pointer is over the list, never over a drop target, and a signal can be dragged but not dropped. `row_drag_sidebar()` hides @@ -594,6 +587,21 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps grab to leak. The probe asserts it as a reading -- `controllers=` against the count taken before the tour opened -- rather than as a claim, because "no lingering grab" is exactly the sort of thing a later change can add back without noticing. +- **Click-away coexists with the plot's gestures because the observer never claims the sequence.** The + slide-over closes on a press anywhere outside it, and the plot underneath already owns pan, zoom, drop and + right-click. A `GtkGestureClick` added to `plot_overlay` in `GTK_PHASE_CAPTURE` sees the press before the + plot's own controllers and **does not call `gtk_gesture_set_state()`**, so the sequence stays unclaimed and + the plot's gestures run exactly as before; claiming it (or putting a full-area target over the plot) would + swallow the very pan the click was part of. The observer decides inside/outside by + `gtk_widget_compute_point()` into the revealer plus `gtk_widget_contains()`, never by pick. The press + that closes the panel still reaches the plot, which is accepted: it is one click and not a drag the user + started on purpose. `[slideover]` reads `click_inside_closes=0 click_outside_closes=1`. +- **`AdwToggleGroup` is the segmented switch** (libadwaita >= 1.7; the build asks for 1.8). It replaces the + `AdwViewSwitcher` for a source with several panels (the E-1608: Analog out | Digital I/O). Its toggles are + named by the slide-over page ids, and the group's `notify::active-name` is the *input*; writing it + ourselves (`slideover_open` on a source page) goes through `SlideOver::syncing`, or the program's own + write comes back as a click and re-opens the page it just opened. It is hidden unless a source page is + open and there are two or more, which `[badges] switch_visible=` reads. - **The tour is one implementation over two windows.** `ui_impl::TourView` holds the overlay children and the walk's place in it (`tour_build`/`tour_begin`/`tour_advance`/`tour_end`/`tour_update`), and what differs between the windows is only the *stops*: a `TourStop` names a widget the owner resolves, @@ -602,15 +610,26 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps the view's `on_done`; the window's passes null, the chooser's sets `Config::tour_pending`, which is how the two halves are one tour. `App` carries its own `TourView` (`app.tour`), so the machinery is compiled once and the probe reads either window's state by the same fields. -- **A stop declares the page it stands on, and the tour selects that page as the stop is entered.** A - stop whose target lives on another page of a stack -- the window's `signals` (and `trigger`) stops - on the sidebar's `signals` page, the chooser's "Two kinds of input" stop on its `waveforms` page -- - names that page in `TourStop::page`, and `show_step()` selects it from `TourView::page_stack` - before anything is measured. The page switch is **not** followed by a one-shot measure: `show_step()` - selects the page and `tour_update()`, which already runs every frame, fills the spotlight in on the - frame *after*, when the new page has been laid out. A stack allocates the new page's widgets on the +- **A stop declares the page it stands on, and the tour selects that page as the stop is entered -- + by a hook in the main window and by a stack in the chooser.** The main window has no page stack any + more: its pages are the slide-over's. A `TourStop::page` there is a slide-over page id (`trigger`), and + `TourView::on_page(page_user, page)` opens it -- or, for a stop whose `page` is null, **closes** the + panel, so the card never points at the status line over an open panel. `tour_end()` calls + `on_page(nullptr)` *only if a walk was running* (so Skip, Esc and the last Next close a panel the tour + opened, while dismissing an idle tour does not close one the user opened). The chooser has no hook and + keeps `TourView::page_stack` (an `AdwViewStack`, selected by name). `select_view_page()` is the single + entry for both, and `show_step()` calls it before anything is measured. + Two rules follow from a panel that *slides*. The page switch is **not** followed by a one-shot measure: + `tour_update()`, which already runs every frame, fills the spotlight in on a later one, and for a stop + with a page it waits for `TourView::page_settled` (the slide-over's `gtk_revealer_get_child_revealed()`) + -- until then the target is moving, so the surface is dimmed with an empty hole. And **the hole is the + target's bounds clipped by every `GtkScrolledWindow` above it** (`tour_target_bounds()`): a form taller + than the panel's scroller overruns it, and an unclipped hole spotlights the badge bar and the status + line under the panel. A target scrolled out entirely is a *measured, empty* rectangle (the function + returns true), not a failure -- `tour_update()` zeroes the hole on it, because taking "false" for "not + measured yet" keeps the previous frame's hole standing. A stack allocates a new page's widgets on the frame after the switch, so a measurement taken in the switch's own tick reads zero (the allocation - bullet in [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)) -- and reading the + bullet in [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)) -- reading the page *name* back is fine immediately, because that is state and not geometry. - **The stop filter runs on `get_mapped()`, not `is_visible()`, and that is what separates a wrong page from a collapsed pane.** GTK 4.24's `gtk_widget_is_visible()` walks only the widgets' own @@ -619,11 +638,14 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps `gtk_widget_set_child_visible()`, so a widget on another page, *and* one in a collapsed pane, both read `is_visible() == TRUE` (verified against 4.24.1) -- the old filter kept a stop it meant to drop and left the spotlight on a target with no allocation. `stop_is_walkable()` keeps a stop when - its target is **mapped**, or when it names a page and the stack that page belongs to is mapped: a - wrong page leaves the stack mapped, so the stop is kept and the page switch reveals it; a collapsed - pane leaves the stack unmapped too, so no page can reveal it and the stop is dropped. The filter - lives in `tour_begin()`, not in each window's `start_tour()`, both so the two windows cannot drift - into two rules and because the rule needs the view's own `page_stack`. + its target is **mapped**; a stop that names a page of a view with an `on_page` hook is **always** + kept (the hook opens the page whatever the dock is doing, since the slide-over does not depend on it); + and in a view with a `page_stack` a stop that names a page is kept when the stack is mapped -- a wrong + page leaves the stack mapped, so the page switch reveals it, while a collapsed pane leaves it unmapped + too and the stop is dropped. So the dock's stop is dropped in a window narrow enough to overlay it, and + the main window's tour is four steps there (`[toursurface] collapsed: ... steps=4`). The filter + lives in `tour_begin()`, not in each window's `start_tour()`, so the two windows cannot drift + into two rules. - **The chooser's tour needs an overlay over its *whole* window, and `AdwWindow`'s content is replaced, not wrapped.** Two of the chooser's stops name header-bar buttons, and an overlay inside the `AdwToolbarView`'s `content` would give them negative coordinates and clamp the hole to the top @@ -657,8 +679,9 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps - **The `help figures:` line is captured on the tick, like every other size the dump prints.** It names ten surfaces' window-coordinate rectangles for the help's annotated figures, and the dump runs after the window is destroyed -- a `gtk_widget_compute_bounds()` there is the "no `gtk_widget_*` call in the - dump" rule again. `is_visible()`, not `get_visible()`: a widget under a hidden parent -- the trigger - summary while the pane is in configuration mode -- keeps its own flag true and reads 0x0, and a crop + dump" rule again. `is_visible()` *and* `get_mapped()`, not `get_visible()`: a widget under a hidden parent -- the trigger + summary while the pane is in configuration mode -- keeps its own flag true and reads 0x0 (and a widget on a closed slide-over page, `trigger_*` + while the panel is shut, would read a bogus `630,46,0,0` without the mapped test), and a crop box drawn from that is a box over the wrong thing. Its numbers depend on the *window's* size, which under the documented `xvfb-run` is the window's 756x894 minimum (a 640x480 screen), so a run on a larger screen gives different rectangles for the same panels. diff --git a/docs/claude/invariants-plot-and-layout.md b/docs/claude/invariants-plot-and-layout.md index 897d782..b96155d 100644 --- a/docs/claude/invariants-plot-and-layout.md +++ b/docs/claude/invariants-plot-and-layout.md @@ -1,6 +1,6 @@ # Invariants: the plot, the figure and the layout -How `plot::Figure`/`plot::Plot` may and may not be driven, what a row's state belongs to, the layout editor's overlay, and the rule that layout must not depend on content. +How `plot::Figure`/`plot::Plot` may and may not be driven, what a row's state belongs to, the layout editor's and the slide-over's overlay children, and the rule that layout must not depend on content. Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it. @@ -97,6 +97,31 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear an equal division. `sync_layout_editor()` runs every frame on purpose: `gtk_widget_set_margin_*()` returns early on an unchanged value, so a settled layout costs the comparisons and nothing else, and a resize needs no notification. +- **The slide-over is the second thing placed over the plot, and it is one opaque widget, not a + transparent area.** The same pick rule as the editor's buttons applies, harder: the panel is a + `GtkRevealer` aligned to the end edge (`halign=END`, `valign=FILL`) at `kSlideOverWidth` = 360 px, so + its allocation *is* the part of the plot it covers and everything to its left is still the plot's -- + pan, zoom, drop and hover are untouched. A full-area child with a panel drawn in it would swallow all + of them. Closed, the revealer is `can_target(FALSE)` (and `gtk_overlay_set_clip_overlay()` keeps its + slide-out inside the plot), so the frames of a slide-out do not eat input either. `[slideover]` reads + it back: `outside_hits_plot=1 inside_hits_panel=1`. Three consequences: + the panel **never resizes the plot** (it is an overlay child; `plot_unchanged=1`), its height is the + plot's and never the page's (each page has a `GtkScrolledWindow` with a `NEVER` horizontal policy that + does not propagate its child's height), and content that is taller than the plot -- the trigger form + on a 470 px window -- scrolls inside the panel instead of growing it. The widgets that go in a page + are reparented into it; their widths must fit 360 minus the border (the trigger form measures 267 + minimum, the E-1608's panels 197). +- **The badge bar has a fixed height and sits outside the overlay.** `badge_bar` is 36 px + (`height-request`) between `plot_overlay` and the status line, so the plot above it never moves when a + badge's text changes (`bar_h` = `bar_h_after` in `[badges]`). This is "layout must not depend on + content" for the new row: a badge's state is a word from a short fixed set and the Source badge's name + is a kind's display name, so what changes with the stream is the row's *width* (the bar has room to + spare on the right) and never its height. The badge labels are not ellipsized: a name long enough to + need it would be a defect of the kind's `display_name()`. Rows that appear by trigger condition appear + inside the slide-over's scroller, never in the bar. +- **The dock's width is fixed too** (`side_column` `width-request` 300, the split view's 320 px floor + and cap), and it holds one scroller with the signal list, the cursors and the measurements. A + section's visibility is never what makes the dock taller; the scroller is what absorbs the extra. - **Trace slots are identified by signal name, not channel index.** Enabling a signal or a new CONFIG renumbers the channel list, so an index would silently re-point a trace at another signal. diff --git a/docs/claude/invariants-session-and-export.md b/docs/claude/invariants-session-and-export.md index 169ebdb..89fc64c 100644 --- a/docs/claude/invariants-session-and-export.md +++ b/docs/claude/invariants-session-and-export.md @@ -30,10 +30,14 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear not freeze a colour that is meant to follow the signal list), **the math channels with their graphs**, **the measurements pane**, and **`measure_stats`** — the measurements statistics toggle; written as a bare top-level boolean (`measure_stats = true/false`), defaults to `false` - when absent, and **`dock`** -- the Dock button's state (the user's last word on it, `sidebar_wanted`), - a bare top-level boolean written where the sidebar's `side_tab` used to be, default `true`. The parser - ignores unknown keys, so a file written before the tabs became a dock (`side_tab = "measure"`) still loads - and simply opens with the dock shown; nothing needs to read the old key. Signal identity + when absent, and **`dock`** -- `LayoutState::dock_shown`, the Dock button's state (the user's last word on it, + `App::sidebar_wanted`): a bare top-level boolean written where `side_tab` used to be, default `true`. + It is the **only** thing the dock persists -- its 320 px width is fixed, so there is nothing to store -- + and **the open slide-over page is deliberately not persisted** (a panel that opens by itself at start-up + is a surprise, and a page id from another source kind may not exist). The parser ignores unknown keys + (its key chain has no erroring final `else`), so a file written before the tabs became a dock + (`side_tab = "measure"`, or `side_tab = "source-ao"`) still loads and opens with the dock shown; + nothing reads the old key, and `[dock-session]` asserts it. Signal identity is the *name* throughout (a CONFIG renumbers channels), 'enabled' is read off the channels the model built -- an enabled signal is exactly one that produced a channel -- and `apply_layout()` installs the graphs **before** assigning the rows, since a row may hold a math channel's name and diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 01b9a71..6ef4f21 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -15,6 +15,17 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ Two baselines every run is read against. The UDPS runs must be **unchanged by the source-plugin work**: a UDPS self-check, number-normalised (`sed 's/[0-9.]\+/N/g'`), diffs empty against one taken before it. +For the badge bar / slide-over / dock redesign the same diff is taken **against the commit before that +series (`c572ec2`)**, from a fresh `XDG_CONFIG_HOME` on each side (a stored mock session changes the +trigger lines), and it is "unchanged except" exactly these lines: `panels:` loses `switcher=` (the +trigger form is read through the slide-over, so its width and height change, and the `compact arm +probe` width with them); `switcher:` becomes `dock: shown= sensitive= w= scroller_pages=`; +`panel controls:` prints `dock=` for `switcher=`; `help figures:` loses `side_switcher=`, replaces +`side_stack=` with `badge_bar=` and `dock=`, moves the `trigger_*` rectangles into the slide-over and +shortens `plot=` by the 36 px bar; and the `tour started: steps=` count. A self-check taken **before** +`c572ec2` is no longer the baseline for those lines. The rest is the mock's own jitter (the sign of a +`square y=[...]` or a `trig engine:` figure, the position of one `trigger: edge` line), which the +normalisation does not remove -- anything else is a finding. A clean build emits **one** warning and no more: a `-Woverlength-strings` on the icons' generated GResource `.c`, whose blob is **9248** against C99's 4095-byte string-literal limit with the five action icons and the two app icons that remain (a note about a generated file, which GCC From 54d7f63f3553010a489f8942124d57838b461d8c Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 2 Nov 2026 21:07:56 +0100 Subject: [PATCH 064/127] ui: the s shortcut says dock, not side panel Co-Authored-By: Claude Opus 4.6 --- src/shortcuts.cpp | 2 +- src/ui/shortcuts.ui | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shortcuts.cpp b/src/shortcuts.cpp index 1471949..e8b44f1 100644 --- a/src/shortcuts.cpp +++ b/src/shortcuts.cpp @@ -18,7 +18,7 @@ constexpr Accel kAccels[] = { {GDK_KEY_g, 0, AppAction::ShowSignals, "g", "Go to Signals", "Panes and editors"}, {GDK_KEY_l, 0, AppAction::LayoutEditor, "l", "Show or hide the layout editor", "Panes and editors"}, - {GDK_KEY_s, 0, AppAction::SidePanel, "s", "Show or hide the side panel", + {GDK_KEY_s, 0, AppAction::SidePanel, "s", "Show or hide the dock", "Panes and editors"}, {GDK_KEY_e, GDK_CONTROL_MASK, AppAction::Export, "e", "Export the visible range", "Files and windows"}, diff --git a/src/ui/shortcuts.ui b/src/ui/shortcuts.ui index bab4fe6..b6cdcaf 100644 --- a/src/ui/shortcuts.ui +++ b/src/ui/shortcuts.ui @@ -94,7 +94,7 @@ 1 s - Show or hide the side panel + Show or hide the dock From e36f2a202ba327b40a24452e96417d620ab07ebe Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 00:46:43 +0100 Subject: [PATCH 065/127] docs: plan for the signal badges (step 2 of the redesign) --- .../plans/2026-11-03-ui-badges-signals.md | 832 ++++++++++++++++++ 1 file changed, 832 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-03-ui-badges-signals.md diff --git a/docs/superpowers/plans/2026-11-03-ui-badges-signals.md b/docs/superpowers/plans/2026-11-03-ui-badges-signals.md new file mode 100644 index 0000000..eed708f --- /dev/null +++ b/docs/superpowers/plans/2026-11-03-ui-badges-signals.md @@ -0,0 +1,832 @@ +# Signal badges -- step 2 of the badge-bar redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Put the signals on the badge bar: one badge per enabled signal (a colour dot and the name; up to 6, then one "N signals" badge), each opening a per-signal slide-over page, each a drag handle (to a plot row, or to reorder), plus a trailing `+` that opens the full signal list as a slide-over page. The signal list leaves the dock. + +**Architecture:** The existing `signal_panel` (heading, hint, list, math divider) is *reparented* from the temporary dock into the `signals` slide-over page. A new unit, `src/ui_signal_page.cpp`, owns the lazily-created `signal:` pages (colour, calibration, information, "plot on row N", math edit/delete) and keeps them in step with the model. `src/ui_badges.cpp` grows the signal badges (inside a scroller so the bar's minimum width never depends on how many there are). The drop that today puts a dragged list row on a plot becomes `assign_signal_to_row()`, shared by the drop and by the page's buttons; the badge replaces the list row as the drag source. + +**Tech Stack:** C++20, GTK4, libadwaita >= 1.8, CMake. No unit-test suite: verification is the app's own headless self-check (`xvfb-run`, mock sources) -- extend `src/ui_probe.cpp` / `src/ui_badges.cpp` probes, never a separate harness. + +Spec: `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md` ("Badge bar", "Slide-over panel", rollout step 2). Step 1 plan: `docs/superpowers/plans/2026-11-02-ui-badges-dock-shell.md`. Branch: `feature/ui-badges-dock` (continue on it). + +## Global Constraints + +Copied from the spec and the repository's rules; every task includes them. + +- **Layout must not depend on content** (`docs/claude/invariants-plot-and-layout.md`): the badge bar keeps its fixed 36 px height and its minimum width must not grow with the number of signals (the signal badges sit in a `GtkScrolledWindow`, `GTK_POLICY_EXTERNAL` / `GTK_POLICY_NEVER`); the slide-over still overlays and never resizes the plot. +- **A `GtkOverlay` child is picked wherever its allocation is**: nothing in this step adds an overlay child. +- Signal badges: "one badge per enabled signal, up to 6; beyond that a single 'N signals' badge. A badge shows a colour dot and the name, is a drag handle (reorder, or drop on a plot row, which replaces today's drag from the signal list), and opens that signal's page (colour, calibration, row, math). A trailing `+` opens the `signals` page (the full list, to enable more; the math editor entry)." (spec, verbatim) +- Page ids: `trigger`, `signal:`, `signals`, `measure`, `cursors`, `events`, `source-`. This step adds `signals` and `signal:`. +- One slide-over page at a time; Esc or a click outside closes it; each page has its own scroller; the open page is not saved. +- "Disable, do not hide": a control that does not apply is insensitive and stays on screen (the one precedent kept is the acquire tick, hidden in a file view -- see `rebuild_signal_list`). +- Text buttons only for new controls (the `+` is the character, not an icon); no icons in badges. The colour dot is the existing `chip_markup()` disc. +- The widget-lifetime trap (`docs/claude/invariants-gtk-and-chrome.md`): a click handler that calls `rebuild_signal_panels()` destroys the page/row that owns the emitting button; hold the emitter with `outlive_the_rebuild(widget)` first. A drop handler never destroys the widgets it was emitted from. +- Every new source file carries the SPDX MIT header (`// SPDX-License-Identifier: MIT` / `// SPDX-FileCopyrightText: 2026 Martino Ferrari`). +- Baselines: a clean build emits exactly **one** warning (the icons' `-Woverlength-strings`), an incremental build **zero**; the dump's `gtk warnings:` reads `0`; no `Gtk-CRITICAL`/`Gtk-WARNING`. +- Verification runs are short and serial; kill every mock you start; do not re-run to chase cosmetics. Headless needs `GDK_BACKEND=x11` and, for screenshots, `GSK_RENDERER=cairo` + `import -window root`. +- Do not push, merge or amend. Commit per task on `feature/ui-badges-dock`. + +## Deliberate step-2 deviations from the spec (state them in the final report) + +- **Reorder is in memory only.** The badge order lives in `App::badge_order` for the session; it is not written to the session file (the spec's session section lists only the dock state). A new signal sorts after the ordered ones, in model order. +- **Beyond 6 enabled signals the "N signals" badge replaces the per-signal badges *and* the `+`** (it opens the same `signals` page, so a second button for it would be a duplicate). Per-signal pages for those signals are reached by activating a row of the `signals` list (new in this step); drag-to-row for them is by the page's "Plot on" buttons. +- **The list's drag source is removed**, as the spec says ("replaces today's drag from the signal list"); `row_drag_sidebar()` goes with it if nothing else calls it. +- A signal page opened for a signal that is not acquired shows its acquire tick (so a list row can be activated without first being ticked). + +## File Structure + +| File | Create/Modify | Responsibility | +| --- | --- | --- | +| `src/ui_signal_page.cpp` | Create | The `signal:` pages: ensure/fill/prune, their buttons, the `signal_pages` probe helpers. | +| `src/ui_badges.cpp` | Modify | Signal badges, the `+` badge, "N signals" overflow, scroller, reorder drop, drag source, `signal_badges_probe`. | +| `src/ui_signals.cpp` | Modify | `listed_signals()`, `assign_signal_to_row()`, `reset_signal_color()`, row-activated -> page, list drag source removed, header comment fixed. | +| `src/ui_slideover.cpp` | Modify | `slideover_remove_page()`. | +| `src/ui_app.hpp` | Modify | `Badge` fields, `BadgeBar` fields, `App::badge_order` / `App::signal_pages`, declarations. | +| `src/ui.cpp` | Modify | Signal panel into the `signals` page; `ShowSignals` opens it; probe env var; `row_drag_sidebar` removal. | +| `src/ui_tour.cpp`, `src/ui_context_help.cpp` | Modify | The signals step / hint re-pointed to the page; badge text. | +| `src/ui_probe.cpp` | Modify | Every probe that measured or walked `signal_panel` / `signal_list` while it lived in the dock. | +| `src/ui_widgets.cpp` | Modify | CSS for the signal badge dot/name if needed. | +| `CMakeLists.txt` | Modify | Add `src/ui_signal_page.cpp` next to `src/ui_badges.cpp`. | +| `README.md`, `docs/claude/*.md` | Modify | Task 5. (`USER-GUIDE.md` and the Mallard manual are spec step 4.) | + +--- + +### Task 1: The `signals` page and the `+` badge + +The list moves out of the dock into a slide-over page; the `+` badge and the `g` key open it. Nothing else changes yet (the list rows keep their menu and their drag source). + +**Files:** +- Modify: `src/ui.cpp`, `src/ui_badges.cpp`, `src/ui_app.hpp`, `src/ui_signals.cpp` (header comment), `src/ui_tour.cpp`, `src/ui_context_help.cpp`, `src/ui_probe.cpp` + +**Interfaces:** +- Consumes (step 1): `slideover_add_page(App&, const std::string& id, const std::string& title, GtkWidget* content) -> bool`, `slideover_open/close/toggle(App&, ...)`, `slideover_page(const App&) -> std::string`, `Badge{button,name,state,page}`, `make_badge(...)`, `BadgeBar{bar,signals_slot,system_slot,trigger,source}`. +- Produces: `BadgeBar::plus` (`Badge`, page `"signals"`, name label `"+"`); the `signals` page registered once in `on_activate`. + +- [ ] **Step 1: Freeze today's self-check** + +Build `HEAD` (`cmake --build build -j`), then: + +```sh +python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$! +sleep 1.5 +xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --server 127.0.0.1 --port 8114 > /tmp/ui-signals-base.log 2>&1 +kill $MOCK +sed 's/[0-9.]\+/N/g' /tmp/ui-signals-base.log > .superpowers/sdd/ui-signals-baseline-udps.txt +grep -c 'Gtk-CRITICAL' /tmp/ui-signals-base.log; grep 'gtk warnings' /tmp/ui-signals-base.log +``` +Expected: `0` and `gtk warnings: 0`. Every later task diffs its own normalised run against this file and must explain every differing line from that task's "allowed baseline changes"; any other difference is a defect. + +- [ ] **Step 2: Fix the stray `\n` in `src/ui_signals.cpp` line 4** + +The header comment reads `...with its colour chip and acquire tick, the panel around it,\n// and the drop that puts a signal on a row.` on one line (a literal backslash-n). Replace it with two comment lines: + +```cpp +// The signal list: one row per signal with its colour chip and acquire tick, the panel around it, +// and the drop that puts a signal on a row. +``` + +- [ ] **Step 3: Reparent the panel into a `signals` page (`src/ui.cpp`, `on_activate`, ~line 5172)** + +Replace + +```cpp + app->signal_panel = build_signal_panel(*app); + gtk_box_append(GTK_BOX(app->dock_page), app->signal_panel); + gtk_box_append(GTK_BOX(app->dock_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + gtk_box_append(GTK_BOX(app->dock_page), app->cursors_panel); +``` +with +```cpp + app->signal_panel = build_signal_panel(*app); + gtk_box_append(GTK_BOX(app->dock_page), app->cursors_panel); +``` +and register the page where the other pages are registered (after `build_slideover`, next to the `"trigger"` page; the panel must exist first, so if that registration happens before line 5172, add the `slideover_add_page(*app, "signals", "Signals", app->signal_panel);` call right after the panel is built instead): + +```cpp + slideover_add_page(*app, "signals", "Signals", app->signal_panel); +``` +Read the surrounding code before editing: the registration must happen once, after `build_slideover(*app)` and after `build_signal_panel`. Update the comments that say the dock holds "the signal list, the cursors and the measurements" (`dock_show_section`, `ui_app.hpp` `dock_page`) to "the cursors and the measurements". + +- [ ] **Step 4: `g` opens the page (`run_app_action`, ~line 4682)** + +```cpp + case AppAction::ShowSignals: slideover_toggle(app, "signals"); break; +``` +and in `dock_show_section` delete the `"signals"` branch (the dock no longer holds the list); keep `"measure"`. + +- [ ] **Step 5: The `+` badge (`src/ui_badges.cpp`, `src/ui_app.hpp`)** + +In `BadgeBar` add `Badge plus;`. In `build_badge_bar`, after the Source badge: + +```cpp + // The full signal list. Insensitive until the stream has declared signals ("disable, do not hide"). + make_badge(app, bb.plus, bb.signals_slot, "+", "signals"); + gtk_widget_set_tooltip_text(bb.plus.button, "all signals: switch more on, add a math channel"); +``` +(`make_badge` appends a second, empty `state` label; for `+` leave it empty, it takes no width.) In `sync_badge_bar`, before the early return at the `bb.source.button == nullptr` check: + +```cpp + if (bb.plus.button != nullptr) { + gtk_widget_set_sensitive(bb.plus.button, !app.model->signals().empty()); + set_open(bb.plus.button, slideover_page(app) == "signals"); + } +``` +Step 2's later tasks insert the signal badges *before* the `+` in `signals_slot`; Task 3 handles the ordering, so do not worry about it here. + +- [ ] **Step 6: Tour and context help** + +`src/ui_tour.cpp`: the `"signals"` step (line ~460) becomes `{"signals", "signals", "The signals", ...}` (second field is the slide-over page; see the `"trigger"` step), text: `"Every signal the source declares, each with the switch that acquires it. The + badge opens this list; a signal has to be switched on before it can be plotted, measured or searched."`. The `"side"` step text becomes `"The dock beside the plot: the cursors, then the measurements. The buttons in the bar above show and hide it."`. Keep `&App::signal_list` as the rect. `src/ui_context_help.cpp` line ~92: replace the last sentence of the `"signals"` hint (`A signal that is dragged onto a plot is drawn there.`) with `A signal's badge, dragged onto a plot, draws it there.`; the `"signals_config"` hint's `which plot a signal is dragged onto` becomes `which plot a signal is drawn on`. + +- [ ] **Step 7: Re-point the probes that treated the list as part of the dock** + +`src/ui_probe.cpp` references `signal_panel`/`signal_list` at ~103-186, ~1828, ~1853, ~3843-3847, ~5827, ~6165, ~6431 and the tour-surface probe ~7024-7097. The page is closed by default, so widgets inside it are unmapped and have no allocation. For each: (a) a probe that measures the panel's allocated size -- open the `signals` page first (`slideover_open(*a, "signals")`) and close it after, or measure with `gtk_widget_measure`; (b) a probe that walks the rows needs nothing (children exist while unmapped); (c) the stress line at ~1853 (`gtk_widget_set_size_request(a->signal_panel, -1, 3000)`) existed to overflow the *dock's* scroller; re-point it to `a->cursors_panel`; (d) the `ShowSignals` check at ~6431 asserts `slideover_page(*a) == "signals"` and then closes it; (e) `~6165` (`gtk_widget_get_parent(app.signal_list)`) -- read what it needs and take it from the page instead. Print the same lines as before wherever the value is still meaningful. + +- [ ] **Step 8: Verify** + +```sh +cmake --build build -j 2>&1 | grep -E 'warning|error'; echo "rc=$?" +python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$! +sleep 1.5 +xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --server 127.0.0.1 --port 8114 > /tmp/ui-signals-t1.log 2>&1 +kill $MOCK +sed 's/[0-9.]\+/N/g' /tmp/ui-signals-t1.log | diff .superpowers/sdd/ui-signals-baseline-udps.txt - ; grep -c Gtk-CRITICAL /tmp/ui-signals-t1.log; grep 'gtk warnings' /tmp/ui-signals-t1.log +``` +Then run `UDPSCOPE_PROBE_SLIDEOVER_MS=1500`, `UDPSCOPE_PROBE_BADGES_MS=1500` and `UDPSCOPE_PROBE_TOUR_SURFACE_MS=` the same way. Expected: zero build warnings; the slide-over and badge probes read as in step 1 (`table_bad=0`, `open_marked=1`, `closed_unmarked=1`); the tour-surface probe shows the `signals` step now opens the `signals` page and `tour_plan_holds(signal_list)=1`. **Allowed baseline changes:** the dock's size/position lines that included the list (re-pointed per Step 7), any `signal_panel` geometry line. Explain each in the report. + +- [ ] **Step 9: Commit** + +```sh +git add -A src docs +git commit -m "ui: the signal list is the signals slide-over page, opened by a + badge or g" +``` + +--- + +### Task 2: Per-signal pages, `assign_signal_to_row()` and `reset_signal_color()` + +Builds `signal:` pages (nothing opens them yet except activating a list row) and extracts the two actions the page shares with the list. + +**Files:** +- Create: `src/ui_signal_page.cpp` +- Modify: `src/ui_signals.cpp`, `src/ui_slideover.cpp`, `src/ui_app.hpp`, `src/ui.cpp` (probe env var), `src/ui_probe.cpp` or `src/ui_signal_page.cpp` (probe), `CMakeLists.txt` + +**Interfaces:** +- Produces (declare in `ui_app.hpp`, namespace `udpscope::ui_impl`): + +```cpp +/// Every plottable signal once (the time base skipped, a group's layers folded to the first), in model order. +/// The one rule the list and the badges share. +std::vector listed_signals(const std::vector& signals); +/// Puts `signal` (an index into `signals()`) on plot `row` in the first free slot, switching it on first if +/// need be. Says why not through `set_notice` and returns false when it cannot. The one path a drop and a +/// signal page's "Plot on" button share. +bool assign_signal_to_row(App& app, std::size_t signal, int row); +/// Back to the palette colour for the signal's position. +void reset_signal_color(App& app, std::size_t signal); +/// Holds `widget` alive across the current emission (was `static` in ui_signals.cpp). +void outlive_the_rebuild(GtkWidget* widget); +bool slideover_remove_page(App& app, const std::string& id); // ui_slideover.cpp +/// Creates the page `signal:` if it is missing, fills it, and returns whether it exists afterwards. +bool signal_page_ensure(App& app, const std::string& name); +/// Called at the end of `rebuild_signal_panels`: refills every existing signal page, drops the pages of +/// signals the model no longer has (closing the slide-over first if one of them is open). +void signal_pages_sync(App& app); +``` +- `App` gains `std::map signal_pages;` (name -> the page's content box). + +- [ ] **Step 1: `listed_signals()`** + +In `src/ui_signals.cpp`, add (before `rebuild_signal_list`): + +```cpp +std::vector listed_signals(const std::vector& signals) { + std::vector out; + for (std::size_t i = 0; i < signals.size(); ++i) { + if (ScopeModel::is_time_signal(signals, i)) continue; // the time base is not plottable + const std::string base = layer_base_name(signals[i].name); + bool seen = false; + for (std::size_t j = 0; j < i && !seen; ++j) { + seen = !ScopeModel::is_time_signal(signals, j) && layer_base_name(signals[j].name) == base; + } + if (!seen) out.push_back(i); + } + return out; +} +``` +and make `rebuild_signal_list` use it: + +```cpp + std::vector rows; + for (std::size_t i : listed_signals(signals)) { + // Locked: a row per signal that is *on* ... (keep the existing comment) + if (app.signals_locked && !app.model->signal_enabled(i)) continue; + rows.push_back(i); + } +``` +Behaviour is unchanged (the dump's `rows == signals + 1 + math` line must not move). + +- [ ] **Step 2: `assign_signal_to_row()`** + +Move lines `on_drop_channel` from `if (signal >= app->model->signals().size()) return FALSE;` through `notice("plotting '" ...)` into the new function, with `app->` -> `app.`; keep the `notice` lambda, the "enable it, then take the channel that appears" branch, the free-slot search and the two refusal messages verbatim, `return false`/`return true`. `on_drop_channel` becomes: + +```cpp +gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, gpointer user) { + App* app = static_cast(user); + if (!G_VALUE_HOLDS_STRING(value)) return FALSE; + const int row = row_at(*app, x, y); // where the drop landed decides the row: one area covers them all + const char* text = g_value_get_string(value); + const std::size_t signal = static_cast(std::atoll(text ? text : "0")); + if (!assign_signal_to_row(*app, signal, row)) return FALSE; + cancel_drag_on(*app, row); + return TRUE; +} +``` +`rebuild_signal_panels(app)` stays inside `assign_signal_to_row` (before the notice), as it is today. + +- [ ] **Step 3: `reset_signal_color()`** + +Extract the body of the `recolor` lambda (`ui_signals.cpp` ~386-401) into: + +```cpp +void reset_signal_color(App& app, std::size_t signal) { + std::string name; + { + std::lock_guard lock(app.model->mutex()); + const std::vector& sigs = app.model->signals(); + if (signal >= sigs.size()) return; + name = sigs[signal].name; + } + app.rows->clear_signal_color(name); + app.relayout_pending = true; + rebuild_signal_panels(app); + schedule_layout_save(app); +} +``` +The lambda keeps `outlive_the_rebuild(GTK_WIDGET(b))` first (the list rebuild destroys it) and then calls this. Remove `static` from `outlive_the_rebuild` and move its definition above `rebuild_signal_list` users unchanged. + +- [ ] **Step 4: `slideover_remove_page()` (`src/ui_slideover.cpp`)** + +Read how `slideover_add_page` records a page (stack child name, any title/page registry on `SlideOver`). Implement the inverse: returns false if the id is unknown; refuses (returns false) if it is the open page; otherwise removes the stack child (`gtk_stack_remove`) and erases whatever registry entry `slideover_add_page` made. + +- [ ] **Step 5: `src/ui_signal_page.cpp`** + +Create the file with the SPDX header, `#include "ui_app.hpp"`, ``, ``, namespaces `udpscope::ui_impl`. Page ids are `"signal:" + name`. Contents (an anonymous namespace plus the two public functions): + +```cpp +namespace { + +std::string page_id(const std::string& name) { return "signal:" + name; } + +/// The index of `name` in the model's signals, or npos. +std::size_t signal_index(App& app, const std::string& name) { + std::lock_guard lock(app.model->mutex()); + const std::vector& s = app.model->signals(); + for (std::size_t i = 0; i < s.size(); ++i) { + if (s[i].name == name) return i; + } + return std::string::npos; +} + +GtkWidget* text_button(const char* label, GtkWidget* parent) { + GtkWidget* b = gtk_button_new_with_label(label); + gtk_box_append(GTK_BOX(parent), b); + return b; +} + +/// Stores the signal's index and the page's name on the button, so a handler resolves identity the way the +/// list does. +void tag(GtkWidget* b, std::size_t index) { + g_object_set_data(G_OBJECT(b), "udpscope-signal", GSIZE_TO_POINTER(index)); +} +std::size_t tagged(GtkButton* b) { + return static_cast(GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(b), "udpscope-signal"))); +} + +void fill(App& app, const std::string& name, GtkWidget* page) { + for (GtkWidget* c = gtk_widget_get_first_child(page); c != nullptr;) { + GtkWidget* next = gtk_widget_get_next_sibling(c); + gtk_box_remove(GTK_BOX(page), c); + c = next; + } + const std::size_t index = signal_index(app, name); + if (index == std::string::npos) return; + // A snapshot, taken under the lock: the chip helper wants the whole vector, and the producer replaces it + // on every CONFIG. + std::vector all; + { + std::lock_guard lock(app.model->mutex()); + all = app.model->signals(); + } + if (index >= all.size()) return; + const SignalInfo sig = all[index]; + const bool enabled = app.model->signal_enabled(index); + const bool plotted = signal_plotted(app, sig.name); + const bool unlocked = !app.signals_locked; + + // Back to the list: a page reached from a list row has no other way home. + GtkWidget* back = text_button("All signals", page); + gtk_widget_set_halign(back, GTK_ALIGN_START); + gtk_widget_add_css_class(back, "flat"); + g_signal_connect(back, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { + slideover_open(*static_cast(user), "signals"); + }), + &app); + + GtkWidget* title = gtk_label_new(sig.name.c_str()); + gtk_widget_add_css_class(title, "title-3"); + gtk_widget_set_halign(title, GTK_ALIGN_START); + gtk_label_set_ellipsize(GTK_LABEL(title), PANGO_ELLIPSIZE_END); + gtk_box_append(GTK_BOX(page), title); + if (!sig.unit.empty()) { + GtkWidget* unit = gtk_label_new(sig.unit.c_str()); + gtk_widget_add_css_class(unit, "dim-label"); + gtk_widget_set_halign(unit, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(page), unit); + } + + // Acquire / compute. The same handler as the list's tick, which reads the signal index off the widget. + GtkWidget* enable = gtk_check_button_new_with_label(sig.math ? "Compute this channel" : "Acquire this signal"); + app.updating_panels = true; + gtk_check_button_set_active(GTK_CHECK_BUTTON(enable), enabled); + app.updating_panels = false; + g_object_set_data(G_OBJECT(enable), "udpscope-signal", GSIZE_TO_POINTER(index)); + g_signal_connect(enable, "notify::active", G_CALLBACK(on_signal_switch), &app); + gtk_widget_set_visible(enable, sig.math || app.waveform_name.empty()); + gtk_box_append(GTK_BOX(page), enable); + + // Colour. + GtkWidget* colour_head = gtk_label_new("Colour"); + gtk_widget_add_css_class(colour_head, "heading"); + gtk_widget_set_halign(colour_head, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(page), colour_head); + GtkWidget* chip = gtk_label_new(nullptr); + gtk_label_set_markup(GTK_LABEL(chip), signal_chip_markup(app, all, index).c_str()); + GtkWidget* colour_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_box_append(GTK_BOX(colour_row), chip); + GtkWidget* pick = text_button("Change colour...", colour_row); + gtk_widget_set_sensitive(pick, plotted && unlocked); + gtk_widget_set_tooltip_text(pick, plotted ? "the colour this signal is plotted in" + : "on no plot, so there is no trace to colour"); + tag(pick, index); + g_signal_connect(pick, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { + open_signal_color(*static_cast(user), tagged(b)); + }), + &app); + GtkWidget* reset = text_button("Default colour", colour_row); + gtk_widget_set_sensitive(reset, unlocked && app.rows->signal_colors().count(sig.name) != 0); + tag(reset, index); + g_signal_connect(reset, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { + outlive_the_rebuild(GTK_WIDGET(b)); + reset_signal_color(*static_cast(user), tagged(b)); + }), + &app); + gtk_box_append(GTK_BOX(page), colour_row); + + // Row: "Plot on 1 2 3 ..." and the way back off. + GtkWidget* row_head = gtk_label_new("Plot on"); + gtk_widget_add_css_class(row_head, "heading"); + gtk_widget_set_halign(row_head, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(page), row_head); + GtkWidget* rows_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); + for (int r = 0; r < app.rows->row_count(); ++r) { + GtkWidget* b = text_button(std::to_string(r + 1).c_str(), rows_box); + tag(b, index); + g_object_set_data(G_OBJECT(b), "udpscope-row", GINT_TO_POINTER(r)); + gtk_widget_set_tooltip_text(b, "draw this signal on this plot"); + g_signal_connect(b, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { + outlive_the_rebuild(GTK_WIDGET(b)); + assign_signal_to_row(*static_cast(user), tagged(b), + GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), "udpscope-row"))); + }), + &app); + } + gtk_box_append(GTK_BOX(page), rows_box); + GtkWidget* off = text_button("Remove from plots", page); + gtk_widget_set_halign(off, GTK_ALIGN_START); + gtk_widget_set_sensitive(off, plotted); + tag(off, index); + g_signal_connect(off, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { + App* a = static_cast(user); + outlive_the_rebuild(GTK_WIDGET(b)); + std::string n; + { + std::lock_guard lock(a->model->mutex()); + const std::vector& s = a->model->signals(); + if (tagged(b) >= s.size()) return; + n = s[tagged(b)].name; + } + const int gone = remove_signal_from_plots(*a, n); + a->relayout_pending = true; + rebuild_signal_panels(*a); + schedule_layout_save(*a); + set_notice(*a, "'" + n + "' left " + std::to_string(gone) + (gone == 1 ? " plot" : " plots"), 4); + }), + &app); + + // Information (a streamed signal's), calibration (ditto), and a math channel's editor. + GtkWidget* more_head = gtk_label_new(sig.math ? "Math channel" : "Signal"); + gtk_widget_add_css_class(more_head, "heading"); + gtk_widget_set_halign(more_head, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(page), more_head); + GtkWidget* more = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + if (sig.math) { + GtkWidget* edit = text_button("Edit graph...", more); + tag(edit, index); + g_signal_connect(edit, "clicked", G_CALLBACK(on_math_edit_clicked), &app); + GtkWidget* del = text_button("Delete", more); + gtk_widget_add_css_class(del, "destructive-action"); + tag(del, index); + g_signal_connect(del, "clicked", G_CALLBACK(on_math_delete_clicked), &app); + } else { + GtkWidget* info = text_button("Information", more); + tag(info, index); + g_signal_connect(info, "clicked", G_CALLBACK(on_info_clicked), &app); + GtkWidget* cal = text_button("Calibrate...", more); + gtk_widget_set_sensitive(cal, unlocked); + gtk_widget_set_tooltip_text(cal, "gain and offset, applied at ingest"); + tag(cal, index); + g_signal_connect(cal, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { + App* a = static_cast(user); + open_calibration(*a, GTK_WINDOW(a->window), tagged(b)); + }), + &app); + } + gtk_box_append(GTK_BOX(page), more); +} + +} // namespace +``` +`on_math_edit_clicked`, `on_math_delete_clicked`, `on_info_clicked`, `open_calibration`, `open_signal_color`, `signal_plotted` and `signal_chip_markup` are already declared in `ui_app.hpp` (check; if `on_math_edit_clicked` / `on_math_delete_clicked` are not, add the declarations). `on_math_edit_clicked` calls `dismiss_row_menu(button)`, which looks for a `GtkPopover` ancestor and does nothing when there is none -- confirm that by reading it. `on_math_delete_clicked` already calls `outlive_the_rebuild`. Then the two public functions: + +```cpp +bool signal_page_ensure(App& app, const std::string& name) { + if (signal_index(app, name) == std::string::npos) return false; + auto it = app.signal_pages.find(name); + if (it == app.signal_pages.end()) { + GtkWidget* page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_margin_top(page, 12); + gtk_widget_set_margin_bottom(page, 12); + gtk_widget_set_margin_start(page, 12); + gtk_widget_set_margin_end(page, 12); + if (!slideover_add_page(app, page_id(name), name, page)) return false; + it = app.signal_pages.emplace(name, page).first; + } + fill(app, name, it->second); + return true; +} + +void signal_pages_sync(App& app) { + for (auto it = app.signal_pages.begin(); it != app.signal_pages.end();) { + if (signal_index(app, it->first) == std::string::npos) { + if (slideover_page(app) == page_id(it->first)) slideover_close(app); + slideover_remove_page(app, page_id(it->first)); + it = app.signal_pages.erase(it); + } else { + fill(app, it->first, it->second); + ++it; + } + } +} +``` +`slideover_add_page` wraps `content` in a scroller and returns it through the stack, so `page` stays the box `fill` clears. Check that the gtk widget `page` is not destroyed by `slideover_remove_page` before `erase` (it is not used afterwards). Call `signal_pages_sync(app)` at the end of `rebuild_signal_panels` (before `app.signal_generation = ...`). `signal_index` takes the model mutex, so `signal_pages_sync` must not be called with it held (`rebuild_signal_panels` releases it before the list rebuild). + +- [ ] **Step 6: A list row opens its page** + +In `rebuild_signal_list`, tag each row: `g_object_set_data(G_OBJECT(row), "udpscope-signal", GSIZE_TO_POINTER(i));` (the math divider row has none and is not activatable). In `build_signal_panel`, after the null check: + +```cpp + g_signal_connect(app.signal_list, "row-activated", + G_CALLBACK(+[](GtkListBox*, GtkListBoxRow* row, gpointer user) { + App* a = static_cast(user); + const std::size_t index = static_cast( + GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(row), "udpscope-signal"))); + std::string name; + { + std::lock_guard lock(a->model->mutex()); + const std::vector& s = a->model->signals(); + if (index >= s.size()) return; + name = s[index].name; + } + if (signal_page_ensure(*a, name)) slideover_open(*a, "signal:" + name); + }), + &app); +``` +If `signals-panel.ui`'s list box has `activate-on-single-click` off or selection mode that swallows activation, set `activate-on-single-click` to true in code. Read the file; the row's own check button / colour-chip gesture / menu button must still work (they consume their own clicks). + +- [ ] **Step 7: Probe `UDPSCOPE_PROBE_SIGNAL_PAGES_MS`** + +Declare `gboolean signal_page_probe(gpointer)` (put it in `ui_signal_page.cpp`), schedule it in `ui.cpp` next to the badges probe. It takes the first non-math listed signal (from `listed_signals`), calls `signal_page_ensure`, `slideover_open("signal:")` and prints: + +``` +[signalpage] name= open= pages= enable_visible=<0|1> plot_buttons= plot_on_row0=<1 if assign_signal_to_row(...,0) returned true and signal_plotted> removed= back_to_list= page "signals"> pruned=<1 after the page's signal is gone> +``` +To check `pruned` without a CONFIG, call `slideover_close`, `slideover_remove_page` directly is *not* a test of `signal_pages_sync`; instead leave `pruned=-1` when it cannot be exercised, and exercise `signal_pages_sync` by the ordinary reconfiguration paths (adding then deleting a math channel with `add_math_channel` / `a->math.erase(...)`+`apply_math_channels` if the probe harness already does that elsewhere; otherwise `-1`). Close the slide-over at the end. + +- [ ] **Step 8: Verify** + +Build (zero warnings), then the UDPS baseline run as in Task 1 Step 8 diffed against `.superpowers/sdd/ui-signals-baseline-udps.txt` (expected: the Task 1 differences only), then: + +```sh +python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$! +sleep 1.5 +xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=5000 UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SIGNAL_PAGES_MS=1500 \ + ./build/udpscope --server 127.0.0.1 --port 8114 2>&1 | grep -E '\[signalpage\]|\[udpscope\] drop|Gtk-|gtk warnings' +kill $MOCK +``` +Expected: `open=1 plot_on_row0=1 removed=1 back_to_list=1`, `plot_buttons` equal to the figure's row count, `gtk warnings: 0`, no `Gtk-CRITICAL`. The existing drop probes (`[drop]` lines in the baseline) must read unchanged: they now run through `assign_signal_to_row`. + +- [ ] **Step 9: Commit** + +```sh +git add -A src CMakeLists.txt docs +git commit -m "ui: per-signal slide-over pages; the drop's assignment and the colour reset as shared functions" +``` + +--- + +### Task 3: The signal badges, the "N signals" overflow and the order + +**Files:** +- Modify: `src/ui_badges.cpp`, `src/ui_app.hpp`, `src/ui_signals.cpp` (call), `src/ui.cpp` (probe env var), `src/ui_widgets.cpp` (CSS, if any) + +**Interfaces:** +- Consumes: `listed_signals`, `signal_page_ensure`, `signal_chip_markup`, `signal_pages_sync` (Task 2). +- Produces: + +```cpp +// ui_app.hpp +struct Badge { /* existing */ std::string signal_name; std::size_t signal = 0; }; // the two new fields: empty/0 for non-signal badges +struct BadgeBar { + /* existing */ + GtkWidget* signals_row = nullptr; ///< the box inside the scroller: signal badges, then "+" + std::vector> signal_badges; ///< one per shown enabled signal; empty when overflowed + Badge overflow; ///< the "N signals" badge, used only beyond kMaxSignalBadges + Badge plus; ///< (Task 1) +}; +inline constexpr std::size_t kMaxSignalBadges = 6; +// App: std::vector badge_order; ///< names, in the order the user left the badges (memory only) +void sync_signal_badges(App& app); ///< rebuilds the signal badges from the model; ui_badges.cpp +gboolean signal_badges_probe(gpointer user); +``` +Include `` in `ui_app.hpp` if it is not already. + +- [ ] **Step 1: Put the signals in a scroller (`build_badge_bar`)** + +Before creating any badge: + +```cpp + // The signal badges sit in a scroller: a bar whose minimum width grew with the number of signals would + // widen the window (layout must not depend on content). EXTERNAL scrolls by wheel/touch with no bar. + GtkWidget* scroller = gtk_scrolled_window_new(); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_EXTERNAL, GTK_POLICY_NEVER); + gtk_widget_set_hexpand(scroller, TRUE); + bb.signals_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), bb.signals_row); + gtk_box_append(GTK_BOX(bb.signals_slot), scroller); +``` +and change the `+` badge to `make_badge(app, bb.plus, bb.signals_row, "+", "signals")` (it follows the signal badges because they are inserted before it; see Step 3). Move `make_badge`'s parent argument usage accordingly. + +- [ ] **Step 2: `make_signal_badge`** + +In the anonymous namespace of `ui_badges.cpp`: + +```cpp +/// A signal's badge: its colour dot (grey when it has no trace) and its name. Words and a dot, no icon. +void make_signal_badge(App& app, Badge& b, const std::vector& signals, std::size_t index) { + b.signal = index; + b.signal_name = signals[index].name; + b.page = "signal:" + b.signal_name; + b.button = gtk_button_new(); + gtk_widget_add_css_class(b.button, "flat"); + gtk_widget_add_css_class(b.button, "udpscope-badge"); + GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget* dot = gtk_label_new(nullptr); + gtk_label_set_markup(GTK_LABEL(dot), signal_chip_markup(app, signals, index).c_str()); + b.name = gtk_label_new(b.signal_name.c_str()); + gtk_label_set_ellipsize(GTK_LABEL(b.name), PANGO_ELLIPSIZE_END); + gtk_label_set_max_width_chars(GTK_LABEL(b.name), 14); + gtk_box_append(GTK_BOX(row), dot); + gtk_box_append(GTK_BOX(row), b.name); + gtk_button_set_child(GTK_BUTTON(b.button), row); + gtk_widget_set_tooltip_text(b.button, b.signal_name.c_str()); + g_object_set_data(G_OBJECT(b.button), "udpscope-app", &app); + g_signal_connect(b.button, "clicked", G_CALLBACK(on_badge_clicked), &b); +} +``` +`b.state` stays `nullptr`; `sync_badge_bar` never touches it for these. In `on_badge_clicked`, before `slideover_toggle(*app, b->page)`: + +```cpp + // A signal's page is made on first use (and kept in step by `signal_pages_sync`). + if (b->page.rfind("signal:", 0) == 0 && !signal_page_ensure(*app, b->signal_name)) return; +``` + +- [ ] **Step 3: `sync_signal_badges`** + +```cpp +void sync_signal_badges(App& app) { + BadgeBar& bb = app.badges; + if (bb.signals_row == nullptr) return; + std::vector signals; + { + std::lock_guard lock(app.model->mutex()); + signals = app.model->signals(); + } + // The enabled signals, in the user's order: names in `badge_order` first, the rest in model order. + std::vector shown; + for (std::size_t i : listed_signals(signals)) { + if (app.model->signal_enabled(i)) shown.push_back(i); + } + const auto rank = [&](std::size_t i) { + const auto it = std::find(app.badge_order.begin(), app.badge_order.end(), signals[i].name); + return static_cast(it - app.badge_order.begin()); // == size() when unknown: sorts last + }; + std::stable_sort(shown.begin(), shown.end(), [&](std::size_t a, std::size_t b) { return rank(a) < rank(b); }); + + // Clear: every signal badge, and the overflow if it was there. The "+" is kept. + for (GtkWidget* c = gtk_widget_get_first_child(bb.signals_row); c != nullptr;) { + GtkWidget* next = gtk_widget_get_next_sibling(c); + if (c != bb.plus.button) gtk_box_remove(GTK_BOX(bb.signals_row), c); + c = next; + } + bb.signal_badges.clear(); + bb.overflow = Badge{}; + + const bool overflowed = shown.size() > kMaxSignalBadges; + if (overflowed) { + make_badge(app, bb.overflow, bb.signals_row, "", "signals"); + gtk_label_set_text(GTK_LABEL(bb.overflow.name), (std::to_string(shown.size()) + " signals").c_str()); + } else { + for (std::size_t i : shown) { + bb.signal_badges.push_back(std::make_unique()); + make_signal_badge(app, *bb.signal_badges.back(), signals, i); + gtk_box_insert_child_after(GTK_BOX(bb.signals_row), bb.signal_badges.back()->button, + gtk_widget_get_prev_sibling(bb.plus.button)); + } + } + // The overflow badge stands for the "+" (it opens the same page); the "+" is shown only when it is not. + gtk_widget_set_visible(bb.plus.button, !overflowed); + sync_badge_bar(app); +} +``` +`make_badge` appends to its parent; for the overflow badge it lands after the `+`, which is hidden then, so the order is immaterial. `make_badge(..., "", ...)` with an empty name: fine, the text is set right after. (`Badge{}` assignment: `Badge` holds only pointers/strings so this is a plain reset; the overflow button was destroyed by the clear loop.) The "+" being hidden when overflowed is the one deliberate use of hiding: the overflow badge replaces it. In `sync_badge_bar` add, for each signal badge and the overflow, `set_open(button, slideover_page(app) == badge.page)` (the overflow's page is `"signals"`, so it shares the `+`'s rule: both read the same state, only one is visible). + +- [ ] **Step 4: Rebuild when the model's structure or a plot assignment changes** + +At the end of `rebuild_signal_panels` (after `signal_pages_sync(app)` from Task 2) add `sync_signal_badges(app);`. Also call it once at the end of `build_badge_bar` (after the `+` is created) so the first frame is right. Also add `std::vector badge_order;` and `#include ` / `` where needed. Every event that changes a dot (assign, remove, colour, enable) already calls `rebuild_signal_panels`. + +- [ ] **Step 5: Probe `UDPSCOPE_PROBE_SIGNAL_BADGES_MS`** + +Schedule `signal_badges_probe` in `ui.cpp` (like the badges probe). It prints one line: + +``` +[sigbadges] enabled= badges= overflow=<0|1> overflow_text= plus_visible=<0|1> dot_plotted= bar_h= bar_h_fixed=<1 if bar_h before == after toggling one signal off and on> bar_min_w= opens=<1 if emitting "clicked" on badges[0] makes slideover_page == signal:, and a second emit closes it> plus_opens=<1 if the same for plus (or the overflow when overflowed)> +``` +Toggle a signal with `app.model->set_signal_enabled(i, false)` then `rebuild_signal_panels(app)`, and back. `bar_min_w` is only a record: the check is that it is the same with the mock's N signals as `bar_min_w` printed by the E-1608 run in Task 5 (stated there). + +- [ ] **Step 6: Verify** + +Build (zero warnings). UDPS run with `UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SIGNAL_BADGES_MS=1500` against the mock: expected `badges == enabled` (or `overflow=1` if the mock declares more than 6), `bar_h=36` or the bar's measured value with `bar_h_fixed=1`, `opens=1 plus_opens=1`, `gtk warnings: 0`. Then the Task 1 baseline diff and the slide-over/badges/tour-surface probes: **allowed baseline changes:** none beyond Tasks 1-2. A screenshot at 1400x900 (`GSK_RENDERER=cairo`, `import -window root`) to look at the bar once. + +- [ ] **Step 7: Commit** + +```sh +git add -A src docs +git commit -m "ui: one badge per enabled signal, an N signals overflow, and the order kept in memory" +``` + +--- + +### Task 4: Badge drag (to a plot row, and to reorder); the list stops being a drag source + +**Files:** +- Modify: `src/ui_badges.cpp`, `src/ui_signals.cpp`, `src/ui.cpp`, `src/ui_app.hpp`, `src/ui_probe.cpp` + +**Interfaces:** +- Consumes: `on_drag_prepare(GtkDragSource*, double, double, gpointer)` (payload: the signal index as a decimal string), `DragContext{App*, size_t}`, `on_drop_channel` (the plot area's drop target, unchanged), `badge_order`, `signal_badges`. +- Produces: nothing new in `ui_app.hpp` except removals. + +- [ ] **Step 1: The badge is a drag source and a reorder target (`make_signal_badge`)** + +Append to `make_signal_badge`: + +```cpp + // A drag handle: the same payload the list's rows used to carry, so the plot area's drop target takes it + // unchanged. The slide-over steps aside for the drag, since it covers the plot's right edge. + DragContext* ctx = new DragContext{&app, index}; + GtkDragSource* source = gtk_drag_source_new(); + gtk_drag_source_set_actions(source, GDK_ACTION_COPY); + g_signal_connect_data(source, "prepare", G_CALLBACK(on_drag_prepare), ctx, + [](gpointer data, GClosure*) { delete static_cast(data); }, + G_CONNECT_DEFAULT); + g_signal_connect(source, "drag-begin", G_CALLBACK(+[](GtkDragSource*, GdkDrag*, gpointer user) { + slideover_close(*static_cast(user)); + }), + &app); + gtk_widget_add_controller(b.button, GTK_EVENT_CONTROLLER(source)); + + // Dropping another signal's badge on this one moves it in front of this one. + GtkDropTarget* drop = gtk_drop_target_new(G_TYPE_STRING, GDK_ACTION_COPY); + g_signal_connect(drop, "drop", G_CALLBACK(on_badge_drop), &b); + gtk_widget_add_controller(b.button, GTK_EVENT_CONTROLLER(drop)); +``` +and, above it in the anonymous namespace: + +```cpp +/// The reorder: widgets are moved, never rebuilt, so neither the dragged badge nor this target (whose "drop" +/// emission is running) is destroyed under the drag. +gboolean on_badge_drop(GtkDropTarget*, const GValue* value, double, double, gpointer user) { + Badge* target = static_cast(user); + App* app = static_cast(g_object_get_data(G_OBJECT(target->button), "udpscope-app")); + if (app == nullptr || !G_VALUE_HOLDS_STRING(value)) return FALSE; + const char* text = g_value_get_string(value); + const std::size_t signal = static_cast(std::atoll(text ? text : "0")); + Badge* moved = nullptr; + for (auto& b : app->badges.signal_badges) { + if (b->signal == signal) moved = b.get(); + } + if (moved == nullptr) return FALSE; + if (moved == target) return TRUE; + GtkWidget* prev = gtk_widget_get_prev_sibling(target->button); + if (prev != moved->button) { // already directly in front: nothing to move + gtk_box_reorder_child_after(GTK_BOX(app->badges.signals_row), moved->button, prev); + } + // The order, as the widgets now stand. + app->badge_order.clear(); + for (GtkWidget* c = gtk_widget_get_first_child(app->badges.signals_row); c != nullptr; + c = gtk_widget_get_next_sibling(c)) { + for (auto& b : app->badges.signal_badges) { + if (b->button == c) app->badge_order.push_back(b->signal_name); + } + } + return TRUE; +} +``` +`gtk_box_reorder_child_after(box, child, nullptr)` puts the child first, which is what a `nullptr` `prev` means here. + +- [ ] **Step 2: Remove the list's drag source (`rebuild_signal_list`)** + +Delete the `DragContext* ctx ...` block through `gtk_widget_add_controller(row, GTK_EVENT_CONTROLLER(source));` (~454-471). `grep -n row_drag_sidebar src` : if `rebuild_signal_list` was its only caller, delete `row_drag_sidebar`, its declaration and `App::sidebar_hidden_for_drag`; if something else calls it, leave it. Update the comment above the list's row loop that mentions dragging, if any. + +- [ ] **Step 3: The probes that drove the list's drag** + +`src/ui_probe.cpp` ~3843-3847 walks `signal_list` rows (read what it does: if it performs a drag or a drop by index, it now takes the index from `signal_badges[0]->signal` and calls `assign_signal_to_row` / `on_drop_channel` with the same payload). Keep every printed line it printed before. + +- [ ] **Step 4: Extend `signal_badges_probe`** + +After the existing line print a second: + +``` +[sigbadges] drag_sources= drop_targets=<... GtkDropTarget> prepare_ok=<1 if on_drag_prepare for badges[0]'s DragContext returns a non-null provider> reorder_before= reorder_after=<...> reorder_ok=<1 if dropping the last badge on the first moved it to the front and badge_order matches the widget order> payload_drop=<1 if on_drop_channel-equivalent: assign_signal_to_row(badges[0]->signal, 0) leaves it plotted> list_drag= +``` +Controllers are enumerated with `gtk_widget_observe_controllers()` (a `GListModel`) and `G_TYPE_CHECK_INSTANCE_TYPE`/`GTK_IS_DRAG_SOURCE`. With fewer than 2 badges print `reorder_ok=-1`. Drop the reordered badge back into its place at the end so later probes see the original order. For the `GValue`: `GValue v = G_VALUE_INIT; g_value_init(&v, G_TYPE_STRING); g_value_set_string(&v, std::to_string(signal).c_str());` and call `on_badge_drop` directly -- it is in the anonymous namespace of `ui_badges.cpp` where the probe lives, so no export is needed. + +- [ ] **Step 5: Verify** + +Build (zero warnings; `-Wunused` would flag a leftover `row_drag_sidebar`). UDPS run with `UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SIGNAL_BADGES_MS=1500`. Expected: `drag_sources == drop_targets == badges`, `prepare_ok=1`, `reorder_ok=1` (or `-1` if fewer than 2), `payload_drop=1`, `list_drag=0`, `gtk warnings: 0`, no `Gtk-CRITICAL`. Then the Task 1 baseline diff: **allowed changes:** a drag-by-list-row probe line that now reports through the badge (explain it). A real pointer drag cannot be simulated headless: say so in the report, and note that the drop path itself is `assign_signal_to_row` (probed in Task 2). + +- [ ] **Step 6: Commit** + +```sh +git add -A src docs +git commit -m "ui: the signal badge is the drag handle (to a plot row, or to reorder); the list rows stop dragging" +``` + +--- + +### Task 5: Docs, the E-1608 run and the sweep of what the list's move touched + +**Files:** +- Modify: `README.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md`, `docs/claude/invariants-plot-and-layout.md`, `docs/claude/architecture.md`, `docs/claude/invariants-gtk-and-chrome.md` (only if it names the list as being in the sidebar), `src/ui_tour.cpp` (badges step text), `SPECS.md` is **not** touched. + +- [ ] **Step 1: The E-1608 run (the spec's stress case)** + +```sh +python3 tools/mock_e1608.py & MOCK=$! +sleep 1 +xvfb-run -a -s "-screen 0 1400x900x24" sh -c 'GDK_BACKEND=x11 GSK_RENDERER=cairo UDPSCOPE_EXIT_AFTER_MS=7000 UDPSCOPE_ENABLE_ALL_MS=1500 UDPSCOPE_PROBE_SIGNAL_BADGES_MS=3000 ./build/udpscope --source e1608 --source-opt address=127.0.0.1 > /tmp/ui-signals-e1608.log 2>&1 & sleep 4.5; import -window root /tmp/ui-signals-e1608.png; wait' +kill $MOCK +grep -E '\[sigbadges\]|Gtk-|gtk warnings' /tmp/ui-signals-e1608.log +``` +Expected: `overflow=1 overflow_text= signals plus_visible=0`, `bar_h_fixed=1`, and `bar_min_w` equal to the UDPS run's (the scroller keeps it constant -- if it differs by more than the one badge's rounding, the scroller is not constraining: fix before going on). Look at the screenshot once: the "N signals" badge, Trigger and Source on the right, bar height unchanged. Then open the `signals` page from a probe or by `g`, activate a row (E-1608 has up to ~17): the page for it exists (`[signalpage]` run against the mock too: `UDPSCOPE_PROBE_SIGNAL_PAGES_MS=3000`). Kill the mock. + +- [ ] **Step 2: Docs** + +- `README.md`: the signals section describes the badge bar (signal badges, `+`, the six-badge overflow, drag to a plot or to reorder, the per-signal page) instead of the list in the sidebar; state that the order is kept for the session only. +- `docs/claude/verification.md`: add `UDPSCOPE_PROBE_SIGNAL_PAGES_MS` and `UDPSCOPE_PROBE_SIGNAL_BADGES_MS` next to the badge probes; `docs/claude/probe-readings.md`: the `[signalpage]` line and the two `[sigbadges]` lines, each field with the value it must read and the defect a wrong reading names (the fields above). +- `docs/claude/invariants-plot-and-layout.md`: one rule -- the badge bar's signal badges live in a scroller so the bar's minimum width never depends on the signal count (the E-1608 case), and the bar's height is fixed. +- `docs/claude/architecture.md`: the page ids now implemented (`signals`, `signal:`), `ui_signal_page.cpp`, and that the dock holds the cursors and measurements only. `docs/claude/invariants-gtk-and-chrome.md`: if it describes `outlive_the_rebuild` or the list's drag/sidebar hiding, say it now covers the signal page's buttons and that the list is no longer a drag source. +- `src/ui_tour.cpp`: the `"badges"` step text becomes `"Setup is reached by the thing it configures. A badge per signal opens that signal's colour, calibration and plot; the + opens the full list; Trigger and Source open their own forms. Drag a signal badge onto a plot to draw it there, or onto another badge to reorder. Click again, press Esc or click the plot to close the panel."` + +- [ ] **Step 3: Final run** + +Build from scratch (`rm -rf build/CMakeFiles/udpscope.dir; cmake --build build -j 2>&1 | grep -c warning` -- expected exactly `1`, the icons' warning; if a clean rebuild is too slow, report the incremental result instead), the UDPS baseline diff, and the slide-over / badges / signal-pages / signal-badges / tour-surface probes once each. Report every baseline difference with its explanation. + +- [ ] **Step 4: Commit** + +```sh +git add -A src docs README.md +git commit -m "docs: the signal badges, the signal pages and the six-badge overflow (step 2 of the redesign)" +``` From 0dd235e41c1130f922232f9705223651a400134d Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 00:54:25 +0100 Subject: [PATCH 066/127] ui: the signal list is the signals slide-over page, opened by a + badge or g --- README.md | 8 +-- docs/claude/invariants-gtk-and-chrome.md | 7 +-- docs/claude/probe-readings.md | 28 +++++----- docs/claude/verification.md | 6 +-- src/ui.cpp | 27 ++++------ src/ui_app.hpp | 5 +- src/ui_badges.cpp | 7 +++ src/ui_context_help.cpp | 6 +-- src/ui_probe.cpp | 66 +++++++++++++++--------- src/ui_signals.cpp | 3 +- src/ui_tour.cpp | 10 ++-- 11 files changed, 98 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index e5fb6e8..e15a8f0 100644 --- a/README.md +++ b/README.md @@ -2805,7 +2805,7 @@ reaches every widget). The full key table: | `t` | enable / disable trigger mode | | `c` | enable / disable the cursors | | `m` | go to Measure (scroll the dock to the cursors and the measurements) | -| `g` | go to Signals (scroll the dock back to the signal list) | +| `g` | go to Signals (open or close the signal list, the `signals` slide-over page, also reached by the `+` badge) | | `l` | show / hide the layout editor | | `s` | show / hide the dock | | `Esc` | close the slide-over panel (the guided tour takes it first) | @@ -2925,9 +2925,9 @@ now — they are listed so they are not mistaken for anything else: signal list -- in one scrolling column; with four of them open in a 320 px sidebar every section was short of room, and finding one meant scrolling past the others. The sidebar was then two text tabs (a **Measure** tab and a **Signals** tab, with a source kind's own tabs beside them), and four - tabs did not fit 320 px. It is **one dock** now, with no tab switcher: the signal list, the cursors and the - measurements in one scroller, shown from the start and insensitive until the stream has signals; `m` and - `g` scroll it to the cursors and back to the signal list. The **trigger form** and a **source's own + tabs did not fit 320 px. It is **one dock** now, with no tab switcher: the cursors and the + measurements in one scroller, shown from the start and insensitive until the stream has signals; `m` + scrolls it to the cursors; the **signal list** is the `signals` slide-over page (`g`, or the `+` badge). The **trigger form** and a **source's own controls** are pages of the slide-over, reached from the badge bar. Both panes also have a **locked** mode, which 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 diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md index 0fb5b0a..335f3d8 100644 --- a/docs/claude/invariants-gtk-and-chrome.md +++ b/docs/claude/invariants-gtk-and-chrome.md @@ -229,8 +229,8 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear replaced an `AdwViewSwitcher` over an `AdwViewStack`, whose four text tabs did not fit 320 px (and a source can add more); a source kind's own panels are slide-over pages, not dock sections. The reason is the one this trap teaches: sections competing for one 320 px column each left every one of them short of room, and - a scroller gives each its natural height. `m` and `g` scroll the dock to the cursors and to the signal list - (`dock_show_section()`). A *feature's* on/off is no longer a visibility at all -- the + a scroller gives each its natural height. `m` scrolls the dock to the cursors (`dock_show_section()`); the signal list is + not a dock section but the `signals` slide-over page, which `g` and the `+` badge open. A *feature's* on/off is no longer a visibility at all -- the **cursors'** toggle sets its pane's **body** insensitive -- the readout has nothing to show with the instruments off -- and the *body* and not the pane, because the pane's heading carries its own switch (see the next bullet). The **trigger's body is deliberately never disabled**: a trigger is configured @@ -643,7 +643,8 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps and in a view with a `page_stack` a stop that names a page is kept when the stack is mapped -- a wrong page leaves the stack mapped, so the page switch reveals it, while a collapsed pane leaves it unmapped too and the stop is dropped. So the dock's stop is dropped in a window narrow enough to overlay it, and - the main window's tour is four steps there (`[toursurface] collapsed: ... steps=4`). The filter + the main window's tour is five steps there (`[toursurface] collapsed: ... steps=5`: the signals stop names + the `signals` slide-over page, which does not depend on the dock either). The filter lives in `tour_begin()`, not in each window's `start_tour()`, so the two windows cannot drift into two rules. - **The chooser's tour needs an overlay over its *whole* window, and `AdwWindow`'s content is diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index bbcf7ec..c50259e 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -969,8 +969,8 @@ two also catch a row whose accelerator went missing. `settings=` must be a real slide); the `on_page` hook is what opens the page for the trigger stop and closes the panel for every stop that names none. The lines: - - `[toursurface] before: slideover=none signal_list_mapped=1 running=1` -- the state a first run starts - in (panel closed, dock on screen); the auto-started walk is dismissed. + - `[toursurface] before: slideover=none signal_list_mapped=0 running=1` -- the state a first run starts + in (panel closed, dock on screen; the signal list is a slide-over page, so unmapped); the auto-started walk is dismissed. - `[toursurface] on trigger: slideover=trigger trigger_body_mapped=1 side_box_mapped=1` -- a frame after the page was opened: the dock is still on screen under the panel (they are not alternatives). - `[toursurface] tour: visible=1 steps=6 step=0 trigger_stop=1 signals_stop=1` -- the plan: **six @@ -978,8 +978,8 @@ two also catch a row whose accelerator went missing. `settings=` must be a real - `[toursurface] step=N/6 title='…' slideover= target_mapped=D` -- one per stop, in order: *The plot*, *The badges*, *The trigger*, *The dock*, *The signals*, *The status line*. **Step 1 reads `slideover=none` although the probe started with the panel open** (a stop with no page closes it), - **step 3 reads `slideover=trigger`, and step 4 reads `slideover=none` again**; every `target_mapped` - is 1. + **step 3 reads `slideover=trigger`, step 4 reads `slideover=none` again, and step 5 (the signals) + reads `slideover=signals`**; every `target_mapped` is 1. - `[toursurface] stop=N/6 visible=1 slideover=… spot=[…] raw=[…] target=[…] contains=1 inside=I card=[…] clear=C` -- the `tour_geometry_line()` the helpui probe uses, one per stop, read a frame after the stop was entered (the spotlight is withheld until the panel has finished sliding in, `page_settled`): **`contains=1` on @@ -993,13 +993,13 @@ two also catch a row whose accelerator went missing. `settings=` must be a real - `[toursurface] restarted at trigger: running=1 slideover=trigger` then `[toursurface] skipped: running=0 slideover=none` -- the walk parked on the trigger stop and dismissed (the Skip/Esc route): **`slideover=none` after the dismissal** is the tour not leaving a panel behind. - - `[toursurface] collapsed: side_box_mapped=0 steps=4 trigger_stop=1 signals_stop=0 side_stop=0` -- the + - `[toursurface] collapsed: side_box_mapped=0 steps=5 trigger_stop=1 signals_stop=1 side_stop=0` -- the filter with the dock put away (`sidebar_wanted` off; `update_side_visibility()` re-asserts it, so this - is how a probe puts the dock away for good): the dock's two stops are dropped, **and the trigger stop is - kept** because the slide-over does not depend on the dock -- four stops remain (plot, badges, trigger, - status). -- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | c: cursors: 0->1 | m: dock 0->243 (cursors_y=243 reach=2256 at_cursors=1) | g: dock 243->0 at_top=1 | … | esc: opened=1 handled=1 closed=1 idle_handled=0 | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** (`g` returns, so its pair reads `243->0`), **`refused` must be 1**, and the `esc:` reading must be `opened=1 handled=1 closed=1 idle_handled=0`. - The `m`/`g` readings are the dock scroller's vertical adjustment before and after (the probe pads the dock to 3000 px so there is something to scroll, and undoes it at the end): **`m` must land on `cursors_y`, the cursors panel's y in the dock (`at_cursors=1`), and `g` must return to 0 (`at_top=1`)**, which is what says those two keys scroll to a section rather than toggling a pane; a `reach` of 0 would mean the dock fit its viewport and the probe's padding was lost; `t`/`c` read the toolbar toggle's own state, because those two enable a feature and the button is where that state is visible. **The `esc:` reading goes through `on_app_key_pressed()` with `GDK_KEY_Escape`**, the handler the window's key controller calls, and not through `slideover_escape()`: the probe opens the `trigger` slide-over page (`opened=1`), sends the key (`handled=1`: it was consumed, and `closed=1`: the page is gone), then sends it again with nothing open (`idle_handled=0`: Escape is left alone when there is nothing for it to close, so a dialog or popover under it still gets it). `handled=0` or `closed=0` is the slide-over's Escape never reaching the panel from the window's key path -- a handler that returns before the slide-over test, or a controller in the wrong phase -- which the `[slideover] esc_closes=1` reading (a direct call to `slideover_escape()`) cannot see. **Limit of the `m`/`g` readings:** the padding is 3000 px at the end of the page, so `reach` is non-zero on any window shorter than that and the key has somewhere to scroll, but it does not push the cursors panel out of the viewport -- on a window whose dock viewport is taller than `cursors_y` (a tall window) the section is already in view at offset 0 and `m` still scrolls to it; `at_cursors=1` is the key landing the section's top at the viewport's top, which is the reading, not "the section was hidden before". 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 `: : ->` — 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). + is how a probe puts the dock away for good): the dock's own stop is dropped, **and the trigger and + signals stops are kept** because the slide-over does not depend on the dock -- five stops remain (plot, + badges, trigger, signals, status). +- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | c: cursors: 0->1 | m: dock 0->243 (cursors_y=243 reach=2256 at_cursors=1) | g: page=1 dock_still=1 closed=1 | … | esc: opened=1 handled=1 closed=1 idle_handled=0 | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** (`g` is not a pair: it opens the `signals` slide-over page and a second press closes it), **`refused` must be 1**, and the `esc:` reading must be `opened=1 handled=1 closed=1 idle_handled=0`. + The `m`/`g` readings are the dock scroller's vertical adjustment before and after (the probe pads the dock to 3000 px so there is something to scroll, and undoes it at the end): **`m` must land on `cursors_y`, the cursors panel's y in the dock (`at_cursors=1`), and `g` must read `page=1 dock_still=1 closed=1`** (the `signals` page opened, the dock's scroll untouched, a second press closing it), which is what says `m` scrolls to a section and `g` opens the list's page rather than toggling a pane; a `reach` of 0 would mean the dock fit its viewport and the probe's padding was lost; `t`/`c` read the toolbar toggle's own state, because those two enable a feature and the button is where that state is visible. **The `esc:` reading goes through `on_app_key_pressed()` with `GDK_KEY_Escape`**, the handler the window's key controller calls, and not through `slideover_escape()`: the probe opens the `trigger` slide-over page (`opened=1`), sends the key (`handled=1`: it was consumed, and `closed=1`: the page is gone), then sends it again with nothing open (`idle_handled=0`: Escape is left alone when there is nothing for it to close, so a dialog or popover under it still gets it). `handled=0` or `closed=0` is the slide-over's Escape never reaching the panel from the window's key path -- a handler that returns before the slide-over test, or a controller in the wrong phase -- which the `[slideover] esc_closes=1` reading (a direct call to `slideover_escape()`) cannot see. **Limit of the `m`/`g` readings:** the padding is 3000 px at the end of the page, so `reach` is non-zero on any window shorter than that and the key has somewhere to scroll, but it does not push the cursors panel out of the viewport -- on a window whose dock viewport is taller than `cursors_y` (a tall window) the section is already in view at offset 0 and `m` still scrolls to it; `at_cursors=1` is the key landing the section's top at the viewport's top, which is the reading, not "the section was hidden before". 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 `: : ->` — 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). - `[color] '' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1)`, then `[color] override: trace=#123456 chip_agrees=1`, `[color] session: entries=1 rgb=#123456` and `[color] default: trace=#RRGGBB chip_agrees=1` (stderr, `UDPSCOPE_PROBE_COLOR_MS`) — **all three `chip_agrees` must be 1**, the override line's `trace` must be `#123456`, the session line must read `entries=1 rgb=#123456`, and the default line's `trace` must be back to the first line's. Run it with `UDPSCOPE_ENABLE_ALL_MS` ahead of it, or there is no bound trace and the probe prints `no bound trace to recolour` instead of asserting anything; on the standard mock the first line reads `'sine' at row 0 slot 0: trace=#4FA3FF`. Two things make this an assertion rather than a tautology. The colours are read out of **`plot::Plot::trace().color`** — what the renderer will stroke, reached through `Row::plot` *after* `relayout_areas()`, since a new colour is a new figure — and out of the chip's own `` markup, not by asking `RowSet::signal_color()` twice, which would pass against a chip that never reached the trace. And the override is `#123456`, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips `collect_layout()` → `save_layout()` → `load_layout()` on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit. - `[reseq-probe] reseq=N late=N stale=N overflows=N holes=N hole_slots=N backfilled=N gaps_refused=N open_holes=N bands=N bands_repaired=N nan_slots=N scanned=N trigger_in_hole=D` @@ -1819,14 +1819,14 @@ and `regained` reads 400 ms later. ## `[side-tab-probe]` ``` -[side-tab-probe] section=signals accums=4 baseline recorded (measure at scroll 2732 of 2732, outside_at_top=1) -[side-tab-probe] section=signals rows=4 accums=4 advanced=4 min_n=18 +[side-tab-probe] section=cursors accums=4 baseline recorded (measure at scroll 2732 of 2732, outside_at_top=1) +[side-tab-probe] section=cursors rows=4 accums=4 advanced=4 min_n=18 ``` `UDPSCOPE_PROBE_SIDE_TAB_MS` (the variable and the tag keep their pre-dock names; the function is `dock_section_probe`) is the one reading for the rule that **the statistics accumulator keeps running -while the Measure section is out of view**. It scrolls the dock *to* the Measure section (`dock_show_section("measure")`), forces one pass so there is a baseline, then scrolls back to Signals and reads again two seconds later. To make the scroll observable the probe pads `dock_page` **and the signal list** to 3000 px each (probe only, both undone in the report), and it re-arms once after 400 ms so the first reading has the layout. +while the Measure section is out of view**. It scrolls the dock *to* the measurements (`dock_show_section("measure")` lands on the cursors panel, the dock's top, so the probe then moves to the measurements' heading itself), forces one pass so there is a baseline, then scrolls back to the top (the cursors) and reads again two seconds later. To make the scroll observable the probe pads `dock_page` **and the cursors panel** to 3000 px each (probe only, both undone in the report), and it re-arms once after 400 ms so the first reading has the layout. -- **`outside_at_top=1` is the precondition**: with the dock at offset 0, the Measure section (the cursors panel, where `dock_show_section("measure")` lands) starts at or below the viewport's bottom edge, so the "away from the section" state really has the measurements out of view and `advanced=4` says something. `outside_at_top=0` means the probe's own set-up failed and the run proves nothing -- before the signal list was padded it read `0` on the documented window, where the section's top is 243 px down and the dock's viewport is taller than that. **The limit is the 3000 px:** the guarantee holds for a dock viewport shorter than the padding (any real window); a viewport taller than the signal list's padding, about 3000 logical px, would read `0` again. +- **`outside_at_top=1` is the precondition**: with the dock at offset 0, the Measure section (the measurements' heading, below the padded cursors panel) starts at or below the viewport's bottom edge, so the "away from the section" state really has the measurements out of view and `advanced=4` says something. `outside_at_top=0` means the probe's own set-up failed and the run proves nothing -- unpadded, the section's top sits a short way down and the dock's viewport is taller than that. **The limit is the 3000 px:** the guarantee holds for a dock viewport shorter than the padding (any real window); a viewport taller than the cursors panel's padding, about 3000 logical px, would read `0` again. - **`advanced=4` is the assertion**: every accumulator gained passes while its section was out of view, and `min_n` (the smallest `n` seen) must be non-zero too. **`advanced=0` is the defect**: something gated the diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 6ef4f21..6308e23 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -424,8 +424,8 @@ precedent, not an exception. phases: `phase=0`, `phase=1` (after pause) and `phase=2` (after Reset). Needs `UDPSCOPE_ENABLE_ALL_MS` ahead of it and `UDPSCOPE_PROBE_MEASURE_MS` to add measurements first. - `UDPSCOPE_PROBE_SIDE_TAB_MS=3000` with `UDPSCOPE_EXIT_AFTER_MS=11000` — the measurements in the - dock's Measure section, a forced pass, then the dock scrolled back to the signal list (the Measure - section out of view) and read again two seconds later. The probe pads the dock page and the signal list to + dock's Measure section, a forced pass, then the dock scrolled back to its top (the + measurements out of view) and read again two seconds later. The probe pads the dock page and the cursors panel to 3000 px each so the Measure section really starts below the viewport (`outside_at_top=1`) on any real window. **`advanced=4` is the assertion** (every accumulator gained passes while its section was out of view); `advanced=0` is the defect where the accumulation is gated on the visible section, which would leave a gap in min/max/avg for @@ -656,7 +656,7 @@ as distinct from the fault injection below, which exercises ingest: (and a dismissal on the trigger stop) leave none. It then puts the dock *away* and starts the tour again: the dock's stops are dropped while the trigger stop, which does not depend on the dock, stays. `[toursurface] stop=3/6 … slideover=trigger … contains=1` and - `[toursurface] collapsed: side_box_mapped=0 steps=4 trigger_stop=1 signals_stop=0` are the assertions; its + `[toursurface] collapsed: side_box_mapped=0 steps=5 trigger_stop=1 signals_stop=1` are the assertions; its lines are in [probe-readings.md](probe-readings.md). Run it alone with `UDPSCOPE_EXIT_AFTER_MS=9000 UDPSCOPE_EXIT_DUMP=1` against a live source. - `UDPSCOPE_PROBE_DEFAULTS_MS` — opens **Calibrate** and reads every spin button in it and in the diff --git a/src/ui.cpp b/src/ui.cpp index 095f553..1bf285e 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -2864,19 +2864,13 @@ void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer open_row_menu(*app, row, x, y, widget); } -/// Scrolls the dock to a section. The dock is one scroller (`App::dock_scroller`) holding the signal list, -/// the cursors and the measurements top to bottom, so "measure" is the cursors panel's offset in the -/// page, and "signals" the top. Nothing is hidden or removed: both stay in the page. An unknown name is a -/// no-op, so a key bound to a section a later version drops cannot blank anything. +/// Scrolls the dock to a section. The dock is one scroller (`App::dock_scroller`) holding the cursors and +/// the measurements top to bottom, so "measure" is the cursors panel's offset in the page. Nothing is +/// hidden or removed: both stay in the page. An unknown name (the signal list is a slide-over page now, +/// not a dock section) is a no-op, so a key bound to a section a later version drops cannot blank anything. void dock_show_section(App& app, const char* section) { if (app.dock_scroller == nullptr || app.dock_page == nullptr || section == nullptr) return; GtkAdjustment* adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(app.dock_scroller)); - if (std::strcmp(section, "signals") == 0) { - // The list is the first section: its own offset in the page is the page's margin, and "go to Signals" - // means the top of the dock. - gtk_adjustment_set_value(adj, 0.0); - return; - } if (std::strcmp(section, "measure") != 0 || app.cursors_panel == nullptr) return; graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f); graphene_point_t at; @@ -4679,7 +4673,7 @@ void run_app_action(App& app, AppAction action) { toggle(app.cursors_toggle); break; case AppAction::ShowMeasure: dock_show_section(app, "measure"); break; - case AppAction::ShowSignals: dock_show_section(app, "signals"); break; + case AppAction::ShowSignals: slideover_toggle(app, "signals"); break; case AppAction::LayoutEditor: toggle(app.layout_toggle); break; @@ -5153,9 +5147,9 @@ void on_activate(GtkApplication* application, gpointer user) { gtk_popover_set_has_arrow(GTK_POPOVER(app->status_popover), TRUE); } - // The right-hand side is the dock: one scroller, the signal list first (it is what is dragged onto the - // plot), then the cursors and the measurements. Every section is always there -- "go to Measure" scrolls - // to it instead of switching a tab, so the measurements are as live as the list above them. + // The right-hand side is the dock: one scroller, the cursors then the measurements. Every section is + // always there -- "go to Measure" scrolls to it instead of switching a tab, so the measurements are as + // live as the cursors above them. The signal list is a slide-over page (reached by the `+` badge). // `side` and `dock_page`/`dock_scroller` are resolved above with the rest of the skeleton and checked // by the null guard; assign the member here. app->side_box = side; @@ -5169,9 +5163,10 @@ void on_activate(GtkApplication* application, gpointer user) { // would spread its rows (or draw its frame) over empty space under the last one. gtk_widget_set_valign(app->trigger_panel, GTK_ALIGN_START); slideover_add_page(*app, "trigger", "Trigger", app->trigger_panel); + // The signal list is the `signals` page, reached by the `+` badge or `g`; the panel is built here, after + // `build_slideover`, and registered once. app->signal_panel = build_signal_panel(*app); - gtk_box_append(GTK_BOX(app->dock_page), app->signal_panel); - gtk_box_append(GTK_BOX(app->dock_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + slideover_add_page(*app, "signals", "Signals", app->signal_panel); gtk_box_append(GTK_BOX(app->dock_page), app->cursors_panel); gtk_box_append(GTK_BOX(app->dock_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); gtk_box_append(GTK_BOX(app->dock_page), measures_panel); diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 9fff5bb..6c1690b 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -156,6 +156,7 @@ struct BadgeBar { GtkWidget* system_slot = nullptr; ///< right: Trigger, Source Badge trigger; Badge source; ///< opens the first source page; a read-out when the source has none + Badge plus; ///< the "+" in the signals slot: opens the `signals` page }; struct App { @@ -253,8 +254,8 @@ struct App { GtkWidget* trigger_panel = nullptr; GtkWidget* signal_panel = nullptr; GtkWidget* side_box = nullptr; - /// The dock: the sidebar's one scroller and the box inside it, which holds the signal list, the - /// cursors and the measurements (`dock_show_section()` scrolls it). A feature's own on/off never + /// The dock: the sidebar's one scroller and the box inside it, which holds the cursors and + /// the measurements (`dock_show_section()` scrolls it). A feature's own on/off never /// removes a section (it sets its panel's sensitivity instead). GtkWidget* dock_page = nullptr; GtkWidget* dock_scroller = nullptr; diff --git a/src/ui_badges.cpp b/src/ui_badges.cpp index 024379a..dfdf1c4 100644 --- a/src/ui_badges.cpp +++ b/src/ui_badges.cpp @@ -86,6 +86,9 @@ void build_badge_bar(App& app) { gtk_widget_set_can_target(bb.source.button, FALSE); gtk_widget_set_focusable(bb.source.button, FALSE); } + // The full signal list. Insensitive until the stream has declared signals ("disable, do not hide"). + make_badge(app, bb.plus, bb.signals_slot, "+", "signals"); + gtk_widget_set_tooltip_text(bb.plus.button, "all signals: switch more on, add a math channel"); sync_badge_bar(app); } @@ -100,6 +103,10 @@ void sync_badge_bar(App& app) { set_text_if_changed(bb.trigger.state, trigger_badge_state(enabled, armed, pending, has_event)); set_open(bb.trigger.button, slideover_page(app) == bb.trigger.page); + if (bb.plus.button != nullptr) { + gtk_widget_set_sensitive(bb.plus.button, !app.model->signals().empty()); + set_open(bb.plus.button, slideover_page(app) == "signals"); + } if (bb.source.button == nullptr) return; // The source's name and link state. A file has neither a link nor a kind worth naming. const bool file_mode = app.replaying || !app.model->config().open_waveform.empty(); diff --git a/src/ui_context_help.cpp b/src/ui_context_help.cpp index f2ce6f1..459725b 100644 --- a/src/ui_context_help.cpp +++ b/src/ui_context_help.cpp @@ -92,7 +92,7 @@ constexpr HelpHint kHelpHints[] = { {"signals", "The signals", "Every signal the source declares, each with the switch that acquires it. Nothing is buffered " "until it is switched on, and only acquired signals appear in the drop targets and the " - "measurement editor. A signal that is dragged onto a plot is drawn there.", + "measurement editor. A signal's badge, dragged onto a plot, draws it there.", "G", &App::signals_head}, {"events", "The event history", "Every triggered acquisition the cache keeps, with a picture of each. Open one to read it as a " @@ -117,8 +117,8 @@ constexpr HelpHint kHelpHints[] = { "", &App::trigger_search}, {"signals_config", "Configuring signals", "The list is locked until the pencil above opens the per-signal controls: the calibration that " - "maps raw counts to a unit, the colour a trace is drawn in, and which plot a signal is dragged " - "onto. A signal nothing measures can be switched off to stop acquiring it.", + "maps raw counts to a unit, the colour a trace is drawn in, and which plot a signal is drawn " + "on. A signal nothing measures can be switched off to stop acquiring it.", "", &App::signals_note}, {"window", "The time window", "How much time is on screen, counted back from the newest sample. Type a span, change its unit, " diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 5223de3..59f0202 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -93,17 +93,14 @@ int on_render_probe(GtkGLArea*, GdkGLContext*, gpointer user) { return FALSE; // same reason the adapter does: never stop the emission } -gboolean exit_probe_step3(gpointer user) { +gboolean exit_probe_step3b(gpointer user) { App* a = static_cast(user); - a->dbg_mode_live = a->status_detail; GtkWidget* window = a->window; a->window = nullptr; // stop the tick before the widgets go - a->dbg_side_w = gtk_widget_get_width(a->side_box); - a->dbg_side_h = gtk_widget_get_height(a->side_box); + // The signal list is the `signals` page, opened by step 3 300 ms ago: its allocation is only real once + // the page is open and the reveal has run. a->dbg_signal_w = gtk_widget_get_width(a->signal_panel); a->dbg_signal_h = gtk_widget_get_height(a->signal_panel); - a->dbg_trigger_w = gtk_widget_get_width(a->trigger_panel); - a->dbg_trigger_h = gtk_widget_get_height(a->trigger_panel); // How many rows of the signal list carry a drag source: dragging a signal onto a plot is how a plot is // filled, so it has to survive both pane modes -- the locked list drops the tick, the menu and the `+`, // and this is the reading that says it did not drop the drag with them. One row is the `MATH` divider, @@ -211,11 +208,25 @@ gboolean exit_probe_step3(gpointer user) { } // `dbg_window_text` is kept by `sync_window_display()`, the only place that knows the span the // window control is showing: there is no label to read it back from any more. - slideover_close(*a); // opened by step 1 for the allocations read above + slideover_close(*a); // opened by step 3 for the signal list's allocations read above if (window != nullptr) gtk_window_destroy(GTK_WINDOW(window)); return G_SOURCE_REMOVE; } +/// Reads the sizes that need the `trigger` page open (step 1 opened it), then swaps it for the `signals` +/// page -- the slide-over holds one page -- and leaves step 3b the 300 ms the reveal needs. +gboolean exit_probe_step3(gpointer user) { + App* a = static_cast(user); + a->dbg_mode_live = a->status_detail; + a->dbg_side_w = gtk_widget_get_width(a->side_box); + a->dbg_side_h = gtk_widget_get_height(a->side_box); + a->dbg_trigger_w = gtk_widget_get_width(a->trigger_panel); + a->dbg_trigger_h = gtk_widget_get_height(a->trigger_panel); + slideover_open(*a, "signals"); + g_timeout_add(300, exit_probe_step3b, a); + return G_SOURCE_REMOVE; +} + /// The frame after the pane was locked: read the compact arm control's *allocation*, then leave the /// configuration mode and trigger mode as step 2 used to, and hand the chain back its 400 ms. The button /// is created hidden and this is the first tick it is shown, so its width in the same tick would always @@ -1794,15 +1805,16 @@ gboolean stats_probe(gpointer user) { } /// Which section the dock's scroller is showing at its top: "measure" once it has been scrolled to the -/// cursors panel or past it, "signals" above that. Read from the adjustment, so it is the state the user -/// would see and not the last call made. +/// measurements' heading or past it, "cursors" above that (the cursors panel is the dock's first section; +/// the signal list is a slide-over page). Read from the adjustment, so it is the state the user would see +/// and not the last call made. static const char* dock_section_in_view(App& a) { - if (a.dock_scroller == nullptr || a.cursors_panel == nullptr || a.dock_page == nullptr) return "none"; + if (a.dock_scroller == nullptr || a.measures_head == nullptr || a.dock_page == nullptr) return "none"; graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f); graphene_point_t at; - if (!gtk_widget_compute_point(a.cursors_panel, a.dock_page, &zero, &at)) return "none"; + if (!gtk_widget_compute_point(a.measures_head, a.dock_page, &zero, &at)) return "none"; GtkAdjustment* adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(a.dock_scroller)); - return gtk_adjustment_get_value(adj) > 0.5 * at.y ? "measure" : "signals"; + return gtk_adjustment_get_value(adj) > 0.5 * at.y ? "measure" : "cursors"; } /// Phase two of the dock-section probe: the accumulators must have advanced while the Measure section was @@ -1825,7 +1837,7 @@ gboolean dock_section_probe_report(gpointer user) { } } if (a->dock_page != nullptr) gtk_widget_set_size_request(a->dock_page, -1, -1); - if (a->signal_panel != nullptr) gtk_widget_set_size_request(a->signal_panel, -1, -1); + if (a->cursors_panel != nullptr) gtk_widget_set_size_request(a->cursors_panel, -1, -1); std::fprintf(stderr, "[side-tab-probe] section=%s rows=%zu accums=%zu advanced=%" G_GUINT64_FORMAT " min_n=%" G_GUINT64_FORMAT "\n", @@ -1847,10 +1859,11 @@ gboolean dock_section_probe(gpointer user) { if (!padded && a->dock_page != nullptr) { padded = true; gtk_widget_set_size_request(a->dock_page, -1, 3000); - // ... and the signal list taller than any window too, which is what pushes the Measure section below - // the viewport at offset 0 (padding the page's end only makes it scrollable, and on a window taller than - // the section's y the section would stay in view). Undone in the report. - if (a->signal_panel != nullptr) gtk_widget_set_size_request(a->signal_panel, -1, 3000); + // ... and the cursors panel (the dock's first section, above the measurements) taller than any window + // too, which is what pushes the measurements below the viewport at offset 0 (padding the page's end only + // makes it scrollable, and on a window taller than the section's y the section would stay in view). + // Undone in the report. + if (a->cursors_panel != nullptr) gtk_widget_set_size_request(a->cursors_panel, -1, 3000); g_timeout_add(400, dock_section_probe, a); return G_SOURCE_REMOVE; } @@ -1892,16 +1905,16 @@ gboolean dock_section_probe(gpointer user) { gtk_check_button_set_active(GTK_CHECK_BUTTON(a->measures_stats_check), TRUE); } GtkAdjustment* dock_adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(a->dock_scroller)); - // From the top, is the measure section (the cursors panel, where `dock_show_section("measure")` lands) - // below the viewport? Only then is scrolling to it a move *into* view and back out a move *away*; a + // From the top, is the measure section (the measurements' heading) below the viewport? Only then is scrolling to it a move *into* view and back out a move *away*; a // section already in view at offset 0 would make the whole probe a reading of nothing. gtk_adjustment_set_value(dock_adj, 0.0); graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f); graphene_point_t measure_at = GRAPHENE_POINT_INIT(-1.f, -1.f); const bool located = - a->cursors_panel != nullptr && gtk_widget_compute_point(a->cursors_panel, a->dock_page, &zero, &measure_at); + a->measures_head != nullptr && gtk_widget_compute_point(a->measures_head, a->dock_page, &zero, &measure_at); const bool outside_at_top = located && static_cast(measure_at.y) >= gtk_adjustment_get_page_size(dock_adj); - dock_show_section(*a, "measure"); + dock_show_section(*a, "measure"); // lands on the cursors panel, the dock's top: a no-op scroll + if (located) gtk_adjustment_set_value(dock_adj, measure_at.y); // ... so go to the measurements themselves // Where the dock went: `scrolled` is its offset and `reach` the most it can scroll. A reach of 0 would // mean the content fits and "away from the section" is not a state this run can reach. const double scrolled = gtk_adjustment_get_value(dock_adj); @@ -1911,7 +1924,7 @@ gboolean dock_section_probe(gpointer user) { for (const MeasureStatsAccum& s : a->measure_stats) g_dock_section_n_before.push_back(s.n); // Away from the section the measurements are in: they are scrolled out of the viewport (the widgets // stay mapped, only clipped), and the tick keeps calling `update_measures()` regardless. - dock_show_section(*a, "signals"); + gtk_adjustment_set_value(dock_adj, 0.0); std::fprintf(stderr, "[side-tab-probe] section=%s accums=%zu baseline recorded (measure at scroll %.0f of %.0f, " "outside_at_top=%d)\n", @@ -6427,12 +6440,17 @@ gboolean keys_probe(gpointer user) { line += buf; } { + // `g` is the signal list's slide-over page now: it opens it, the dock does not move, and a second `g` + // closes it again. const double before = dock_value(); run_app_action(*a, AppAction::ShowSignals); + const bool opened = slideover_page(*a) == "signals"; const double after = dock_value(); + run_app_action(*a, AppAction::ShowSignals); + const bool closed = slideover_page(*a).empty(); char buf[96]; - std::snprintf(buf, sizeof(buf), " g: dock %.0f->%.0f at_top=%d |", before, after, - std::fabs(after) < 1.0 ? 1 : 0); + std::snprintf(buf, sizeof(buf), " g: page=%d dock_still=%d closed=%d |", opened ? 1 : 0, + std::fabs(after - before) < 1.0 ? 1 : 0, closed ? 1 : 0); line += buf; } before_after("l: layout", a->layout_toggle, AppAction::LayoutEditor); diff --git a/src/ui_signals.cpp b/src/ui_signals.cpp index a57cd4e..7339904 100644 --- a/src/ui_signals.cpp +++ b/src/ui_signals.cpp @@ -1,7 +1,8 @@ // SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari // -// The signal list: one row per signal with its colour chip and acquire tick, the panel around it,\n// and the drop that puts a signal on a row. +// The signal list: one row per signal with its colour chip and acquire tick, the panel around it, +// and the drop that puts a signal on a row. #include "ui_app.hpp" diff --git a/src/ui_tour.cpp b/src/ui_tour.cpp index 21f8a49..a17f297 100644 --- a/src/ui_tour.cpp +++ b/src/ui_tour.cpp @@ -454,12 +454,12 @@ constexpr TourStep kTourSteps[] = { "the view then freezes on the first event that matches, however rare it is.", &App::trigger_body}, {"side", nullptr, "The dock", - "The dock beside the plot: the list of signals, then the cursors and the measurements. The " - "buttons in the bar above show and hide it.", + "The dock beside the plot: the cursors, then the measurements. The buttons in the bar above " + "show and hide it.", &App::side_box}, - {"signals", nullptr, "The signals", - "Every signal the source declares, each with the switch that acquires it. A signal has to be " - "switched on before it can be plotted, measured or searched.", + {"signals", "signals", "The signals", + "Every signal the source declares, each with the switch that acquires it. The + badge opens " + "this list; a signal has to be switched on before it can be plotted, measured or searched.", &App::signal_list}, {"status", nullptr, "The status line", "The stream's own report, once a second: the display mode, the rate arriving, the window on " From baf4a1f20fba4cda71cadefb10efacce3836a985 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 01:03:51 +0100 Subject: [PATCH 067/127] ui: per-signal slide-over pages; the drop's assignment and the colour reset as shared functions Co-Authored-By: Claude Opus 4.6 --- CMakeLists.txt | 1 + docs/claude/probe-readings.md | 13 ++ docs/claude/verification.md | 4 + src/ui.cpp | 3 + src/ui_app.hpp | 23 ++ src/ui_signal_page.cpp | 380 ++++++++++++++++++++++++++++++++++ src/ui_signals.cpp | 134 +++++++----- src/ui_slideover.cpp | 19 ++ 8 files changed, 529 insertions(+), 48 deletions(-) create mode 100644 src/ui_signal_page.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index db2b8f6..eaeff16 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -276,6 +276,7 @@ add_executable(udpscope src/ui_badges.cpp src/ui_trigger.cpp src/ui_signals.cpp + src/ui_signal_page.cpp src/ui_context_help.cpp src/ui_tour.cpp ) diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index c50259e..62ba959 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -2634,6 +2634,19 @@ loads a file as the previous version wrote it (`side_tab = "measure"`) into a `L leave `dock_shown` at the default (`true`), so `ok=1` requires `dock_shown == default` -- the parser ignores unknown keys, and this is what says the old key still does not break a session. A load that left the caller's `false` alone would read `dock_shown=0 default=1 ok=0`. The probe writes only to a temp path. +## `[signalpage]` + +``` +[signalpage] name=sine open=1 pages=1 enable_visible=1 plot_buttons=1 plot_on_row0=1 removed=1 btn_row0=1 back_to_list=1 row_opens=1 pruned=1 rows=1 +``` + +`UDPSCOPE_PROBE_SIGNAL_PAGES_MS` builds and drives the page of the first streamed signal. `open=1` (the slide-over shows `signal:`), `pages=1` +(one page in `App::signal_pages`), `enable_visible=1` (the acquire tick is shown in live mode; `0` in a file view), `plot_buttons=` equal to +`rows=` (one "Plot on" button per figure row), `plot_on_row0=1` (`assign_signal_to_row()` returned true and the signal is plotted), `removed=1` +(the page's "Remove from plots" took it off), `btn_row0=1` (the "Plot on 1" button, the user's path, put it back), `back_to_list=1` ("All signals" +target), `row_opens=1` (activating the list row opens the page), and `pruned=1`: a math channel's page was opened, its **Delete** button clicked, and +the slide-over closed and the page was dropped by `signal_pages_sync()`. `pruned=-1` means no math channel could be added; `0` is a stale page. + ## `[badges]` ``` diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 6308e23..9a5184f 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -447,6 +447,10 @@ precedent, not an exception. read `click_inside_closes=0 still_open=1 click_outside_closes=1 esc_closes=1 closed=1`. For a screenshot add `UDPSCOPE_PROBE_SLIDEOVER_HOLD=1`, which leaves the page open after line 1 (run with `GSK_RENDERER=cairo` and `import -window root`): the panel must be drawn over the traces, opaque. +- `UDPSCOPE_PROBE_SIGNAL_PAGES_MS=1500` with `UDPSCOPE_EXIT_AFTER_MS=5000` and `UDPSCOPE_ENABLE_ALL_MS=500` -- the + per-signal `signal:` slide-over pages. **Against a config that has already seen the tour** (a first run's + auto-tour closes the slide-over at about 1.5 s, which is when this probe opens its page). One `[signalpage]` line; + see [probe-readings.md](probe-readings.md). - `UDPSCOPE_PROBE_BADGES_MS=2000` with `UDPSCOPE_EXIT_AFTER_MS=5000` and `UDPSCOPE_ENABLE_ALL_MS=500` -- the badge bar under the plot. One `[badges]` line, which must read `table_bad=0 fixed=1 open_marked=1 page_open=1 closed_unmarked=1`: `table_bad` counts the rows of the 16-input `trigger_badge_state()` table the probe diff --git a/src/ui.cpp b/src/ui.cpp index 1bf285e..5dacc3b 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -5887,6 +5887,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_PROBE_BADGES_MS")) { g_timeout_add(static_cast(std::atoi(env)), badges_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_SIGNAL_PAGES_MS")) { + g_timeout_add(static_cast(std::atoi(env)), signal_page_probe, &app); + } if (const char* env = g_getenv("UDPSCOPE_PROBE_SEARCH_MS")) { g_timeout_add(static_cast(std::atoi(env)), search_probe, &app); } diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 6c1690b..c6cf45c 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -44,6 +44,7 @@ #include #include #include +#include #include #include #include @@ -885,6 +886,8 @@ struct App { guint layout_save_source = 0; bool layout_loaded = false; unsigned signal_generation = ~0u; + /// Signal name -> the content box of its `signal:` slide-over page (src/ui_signal_page.cpp). + std::map signal_pages; bool updating_panels = false; ///< guards signal handlers during a rebuild bool relayout_pending = false; /// True once the renderer's `Style` has been stated (see `tick()`): the renderer does not @@ -1669,6 +1672,24 @@ std::string signal_chip_markup(App& app, const std::vector& signals, void on_signal_color_chosen(GObject* source, GAsyncResult* res, gpointer user); void open_signal_color(App& app, std::size_t signal); void rebuild_signal_list(App& app, const std::vector& signals); +/// Every plottable signal once (the time base skipped, a group's layers folded to the first), in model order. +/// The one rule the list and the badges share. +std::vector listed_signals(const std::vector& signals); +/// Puts `signal` (an index into `signals()`) on plot `row` in the first free slot, switching it on first if +/// need be. Says why not through `set_notice` and returns false when it cannot. The one path a drop and a +/// signal page's "Plot on" button share. +bool assign_signal_to_row(App& app, std::size_t signal, int row); +/// Back to the palette colour for the signal's position. +void reset_signal_color(App& app, std::size_t signal); +/// Holds `widget` alive across the current emission: a handler that rebuilds the list or a page destroys the +/// widget it was emitted from. +void outlive_the_rebuild(GtkWidget* widget); +/// Creates the page `signal:` if it is missing, fills it, and returns whether it exists afterwards. +bool signal_page_ensure(App& app, const std::string& name); +/// Called at the end of `rebuild_signal_panels`: refills every existing signal page, drops the pages of +/// signals the model no longer has (closing the slide-over first if one of them is open). +void signal_pages_sync(App& app); +gboolean signal_page_probe(gpointer user); // UDPSCOPE_PROBE_SIGNAL_PAGES_MS /// The `MATH` divider row in the one channel list, and the `+` it carries. GtkWidget* make_math_divider(App& app, bool with_add); void on_math_add_clicked(GtkButton* button, gpointer user); @@ -1915,6 +1936,8 @@ gboolean dock_session_probe(gpointer user); // UDPSCOPE_PROBE_DOCK_SESSION_MS void build_slideover(App& app); // once, after the plot_overlay exists bool slideover_add_page(App& app, const std::string& id, const std::string& title, GtkWidget* content); bool slideover_has_page(const App& app, const std::string& id); +/// The inverse of `slideover_add_page`: false when the id is unknown or is the open page. +bool slideover_remove_page(App& app, const std::string& id); bool slideover_open(App& app, const std::string& id); // false when the id is unknown void slideover_close(App& app); bool slideover_toggle(App& app, const std::string& id); // open if closed or on another page, else close diff --git a/src/ui_signal_page.cpp b/src/ui_signal_page.cpp new file mode 100644 index 0000000..894ac16 --- /dev/null +++ b/src/ui_signal_page.cpp @@ -0,0 +1,380 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari +// +// The per-signal slide-over pages (`signal:`): one signal's acquire switch, colour, the plot it is on, +// its information and calibration (a math channel's editor instead). Opened from a row of the signal list; +// kept in step with the model by `signal_pages_sync()`, which `rebuild_signal_panels()` ends with. + +#include "ui_app.hpp" + +#include + +#include +#include +#include +#include + +namespace udpscope { +namespace ui_impl { + +namespace { + +std::string page_id(const std::string& name) { return "signal:" + name; } + +/// The index of `name` in the model's signals, or npos. +std::size_t signal_index(App& app, const std::string& name) { + std::lock_guard lock(app.model->mutex()); + const std::vector& s = app.model->signals(); + for (std::size_t i = 0; i < s.size(); ++i) { + if (s[i].name == name) return i; + } + return std::string::npos; +} + +GtkWidget* text_button(const char* label, GtkWidget* parent) { + GtkWidget* b = gtk_button_new_with_label(label); + gtk_box_append(GTK_BOX(parent), b); + return b; +} + +/// Stores the signal's index and the page's name on the button, so a handler resolves identity the way the +/// list does. +void tag(GtkWidget* b, std::size_t index) { + g_object_set_data(G_OBJECT(b), "udpscope-signal", GSIZE_TO_POINTER(index)); +} +std::size_t tagged(GtkButton* b) { + return static_cast(GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(b), "udpscope-signal"))); +} + +void fill(App& app, const std::string& name, GtkWidget* page) { + for (GtkWidget* c = gtk_widget_get_first_child(page); c != nullptr;) { + GtkWidget* next = gtk_widget_get_next_sibling(c); + gtk_box_remove(GTK_BOX(page), c); + c = next; + } + const std::size_t index = signal_index(app, name); + if (index == std::string::npos) return; + // A snapshot, taken under the lock: the chip helper wants the whole vector, and the producer replaces it + // on every CONFIG. + std::vector all; + { + std::lock_guard lock(app.model->mutex()); + all = app.model->signals(); + } + if (index >= all.size()) return; + const SignalInfo sig = all[index]; + const bool enabled = app.model->signal_enabled(index); + const bool plotted = signal_plotted(app, sig.name); + const bool unlocked = !app.signals_locked; + + // Back to the list: a page reached from a list row has no other way home. + GtkWidget* back = text_button("All signals", page); + gtk_widget_set_halign(back, GTK_ALIGN_START); + gtk_widget_add_css_class(back, "flat"); + g_signal_connect(back, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { + slideover_open(*static_cast(user), "signals"); + }), + &app); + + GtkWidget* title = gtk_label_new(sig.name.c_str()); + gtk_widget_add_css_class(title, "title-3"); + gtk_widget_set_halign(title, GTK_ALIGN_START); + gtk_label_set_ellipsize(GTK_LABEL(title), PANGO_ELLIPSIZE_END); + gtk_box_append(GTK_BOX(page), title); + if (!sig.unit.empty()) { + GtkWidget* unit = gtk_label_new(sig.unit.c_str()); + gtk_widget_add_css_class(unit, "dim-label"); + gtk_widget_set_halign(unit, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(page), unit); + } + + // Acquire / compute. The same handler as the list's tick, which reads the signal index off the widget. + GtkWidget* enable = gtk_check_button_new_with_label(sig.math ? "Compute this channel" : "Acquire this signal"); + app.updating_panels = true; + gtk_check_button_set_active(GTK_CHECK_BUTTON(enable), enabled); + app.updating_panels = false; + g_object_set_data(G_OBJECT(enable), "udpscope-signal", GSIZE_TO_POINTER(index)); + g_signal_connect(enable, "notify::active", G_CALLBACK(on_signal_switch), &app); + gtk_widget_set_visible(enable, sig.math || app.waveform_name.empty()); + gtk_box_append(GTK_BOX(page), enable); + + // Colour. + GtkWidget* colour_head = gtk_label_new("Colour"); + gtk_widget_add_css_class(colour_head, "heading"); + gtk_widget_set_halign(colour_head, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(page), colour_head); + GtkWidget* chip = gtk_label_new(nullptr); + gtk_label_set_markup(GTK_LABEL(chip), signal_chip_markup(app, all, index).c_str()); + GtkWidget* colour_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_box_append(GTK_BOX(colour_row), chip); + GtkWidget* pick = text_button("Change colour...", colour_row); + gtk_widget_set_sensitive(pick, plotted && unlocked); + gtk_widget_set_tooltip_text(pick, plotted ? "the colour this signal is plotted in" + : "on no plot, so there is no trace to colour"); + tag(pick, index); + g_signal_connect(pick, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { + open_signal_color(*static_cast(user), tagged(b)); + }), + &app); + GtkWidget* reset = text_button("Default colour", colour_row); + gtk_widget_set_sensitive(reset, unlocked && app.rows->signal_colors().count(sig.name) != 0); + tag(reset, index); + g_signal_connect(reset, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { + outlive_the_rebuild(GTK_WIDGET(b)); + reset_signal_color(*static_cast(user), tagged(b)); + }), + &app); + gtk_box_append(GTK_BOX(page), colour_row); + + // Row: "Plot on 1 2 3 ..." and the way back off. + GtkWidget* row_head = gtk_label_new("Plot on"); + gtk_widget_add_css_class(row_head, "heading"); + gtk_widget_set_halign(row_head, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(page), row_head); + GtkWidget* rows_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); + gtk_widget_set_name(rows_box, "udpscope-plot-on"); // the probe counts its buttons + for (int r = 0; r < app.rows->row_count(); ++r) { + GtkWidget* b = text_button(std::to_string(r + 1).c_str(), rows_box); + tag(b, index); + g_object_set_data(G_OBJECT(b), "udpscope-row", GINT_TO_POINTER(r)); + gtk_widget_set_tooltip_text(b, "draw this signal on this plot"); + g_signal_connect(b, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { + outlive_the_rebuild(GTK_WIDGET(b)); + assign_signal_to_row(*static_cast(user), tagged(b), + GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), "udpscope-row"))); + }), + &app); + } + gtk_box_append(GTK_BOX(page), rows_box); + GtkWidget* off = text_button("Remove from plots", page); + gtk_widget_set_halign(off, GTK_ALIGN_START); + gtk_widget_set_sensitive(off, plotted); + tag(off, index); + g_signal_connect(off, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { + App* a = static_cast(user); + outlive_the_rebuild(GTK_WIDGET(b)); + std::string n; + { + std::lock_guard lock(a->model->mutex()); + const std::vector& s = a->model->signals(); + if (tagged(b) >= s.size()) return; + n = s[tagged(b)].name; + } + const int gone = remove_signal_from_plots(*a, n); + a->relayout_pending = true; + rebuild_signal_panels(*a); + schedule_layout_save(*a); + set_notice(*a, "'" + n + "' left " + std::to_string(gone) + (gone == 1 ? " plot" : " plots"), 4); + }), + &app); + + // Information (a streamed signal's), calibration (ditto), and a math channel's editor. + GtkWidget* more_head = gtk_label_new(sig.math ? "Math channel" : "Signal"); + gtk_widget_add_css_class(more_head, "heading"); + gtk_widget_set_halign(more_head, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(page), more_head); + GtkWidget* more = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + if (sig.math) { + GtkWidget* edit = text_button("Edit graph...", more); + tag(edit, index); + g_signal_connect(edit, "clicked", G_CALLBACK(on_math_edit_clicked), &app); + GtkWidget* del = text_button("Delete", more); + gtk_widget_add_css_class(del, "destructive-action"); + tag(del, index); + g_signal_connect(del, "clicked", G_CALLBACK(on_math_delete_clicked), &app); + } else { + GtkWidget* info = text_button("Information", more); + tag(info, index); + g_signal_connect(info, "clicked", G_CALLBACK(on_info_clicked), &app); + GtkWidget* cal = text_button("Calibrate...", more); + gtk_widget_set_sensitive(cal, unlocked); + gtk_widget_set_tooltip_text(cal, "gain and offset, applied at ingest"); + tag(cal, index); + g_signal_connect(cal, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { + App* a = static_cast(user); + open_calibration(*a, GTK_WINDOW(a->window), tagged(b)); + }), + &app); + } + gtk_box_append(GTK_BOX(page), more); +} + +} // namespace + +bool signal_page_ensure(App& app, const std::string& name) { + if (signal_index(app, name) == std::string::npos) return false; + auto it = app.signal_pages.find(name); + if (it == app.signal_pages.end()) { + GtkWidget* page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_margin_top(page, 12); + gtk_widget_set_margin_bottom(page, 12); + gtk_widget_set_margin_start(page, 12); + gtk_widget_set_margin_end(page, 12); + if (!slideover_add_page(app, page_id(name), name, page)) return false; + it = app.signal_pages.emplace(name, page).first; + } + fill(app, name, it->second); + return true; +} + +void signal_pages_sync(App& app) { + for (auto it = app.signal_pages.begin(); it != app.signal_pages.end();) { + if (signal_index(app, it->first) == std::string::npos) { + if (slideover_page(app) == page_id(it->first)) slideover_close(app); + slideover_remove_page(app, page_id(it->first)); + it = app.signal_pages.erase(it); + } else { + fill(app, it->first, it->second); + ++it; + } + } +} + +// --- the probe --------------------------------------------------------------------------------- + +namespace { + +/// The first descendant of `root` for which `want` holds, or null. +template +GtkWidget* find_descendant(GtkWidget* root, Pred want) { + for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr; c = gtk_widget_get_next_sibling(c)) { + if (want(c)) return c; + if (GtkWidget* deeper = find_descendant(c, want)) return deeper; + } + return nullptr; +} + +bool is_button_labelled(GtkWidget* w, const char* label) { + if (!GTK_IS_BUTTON(w)) return false; + const char* l = gtk_button_get_label(GTK_BUTTON(w)); + return l != nullptr && std::string(l) == label; +} + +} // namespace + +gboolean signal_page_probe(gpointer user) { + App& a = *static_cast(user); + std::vector all; + { + std::lock_guard lock(a.model->mutex()); + all = a.model->signals(); + } + std::size_t index = std::string::npos; + for (std::size_t i : listed_signals(all)) { + if (!all[i].math) { + index = i; + break; + } + } + if (index == std::string::npos) { + std::fprintf(stderr, "[signalpage] no listed signal\n"); + return G_SOURCE_REMOVE; + } + const std::string name = all[index].name; + const std::string id = page_id(name); + const bool made = signal_page_ensure(a, name); + slideover_open(a, id); + const int open = slideover_page(a) == id ? 1 : 0; + const int pages = static_cast(a.signal_pages.size()); + + int enable_visible = -1; + int plot_buttons = -1; + if (made) { + GtkWidget* page = a.signal_pages[name]; + GtkWidget* enable = find_descendant(page, [](GtkWidget* w) { return GTK_IS_CHECK_BUTTON(w); }); + if (enable != nullptr) enable_visible = gtk_widget_get_visible(enable) ? 1 : 0; + GtkWidget* rows_box = find_descendant(page, [](GtkWidget* w) { + return std::string(gtk_widget_get_name(w)) == "udpscope-plot-on"; + }); + if (rows_box != nullptr) { + plot_buttons = 0; + for (GtkWidget* c = gtk_widget_get_first_child(rows_box); c != nullptr; + c = gtk_widget_get_next_sibling(c)) { + ++plot_buttons; + } + } + } + + // Plot on row 0 through the function the "Plot on" buttons call, then take it off through the page's own + // button (outlive_the_rebuild + remove_signal_from_plots + the page's refill). + const bool assigned = assign_signal_to_row(a, index, 0); + const int plot_on_row0 = assigned && signal_plotted(a, name) ? 1 : 0; + int removed = -1; + auto page_it = a.signal_pages.find(name); + GtkWidget* off = page_it == a.signal_pages.end() + ? nullptr + : find_descendant(page_it->second, [](GtkWidget* w) { + return is_button_labelled(w, "Remove from plots"); + }); + if (off != nullptr) { + g_signal_emit_by_name(off, "clicked"); + removed = signal_plotted(a, name) ? 0 : 1; + } + // The page's own "Plot on 1" button (the user's path), then off again. + int btn_row0 = -1; + page_it = a.signal_pages.find(name); + GtkWidget* rows_box_now = + page_it == a.signal_pages.end() ? nullptr : find_descendant(page_it->second, [](GtkWidget* w) { + return std::string(gtk_widget_get_name(w)) == "udpscope-plot-on"; + }); + if (rows_box_now != nullptr && gtk_widget_get_first_child(rows_box_now) != nullptr) { + g_signal_emit_by_name(gtk_widget_get_first_child(rows_box_now), "clicked"); + btn_row0 = signal_plotted(a, name) ? 1 : 0; + page_it = a.signal_pages.find(name); + off = page_it == a.signal_pages.end() + ? nullptr + : find_descendant(page_it->second, [](GtkWidget* w) { return is_button_labelled(w, "Remove from plots"); }); + if (off != nullptr) g_signal_emit_by_name(off, "clicked"); + } + slideover_open(a, "signals"); + const int back_to_list = slideover_page(a) == "signals" ? 1 : 0; + + // Activating the signal's row in the list opens its page (the path a click takes). + slideover_close(a); + int row_opens = -1; + for (GtkWidget* r = gtk_widget_get_first_child(a.signal_list); r != nullptr; r = gtk_widget_get_next_sibling(r)) { + if (GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(r), "udpscope-signal")) == index && GTK_IS_LIST_BOX_ROW(r)) { + g_signal_emit_by_name(r, "activate"); + row_opens = slideover_page(a) == id ? 1 : 0; + break; + } + } + + // Pruning: a math channel's page, open, then the channel deleted; the sync must close the panel and drop + // the page. Left at -1 when no channel can be added (nothing acquired to build it from). + int pruned = -1; + const std::size_t math_before = a.math.size(); + add_math_channel(a); + if (a.math.size() == math_before + 1) { + const std::string mname = a.math.back().name; + if (signal_page_ensure(a, mname)) { + slideover_open(a, page_id(mname)); + // Through the page's own Delete button: the click destroys the page it came from. + GtkWidget* del = find_descendant(a.signal_pages[mname], + [](GtkWidget* w) { return is_button_labelled(w, "Delete"); }); + if (del != nullptr) { + g_signal_emit_by_name(del, "clicked"); + } else { + a.math.pop_back(); + apply_math_channels(a); + } + pruned = a.signal_pages.count(mname) == 0 && !slideover_has_page(a, page_id(mname)) && + slideover_page(a).empty() + ? 1 + : 0; + } else { + a.math.pop_back(); + apply_math_channels(a); + } + } + slideover_close(a); + std::fprintf(stderr, + "[signalpage] name=%s open=%d pages=%d enable_visible=%d plot_buttons=%d plot_on_row0=%d " + "removed=%d btn_row0=%d back_to_list=%d row_opens=%d pruned=%d rows=%d\n", + name.c_str(), open, pages, enable_visible, plot_buttons, plot_on_row0, removed, btn_row0, + back_to_list, row_opens, pruned, a.rows->row_count()); + return G_SOURCE_REMOVE; +} + +} // namespace ui_impl +} // namespace udpscope diff --git a/src/ui_signals.cpp b/src/ui_signals.cpp index 7339904..d55a71e 100644 --- a/src/ui_signals.cpp +++ b/src/ui_signals.cpp @@ -200,7 +200,7 @@ GtkWidget* make_math_divider(App& app, bool with_add) { /// emission, and a button's own code keeps running after the handler returns, so the last reference /// has to outlive the emission: hold one here and drop it from an idle. This is the same repair /// `on_signal_switch()` makes, for the same reason. -static void outlive_the_rebuild(GtkWidget* widget) { +void outlive_the_rebuild(GtkWidget* widget) { g_object_ref(widget); g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, +[](gpointer) -> gboolean { return G_SOURCE_REMOVE; }, widget, g_object_unref); @@ -257,6 +257,34 @@ void on_math_delete_clicked(GtkButton* button, gpointer user) { apply_math_channels(*a); } +std::vector listed_signals(const std::vector& signals) { + std::vector out; + for (std::size_t i = 0; i < signals.size(); ++i) { + if (ScopeModel::is_time_signal(signals, i)) continue; // the time base is not plottable + const std::string base = layer_base_name(signals[i].name); + bool seen = false; + for (std::size_t j = 0; j < i && !seen; ++j) { + seen = !ScopeModel::is_time_signal(signals, j) && layer_base_name(signals[j].name) == base; + } + if (!seen) out.push_back(i); + } + return out; +} + +void reset_signal_color(App& app, std::size_t signal) { + std::string name; + { + std::lock_guard lock(app.model->mutex()); + const std::vector& sigs = app.model->signals(); + if (signal >= sigs.size()) return; + name = sigs[signal].name; + } + app.rows->clear_signal_color(name); + app.relayout_pending = true; + rebuild_signal_panels(app); + schedule_layout_save(app); +} + /// One line per signal: colour chip, checkbox, name, unit, and a menu for the things /// you touch once (information, calibration). A `GtkSwitch` and two always-visible /// buttons made every row about twice this tall, which is what turned a four-signal @@ -282,18 +310,11 @@ void rebuild_signal_list(App& app, const std::vector& signals) { bool math_divider_done = false; // Streamed signals, counted as they are drawn: the dump reads `rows == signals + 1 + math`. std::size_t plain = 0; - for (std::size_t i = 0; i < signals.size(); ++i) { - if (ScopeModel::is_time_signal(signals, i)) continue; // the time base is not plottable + for (std::size_t i : listed_signals(signals)) { // Locked: a row per signal that is *on*, which is the list a user reads rather than the list they // configure. Turning one on is what the pane's [edit] button is for. if (app.signals_locked && !app.model->signal_enabled(i)) continue; - const std::string base = layer_base_name(signals[i].name); - bool seen = false; - for (std::size_t j = 0; j < i && !seen; ++j) { - seen = !ScopeModel::is_time_signal(signals, j) && - layer_base_name(signals[j].name) == base; - } - if (!seen) rows.push_back(i); + rows.push_back(i); } for (std::size_t i : rows) { @@ -385,20 +406,12 @@ void rebuild_signal_list(App& app, const std::vector& signals) { g_object_set_data(G_OBJECT(recolor), "udpscope-signal", GSIZE_TO_POINTER(i)); g_signal_connect(recolor, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { - App* a = static_cast(user); - const std::size_t idx = static_cast( - GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(b), "udpscope-signal"))); - std::string name; - { - std::lock_guard lock(a->model->mutex()); - const std::vector& sigs = a->model->signals(); - if (idx >= sigs.size()) return; - name = sigs[idx].name; - } - a->rows->clear_signal_color(name); - a->relayout_pending = true; - rebuild_signal_panels(*a); - schedule_layout_save(*a); + // The rebuild destroys this button (it is in the list it rebuilds). + outlive_the_rebuild(GTK_WIDGET(b)); + reset_signal_color( + *static_cast(user), + static_cast(GPOINTER_TO_SIZE( + g_object_get_data(G_OBJECT(b), "udpscope-signal")))); }), &app); if (sig.math) { @@ -451,6 +464,8 @@ void rebuild_signal_list(App& app, const std::vector& signals) { if (!app.signals_locked) gtk_box_append(GTK_BOX(box), more); gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box); + // Activating the row opens the signal's page; the row resolves its identity the way its widgets do. + g_object_set_data(G_OBJECT(row), "udpscope-signal", GSIZE_TO_POINTER(i)); DragContext* ctx = new DragContext{&app, i}; GtkDragSource* source = gtk_drag_source_new(); @@ -487,26 +502,18 @@ void rebuild_signal_list(App& app, const std::vector& signals) { app.updating_panels = false; } -gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, - gpointer user) { - App* app = static_cast(user); - if (!G_VALUE_HOLDS_STRING(value)) return FALSE; - // Where the drop landed decides the row: one area covers them all. - const int row = row_at(*app, x, y); - - const char* text = g_value_get_string(value); - const std::size_t signal = static_cast(std::atoll(text ? text : "0")); - if (signal >= app->model->signals().size()) return FALSE; +bool assign_signal_to_row(App& app, std::size_t signal, int row) { + if (signal >= app.model->signals().size()) return false; const auto notice = [&](const std::string& what) { - set_notice(*app, what, 4); + set_notice(app, what, 4); std::fprintf(stderr, "[udpscope] drop: %s\n", what.c_str()); std::fflush(stderr); }; // The dropped signal's channels are the ones the model built for it; take the // first, or the first element if the signal is a vector. - const std::vector& channels = app->model->channels(); + const std::vector& channels = app.model->channels(); int channel = -1; std::string name; for (std::size_t i = 0; i < channels.size(); ++i) { @@ -518,8 +525,8 @@ gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y } if (channel < 0) { // The signal is not enabled: enable it, then take the channel that appears. - app->model->set_signal_enabled(signal, true); - const std::vector& again = app->model->channels(); + app.model->set_signal_enabled(signal, true); + const std::vector& again = app.model->channels(); for (std::size_t i = 0; i < again.size(); ++i) { if (again[i].signal == signal) { channel = static_cast(i); @@ -529,14 +536,14 @@ gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y } } if (channel < 0) { - notice("cannot acquire '" + app->model->signals()[signal].name + + notice("cannot acquire '" + app.model->signals()[signal].name + "' (multi-element signals are not acquired)"); - return FALSE; + return false; } int slot = -1; for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { - if (!app->rows->slot_bound(row, s)) { + if (!app.rows->slot_bound(row, s)) { slot = s; break; } @@ -547,18 +554,30 @@ gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y notice("plot " + std::to_string(row + 1) + " holds four signals already: drop it on another plot, or add a row with the layout " "editor (L)"); - return FALSE; + return false; } // The slot remembers the *signal* (and element), never the channel index: the // channel list is renumbered whenever a signal is enabled or disabled. - const std::vector& signals = app->model->signals(); + const std::vector& signals = app.model->signals(); const SignalInfo& sig = signals[signal]; - app->rows->assign(row, slot, sig.name, name, signal_color_index(signals, signal)); - schedule_layout_save(*app); - app->relayout_pending = true; - rebuild_signal_panels(*app); // the row's colour chip now has a trace to name - cancel_drag_on(*app, row); + app.rows->assign(row, slot, sig.name, name, signal_color_index(signals, signal)); + schedule_layout_save(app); + app.relayout_pending = true; + rebuild_signal_panels(app); // the row's colour chip now has a trace to name notice("plotting '" + name + "' on plot " + std::to_string(row + 1)); + return true; +} + +gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, + gpointer user) { + App* app = static_cast(user); + if (!G_VALUE_HOLDS_STRING(value)) return FALSE; + // Where the drop landed decides the row: one area covers them all. + const int row = row_at(*app, x, y); + const char* text = g_value_get_string(value); + const std::size_t signal = static_cast(std::atoll(text ? text : "0")); + if (!assign_signal_to_row(*app, signal, row)) return FALSE; + cancel_drag_on(*app, row); return TRUE; } @@ -582,6 +601,7 @@ void rebuild_signal_panels(App& app) { if (!have_signals) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.trigger_toggle), FALSE); } + signal_pages_sync(app); app.signal_generation = app.model->generation(); } @@ -607,6 +627,24 @@ GtkWidget* build_signal_panel(App& app) { // it, and `[udpscope] signals panel:` reads the difference. return box != nullptr ? box : gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); } + // Activating a row (a click on the row's own space; its tick, chip and menu consume their own clicks) + // opens that signal's page. + gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(app.signal_list), TRUE); + g_signal_connect(app.signal_list, "row-activated", + G_CALLBACK(+[](GtkListBox*, GtkListBoxRow* row, gpointer user) { + App* a = static_cast(user); + const std::size_t index = static_cast( + GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(row), "udpscope-signal"))); + std::string name; + { + std::lock_guard lock(a->model->mutex()); + const std::vector& s = a->model->signals(); + if (index >= s.size()) return; + name = s[index].name; + } + if (signal_page_ensure(*a, name)) slideover_open(*a, "signal:" + name); + }), + &app); return box; } diff --git a/src/ui_slideover.cpp b/src/ui_slideover.cpp index 40beeae..ee3330a 100644 --- a/src/ui_slideover.cpp +++ b/src/ui_slideover.cpp @@ -8,6 +8,7 @@ #include +#include #include #include #include @@ -122,6 +123,24 @@ bool slideover_has_page(const App& app, const std::string& id) { gtk_stack_get_child_by_name(GTK_STACK(app.slideover.stack), id.c_str()) != nullptr; } +bool slideover_remove_page(App& app, const std::string& id) { + SlideOver& s = app.slideover; + if (!slideover_has_page(app, id) || s.page == id) return false; + GtkWidget* child = gtk_stack_get_child_by_name(GTK_STACK(s.stack), id.c_str()); + if (is_source_page(id)) { + s.source_pages.erase(std::remove(s.source_pages.begin(), s.source_pages.end(), id), + s.source_pages.end()); + AdwToggle* t = adw_toggle_group_get_toggle_by_name(ADW_TOGGLE_GROUP(s.source_switch), id.c_str()); + if (t != nullptr) { + s.syncing = true; + adw_toggle_group_remove(ADW_TOGGLE_GROUP(s.source_switch), t); + s.syncing = false; + } + } + gtk_stack_remove(GTK_STACK(s.stack), child); + return true; +} + bool slideover_open(App& app, const std::string& id) { SlideOver& s = app.slideover; if (!slideover_has_page(app, id)) return false; From 3b1cda50e5c848626339eb362cba616e7e333cd3 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 01:09:29 +0100 Subject: [PATCH 068/127] ui: signal pages honour the signal lock and disable the plot a signal is already on The per-signal page bypassed the signals lock for the acquire tick and the math Edit/Delete buttons, and offered "Plot on r" for a plot the signal was already on. A removed page also let another signal's page show during the close animation. Co-Authored-By: Claude Opus 4.6 --- docs/claude/probe-readings.md | 7 +++-- src/ui_signal_page.cpp | 59 ++++++++++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 62ba959..fb4ddb7 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -2637,12 +2637,15 @@ left the caller's `false` alone would read `dock_shown=0 default=1 ok=0`. The pr ## `[signalpage]` ``` -[signalpage] name=sine open=1 pages=1 enable_visible=1 plot_buttons=1 plot_on_row0=1 removed=1 btn_row0=1 back_to_list=1 row_opens=1 pruned=1 rows=1 +[signalpage] name=sine open=1 pages=1 enable_visible=1 plot_buttons=1 plot_on_row0=1 plot_on_row0_disabled=1 locked_enable_sensitive=0 removed=1 btn_row0=1 back_to_list=1 row_opens=1 pruned=1 rows=1 ``` `UDPSCOPE_PROBE_SIGNAL_PAGES_MS` builds and drives the page of the first streamed signal. `open=1` (the slide-over shows `signal:`), `pages=1` (one page in `App::signal_pages`), `enable_visible=1` (the acquire tick is shown in live mode; `0` in a file view), `plot_buttons=` equal to -`rows=` (one "Plot on" button per figure row), `plot_on_row0=1` (`assign_signal_to_row()` returned true and the signal is plotted), `removed=1` +`rows=` (one "Plot on" button per figure row), `plot_on_row0=1` (`assign_signal_to_row()` returned true and the signal is plotted, from a state where the probe first took it off every plot), `plot_on_row0_disabled=1` +(after the page refilled with the signal on plot 1, that plot's "Plot on" button is insensitive, "already on this plot"; `0` is a button that would put the signal in a second slot of +the same plot), `locked_enable_sensitive=0` (with `signals_locked` set and `signal_pages_sync()` run, the page's Acquire/Compute tick is insensitive; `1` is a page that bypasses +the signal lock -- the same goes for a math channel's Edit graph and Delete, which the probe does not read; the probe restores the lock), `removed=1` (the page's "Remove from plots" took it off), `btn_row0=1` (the "Plot on 1" button, the user's path, put it back), `back_to_list=1` ("All signals" target), `row_opens=1` (activating the list row opens the page), and `pruned=1`: a math channel's page was opened, its **Delete** button clicked, and the slide-over closed and the page was dropped by `signal_pages_sync()`. `pruned=-1` means no math channel could be added; `0` is a stale page. diff --git a/src/ui_signal_page.cpp b/src/ui_signal_page.cpp index 894ac16..e99a04d 100644 --- a/src/ui_signal_page.cpp +++ b/src/ui_signal_page.cpp @@ -96,6 +96,8 @@ void fill(App& app, const std::string& name, GtkWidget* page) { g_object_set_data(G_OBJECT(enable), "udpscope-signal", GSIZE_TO_POINTER(index)); g_signal_connect(enable, "notify::active", G_CALLBACK(on_signal_switch), &app); gtk_widget_set_visible(enable, sig.math || app.waveform_name.empty()); + // The signal lock is the list's lock too: the page may not do what the locked list refuses. + gtk_widget_set_sensitive(enable, unlocked); gtk_box_append(GTK_BOX(page), enable); // Colour. @@ -137,7 +139,13 @@ void fill(App& app, const std::string& name, GtkWidget* page) { GtkWidget* b = text_button(std::to_string(r + 1).c_str(), rows_box); tag(b, index); g_object_set_data(G_OBJECT(b), "udpscope-row", GINT_TO_POINTER(r)); - gtk_widget_set_tooltip_text(b, "draw this signal on this plot"); + // A button for a plot the signal is already on would add it to a second slot there. + bool on_this_row = false; + for (int slot = 0; slot < RowSet::kSlotsPerRow; ++slot) { + if (app.rows->row(r).signal_name[static_cast(slot)] == sig.name) on_this_row = true; + } + gtk_widget_set_sensitive(b, !on_this_row); + gtk_widget_set_tooltip_text(b, on_this_row ? "already on this plot" : "draw this signal on this plot"); g_signal_connect(b, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { outlive_the_rebuild(GTK_WIDGET(b)); assign_signal_to_row(*static_cast(user), tagged(b), @@ -176,10 +184,12 @@ void fill(App& app, const std::string& name, GtkWidget* page) { GtkWidget* more = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); if (sig.math) { GtkWidget* edit = text_button("Edit graph...", more); + gtk_widget_set_sensitive(edit, unlocked); tag(edit, index); g_signal_connect(edit, "clicked", G_CALLBACK(on_math_edit_clicked), &app); GtkWidget* del = text_button("Delete", more); gtk_widget_add_css_class(del, "destructive-action"); + gtk_widget_set_sensitive(del, unlocked); tag(del, index); g_signal_connect(del, "clicked", G_CALLBACK(on_math_delete_clicked), &app); } else { @@ -221,6 +231,11 @@ void signal_pages_sync(App& app) { for (auto it = app.signal_pages.begin(); it != app.signal_pages.end();) { if (signal_index(app, it->first) == std::string::npos) { if (slideover_page(app) == page_id(it->first)) slideover_close(app); + // The stack keeps showing the dying page through the close animation; park it on the list so that + // removing the page does not let another signal's page take its place for those frames. + if (slideover_has_page(app, "signals")) { + gtk_stack_set_visible_child_name(GTK_STACK(app.slideover.stack), "signals"); + } slideover_remove_page(app, page_id(it->first)); it = app.signal_pages.erase(it); } else { @@ -295,12 +310,46 @@ gboolean signal_page_probe(gpointer user) { } } + // Start from a signal that is on no plot (the layout may have it somewhere), through the page's own button. + auto page_it = a.signal_pages.find(name); + if (signal_plotted(a, name) && page_it != a.signal_pages.end()) { + GtkWidget* off0 = find_descendant(page_it->second, [](GtkWidget* w) { + return is_button_labelled(w, "Remove from plots"); + }); + if (off0 != nullptr) g_signal_emit_by_name(off0, "clicked"); + } + // Plot on row 0 through the function the "Plot on" buttons call, then take it off through the page's own // button (outlive_the_rebuild + remove_signal_from_plots + the page's refill). const bool assigned = assign_signal_to_row(a, index, 0); const int plot_on_row0 = assigned && signal_plotted(a, name) ? 1 : 0; + + // The refilled page disables the button of the plot the signal is now on. + int plot_on_row0_disabled = -1; + page_it = a.signal_pages.find(name); + GtkWidget* rows_box_on = page_it == a.signal_pages.end() ? nullptr : find_descendant(page_it->second, [](GtkWidget* w) { + return std::string(gtk_widget_get_name(w)) == "udpscope-plot-on"; + }); + if (rows_box_on != nullptr && gtk_widget_get_first_child(rows_box_on) != nullptr) { + plot_on_row0_disabled = gtk_widget_get_sensitive(gtk_widget_get_first_child(rows_box_on)) ? 0 : 1; + } + + // The signal lock: with it on, the page's acquire tick is insensitive after the sync (the lock change + // re-runs it through rebuild_signal_panels); restored afterwards. + int locked_enable_sensitive = -1; + const bool was_locked = a.signals_locked; + a.signals_locked = true; + signal_pages_sync(a); + page_it = a.signal_pages.find(name); + if (page_it != a.signal_pages.end()) { + GtkWidget* enable_now = find_descendant(page_it->second, [](GtkWidget* w) { return GTK_IS_CHECK_BUTTON(w); }); + if (enable_now != nullptr) locked_enable_sensitive = gtk_widget_get_sensitive(enable_now) ? 1 : 0; + } + a.signals_locked = was_locked; + signal_pages_sync(a); + int removed = -1; - auto page_it = a.signal_pages.find(name); + page_it = a.signal_pages.find(name); GtkWidget* off = page_it == a.signal_pages.end() ? nullptr : find_descendant(page_it->second, [](GtkWidget* w) { @@ -370,9 +419,11 @@ gboolean signal_page_probe(gpointer user) { slideover_close(a); std::fprintf(stderr, "[signalpage] name=%s open=%d pages=%d enable_visible=%d plot_buttons=%d plot_on_row0=%d " + "plot_on_row0_disabled=%d locked_enable_sensitive=%d " "removed=%d btn_row0=%d back_to_list=%d row_opens=%d pruned=%d rows=%d\n", - name.c_str(), open, pages, enable_visible, plot_buttons, plot_on_row0, removed, btn_row0, - back_to_list, row_opens, pruned, a.rows->row_count()); + name.c_str(), open, pages, enable_visible, plot_buttons, plot_on_row0, plot_on_row0_disabled, + locked_enable_sensitive, removed, btn_row0, back_to_list, row_opens, pruned, + a.rows->row_count()); return G_SOURCE_REMOVE; } From ad5e30f3385bbc9ae41df556af41aa075322de57 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 01:11:06 +0100 Subject: [PATCH 069/127] ui: park the slide-over stack only when the dying signal page is the visible one --- docs/claude/probe-readings.md | 6 ++++-- src/ui_signal_page.cpp | 36 +++++++++++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index fb4ddb7..f75f734 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -2637,7 +2637,7 @@ left the caller's `false` alone would read `dock_shown=0 default=1 ok=0`. The pr ## `[signalpage]` ``` -[signalpage] name=sine open=1 pages=1 enable_visible=1 plot_buttons=1 plot_on_row0=1 plot_on_row0_disabled=1 locked_enable_sensitive=0 removed=1 btn_row0=1 back_to_list=1 row_opens=1 pruned=1 rows=1 +[signalpage] name=sine open=1 pages=1 enable_visible=1 plot_buttons=1 plot_on_row0=1 plot_on_row0_disabled=1 locked_enable_sensitive=0 removed=1 btn_row0=1 back_to_list=1 row_opens=1 pruned=1 other_page_kept=1 rows=1 ``` `UDPSCOPE_PROBE_SIGNAL_PAGES_MS` builds and drives the page of the first streamed signal. `open=1` (the slide-over shows `signal:`), `pages=1` @@ -2648,7 +2648,9 @@ the same plot), `locked_enable_sensitive=0` (with `signals_locked` set and `sign the signal lock -- the same goes for a math channel's Edit graph and Delete, which the probe does not read; the probe restores the lock), `removed=1` (the page's "Remove from plots" took it off), `btn_row0=1` (the "Plot on 1" button, the user's path, put it back), `back_to_list=1` ("All signals" target), `row_opens=1` (activating the list row opens the page), and `pruned=1`: a math channel's page was opened, its **Delete** button clicked, and -the slide-over closed and the page was dropped by `signal_pages_sync()`. `pruned=-1` means no math channel could be added; `0` is a stale page. +the slide-over closed and the page was dropped by `signal_pages_sync()`. `pruned=-1` means no math channel could be added; `0` is a stale page. `other_page_kept=1`: with the `trigger` page open, a signal page that is not on show was pruned (a math channel added, its page built, the +channel removed) and the slide-over was still on `trigger`, both by `slideover_page()` and by the stack's visible child; `0` is `signal_pages_sync()` parking the stack on the +signals list while the title and the badge marker still name `trigger`; `-1` when there is no `trigger` page or no math channel could be added. ## `[badges]` diff --git a/src/ui_signal_page.cpp b/src/ui_signal_page.cpp index e99a04d..9e29707 100644 --- a/src/ui_signal_page.cpp +++ b/src/ui_signal_page.cpp @@ -232,8 +232,11 @@ void signal_pages_sync(App& app) { if (signal_index(app, it->first) == std::string::npos) { if (slideover_page(app) == page_id(it->first)) slideover_close(app); // The stack keeps showing the dying page through the close animation; park it on the list so that - // removing the page does not let another signal's page take its place for those frames. - if (slideover_has_page(app, "signals")) { + // removing the page does not let another signal's page take its place for those frames. Only when it + // is the page on show: parking while the slide-over is open on another page would switch the stack + // away from the page that slideover_page(), the title and the badge marker still name. + const char* shown = gtk_stack_get_visible_child_name(GTK_STACK(app.slideover.stack)); + if (shown != nullptr && std::string(shown) == page_id(it->first) && slideover_has_page(app, "signals")) { gtk_stack_set_visible_child_name(GTK_STACK(app.slideover.stack), "signals"); } slideover_remove_page(app, page_id(it->first)); @@ -417,13 +420,38 @@ gboolean signal_page_probe(gpointer user) { } } slideover_close(a); + + // A page that is not on show is pruned while another panel is open: the slide-over stays on that panel. + int other_page_kept = -1; + if (slideover_has_page(a, "trigger")) { + slideover_open(a, "trigger"); + const std::size_t before = a.math.size(); + add_math_channel(a); + if (a.math.size() == before + 1) { + const std::string mname = a.math.back().name; + if (signal_page_ensure(a, mname)) { + const bool on_trigger = slideover_page(a) == "trigger"; + a.math.pop_back(); + apply_math_channels(a); + const char* shown = gtk_stack_get_visible_child_name(GTK_STACK(a.slideover.stack)); + other_page_kept = on_trigger && a.signal_pages.count(mname) == 0 && slideover_page(a) == "trigger" && + shown != nullptr && std::string(shown) == "trigger" + ? 1 + : 0; + } else { + a.math.pop_back(); + apply_math_channels(a); + } + } + slideover_close(a); + } std::fprintf(stderr, "[signalpage] name=%s open=%d pages=%d enable_visible=%d plot_buttons=%d plot_on_row0=%d " "plot_on_row0_disabled=%d locked_enable_sensitive=%d " - "removed=%d btn_row0=%d back_to_list=%d row_opens=%d pruned=%d rows=%d\n", + "removed=%d btn_row0=%d back_to_list=%d row_opens=%d pruned=%d other_page_kept=%d rows=%d\n", name.c_str(), open, pages, enable_visible, plot_buttons, plot_on_row0, plot_on_row0_disabled, locked_enable_sensitive, removed, btn_row0, back_to_list, row_opens, pruned, - a.rows->row_count()); + other_page_kept, a.rows->row_count()); return G_SOURCE_REMOVE; } From f27925da59bf75d33f91bf0b750a69ee7b10625e Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 01:19:13 +0100 Subject: [PATCH 070/127] ui: one badge per enabled signal, an N signals overflow, and the order kept in memory --- docs/claude/probe-readings.md | 14 +++ docs/claude/verification.md | 4 + src/ui.cpp | 8 +- src/ui_app.hpp | 14 +++ src/ui_badges.cpp | 175 +++++++++++++++++++++++++++++++++- src/ui_signals.cpp | 1 + 6 files changed, 213 insertions(+), 3 deletions(-) diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index f75f734..f0e80b5 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -2652,6 +2652,20 @@ the slide-over closed and the page was dropped by `signal_pages_sync()`. `pruned channel removed) and the slide-over was still on `trigger`, both by `slideover_page()` and by the stack's visible child; `0` is `signal_pages_sync()` parking the stack on the signals list while the title and the badge marker still name `trigger`; `-1` when there is no `trigger` page or no math channel could be added. +## `[sigbadges]` + +``` +[sigbadges] enabled=4 badges=4 overflow=0 overflow_text=- plus_visible=1 dot_plotted=4 bar_h=36 bar_h_fixed=1 bar_min_w=336 opens=1 plus_opens=1 +``` + +`UDPSCOPE_PROBE_SIGNAL_BADGES_MS` reads the signal badges. `badges` must equal `enabled` (the enabled, listed signals) while `enabled` is at most 6 +(`kMaxSignalBadges`); beyond that `badges=0 overflow=1 overflow_text= signals plus_visible=0` (the "N signals" badge replaces the badges and the "+"). `dot_plotted` is the number of +badges whose signal is on a plot (its dot is then the trace colour, not the unplotted grey). `bar_h` is the bar's allocated height and `bar_h_fixed=1` that neither it nor its natural +height moved while one signal was switched off and on again (`rebuild_signal_panels()` each time), and that the badge count followed; `0` is a bar whose height depends on its content. +`bar_min_w` is only a record: the scroller must keep it the same however many signals there are (it reads 336 with the mock's 4 and with the overflow forced on). `opens=1`: clicking the +first signal badge opens `signal:` with the badge marked open and a second click closes it (`-1` when overflowed, there being no signal badges); `plus_opens=1` the same for the "+" +(the overflow badge when overflowed) and the `signals` page. + ## `[badges]` ``` diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 9a5184f..03001da 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -451,6 +451,10 @@ precedent, not an exception. per-signal `signal:` slide-over pages. **Against a config that has already seen the tour** (a first run's auto-tour closes the slide-over at about 1.5 s, which is when this probe opens its page). One `[signalpage]` line; see [probe-readings.md](probe-readings.md). +- `UDPSCOPE_PROBE_SIGNAL_BADGES_MS=1500` with `UDPSCOPE_EXIT_AFTER_MS=5000` and `UDPSCOPE_ENABLE_ALL_MS=500` -- the + signal badges in the badge bar. **Against a config that has already seen the tour**, as above (the probe clicks + the badges, and the auto-tour would close the page). One `[sigbadges]` line; see + [probe-readings.md](probe-readings.md). - `UDPSCOPE_PROBE_BADGES_MS=2000` with `UDPSCOPE_EXIT_AFTER_MS=5000` and `UDPSCOPE_ENABLE_ALL_MS=500` -- the badge bar under the plot. One `[badges]` line, which must read `table_bad=0 fixed=1 open_marked=1 page_open=1 closed_unmarked=1`: `table_bad` counts the rows of the 16-input `trigger_badge_state()` table the probe diff --git a/src/ui.cpp b/src/ui.cpp index 5dacc3b..e6705fb 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -4673,7 +4673,10 @@ void run_app_action(App& app, AppAction action) { toggle(app.cursors_toggle); break; case AppAction::ShowMeasure: dock_show_section(app, "measure"); break; - case AppAction::ShowSignals: slideover_toggle(app, "signals"); break; + case AppAction::ShowSignals: + // The `+` badge is insensitive without signals; so is its shortcut. + if (!app.model->signals().empty()) slideover_toggle(app, "signals"); + break; case AppAction::LayoutEditor: toggle(app.layout_toggle); break; @@ -5887,6 +5890,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_PROBE_BADGES_MS")) { g_timeout_add(static_cast(std::atoi(env)), badges_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_SIGNAL_BADGES_MS")) { + g_timeout_add(static_cast(std::atoi(env)), signal_badges_probe, &app); + } if (const char* env = g_getenv("UDPSCOPE_PROBE_SIGNAL_PAGES_MS")) { g_timeout_add(static_cast(std::atoi(env)), signal_page_probe, &app); } diff --git a/src/ui_app.hpp b/src/ui_app.hpp index c6cf45c..85d5896 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -149,7 +149,11 @@ struct Badge { GtkWidget* name = nullptr; GtkWidget* state = nullptr; std::string page; + std::string signal_name; ///< a signal badge: the signal it stands for; empty for the others + std::size_t signal = 0; ///< a signal badge: its index in `ScopeModel::signals()` when it was built }; +/// The most signal badges the bar shows; beyond that one "N signals" badge stands for them all. +inline constexpr std::size_t kMaxSignalBadges = 6; /// The badge bar under the plot (src/ui_badges.cpp). struct BadgeBar { GtkWidget* bar = nullptr; ///< main-window.ui `badge_bar`, fixed height @@ -157,6 +161,11 @@ struct BadgeBar { GtkWidget* system_slot = nullptr; ///< right: Trigger, Source Badge trigger; Badge source; ///< opens the first source page; a read-out when the source has none + GtkWidget* signals_row = nullptr; ///< the box inside the scroller: signal badges, then the "+" + /// One per shown enabled signal; empty when overflowed. `unique_ptr`: each button's "clicked" handler holds + /// a pointer to its `Badge`, which must not move when the vector does. + std::vector> signal_badges; + Badge overflow; ///< the "N signals" badge, used only beyond `kMaxSignalBadges` Badge plus; ///< the "+" in the signals slot: opens the `signals` page }; @@ -174,6 +183,8 @@ struct App { BadgeBar badges; GtkWidget* badge_bar = nullptr; GtkWidget* badge_signals = nullptr; + /// Signal names in the order the user left the badges (memory only; names not in it sort last). + std::vector badge_order; GtkWidget* badge_system = nullptr; /// The one GL area, drawing the `RowSet`'s figure: every row is a row of that /// figure, so there is one widget, one context and one `::render` whatever the row @@ -1952,6 +1963,9 @@ const char* trigger_badge_state(bool enabled, bool armed, bool pending, bool has void build_badge_bar(App& app); // after build_slideover, the toolbar (it reads trigger_toggle) and build_source_panels (the Source badge's page) void sync_badge_bar(App& app); // per tick and on every slide-over open/close; only writes what changed gboolean badges_probe(gpointer user); // UDPSCOPE_PROBE_BADGES_MS +/// Rebuilds the signal badges (one per enabled signal, or the "N signals" overflow) from the model. +void sync_signal_badges(App& app); +gboolean signal_badges_probe(gpointer user); // UDPSCOPE_PROBE_SIGNAL_BADGES_MS gboolean source_kinds_probe_report(gpointer user); gboolean source_kinds_probe(gpointer user); /// MCC E-1608 end to end against `tools/mock_e1608.py`: the stream, the calibration, the panels and diff --git a/src/ui_badges.cpp b/src/ui_badges.cpp index dfdf1c4..6bc5fe5 100644 --- a/src/ui_badges.cpp +++ b/src/ui_badges.cpp @@ -11,6 +11,11 @@ #include #include #include +#include +#include +#include +#include +#include namespace udpscope { namespace ui_impl { @@ -49,6 +54,9 @@ void on_badge_clicked(GtkButton*, gpointer user) { slideover_close(*app); return; } + // A signal's page is made on first use (and kept in step by `signal_pages_sync`). Nothing here rebuilds the + // badges (the page's fill and the slide-over open/close only restyle them), so the badge outlives this call. + if (b->page.rfind("signal:", 0) == 0 && !signal_page_ensure(*app, b->signal_name)) return; slideover_toggle(*app, b->page); } @@ -70,6 +78,28 @@ void make_badge(App& app, Badge& b, GtkWidget* parent, const char* name, const s gtk_box_append(GTK_BOX(parent), b.button); } +/// A signal's badge: its colour dot (grey when it has no trace) and its name. Words and a dot, no icon. +void make_signal_badge(App& app, Badge& b, const std::vector& signals, std::size_t index) { + b.signal = index; + b.signal_name = signals[index].name; + b.page = "signal:" + b.signal_name; + b.button = gtk_button_new(); + gtk_widget_add_css_class(b.button, "flat"); + gtk_widget_add_css_class(b.button, "udpscope-badge"); + GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget* dot = gtk_label_new(nullptr); + gtk_label_set_markup(GTK_LABEL(dot), signal_chip_markup(app, signals, index).c_str()); + b.name = gtk_label_new(b.signal_name.c_str()); + gtk_label_set_ellipsize(GTK_LABEL(b.name), PANGO_ELLIPSIZE_END); + gtk_label_set_max_width_chars(GTK_LABEL(b.name), 14); + gtk_box_append(GTK_BOX(row), dot); + gtk_box_append(GTK_BOX(row), b.name); + gtk_button_set_child(GTK_BUTTON(b.button), row); + gtk_widget_set_tooltip_text(b.button, b.signal_name.c_str()); + g_object_set_data(G_OBJECT(b.button), "udpscope-app", &app); + g_signal_connect(b.button, "clicked", G_CALLBACK(on_badge_clicked), &b); +} + } // namespace void build_badge_bar(App& app) { @@ -86,9 +116,67 @@ void build_badge_bar(App& app) { gtk_widget_set_can_target(bb.source.button, FALSE); gtk_widget_set_focusable(bb.source.button, FALSE); } - // The full signal list. Insensitive until the stream has declared signals ("disable, do not hide"). - make_badge(app, bb.plus, bb.signals_slot, "+", "signals"); + // The signal badges sit in a scroller: a bar whose minimum width grew with the number of signals would + // widen the window (layout must not depend on content). EXTERNAL scrolls by wheel/touch with no bar. + GtkWidget* scroller = gtk_scrolled_window_new(); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_EXTERNAL, GTK_POLICY_NEVER); + gtk_widget_set_hexpand(scroller, TRUE); + bb.signals_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), bb.signals_row); + gtk_box_append(GTK_BOX(bb.signals_slot), scroller); + // The full signal list. Insensitive until the stream has declared signals ("disable, do not hide"). It + // follows the signal badges, which are inserted before it. It has no state, so its empty label is hidden + // rather than leaving a dead gap in the pill. + make_badge(app, bb.plus, bb.signals_row, "+", "signals"); + gtk_widget_set_visible(bb.plus.state, FALSE); gtk_widget_set_tooltip_text(bb.plus.button, "all signals: switch more on, add a math channel"); + sync_signal_badges(app); +} + +void sync_signal_badges(App& app) { + BadgeBar& bb = app.badges; + if (bb.signals_row == nullptr || bb.plus.button == nullptr) return; + std::vector signals; + { + std::lock_guard lock(app.model->mutex()); + signals = app.model->signals(); + } + // The enabled signals, in the user's order: names in `badge_order` first, the rest in model order. + std::vector shown; + for (std::size_t i : listed_signals(signals)) { + if (app.model->signal_enabled(i)) shown.push_back(i); + } + const auto rank = [&](std::size_t i) { + const auto it = std::find(app.badge_order.begin(), app.badge_order.end(), signals[i].name); + return static_cast(it - app.badge_order.begin()); // == size() when unknown: sorts last + }; + std::stable_sort(shown.begin(), shown.end(), [&](std::size_t a, std::size_t b) { return rank(a) < rank(b); }); + + // Clear: every signal badge, and the overflow if it was there. The "+" is kept. The badges' buttons die + // with the removal (their handlers with them), so the `Badge` records they point to can go too. + for (GtkWidget* c = gtk_widget_get_first_child(bb.signals_row); c != nullptr;) { + GtkWidget* next = gtk_widget_get_next_sibling(c); + if (c != bb.plus.button) gtk_box_remove(GTK_BOX(bb.signals_row), c); + c = next; + } + bb.signal_badges.clear(); + bb.overflow = Badge{}; + + const bool overflowed = shown.size() > kMaxSignalBadges; + if (overflowed) { + make_badge(app, bb.overflow, bb.signals_row, "", "signals"); + gtk_widget_set_visible(bb.overflow.state, FALSE); + gtk_label_set_text(GTK_LABEL(bb.overflow.name), (std::to_string(shown.size()) + " signals").c_str()); + } else { + for (std::size_t i : shown) { + bb.signal_badges.push_back(std::make_unique()); + make_signal_badge(app, *bb.signal_badges.back(), signals, i); + gtk_box_insert_child_after(GTK_BOX(bb.signals_row), bb.signal_badges.back()->button, + gtk_widget_get_prev_sibling(bb.plus.button)); + } + } + // The overflow badge stands for the "+" (it opens the same page); the "+" is shown only when it is not. + gtk_widget_set_visible(bb.plus.button, !overflowed); sync_badge_bar(app); } @@ -107,6 +195,13 @@ void sync_badge_bar(App& app) { gtk_widget_set_sensitive(bb.plus.button, !app.model->signals().empty()); set_open(bb.plus.button, slideover_page(app) == "signals"); } + if (bb.overflow.button != nullptr) { + gtk_widget_set_sensitive(bb.overflow.button, !app.model->signals().empty()); + set_open(bb.overflow.button, slideover_page(app) == bb.overflow.page); + } + for (const std::unique_ptr& sb : bb.signal_badges) { + set_open(sb->button, slideover_page(app) == sb->page); + } if (bb.source.button == nullptr) return; // The source's name and link state. A file has neither a link nor a kind worth naming. const bool file_mode = app.replaying || !app.model->config().open_waveform.empty(); @@ -192,5 +287,81 @@ gboolean badges_probe(gpointer user) { return G_SOURCE_REMOVE; } +gboolean signal_badges_probe(gpointer user) { + App* a = static_cast(user); + BadgeBar& bb = a->badges; + const auto bar_height = [&] { + int min_h = 0, nat_h = 0; + gtk_widget_measure(bb.bar, GTK_ORIENTATION_VERTICAL, -1, &min_h, &nat_h, nullptr, nullptr); + return std::make_pair(gtk_widget_get_height(bb.bar), nat_h); + }; + const auto before = bar_height(); + int bar_min_w = 0; + gtk_widget_measure(bb.bar, GTK_ORIENTATION_HORIZONTAL, -1, &bar_min_w, nullptr, nullptr, nullptr); + + // The enabled, listed signals the bar must stand for. + std::vector signals; + { + std::lock_guard lock(a->model->mutex()); + signals = a->model->signals(); + } + std::size_t enabled = 0; + std::size_t toggle = signals.size(); + for (std::size_t i : listed_signals(signals)) { + if (!a->model->signal_enabled(i)) continue; + ++enabled; + if (toggle == signals.size()) toggle = i; + } + const bool overflowed = bb.overflow.button != nullptr; + int dot_plotted = 0; + for (const std::unique_ptr& sb : bb.signal_badges) { + if (signal_plotted(*a, sb->signal_name)) ++dot_plotted; + } + // A copy: the toggling below rebuilds the badges, and the label with them. + const std::string overflow_text = overflowed ? gtk_label_get_text(GTK_LABEL(bb.overflow.name)) : "-"; + const int plus_visible = gtk_widget_get_visible(bb.plus.button) != FALSE ? 1 : 0; + const std::size_t badges = bb.signal_badges.size(); + + // One signal off and on again: the bar's height does not move, and the badges come back. + int bar_h_fixed = -1; + if (toggle != signals.size()) { + a->model->set_signal_enabled(toggle, false); + rebuild_signal_panels(*a); + const auto off = bar_height(); + const std::size_t badges_off = bb.signal_badges.size(); + a->model->set_signal_enabled(toggle, true); + rebuild_signal_panels(*a); + const auto on = bar_height(); + bar_h_fixed = before == off && before == on && (overflowed || badges_off + 1 == badges || badges == 0) ? 1 : 0; + } + + // A signal badge opens its page and the second click closes it; the "+" (the overflow when overflowed) + // does the same with the list. + int opens = -1; + if (!bb.signal_badges.empty()) { + Badge& first = *bb.signal_badges.front(); + const std::string want = "signal:" + first.signal_name; + g_signal_emit_by_name(first.button, "clicked"); + const bool open1 = slideover_page(*a) == want && + gtk_widget_has_css_class(first.button, "udpscope-badge-open") != FALSE; + g_signal_emit_by_name(first.button, "clicked"); + const bool closed = slideover_page(*a).empty() && + gtk_widget_has_css_class(first.button, "udpscope-badge-open") == FALSE; + opens = open1 && closed ? 1 : 0; + } + Badge& list_badge = bb.overflow.button != nullptr ? bb.overflow : bb.plus; + g_signal_emit_by_name(list_badge.button, "clicked"); + const bool plus1 = slideover_page(*a) == "signals"; + g_signal_emit_by_name(list_badge.button, "clicked"); + const int plus_opens = plus1 && slideover_page(*a).empty() ? 1 : 0; + + std::fprintf(stderr, + "[sigbadges] enabled=%zu badges=%zu overflow=%d overflow_text=%s plus_visible=%d dot_plotted=%d " + "bar_h=%d bar_h_fixed=%d bar_min_w=%d opens=%d plus_opens=%d\n", + enabled, badges, overflowed ? 1 : 0, overflow_text.c_str(), plus_visible, dot_plotted, before.first, + bar_h_fixed, bar_min_w, opens, plus_opens); + return G_SOURCE_REMOVE; +} + } // namespace ui_impl } // namespace udpscope diff --git a/src/ui_signals.cpp b/src/ui_signals.cpp index d55a71e..93ee7fa 100644 --- a/src/ui_signals.cpp +++ b/src/ui_signals.cpp @@ -602,6 +602,7 @@ void rebuild_signal_panels(App& app) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.trigger_toggle), FALSE); } signal_pages_sync(app); + sync_signal_badges(app); app.signal_generation = app.model->generation(); } From ca10dfb32313e4ecdcff388f789897854432f135 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 01:30:55 +0100 Subject: [PATCH 071/127] ui: the signal badge is the drag handle (to a plot row, or to reorder); the list rows stop dragging Co-Authored-By: Claude Opus 4.6 --- README.md | 4 +- docs/claude/invariants-gtk-and-chrome.md | 17 +- docs/claude/invariants-session-and-export.md | 9 +- docs/claude/probe-readings.md | 35 ++-- src/ui.cpp | 23 +-- src/ui_app.hpp | 15 +- src/ui_badges.cpp | 162 ++++++++++++++++++- src/ui_probe.cpp | 48 +++--- src/ui_signals.cpp | 26 +-- 9 files changed, 230 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index e15a8f0..d05ad08 100644 --- a/README.md +++ b/README.md @@ -2252,7 +2252,7 @@ exists; it is there rather than on the chip because a second gesture on a one-gl not something anybody would find. The chooser is opened by a `GtkGestureClick` on the existing label rather than by wrapping it in a `GtkButton`, because a button's padding is exactly what made this row twice as tall once (below), and the gesture claims its sequence so a click on the -chip cannot become the start of a row drag. +chip cannot also activate the row (and open its page). A `GtkSwitch` and two always-visible buttons made a row about twice as tall (46 px against 26 px now), @@ -2261,7 +2261,7 @@ button and list-row minimums, not the widgets themselves, set that height, so th row carries a `.udpscope-compact` style class that relaxes them. **Signals start disabled**, because enabling one is what starts acquiring it. -Enable a signal with its checkbox, or drag it onto a plot: the drop enables the +Enable a signal with its checkbox, or drag its badge from the badge bar onto a plot: the drop enables the signal if needed, binds it to that row's first free trace slot, and rebuilds the figure so the legend name is right (there is one widget, so the row a drop lands on is `PlotArea::row_at()`). The status line reports the outcome of a drop diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md index 335f3d8..2d630e3 100644 --- a/docs/claude/invariants-gtk-and-chrome.md +++ b/docs/claude/invariants-gtk-and-chrome.md @@ -245,13 +245,16 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear alone (`sidebar_wanted`) and the dock's *sensitivity* from `have_signals`; the toolbar's Dock button stays sensitive throughout. The wish is persisted (`dock = true|false` in the session file) and a change of it schedules a save. `dock:` in the dump reads the result. -- **An *overlaid* sidebar covers the plot, so a drag out of it has nowhere to land.** `collapsed` in an - `AdwOverlaySplitView` means the sidebar sits *on* the plot rather than beside it: the pointer is over the - list, never over a drop target, and a signal can be dragged but not dropped. `row_drag_sidebar()` hides - the sidebar on `drag-begin` and restores it on `drag-end` — only when collapsed, because side by side the - plot is already visible and hiding the list the drag started in would be startling. `pane drag: sidebar - hidden for the drag=1 restored after it=1` is the reading; the state is forced collapsed the way the - sidebar probe forces it. +- **The signal badge is the drag handle, and a drop handler only moves widgets.** Dragging a signal onto a plot + starts on its badge in the badge bar (a `GtkDragSource` on the badge's button, payload the signal index as a + decimal string, taken by the plot area's existing drop target); the list's rows are not drag sources. A drag + starting with the slide-over open would have part of the plot hidden under the panel, so the source's + `drag-begin` closes it (which only restyles the badges: nothing is destroyed mid-drag). A click without movement + never reaches the drag threshold and still opens the page; past it the source claims the sequence and the + button's click is cancelled. Each badge is also a `GtkDropTarget` for reordering: `on_badge_drop` calls + `gtk_box_reorder_child_after` and rewrites `App::badge_order` from the widgets, and **never rebuilds the badges** -- + the dragged badge and the target whose `drop` emission is running must both survive it. A foreign drag whose string is not a + signal index is refused. `[sigbadges]` reads the controllers, the payload and the reorder; `pane drag:` reads the close. - **A stock icon name has to be one *Adwaita* has, and a blank button is the failure mode.** The two panes' `[edit]` buttons are `document-edit-symbolic` (the pencil: edit) and `object-select-symbolic` (the check: done). The done one was `emblem-ok-symbolic`, which breeze ships and **Adwaita does not** — diff --git a/docs/claude/invariants-session-and-export.md b/docs/claude/invariants-session-and-export.md index 89fc64c..99a2eec 100644 --- a/docs/claude/invariants-session-and-export.md +++ b/docs/claude/invariants-session-and-export.md @@ -18,11 +18,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear a control that changes the configuration. A *first run* — no session file for that source — never reaches `apply_layout()` and stays in configuration mode, which is what a first run is for. The mode is a mode and not a setting: it is deliberately not persisted, so a scope reopened tomorrow starts - where a returning user expects to start. **Dragging a row onto a plot is not configuration and survives - the lock**: filling plots is what the list is *for*, so a locked row keeps its `GtkDragSource` — from - anywhere on it, the colour chip included, which in configuration mode has a click gesture that claims - the sequence precisely so a drag cannot start there. `drag_sources` on the `signals panel:` line is the - reading, and it must be `rows - 1`. + where a returning user expects to start. **Dragging a signal onto a plot is not configuration and survives + the lock**: it is the signal *badge's* drag (the list's rows are no longer drag sources, and the badges do + not follow the lock). `[sigbadges] drag_sources` is the reading, and `drag_sources` on the `signals panel:` + line must be 0. - **The session is the layout.** `LayoutState` persists what makes a reopened scope look like the one that was closed: the window, the rows and their slot signals, the calibration, **the enabled signals**, **the chosen trace colours** (`[[color]]`, `name` + `rgb = "#RRGGBB"` — only the diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index f0e80b5..72d034d 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -1703,18 +1703,21 @@ library measures and draws the rect in tests of its own. ## `panes:` — the locked and configuration modes ``` -[udpscope] pane drag: sidebar hidden for the drag=1 restored after it=1 +[udpscope] pane drag: slide-over closed for the drag=1 [udpscope] pane edits: locked=document-edit-symbolic(ok) config=object-select-symbolic(ok) [udpscope] panes: trigger=locked signals=locked body_visible=0 summary='signal: square | condition: window, falling | level: 0.3 .. 0.6 | mode: single | noise band: 0.2 | pre-trigger: 20 % | hold-off: 1000 ms' [udpscope] panes: trigger=config signals=config body_visible=1 summary='' [udpscope] panes: the same line as the probe found it: 'mode: stream (trigger off)' ``` -**`pane drag:`** is a drag begun through the *source* with the split view forced into its **overlaid** -state, which is what a window with no room beside the plot produces. Both must read `1`: with the sidebar -covering the plot there is no drop target under the pointer, so a drag out of the signal list is -*impossible* rather than merely awkward, and `row_drag_sidebar()` steps the sidebar aside for the length of -the drag and puts it back after. `hidden=0` is an overlaid drag that can never land anywhere. +**`pane drag:`** is a drag begun through the *source* of the first signal badge (`drag-begin` emitted on its +`GtkDragSource`, so the handler is the one a real drag reaches) with the `signals` slide-over page open. It +must read `1`: the slide-over covers the plot's right edge, so a drag that started with it open would have part +of the plot that is not a place to drop, and the badge's `drag-begin` closes it. (The page that was open is put +back afterwards.) `0` is a drag that leaves the panel over the plot; `-1` is no badge to drag (no signal enabled, +or more than six, where the badges are replaced by the "N signals" overflow). It replaced the old +`sidebar hidden for the drag=1 restored after it=1` pair, which tested `row_drag_sidebar()` -- the list rows were +the drag handle and the overlaid sidebar had to step aside; both are gone. **`pane edits:`** is the two `[edit]` buttons in each mode, by name and verdict, and it exists because of a real defect: the "done" icon was `emblem-ok-symbolic`, which **Adwaita does not have** (breeze does), so @@ -2309,10 +2312,9 @@ The signals panel's frame read from the window, and in the same line the *shape* the heading and the hint the `.ui` gives it, the list the rows go in, how many rows that list holds (`rows`), how many of them are streamed signals (`signals`), how many math channels the app has (`math`), and `divider`, which is 1 when `rows == signals + 1 + math` and 0 otherwise. **`drag_sources` must be -`rows - 1`** — one per signal row, the `MATH` divider not being draggable. Dragging a signal onto a plot is -how a plot is filled, so it has to survive both pane modes: the locked list drops the tick, the menu and the -`+`, and this is the reading that says the drag did not go with them. (`0` is a list that has become a thing -you can read but not use.) `labels=1` is a `.ui` that lost +`0`**: dragging a signal onto a plot is the signal *badge's* job now (see `[sigbadges]`, which counts the +badges' own sources), and the list's rows only open the signal's page; a non-zero reading is a second, competing +drag handle on the list. `labels=1` is a `.ui` that lost one of the two labels — counted over the panel's subtree *above* the list, because the rows' own name and unit labels are the code's and the heading sits in a heading row beside the pane's `[edit]` button — `list=0` one that lost the list -- both of which otherwise show as a panel quietly missing a piece -- and **`divider=0` is the merged list that lost its `+`**, the one control that can create @@ -2666,6 +2668,19 @@ height moved while one signal was switched off and on again (`rebuild_signal_pan first signal badge opens `signal:` with the badge marked open and a second click closes it (`-1` when overflowed, there being no signal badges); `plus_opens=1` the same for the "+" (the overflow badge when overflowed) and the `signals` page. +``` +[sigbadges] drag_sources=4 drop_targets=4 prepare_ok=1 reorder_before=sine,square,ramp,noise reorder_after=noise,sine,square,ramp reorder_ok=1 payload_drop=1 list_drag=0 +``` + +The second line is the drag handles. `drag_sources` and `drop_targets` count the `GtkDragSource` / `GtkDropTarget` controllers over all the +signal badges (each badge carries one of each, so both must equal `badges`; beyond six signals the badges are gone and both read 0). `prepare_ok=1`: the first badge's +`prepare` returns a provider whose string is its signal index (the payload the plot area's drop target reads; `0` is a drag that carries nothing or the wrong +signal, `-1` no badge). `reorder_before` / `reorder_after` are the badge names in widget order around a call to the badges' own `drop` handler with the last badge dropped on +the first; `reorder_ok=1` means the last badge is now first, `badge_order` matches the widgets, and the original order was restored afterwards (so later probes see it; +`-1` with fewer than two badges). `payload_drop=1`: the signal is taken off the plots and `assign_signal_to_row(signal, 0)`, the call the plot's drop makes, leaves it plotted +(`-1` no badge). `list_drag` is the number of drag sources on the signal list's first row, and **must be 0**. **A real pointer drag cannot be simulated headless**: the +gesture's threshold, the slide-over closing under it and the pointer-driven drop are untested here; the line reads the controllers, the payload, the reorder and the assignment. + ## `[badges]` ``` diff --git a/src/ui.cpp b/src/ui.cpp index e6705fb..7129ece 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -4634,25 +4634,6 @@ void on_pane_edit_clicked(GtkButton* button, gpointer user) { apply_pane_lock(*app); } -/// Hides the sidebar for the length of a drag, when it is *overlaid*, and restores it after. -/// -/// With the split view collapsed the sidebar covers the plot -- that is what collapsed means -- so a drag -/// that starts in the signal list has nowhere to land: the pointer is over the sidebar and never over a -/// drop target, and the drag is impossible rather than merely awkward. Side by side nothing is done: the -/// plot is already visible, and hiding the list the drag started in would be startling. -void row_drag_sidebar(App& app, bool dragging) { - if (app.side_view == nullptr) return; - AdwOverlaySplitView* split = ADW_OVERLAY_SPLIT_VIEW(app.side_view); - if (dragging) { - if (!adw_overlay_split_view_get_collapsed(split)) return; - app.sidebar_hidden_for_drag = adw_overlay_split_view_get_show_sidebar(split) != FALSE; - if (app.sidebar_hidden_for_drag) adw_overlay_split_view_set_show_sidebar(split, FALSE); - } else if (app.sidebar_hidden_for_drag) { - app.sidebar_hidden_for_drag = false; - adw_overlay_split_view_set_show_sidebar(split, TRUE); - } -} - void run_app_action(App& app, AppAction action) { const auto toggle = [](GtkWidget* button) { if (button == nullptr) return; @@ -6223,8 +6204,8 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit app.window_limit_s); { GtkIconTheme* theme = gtk_icon_theme_get_for_display(gdk_display_get_default()); - std::printf("[udpscope] pane drag: sidebar hidden for the drag=%d restored after it=%d\n", - app.dbg_drag_hid_sidebar, app.dbg_drag_restored_sidebar); + std::printf("[udpscope] pane drag: slide-over closed for the drag=%d\n", + app.dbg_drag_closed_slideover); std::printf("[udpscope] pane edits: locked=%s(%s) config=%s(%s)\n", app.dbg_pane_edit_icon.c_str(), gtk_icon_theme_has_icon(theme, app.dbg_pane_edit_icon.c_str()) ? "ok" : "MISSING", diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 85d5896..277728a 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -531,9 +531,6 @@ struct App { /// True while `sync_panel_switches()` is writing the switches: setting a `GtkSwitch` emits its own /// `notify::active`, and without this the program's own write would come back as a click. bool panel_switch_sync = false; - /// True while a drag has the overlaid sidebar hidden: what it was showing before, so `drag-end` puts it - /// back rather than guessing. - bool sidebar_hidden_for_drag = false; /// The channel a session's trigger names, until `rebuild_trigger_signals()` can resolve it: the /// file stores a *name* and the trigger needs an index into the acquired channels, which do not /// exist until the stream has been described. Empty means "nothing pending". @@ -1166,11 +1163,11 @@ struct App { /// of them are streamed signals. The `MATH` divider and the math channels account for the rest, and /// `rows == signals + 1 + math` is the invariant the dump checks -- a list that lost its `+`. int dbg_signal_rows = 0; - /// How many rows of the list carry a `GtkDragSource`: `rows - 1`, the divider not being draggable. + /// How many rows of the list carry a `GtkDragSource`: 0 -- the signal badges are the drag handles now. int dbg_signal_drag_sources = -1; - /// The drag-with-an-overlaid-sidebar pair: the sidebar hid for the drag, and came back after it. - int dbg_drag_hid_sidebar = -1; - int dbg_drag_restored_sidebar = -1; + /// A drag begun through the first signal badge's source closed the open slide-over (1), or did not (0); + /// -1 when there is no badge to drag. + int dbg_drag_closed_slideover = -1; std::size_t dbg_signal_plain = 0; int dbg_side_column_after_hide = 1; std::string dbg_mode_triggered; @@ -1852,10 +1849,6 @@ const char* trigger_mode_text(TriggerMode m); TriggerMode trigger_mode_from_text(const std::string& s); /// The panel's controls from the model, and the session's trigger into both. void write_trigger_widgets(App& app); -/// Hides the sidebar for the length of a drag when it is *overlaid*, and puts it back after. With the -/// split view collapsed the sidebar covers the plot, so a drag that starts in the signal list has nowhere -/// to land: the pointer is over the sidebar, not over a drop target. -void row_drag_sidebar(App& app, bool dragging); /// The compact line the trigger pane shows while it is locked, and the lock itself: the panes' /// bodies, their [edit] buttons and the signal list's rows all follow from `trigger_locked` and /// `signals_locked`. diff --git a/src/ui_badges.cpp b/src/ui_badges.cpp index 6bc5fe5..f53072f 100644 --- a/src/ui_badges.cpp +++ b/src/ui_badges.cpp @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -78,6 +79,40 @@ void make_badge(App& app, Badge& b, GtkWidget* parent, const char* name, const s gtk_box_append(GTK_BOX(parent), b.button); } +/// The reorder: widgets are moved, never rebuilt, so neither the dragged badge nor this target (whose "drop" +/// emission is running) is destroyed under the drag. The payload is the signal index as a decimal string (the +/// plot area's drop target reads the same one); anything else a foreign drag might carry is refused. +gboolean on_badge_drop(GtkDropTarget*, const GValue* value, double, double, gpointer user) { + Badge* target = static_cast(user); + App* app = static_cast(g_object_get_data(G_OBJECT(target->button), "udpscope-app")); + if (app == nullptr || value == nullptr || !G_VALUE_HOLDS_STRING(value)) return FALSE; + const char* text = g_value_get_string(value); + if (text == nullptr || *text == '\0') return FALSE; + char* end = nullptr; + const unsigned long long parsed = std::strtoull(text, &end, 10); + if (end == nullptr || *end != '\0') return FALSE; + const std::size_t signal = static_cast(parsed); + Badge* moved = nullptr; + for (auto& b : app->badges.signal_badges) { + if (b->signal == signal) moved = b.get(); + } + if (moved == nullptr) return FALSE; + if (moved == target) return TRUE; + GtkWidget* prev = gtk_widget_get_prev_sibling(target->button); + if (prev != moved->button) { // already directly in front: nothing to move + gtk_box_reorder_child_after(GTK_BOX(app->badges.signals_row), moved->button, prev); + } + // The order, as the widgets now stand. + app->badge_order.clear(); + for (GtkWidget* c = gtk_widget_get_first_child(app->badges.signals_row); c != nullptr; + c = gtk_widget_get_next_sibling(c)) { + for (auto& b : app->badges.signal_badges) { + if (b->button == c) app->badge_order.push_back(b->signal_name); + } + } + return TRUE; +} + /// A signal's badge: its colour dot (grey when it has no trace) and its name. Words and a dot, no icon. void make_signal_badge(App& app, Badge& b, const std::vector& signals, std::size_t index) { b.signal = index; @@ -98,6 +133,27 @@ void make_signal_badge(App& app, Badge& b, const std::vector& signal gtk_widget_set_tooltip_text(b.button, b.signal_name.c_str()); g_object_set_data(G_OBJECT(b.button), "udpscope-app", &app); g_signal_connect(b.button, "clicked", G_CALLBACK(on_badge_clicked), &b); + + // A drag handle: the same payload the list's rows used to carry, so the plot area's drop target takes it + // unchanged. The slide-over steps aside for the drag, since it covers the plot's right edge. (A click + // without movement never reaches the drag threshold, so it still opens the page; past the threshold the + // source claims the sequence and the button's click is cancelled.) + DragContext* ctx = new DragContext{&app, index}; + GtkDragSource* source = gtk_drag_source_new(); + gtk_drag_source_set_actions(source, GDK_ACTION_COPY); + g_signal_connect_data(source, "prepare", G_CALLBACK(on_drag_prepare), ctx, + [](gpointer data, GClosure*) { delete static_cast(data); }, + G_CONNECT_DEFAULT); + g_signal_connect(source, "drag-begin", G_CALLBACK(+[](GtkDragSource*, GdkDrag*, gpointer user) { + slideover_close(*static_cast(user)); + }), + &app); + gtk_widget_add_controller(b.button, GTK_EVENT_CONTROLLER(source)); + + // Dropping another signal's badge on this one moves it in front of this one. + GtkDropTarget* drop = gtk_drop_target_new(G_TYPE_STRING, GDK_ACTION_COPY); + g_signal_connect(drop, "drop", G_CALLBACK(on_badge_drop), &b); + gtk_widget_add_controller(b.button, GTK_EVENT_CONTROLLER(drop)); } } // namespace @@ -197,7 +253,9 @@ void sync_badge_bar(App& app) { } if (bb.overflow.button != nullptr) { gtk_widget_set_sensitive(bb.overflow.button, !app.model->signals().empty()); - set_open(bb.overflow.button, slideover_page(app) == bb.overflow.page); + // The overflow stands for every signal's badge too: a signal page opened from a list row marks it. + set_open(bb.overflow.button, + slideover_page(app) == bb.overflow.page || slideover_page(app).rfind("signal:", 0) == 0); } for (const std::unique_ptr& sb : bb.signal_badges) { set_open(sb->button, slideover_page(app) == sb->page); @@ -360,6 +418,108 @@ gboolean signal_badges_probe(gpointer user) { "bar_h=%d bar_h_fixed=%d bar_min_w=%d opens=%d plus_opens=%d\n", enabled, badges, overflowed ? 1 : 0, overflow_text.c_str(), plus_visible, dot_plotted, before.first, bar_h_fixed, bar_min_w, opens, plus_opens); + + // The drag handles. Controllers are enumerated through the widget's own model. + const auto count_controllers = [](GtkWidget* w, bool (*is)(GObject*)) { + int n = 0; + GListModel* controllers = gtk_widget_observe_controllers(w); + const guint items = controllers != nullptr ? g_list_model_get_n_items(controllers) : 0; + for (guint i = 0; i < items; ++i) { + GObject* c = G_OBJECT(g_list_model_get_item(controllers, i)); + if (is(c)) ++n; + g_object_unref(c); + } + return n; + }; + const auto is_source = [](GObject* c) { return GTK_IS_DRAG_SOURCE(c) != FALSE; }; + const auto is_target = [](GObject* c) { return GTK_IS_DROP_TARGET(c) != FALSE; }; + int drag_sources = 0; + int drop_targets = 0; + for (const std::unique_ptr& sb : bb.signal_badges) { + drag_sources += count_controllers(sb->button, is_source); + drop_targets += count_controllers(sb->button, is_target); + } + // The payload: what a drag from the first badge carries, read back from the provider `prepare` returns. + int prepare_ok = -1; + if (!bb.signal_badges.empty()) { + prepare_ok = 0; + GListModel* controllers = gtk_widget_observe_controllers(bb.signal_badges.front()->button); + const guint items = controllers != nullptr ? g_list_model_get_n_items(controllers) : 0; + for (guint i = 0; i < items; ++i) { + GObject* c = G_OBJECT(g_list_model_get_item(controllers, i)); + if (GTK_IS_DRAG_SOURCE(c)) { + GdkContentProvider* provider = nullptr; + g_signal_emit_by_name(c, "prepare", 0.0, 0.0, &provider); + if (provider != nullptr) { + GValue v = G_VALUE_INIT; + g_value_init(&v, G_TYPE_STRING); + if (gdk_content_provider_get_value(provider, &v, nullptr) && g_value_get_string(&v) != nullptr && + std::to_string(bb.signal_badges.front()->signal) == g_value_get_string(&v)) { + prepare_ok = 1; + } + g_value_unset(&v); + g_object_unref(provider); + } + } + g_object_unref(c); + } + } + + // The reorder, by the drop handler the targets run: the last badge dropped on the first goes in front of it, + // and the remembered order follows the widgets. Then the order is put back as it was. + const auto widget_names = [&] { + std::string names; + for (GtkWidget* c = gtk_widget_get_first_child(bb.signals_row); c != nullptr; c = gtk_widget_get_next_sibling(c)) { + for (const std::unique_ptr& sb : bb.signal_badges) { + if (sb->button == c) names += (names.empty() ? "" : ",") + sb->signal_name; + } + } + return names; + }; + std::string reorder_before = "-"; + std::string reorder_after = "-"; + int reorder_ok = -1; + if (bb.signal_badges.size() >= 2) { + const std::vector saved_order = a->badge_order; + reorder_before = widget_names(); + Badge& first = *bb.signal_badges.front(); + Badge& last = *bb.signal_badges.back(); + GValue v = G_VALUE_INIT; + g_value_init(&v, G_TYPE_STRING); + g_value_set_string(&v, std::to_string(last.signal).c_str()); + const gboolean handled = on_badge_drop(nullptr, &v, 0.0, 0.0, &first); + g_value_unset(&v); + reorder_after = widget_names(); + std::string order; + for (const std::string& n : a->badge_order) order += (order.empty() ? "" : ",") + n; + GtkWidget* head = gtk_widget_get_first_child(bb.signals_row); + reorder_ok = handled && head == last.button && order == reorder_after && reorder_after != reorder_before ? 1 : 0; + // Back to the original place: the moved badge goes after the one that was last, and the order is restored. + gtk_box_reorder_child_after(GTK_BOX(bb.signals_row), last.button, bb.signal_badges[bb.signal_badges.size() - 2]->button); + a->badge_order = saved_order; + if (widget_names() != reorder_before) reorder_ok = 0; + } + + // The drop path itself: a badge's payload lands through `assign_signal_to_row`, as the plot area's drop does. + // (The assignment rebuilds the panels, and the badges with them, so the first one is named beforehand.) + int payload_drop = -1; + int list_drag = -1; + if (!bb.signal_badges.empty()) { + const std::size_t signal = bb.signal_badges.front()->signal; + const std::string name = bb.signal_badges.front()->signal_name; + remove_signal_from_plots(*a, name); + const bool assigned = assign_signal_to_row(*a, signal, 0); + payload_drop = assigned && signal_plotted(*a, name) ? 1 : 0; + } + if (a->signal_list != nullptr) { + GtkWidget* row = gtk_widget_get_first_child(a->signal_list); + list_drag = row != nullptr ? count_controllers(row, is_source) : -1; + } + std::fprintf(stderr, + "[sigbadges] drag_sources=%d drop_targets=%d prepare_ok=%d reorder_before=%s reorder_after=%s " + "reorder_ok=%d payload_drop=%d list_drag=%d\n", + drag_sources, drop_targets, prepare_ok, reorder_before.c_str(), reorder_after.c_str(), reorder_ok, + payload_drop, list_drag); return G_SOURCE_REMOVE; } diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 59f0202..72c0fcd 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -101,10 +101,9 @@ gboolean exit_probe_step3b(gpointer user) { // the page is open and the reveal has run. a->dbg_signal_w = gtk_widget_get_width(a->signal_panel); a->dbg_signal_h = gtk_widget_get_height(a->signal_panel); - // How many rows of the signal list carry a drag source: dragging a signal onto a plot is how a plot is - // filled, so it has to survive both pane modes -- the locked list drops the tick, the menu and the `+`, - // and this is the reading that says it did not drop the drag with them. One row is the `MATH` divider, - // which is not draggable: the count must be `rows - 1`. + // How many rows of the signal list carry a drag source: none. Dragging a signal onto a plot is the signal + // badge's job (and survives the pane lock, since the badges do not follow it); the list's rows only open + // the signal's page. The reading says the list did not grow a second, competing handle. if (a->signal_list != nullptr) { int sources = 0; for (GtkWidget* r = gtk_widget_get_first_child(a->signal_list); r != nullptr; @@ -119,34 +118,23 @@ gboolean exit_probe_step3b(gpointer user) { } a->dbg_signal_drag_sources = sources; } - // A drag out of an *overlaid* sidebar: the sidebar has to step aside or the drop has nowhere to land. - // The state is forced the way the sidebar probe forces it -- collapsed is what a window with no room - // beside the plot produces -- and the drag is begun through the *source*, so the handler is the one a - // real drag reaches. - if (a->side_view != nullptr && a->signal_list != nullptr) { - AdwOverlaySplitView* split = ADW_OVERLAY_SPLIT_VIEW(a->side_view); - GtkWidget* row = gtk_widget_get_first_child(a->signal_list); - adw_overlay_split_view_set_collapsed(split, TRUE); - adw_overlay_split_view_set_show_sidebar(split, TRUE); - if (row != nullptr) { - GListModel* controllers = gtk_widget_observe_controllers(row); - const guint n = controllers != nullptr ? g_list_model_get_n_items(controllers) : 0; - for (guint i = 0; i < n; ++i) { - GObject* c = G_OBJECT(g_list_model_get_item(controllers, i)); - if (GTK_IS_DRAG_SOURCE(c)) { - g_signal_emit_by_name(c, "drag-begin", nullptr); - a->dbg_drag_hid_sidebar = - adw_overlay_split_view_get_show_sidebar(split) == FALSE ? 1 : 0; - row_drag_sidebar(*a, false); - a->dbg_drag_restored_sidebar = - adw_overlay_split_view_get_show_sidebar(split) != FALSE ? 1 : 0; - } - g_object_unref(c); + // A drag out of a badge with a slide-over open: the panel covers the plot's right edge, so it has to step + // aside or part of the plot is not a place to drop. The drag is begun through the *source*, so the handler + // is the one a real drag reaches; the page that was open is put back so the readings after it stand. + if (!a->badges.signal_badges.empty() && slideover_has_page(*a, "signals")) { + const std::string was = slideover_page(*a); + slideover_open(*a, "signals"); + GListModel* controllers = gtk_widget_observe_controllers(a->badges.signal_badges.front()->button); + const guint n = controllers != nullptr ? g_list_model_get_n_items(controllers) : 0; + for (guint i = 0; i < n; ++i) { + GObject* c = G_OBJECT(g_list_model_get_item(controllers, i)); + if (GTK_IS_DRAG_SOURCE(c)) { + g_signal_emit_by_name(c, "drag-begin", nullptr); + a->dbg_drag_closed_slideover = slideover_page(*a).empty() ? 1 : 0; } + g_object_unref(c); } - // Left as the window's own width decides, so the readings taken before this are the ones that stand. - adw_overlay_split_view_set_collapsed(split, FALSE); - adw_overlay_split_view_set_show_sidebar(split, TRUE); + if (!was.empty()) slideover_open(*a, was); } // The panes' own switches, and *both* directions of the sync: the header bar's trigger button is // flipped here and the pane's switch has to follow, then the cursors' switch is driven from the diff --git a/src/ui_signals.cpp b/src/ui_signals.cpp index 93ee7fa..b2f36bc 100644 --- a/src/ui_signals.cpp +++ b/src/ui_signals.cpp @@ -302,7 +302,7 @@ void rebuild_signal_list(App& app, const std::vector& signals) { // One line per *signal*, not per channel. A group's model holds each signal once per event; a list // that showed both would name one "sine (A)" and the other "sine (B)" as if they were different - // things, and give them different colours. What the row toggles, colours and drags is the signal. + // things, and give them different colours. What the row toggles and colours is the signal. std::vector rows; // The `MATH` divider goes in before the first math row, and once only: `append_math_signals_locked()` // has already put every math channel in `signals()`, so the boundary is the one thing the deleted @@ -350,8 +350,8 @@ void rebuild_signal_list(App& app, const std::vector& signals) { g_signal_connect(pick, "pressed", G_CALLBACK(+[](GtkGestureClick* g, int, double, double, gpointer user) { App* a = static_cast(user); - // Claimed, so the row's `GtkDragSource` cannot take the same sequence - // and turn a click on the chip into the start of a drag. + // Claimed, so the same click does not also activate the row (which would open + // the signal's page). gtk_gesture_set_state(GTK_GESTURE(g), GTK_EVENT_SEQUENCE_CLAIMED); open_signal_color(*a, static_cast(GPOINTER_TO_SIZE( g_object_get_data(G_OBJECT(g), @@ -466,25 +466,7 @@ void rebuild_signal_list(App& app, const std::vector& signals) { gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box); // Activating the row opens the signal's page; the row resolves its identity the way its widgets do. g_object_set_data(G_OBJECT(row), "udpscope-signal", GSIZE_TO_POINTER(i)); - - DragContext* ctx = new DragContext{&app, i}; - GtkDragSource* source = gtk_drag_source_new(); - gtk_drag_source_set_actions(source, GDK_ACTION_COPY); - g_signal_connect_data(source, "prepare", G_CALLBACK(on_drag_prepare), ctx, - [](gpointer data, GClosure*) { delete static_cast(data); }, - G_CONNECT_DEFAULT); - // A drag out of an *overlaid* sidebar would have nowhere to land (see `row_drag_sidebar()`), so the - // sidebar steps out of the way for as long as the drag lasts. - g_signal_connect(source, "drag-begin", G_CALLBACK(+[](GtkDragSource*, GdkDrag*, gpointer user) { - row_drag_sidebar(*static_cast(user), true); - }), - &app); - g_signal_connect(source, "drag-end", - G_CALLBACK(+[](GtkDragSource*, GdkDrag*, gboolean, gpointer user) { - row_drag_sidebar(*static_cast(user), false); - }), - &app); - gtk_widget_add_controller(row, GTK_EVENT_CONTROLLER(source)); + // Dragging a signal onto a plot is the signal badge's job (`make_signal_badge`): the row opens the page. gtk_list_box_append(GTK_LIST_BOX(list), row); } From f315c677f3b28003b35062eae4672fda3aec81c2 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 01:37:31 +0100 Subject: [PATCH 072/127] ui: reword the docs that still spoke of the list drag; keep disabled signals' badge order; stamp the signal generation before the snapshot Co-Authored-By: Claude Opus 4.6 --- README.md | 9 ++++----- help/C/org.udpscope.app/layout.page | 2 +- help/C/org.udpscope.app/overview.page | 2 +- src/ui/signals-panel.ui | 2 +- src/ui_badges.cpp | 15 ++++++++++++--- src/ui_signals.cpp | 5 ++++- 6 files changed, 23 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index d05ad08..32ffef5 100644 --- a/README.md +++ b/README.md @@ -2931,11 +2931,10 @@ now — they are listed so they are not mistaken for anything else: controls** are pages of the slide-over, reached from the badge bar. Both panes also have a **locked** mode, which 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 - to switch to the full pane, and back. Dragging a signal onto a plot still works while locked -- filling - plots is what the list is for, not configuration -- and a locked row can be dragged from anywhere on it, - the colour chip included. In a window too narrow to show both, where the dock is *overlaid on* the - plot, the dock steps aside for the length of the drag so there is something under the pointer to drop - onto. A **first run** (no session file for that source) comes up + to switch to the full pane, and back. Dragging a signal's badge onto a plot still works while locked -- + filling plots is not configuration. A signal's badge is dragged onto a plot, or onto another badge to + reorder the badges (the order is kept for the session only); the slide-over closes for the length of the + drag so there is something under the pointer to drop onto. A **first run** (no session file for that source) comes up unlocked, because configuring the host is what a first run is for. Each pane's heading also carries its **own switch**, the same control as the header bar's button -- clicking either moves both -- because a pane that is *disabled* while its feature is off is a pane whose controls you would otherwise have to leave to turn it back on. With the math diff --git a/help/C/org.udpscope.app/layout.page b/help/C/org.udpscope.app/layout.page index f5183c3..d6f6663 100644 --- a/help/C/org.udpscope.app/layout.page +++ b/help/C/org.udpscope.app/layout.page @@ -24,7 +24,7 @@ How to reach it -

Drag a signal's chip out of the side panel (s) and drop it on a row to +

Drag a signal's badge from the badge bar onto a row to add a trace.

diff --git a/help/C/org.udpscope.app/overview.page b/help/C/org.udpscope.app/overview.page index 43c2d4d..fd605c2 100644 --- a/help/C/org.udpscope.app/overview.page +++ b/help/C/org.udpscope.app/overview.page @@ -92,7 +92,7 @@ derived from it; Signals holds the trigger and the channel list, where a math channel is a row of that one list under a MATH heading whose + creates one. One row per channel: its colour, an enable switch, its name - and its unit. Channels are dragged from here onto a plot row.

+ and its unit. A channel's badge in the badge bar is dragged onto a plot row.

The plot diff --git a/src/ui/signals-panel.ui b/src/ui/signals-panel.ui index b79a8f8..66fc09a 100644 --- a/src/ui/signals-panel.ui +++ b/src/ui/signals-panel.ui @@ -53,7 +53,7 @@ 6 - drag a signal onto a plot + click a signal for its page; drag its badge onto a plot start 1 dim-label diff --git a/src/ui_badges.cpp b/src/ui_badges.cpp index f53072f..0e36e4c 100644 --- a/src/ui_badges.cpp +++ b/src/ui_badges.cpp @@ -102,14 +102,23 @@ gboolean on_badge_drop(GtkDropTarget*, const GValue* value, double, double, gpoi if (prev != moved->button) { // already directly in front: nothing to move gtk_box_reorder_child_after(GTK_BOX(app->badges.signals_row), moved->button, prev); } - // The order, as the widgets now stand. - app->badge_order.clear(); + // The order, as the widgets now stand; the names that are not shown (signals switched off) keep their old + // relative order after the visible ones, so switching one back on finds it where it was remembered. + std::vector order; for (GtkWidget* c = gtk_widget_get_first_child(app->badges.signals_row); c != nullptr; c = gtk_widget_get_next_sibling(c)) { for (auto& b : app->badges.signal_badges) { - if (b->button == c) app->badge_order.push_back(b->signal_name); + if (b->button == c) order.push_back(b->signal_name); } } + const std::size_t shown_count = order.size(); + for (const std::string& n : app->badge_order) { + if (std::find(order.begin(), order.begin() + static_cast(shown_count), n) == + order.begin() + static_cast(shown_count)) { + order.push_back(n); + } + } + app->badge_order = std::move(order); return TRUE; } diff --git a/src/ui_signals.cpp b/src/ui_signals.cpp index b2f36bc..0a1cc28 100644 --- a/src/ui_signals.cpp +++ b/src/ui_signals.cpp @@ -569,6 +569,9 @@ void rebuild_signal_panels(App& app) { // GTK calls (a use-after-free of the name strings otherwise). std::vector signals; bool have_signals = false; + // The generation this build answers to is read *before* the snapshot: a CONFIG landing between the two + // would otherwise be stamped as seen without having been, and the tick would never rebuild for it. + const unsigned generation = app.model->generation(); { std::lock_guard lock(app.model->mutex()); signals = app.model->signals(); @@ -585,7 +588,7 @@ void rebuild_signal_panels(App& app) { } signal_pages_sync(app); sync_signal_badges(app); - app.signal_generation = app.model->generation(); + app.signal_generation = generation; } GtkWidget* build_signal_panel(App& app) { From 5242babf993d99f1f8f3b98af355e6d1de6e317b Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 01:48:43 +0100 Subject: [PATCH 073/127] docs: the signal badges, the signal pages and the six-badge overflow (step 2 of the redesign) Also wrap the signals page's hint line, which at 375 px minimum widened the whole slide-over panel to 376. Co-Authored-By: Claude Opus 4.6 --- README.md | 26 ++++++++++++++++------- docs/claude/architecture.md | 14 +++++++----- docs/claude/invariants-gtk-and-chrome.md | 2 +- docs/claude/invariants-plot-and-layout.md | 15 +++++++++++-- docs/claude/probe-readings.md | 5 +++-- docs/claude/verification.md | 5 +++++ src/ui/signals-panel.ui | 2 ++ src/ui_tour.cpp | 7 +++--- 8 files changed, 55 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 32ffef5..8f3f433 100644 --- a/README.md +++ b/README.md @@ -98,11 +98,20 @@ src/layout_store.* the per-source session layout file (TOML), written and read b src/event_series.hpp an immutable captured event as a plot::data::Series src/ui.* the GTK4 window: toolbar, trigger form, signal list, plot box, the dock src/ui_slideover.cpp the slide-over panel over the plot's right edge, one page at a time -src/ui_badges.cpp the badge bar under the plot: the Trigger and Source badges +src/ui_badges.cpp the badge bar under the plot: the signal badges, the + badge, Trigger and Source +src/ui_signal_page.cpp the per-signal slide-over pages (`signal:`) ``` -**The window.** The plot is the window. Under it sits a fixed-height **badge bar** (36 px) whose -right-hand end carries two words-only badges: **Trigger** (`off`, `armed`, `capturing`, `triggered` +**The window.** The plot is the window. Under it sits a fixed-height **badge bar** (36 px). Its left +part is the **signals**: one badge per *enabled* signal (a dot in the signal's colour, filled while the +signal is plotted, and its name), then a `+` badge that opens the full signal list (`g` does the same). +A signal badge opens that signal's own page in the slide-over -- colour, calibration and the *Plot on N* +buttons -- and is also the drag handle: drag it onto a plot to draw the signal there, or onto another +badge to reorder them. Past **six** signal badges the bar shows a single **"N signals"** badge in their +place (it opens the list, and `+` is hidden): the E-1608's 8 analog inputs and counter would not fit +otherwise. The signal badges sit in a scroller, so the bar's minimum width, and with it the window's, +never depends on how many signals there are. The badge order is kept **for the session only**; it is not +written to the session file. The right-hand end carries two words-only badges: **Trigger** (`off`, `armed`, `capturing`, `triggered` or `disarmed`) and **Source** (the kind's name and `connected`/`disconnected`; a read-out, not a button, for a source with no controls of its own, and for a file). A badge opens the **slide-over**: a 360 px panel laid over the plot's right edge, opaque, with its own title, a *Close* button and one scroller per @@ -110,9 +119,9 @@ page. The Trigger badge opens the trigger form; the Source badge opens the sourc (the E-1608's *Analog out* and *Digital I/O*, behind a segmented switch at the top of the panel). It closes with a second click on its badge, with `Esc`, with *Close*, or with a click on the plot; it never resizes the plot. The right-hand **dock** (the existing sidebar, 320 px) holds what is left of the old -tabs in one scroller: the signal list, the cursors and the measurements, shown from the start and -insensitive until the stream has signals. It is temporary -- see "Review findings not (yet) -implemented" for what the redesign still has to do with it. +tabs in one scroller: the cursors and the measurements, shown from the start and insensitive until +the stream has signals (the signal list moved to the `signals` slide-over page). It is temporary -- see +"Review findings not (yet) implemented" for what the redesign still has to do with it. Threading: one producer thread owns the data source (the UDPStreamer client is the built-in one) and calls `poll()`; the GTK main thread owns every widget and the GL contexts. @@ -2950,8 +2959,9 @@ now — they are listed so they are not mistaken for anything else: dock's *shown* state only, not a width -- the dock's width is fixed, so there is nothing to persist -- and does not store the open slide-over page. (3) The **Trigger badge** shows `off`, `armed` and `triggered` as the spec says, and two more words, `capturing` (a post-trigger window is filling) and - `disarmed` (trigger on, not armed), so the label never claims a state the engine is not in. Still to - come, in the spec's order: the signal badges and drag (2), the compact readouts (3), the events + `disarmed` (trigger on, not armed), so the label never claims a state the engine is not in. The signal badges, the + signal pages and the drag (step 2) are done. Still to + come, in the spec's order: the compact readouts (3), the events section in the dock, the deletion of the left pane and the Events toggle, and `USER-GUIDE.md` and the manual's figures (4). Until then the events pane, the header's Events toggle and the manual still describe the previous layout. diff --git a/docs/claude/architecture.md b/docs/claude/architecture.md index 67454b7..10319af 100644 --- a/docs/claude/architecture.md +++ b/docs/claude/architecture.md @@ -99,19 +99,23 @@ touching the data path. - **`SlideOver`** (`src/ui_slideover.cpp`) is one `GtkRevealer` holding a `GtkStack`, added as the second child placed *over* the plot (after the layout editor's buttons) in `GtkOverlay#plot_overlay`. A page is a `slideover_add_page(app, id, title, widget)`: the widget is wrapped in a `GtkScrolledWindow` of its - own and registered under its id (`trigger`, `source-`; `signal:`, `signals`, `measure`, - `cursors` and `events` are reserved for later steps). One page is open at a time and `App::slideover.page` + own and registered under its id (`trigger`, `source-`, `signals` (the signal list) and + `signal:` (one signal's page, `src/ui_signal_page.cpp`, created on demand by `signal_page_ensure()` + and pruned by `signal_pages_sync()`, which `rebuild_signal_panels()` ends with); `measure`, `cursors` + and `events` are reserved for later steps). One page is open at a time and `App::slideover.page` is its id, empty when closed. The open page is **not** a session value. Esc, *Close*, a second click on the badge and a click on the plot close it; the click-away is a capture-phase observer (see [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). With two or more `source-*` pages, an `AdwToggleGroup` under the header is the segmented switch between them. - **`BadgeBar`** (`src/ui_badges.cpp`) is the fixed-height row between the plot and the status line - (`main-window.ui` `badge_bar`, two slots: signals, left, empty until step 2; system, right, holding the - **Trigger** and **Source** badges). A badge is a flat button whose `page` is the slide-over page it + (`main-window.ui` `badge_bar`, two slots: signals, left, a scroller holding one badge per enabled signal + and the `+`, or a single "N signals" badge past six; system, right, holding the **Trigger** and + **Source** badges). `sync_signal_badges()` rebuilds the signal badges (from `rebuild_signal_panels()`); + their order is `App::badge_order`, in memory only. A badge is a flat button whose `page` is the slide-over page it toggles; the Source badge's page is the first `source-*` page, and a kind with none (UDPS, Flat UDP, a file) leaves it a read-out with `can_target` off. - **The dock** is the `AdwOverlaySplitView` sidebar (`side_box` > `dock_scroller` > `dock_page`): the - signal list, the cursors and the measurements in one scroller. It holds no tab switcher and no + cursors and the measurements only, in one scroller (the signal list is the `signals` page). It holds no tab switcher and no revealer, and it does not hold a source kind's panels (those are slide-over pages). **When each is built and when it is read.** `build_slideover()` runs in `on_activate` right after the tour diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md index 2d630e3..deb5551 100644 --- a/docs/claude/invariants-gtk-and-chrome.md +++ b/docs/claude/invariants-gtk-and-chrome.md @@ -225,7 +225,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear take room has to be hidden and not merely unrevealed; the revealer keeps the slide while the pane *is* open. The allocated width is how this was found -- the revealer's own measure said 0. **The sidebar is one dock now, and has no `GtkRevealer` left in it**: a single `GtkScrolledWindow` - (`dock_scroller`) holding the signal list, the cursors and the measurements one under another. It + (`dock_scroller`) holding the cursors and the measurements one under another. It replaced an `AdwViewSwitcher` over an `AdwViewStack`, whose four text tabs did not fit 320 px (and a source can add more); a source kind's own panels are slide-over pages, not dock sections. The reason is the one this trap teaches: sections competing for one 320 px column each left every one of them short of room, and diff --git a/docs/claude/invariants-plot-and-layout.md b/docs/claude/invariants-plot-and-layout.md index b96155d..52cbe9b 100644 --- a/docs/claude/invariants-plot-and-layout.md +++ b/docs/claude/invariants-plot-and-layout.md @@ -110,7 +110,9 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear does not propagate its child's height), and content that is taller than the plot -- the trigger form on a 470 px window -- scrolls inside the panel instead of growing it. The widgets that go in a page are reparented into it; their widths must fit 360 minus the border (the trigger form measures 267 - minimum, the E-1608's panels 197). + minimum, the E-1608's panels 197). **One page wider than that widens the panel + for all of them** (the stack measures every page): the signals page's hint line once did, unwrapped, and + `[slideover] panel=` read 376 instead of 360; the label now wraps. - **The badge bar has a fixed height and sits outside the overlay.** `badge_bar` is 36 px (`height-request`) between `plot_overlay` and the status line, so the plot above it never moves when a badge's text changes (`bar_h` = `bar_h_after` in `[badges]`). This is "layout must not depend on @@ -119,8 +121,17 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear spare on the right) and never its height. The badge labels are not ellipsized: a name long enough to need it would be a defect of the kind's `display_name()`. Rows that appear by trigger condition appear inside the slide-over's scroller, never in the bar. +- **The signal badges live in a scroller, so the bar's minimum width never depends on the signal count.** + The bar's signals slot holds a `GtkScrolledWindow` (horizontal `EXTERNAL`, vertical `NEVER`) around one + row of badges and the `+`, because a button row's minimum width is the sum of its children: nine badges + (the E-1608: eight inputs and the counter) would push the window's minimum width past a laptop screen. + Past `kMaxSignalBadges` (6) the row is replaced by one "N signals" badge, which also stands for the `+`. + The bar's height stays 36 px in every one of these states (`bar_h_fixed` in `[sigbadges]`), and + `bar_min_w` reads the same with one signal, nine signals and the overflow (329 on the E-1608, 336 on UDPS: + the Source badge of each kind reads differently). A change that makes it grow with the signal count has + taken the scroller out of the measurement. - **The dock's width is fixed too** (`side_column` `width-request` 300, the split view's 320 px floor - and cap), and it holds one scroller with the signal list, the cursors and the measurements. A + and cap), and it holds one scroller with the cursors and the measurements. A section's visibility is never what makes the dock taller; the scroller is what absorbs the extra. - **Trace slots are identified by signal name, not channel index.** Enabling a signal or a new CONFIG renumbers the channel list, so an index would silently re-point a diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 72d034d..143253c 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -987,7 +987,8 @@ two also catch a row whose accelerator went missing. `settings=` must be a real in the `[helpui]` entry above, where `raw`, `target` and `inside` are explained). `clear=0` is read as geometry, not as a placement bug, on the plot stop (the target is the whole content) and on the trigger stop at the documented 756 px window (the panel is 360 px of it, so the card has no side wide enough and goes flush - left over the plot, overlapping the panel's edge by a few pixels). + left over the plot, overlapping the panel's edge by a few pixels), and on the `signals` stop for the same + reason (the dock beside it is 320 px and the card has no side wide enough). - `[toursurface] finished: running=0 slideover=none` -- the last Next ended the walk and **the panel is closed with it**. - `[toursurface] restarted at trigger: running=1 slideover=trigger` then @@ -2664,7 +2665,7 @@ signals list while the title and the badge marker still name `trigger`; `-1` whe (`kMaxSignalBadges`); beyond that `badges=0 overflow=1 overflow_text= signals plus_visible=0` (the "N signals" badge replaces the badges and the "+"). `dot_plotted` is the number of badges whose signal is on a plot (its dot is then the trace colour, not the unplotted grey). `bar_h` is the bar's allocated height and `bar_h_fixed=1` that neither it nor its natural height moved while one signal was switched off and on again (`rebuild_signal_panels()` each time), and that the badge count followed; `0` is a bar whose height depends on its content. -`bar_min_w` is only a record: the scroller must keep it the same however many signals there are (it reads 336 with the mock's 4 and with the overflow forced on). `opens=1`: clicking the +`bar_min_w` is only a record: the scroller must keep it the same however many signals there are (it reads 336 with the UDPS mock's 4 and with the overflow forced on, and 329 against the E-1608 mock with one signal and with nine -- the overflow, `enabled=9 badges=0 overflow=1 overflow_text=9 signals plus_visible=0 bar_h=36 bar_h_fixed=1`; a value that grows with the signal count is a scroller that has stopped constraining the bar). `opens=1`: clicking the first signal badge opens `signal:` with the badge marked open and a second click closes it (`-1` when overflowed, there being no signal badges); `plus_opens=1` the same for the "+" (the overflow badge when overflowed) and the `signals` page. diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 03001da..7c749b1 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -447,6 +447,11 @@ precedent, not an exception. read `click_inside_closes=0 still_open=1 click_outside_closes=1 esc_closes=1 closed=1`. For a screenshot add `UDPSCOPE_PROBE_SLIDEOVER_HOLD=1`, which leaves the page open after line 1 (run with `GSK_RENDERER=cairo` and `import -window root`): the panel must be drawn over the traces, opaque. +- **Slide-over and page probes (`SLIDEOVER`, `BADGES`, `SIGNAL_PAGES`, `SIGNAL_BADGES`) need a config that has already seen + the first-run tour**: a fresh `XDG_CONFIG_HOME` auto-starts the tour at about 1.5 s, and a tour stop with no page closes the + slide-over under the probe. Seed it with `mkdir -p $D/udpscope && printf 'tour_seen = true\n' > $D/udpscope/settings.toml` + (or one short throw-away run, which writes the key when the tour starts) and run with `XDG_CONFIG_HOME=$D`. Delete the + per-source subdirectory (`$D/udpscope/_`) between runs that must start from a first-run layout. - `UDPSCOPE_PROBE_SIGNAL_PAGES_MS=1500` with `UDPSCOPE_EXIT_AFTER_MS=5000` and `UDPSCOPE_ENABLE_ALL_MS=500` -- the per-signal `signal:` slide-over pages. **Against a config that has already seen the tour** (a first run's auto-tour closes the slide-over at about 1.5 s, which is when this probe opens its page). One `[signalpage]` line; diff --git a/src/ui/signals-panel.ui b/src/ui/signals-panel.ui index 66fc09a..33c775a 100644 --- a/src/ui/signals-panel.ui +++ b/src/ui/signals-panel.ui @@ -55,6 +55,8 @@ click a signal for its page; drag its badge onto a plot start + 0 + 1 1 dim-label diff --git a/src/ui_tour.cpp b/src/ui_tour.cpp index a17f297..9085d63 100644 --- a/src/ui_tour.cpp +++ b/src/ui_tour.cpp @@ -445,9 +445,10 @@ constexpr TourStep kTourSteps[] = { "zooms, a drag pans, and a right-click on a row opens that plot's own menu.", &App::area_overlay}, {"badges", nullptr, "The badges", - "Setup is reached by the thing it configures. The Trigger badge opens the trigger form; the " - "Source badge opens the source's own controls. Click it again, press Esc or click the plot to " - "close the panel.", + "Setup is reached by the thing it configures. A badge per signal opens that signal's colour, " + "calibration and plot; the + opens the full list; Trigger and Source open their own forms. Drag a " + "signal badge onto a plot to draw it there, or onto another badge to reorder. Click again, press " + "Esc or click the plot to close the panel.", &App::badge_bar}, {"trigger", "trigger", "The trigger", "Describe an event -- a signal, a condition, a level and the window around it -- and arm it: " From dad424f1def7eb6f1daaf519457a0564a3375c2f Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 01:53:09 +0100 Subject: [PATCH 074/127] ui: read the signal count under the lock in the badge bar; validate the drop payload --- src/ui.cpp | 10 ++++++++-- src/ui_badges.cpp | 9 +++++++-- src/ui_signals.cpp | 6 +++++- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/ui.cpp b/src/ui.cpp index 7129ece..701b09e 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -4654,10 +4654,16 @@ void run_app_action(App& app, AppAction action) { toggle(app.cursors_toggle); break; case AppAction::ShowMeasure: dock_show_section(app, "measure"); break; - case AppAction::ShowSignals: + case AppAction::ShowSignals: { // The `+` badge is insensitive without signals; so is its shortcut. - if (!app.model->signals().empty()) slideover_toggle(app, "signals"); + bool have_signals = false; + { + std::lock_guard lock(app.model->mutex()); + have_signals = !app.model->signals().empty(); + } + if (have_signals) slideover_toggle(app, "signals"); break; + } case AppAction::LayoutEditor: toggle(app.layout_toggle); break; diff --git a/src/ui_badges.cpp b/src/ui_badges.cpp index 0e36e4c..fbdffe5 100644 --- a/src/ui_badges.cpp +++ b/src/ui_badges.cpp @@ -256,12 +256,17 @@ void sync_badge_bar(App& app) { set_text_if_changed(bb.trigger.state, trigger_badge_state(enabled, armed, pending, has_event)); set_open(bb.trigger.button, slideover_page(app) == bb.trigger.page); + bool have_signals = false; + { + std::lock_guard lock(app.model->mutex()); + have_signals = !app.model->signals().empty(); + } if (bb.plus.button != nullptr) { - gtk_widget_set_sensitive(bb.plus.button, !app.model->signals().empty()); + gtk_widget_set_sensitive(bb.plus.button, have_signals); set_open(bb.plus.button, slideover_page(app) == "signals"); } if (bb.overflow.button != nullptr) { - gtk_widget_set_sensitive(bb.overflow.button, !app.model->signals().empty()); + gtk_widget_set_sensitive(bb.overflow.button, have_signals); // The overflow stands for every signal's badge too: a signal page opened from a list row marks it. set_open(bb.overflow.button, slideover_page(app) == bb.overflow.page || slideover_page(app).rfind("signal:", 0) == 0); diff --git a/src/ui_signals.cpp b/src/ui_signals.cpp index 0a1cc28..5b7c392 100644 --- a/src/ui_signals.cpp +++ b/src/ui_signals.cpp @@ -557,7 +557,11 @@ gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y // Where the drop landed decides the row: one area covers them all. const int row = row_at(*app, x, y); const char* text = g_value_get_string(value); - const std::size_t signal = static_cast(std::atoll(text ? text : "0")); + if (text == nullptr || *text == '\0') return FALSE; + char* end = nullptr; + const unsigned long long parsed = std::strtoull(text, &end, 10); + if (end == nullptr || *end != '\0') return FALSE; + const std::size_t signal = static_cast(parsed); if (!assign_signal_to_row(*app, signal, row)) return FALSE; cancel_drag_on(*app, row); return TRUE; From b98a3478ff154cf683eeb625515f2fcdbd20ef6f Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 02:14:37 +0100 Subject: [PATCH 075/127] badges: source badge shows the name only; drop the signals lock; the signals page proposes what can be added The link state is the status bar's, so the Source badge loses its state text. The signals have no lock (the badges and signal pages are direct manipulation), and the signals page lists only unacquired signals and uncomputed math channels (plus the acquired ones while the badge bar has overflowed). Co-Authored-By: Claude Opus 4.6 --- src/ui.cpp | 37 +++++++++------------ src/ui/signals-panel.ui | 37 +++++++++++---------- src/ui_app.hpp | 33 ++++++++++-------- src/ui_badges.cpp | 18 +++++----- src/ui_context_help.cpp | 13 ++++---- src/ui_layout.cpp | 9 +++-- src/ui_probe.cpp | 40 +++++++++++----------- src/ui_signal_page.cpp | 44 ++++++++++-------------- src/ui_signals.cpp | 74 +++++++++++++++++++++++++---------------- src/ui_tour.cpp | 7 ++-- 10 files changed, 164 insertions(+), 148 deletions(-) diff --git a/src/ui.cpp b/src/ui.cpp index 701b09e..d8d8555 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -4595,9 +4595,8 @@ void on_panel_switch_toggled(GObject* obj, GParamSpec*, gpointer user) { sync_panel_switches(*app); } -/// Both panes' modes, applied together: the trigger's body against its summary line, the two [edit] -/// buttons against what they would do, and the signal list rebuilt because the lock decides what a row -/// carries. Idempotent, so a caller only flips a flag and calls this. +/// The trigger pane's mode: its body against its summary line and its [edit] button against what it would +/// do. Idempotent, so a caller only flips the flag and calls this. void apply_pane_lock(App& app) { if (app.trigger_body != nullptr) gtk_widget_set_visible(app.trigger_body, !app.trigger_locked); if (app.trigger_summary != nullptr) { @@ -4614,23 +4613,16 @@ void apply_pane_lock(App& app) { gtk_widget_set_tooltip_text(button, tip.c_str()); }; edit_button(app.trigger_edit, app.trigger_locked, "the trigger"); - edit_button(app.signals_edit, app.signals_locked, "the signals"); // The lock decides whether the compact line's arm control is the one on screen: with the body shown the // body's own button is there, and two arm buttons at once would be two controls for one state. sync_arm_buttons(app); sync_trigger_summary(app); - // The list, because the lock is what decides whether a row carries a tick, a menu and the `+`. - if (app.signal_list != nullptr) rebuild_signal_panels(app); } -/// Either pane's [edit] button: one handler, because the two differ only in which flag they flip. +/// The trigger pane's [edit] button. void on_pane_edit_clicked(GtkButton* button, gpointer user) { App* app = static_cast(user); - if (GTK_WIDGET(button) == app->trigger_edit) { - app->trigger_locked = !app->trigger_locked; - } else if (GTK_WIDGET(button) == app->signals_edit) { - app->signals_locked = !app->signals_locked; - } + if (GTK_WIDGET(button) == app->trigger_edit) app->trigger_locked = !app->trigger_locked; apply_pane_lock(*app); } @@ -6150,12 +6142,12 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit static_cast(app.dbg_latches_at_disarm)); std::printf("[udpscope] window control: '%s'\n", app.dbg_window_text.c_str()); { - // The signals panel's frame, read from the window rather than from the `.ui`: the two labels and the - // list the file gives it. A `.ui` that lost one of them reads here -- `labels=1` or `list=0` -- rather + // The signals panel's frame, read from the window rather than from the `.ui`: the three labels (heading, + // hint, and the "every signal is acquired" one, hidden or not) and the list the file gives it. A `.ui` that lost one of them reads here -- `labels=1` or `list=0` -- rather // than showing as a panel quietly missing its hint. GtkWidget* panel = app.signal_list != nullptr ? gtk_widget_get_parent(app.signal_list) : nullptr; // Counted over the panel's *subtree*, not its direct children: the heading lives in a heading row - // beside the pane's [edit] button, and a count of children would read 1 whatever the file lost. + // and the hint in a box of its own, and a count of children would read 1 whatever the file lost. int labels = 0; int lists = 0; const std::function count = [&](GtkWidget* w) { @@ -6175,13 +6167,15 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit // ones, and the `MATH` divider plus the math channels account for the rest -- so // `rows == signals + 1 + math` is the invariant, and `divider=0` is a list that lost its `+`. std::printf("[udpscope] signals panel: labels=%d list=%d rows=%d signals=%zu math=%zu " - "divider=%d drag_sources=%d\n", - labels, lists, app.dbg_signal_rows, app.dbg_signal_plain, app.math.size(), + "divider=%d drag_sources=%d addable=%zu addable_ok=%d empty_label=%d\n", + labels, lists, app.dbg_signal_rows, app.dbg_signal_plain, app.dbg_signal_math_rows, app.dbg_signal_rows == - static_cast(app.dbg_signal_plain + 1 + app.math.size()) + static_cast(app.dbg_signal_plain + 1 + app.dbg_signal_math_rows) ? 1 : 0, - app.dbg_signal_drag_sources); + app.dbg_signal_drag_sources, app.dbg_signal_addable, + app.dbg_signal_plain + app.dbg_signal_math_rows == app.dbg_signal_addable ? 1 : 0, + app.dbg_signal_empty_label); } { // The events pane's frame, read from the window: the four widgets the `.ui` gives it, and the list @@ -6218,9 +6212,8 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit app.dbg_pane_view_icon.c_str(), gtk_icon_theme_has_icon(theme, app.dbg_pane_view_icon.c_str()) ? "ok" : "MISSING"); } - std::printf("[udpscope] panes: trigger=%s signals=%s body_visible=%d summary='%s'\n", - app.dbg_trigger_locked ? "locked" : "config", - app.dbg_signals_locked ? "locked" : "config", app.dbg_trigger_body_visible, + std::printf("[udpscope] panes: trigger=%s body_visible=%d summary='%s'\n", + app.dbg_trigger_locked ? "locked" : "config", app.dbg_trigger_body_visible, app.dbg_trigger_summary.c_str()); std::printf("[udpscope] panes: the same line as the probe found it: '%s'\n", app.dbg_trigger_summary_early.c_str()); diff --git a/src/ui/signals-panel.ui b/src/ui/signals-panel.ui index 33c775a..3cd8090 100644 --- a/src/ui/signals-panel.ui +++ b/src/ui/signals-panel.ui @@ -2,11 +2,13 @@ + horizontal 8 - Signals + Add a signal start 1 - - - document-edit-symbolic - center - flat - - @@ -53,7 +45,7 @@ 6 - click a signal for its page; drag its badge onto a plot + tick a signal to acquire it; click one to open its page start 0 1 @@ -63,6 +55,17 @@ + + + + every signal is acquired + start + 0 + 1 + 0 + dim-label + + none diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 277728a..a9322ee 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -535,13 +535,12 @@ struct App { /// file stores a *name* and the trigger needs an index into the acquired channels, which do not /// exist until the stream has been described. Empty means "nothing pending". std::string pending_trigger_channel; - /// The two panes' modes. *Locked* is the one a returning user gets: the trigger pane shows a line - /// that says what it is set to, the signal list shows only the signals that are on, and neither - /// offers a control that changes the configuration. A first run -- no session file for this source - /// -- starts in configuration mode, because that is what a first run is for. The flags last for the - /// session: a lock is a mode, not a setting, so it is not persisted. + /// The trigger pane's mode. *Locked* is the one a returning user gets: the pane shows a line that + /// says what it is set to and offers no control that changes the configuration. A first run -- no + /// session file for this source -- starts in configuration mode, because that is what a first run is + /// for. The flag lasts for the session: a lock is a mode, not a setting, so it is not persisted. + /// (The signals have no lock: the badges and the signal pages are direct manipulation.) bool trigger_locked = false; - bool signals_locked = false; /// The locked trigger pane's content: a small definition list, because one line cannot hold the /// signal, the condition, the level and the mode, and those are what a user reads the pane for. GtkWidget* trigger_summary = nullptr; @@ -553,14 +552,14 @@ struct App { /// pane is locked, which is the one combination where the body's own Arm button has gone with the body. GtkWidget* trigger_summary_arm = nullptr; GtkWidget* trigger_edit = nullptr; - GtkWidget* signals_edit = nullptr; + /// The dim label of the `signals` page that says nothing is left to add. + GtkWidget* signals_empty = nullptr; int dbg_trigger_locked = -1; int dbg_summary_arm_off = -1; ///< the compact line's arm control with the trigger *off* int dbg_summary_arm_visible = -1; ///< the compact line's arm control, locked and with the trigger on int dbg_summary_arm_sensitive = -1; std::string dbg_summary_arm_label; ///< its text, which is the armed state in the user's words int dbg_summary_arm_width = -1; ///< its allocation a frame later: 0 means "not laid out" - int dbg_signals_locked = -1; int dbg_trigger_body_visible = -1; /// The compact line the locked trigger pane shows. It is the pane's whole content in that mode, so it /// is read rather than assumed: empty here is a locked pane showing nothing at all. @@ -1160,7 +1159,7 @@ struct App { int dbg_trigger_items = 0; int dbg_trigger_selected = -1; /// The signal list's shape, captured where the list is built: how many rows it holds, and how many - /// of them are streamed signals. The `MATH` divider and the math channels account for the rest, and + /// of them are streamed signals. The `MATH` divider and the math rows account for the rest, and /// `rows == signals + 1 + math` is the invariant the dump checks -- a list that lost its `+`. int dbg_signal_rows = 0; /// How many rows of the list carry a `GtkDragSource`: 0 -- the signal badges are the drag handles now. @@ -1169,6 +1168,11 @@ struct App { /// -1 when there is no badge to drag. int dbg_drag_closed_slideover = -1; std::size_t dbg_signal_plain = 0; + /// The math channels among those rows, and the number of signals the page should be proposing (counted from + /// the model, not from the rows built), and whether the "every signal is acquired" label was on screen. + std::size_t dbg_signal_math_rows = 0; + std::size_t dbg_signal_addable = 0; + int dbg_signal_empty_label = -1; int dbg_side_column_after_hide = 1; std::string dbg_mode_triggered; std::string dbg_window_text; @@ -1683,6 +1687,10 @@ void rebuild_signal_list(App& app, const std::vector& signals); /// Every plottable signal once (the time base skipped, a group's layers folded to the first), in model order. /// The one rule the list and the badges share. std::vector listed_signals(const std::vector& signals); +/// Whether more signals are acquired than `kMaxSignalBadges`: the badge bar then shows one "N signals" badge, +/// and the `signals` page lists the acquired signals as well, since their rows are the only way to a page. +/// The one rule the bar and the page share. +bool signal_badges_overflowed(App& app, const std::vector& signals); /// Puts `signal` (an index into `signals()`) on plot `row` in the first free slot, switching it on first if /// need be. Says why not through `set_notice` and returns false when it cannot. The one path a drop and a /// signal page's "Plot on" button share. @@ -1699,7 +1707,7 @@ bool signal_page_ensure(App& app, const std::string& name); void signal_pages_sync(App& app); gboolean signal_page_probe(gpointer user); // UDPSCOPE_PROBE_SIGNAL_PAGES_MS /// The `MATH` divider row in the one channel list, and the `+` it carries. -GtkWidget* make_math_divider(App& app, bool with_add); +GtkWidget* make_math_divider(App& app); void on_math_add_clicked(GtkButton* button, gpointer user); /// The index in `App::math` of the channel a synthetic signal is named after, or `-1`. int math_index_for_signal(App& app, const std::string& name); @@ -1849,9 +1857,8 @@ const char* trigger_mode_text(TriggerMode m); TriggerMode trigger_mode_from_text(const std::string& s); /// The panel's controls from the model, and the session's trigger into both. void write_trigger_widgets(App& app); -/// The compact line the trigger pane shows while it is locked, and the lock itself: the panes' -/// bodies, their [edit] buttons and the signal list's rows all follow from `trigger_locked` and -/// `signals_locked`. +/// The compact line the trigger pane shows while it is locked, and the lock itself: the pane's body and +/// its [edit] button follow from `trigger_locked`. void sync_trigger_summary(App& app); /// Both arm buttons -- the panel's and the compact line's -- from the one armed state the producer keeps in diff --git a/src/ui_badges.cpp b/src/ui_badges.cpp index fbdffe5..485a84b 100644 --- a/src/ui_badges.cpp +++ b/src/ui_badges.cpp @@ -181,6 +181,9 @@ void build_badge_bar(App& app) { gtk_widget_set_can_target(bb.source.button, FALSE); gtk_widget_set_focusable(bb.source.button, FALSE); } + // The link state is the status bar's to say; the badge names the source and opens its pages. The empty + // state label is hidden, as the "+" badge's is, rather than leaving a gap in the pill. + gtk_widget_set_visible(bb.source.state, FALSE); // The signal badges sit in a scroller: a bar whose minimum width grew with the number of signals would // widen the window (layout must not depend on content). EXTERNAL scrolls by wheel/touch with no bar. GtkWidget* scroller = gtk_scrolled_window_new(); @@ -227,7 +230,7 @@ void sync_signal_badges(App& app) { bb.signal_badges.clear(); bb.overflow = Badge{}; - const bool overflowed = shown.size() > kMaxSignalBadges; + const bool overflowed = shown.size() > kMaxSignalBadges; // == signal_badges_overflowed() if (overflowed) { make_badge(app, bb.overflow, bb.signals_row, "", "signals"); gtk_widget_set_visible(bb.overflow.state, FALSE); @@ -275,18 +278,15 @@ void sync_badge_bar(App& app) { set_open(sb->button, slideover_page(app) == sb->page); } if (bb.source.button == nullptr) return; - // The source's name and link state. A file has neither a link nor a kind worth naming. + // The source's name; the link state is the status bar's. A file has no kind worth naming. const bool file_mode = app.replaying || !app.model->config().open_waveform.empty(); std::string name = "Source"; - const char* state = ""; if (file_mode) { name = "File"; - } else { - if (SourceKind* kind = find_kind(app.source_kind)) name = kind->display_name(); - state = app.model->stats().connected.load(std::memory_order_relaxed) ? "connected" : "disconnected"; + } else if (SourceKind* kind = find_kind(app.source_kind)) { + name = kind->display_name(); } set_text_if_changed(bb.source.name, name.c_str()); - set_text_if_changed(bb.source.state, state); set_open(bb.source.button, slideover_page(app).rfind("source-", 0) == 0); } @@ -348,10 +348,10 @@ gboolean badges_probe(gpointer user) { } slideover_close(*a); std::fprintf(stderr, - "[badges] source name=%s state=%s pages=%zu switch_visible=%d can_target=%d second_page=%s " + "[badges] source name=%s pages=%zu switch_visible=%d can_target=%d second_page=%s " "second_ok=%d open_marked=%d closed_unmarked=%d panel_min_w=%d\n", gtk_label_get_text(GTK_LABEL(a->badges.source.name)), - gtk_label_get_text(GTK_LABEL(a->badges.source.state)), a->slideover.source_pages.size(), + a->slideover.source_pages.size(), switch_visible ? 1 : 0, gtk_widget_get_can_target(a->badges.source.button) ? 1 : 0, second_page.empty() ? "-" : second_page.c_str(), second_ok, first.empty() ? -1 : marked ? 1 : 0, diff --git a/src/ui_context_help.cpp b/src/ui_context_help.cpp index 459725b..4ca19a9 100644 --- a/src/ui_context_help.cpp +++ b/src/ui_context_help.cpp @@ -89,9 +89,10 @@ constexpr HelpHint kHelpHints[] = { "arithmetic, wired together and named here. It is a channel like any other, so it can be " "plotted, measured, searched and exported.", "", &App::math_head}, - {"signals", "The signals", - "Every signal the source declares, each with the switch that acquires it. Nothing is buffered " - "until it is switched on, and only acquired signals appear in the drop targets and the " + {"signals", "Add a signal", + "The signals the source declares that are not acquired yet, and the math channels not computed, " + "each with the tick that acquires it; an acquired signal has a badge in the bar instead. Nothing " + "is buffered until it is ticked, and only acquired signals appear in the drop targets and the " "measurement editor. A signal's badge, dragged onto a plot, draws it there.", "G", &App::signals_head}, {"events", "The event history", @@ -116,9 +117,9 @@ constexpr HelpHint kHelpHints[] = { "every row.", "", &App::trigger_search}, {"signals_config", "Configuring signals", - "The list is locked until the pencil above opens the per-signal controls: the calibration that " - "maps raw counts to a unit, the colour a trace is drawn in, and which plot a signal is drawn " - "on. A signal nothing measures can be switched off to stop acquiring it.", + "A signal's own page, opened by its badge or by its row here, holds the calibration that maps " + "raw counts to a unit, the colour a trace is drawn in, and which plot a signal is drawn on. A " + "signal nothing measures can be switched off there to stop acquiring it.", "", &App::signals_note}, {"window", "The time window", "How much time is on screen, counted back from the newest sample. Type a span, change its unit, " diff --git a/src/ui_layout.cpp b/src/ui_layout.cpp index db631af..e3c66d6 100644 --- a/src/ui_layout.cpp +++ b/src/ui_layout.cpp @@ -516,12 +516,11 @@ void apply_layout(App& app, const LayoutState& st) { // above have built the channel list. apply_trigger_layout(app, st.trigger); - // A session that loaded is a user who has been here before, and that is what the lock means: the two - // panes come up showing what they are set to rather than showing every control. A first run -- no - // file for this source -- never reaches this function and stays in configuration mode, which is what - // a first run is for. + // A session that loaded is a user who has been here before, and that is what the lock means: the trigger + // pane comes up showing what it is set to rather than showing every control. A first run -- no file for + // this source -- never reaches this function and stays in configuration mode, which is what a first run + // is for. app.trigger_locked = true; - app.signals_locked = true; apply_pane_lock(app); } diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 72c0fcd..228ffdd 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -102,7 +102,7 @@ gboolean exit_probe_step3b(gpointer user) { a->dbg_signal_w = gtk_widget_get_width(a->signal_panel); a->dbg_signal_h = gtk_widget_get_height(a->signal_panel); // How many rows of the signal list carry a drag source: none. Dragging a signal onto a plot is the signal - // badge's job (and survives the pane lock, since the badges do not follow it); the list's rows only open + // badge's job (the signals have no lock to survive); the list's rows only open // the signal's page. The reading says the list did not grow a second, competing handle. if (a->signal_list != nullptr) { int sources = 0; @@ -3836,32 +3836,35 @@ gboolean enable_all_probe(gpointer user) { std::fprintf(stderr, "[enable-all-probe] %zu channel(s) enabled and assigned to plots, notice='%s'\n", a->model->channel_count(), a->notice.c_str()); std::fflush(stderr); - // Then switch one off through its own switch in the signal list: a disabled signal must leave the plots, - // not sit there drawing nothing in a slot a live one could take. Late enough that a math probe armed behind - // this one has had its graphs installed -- the dependants are the point of the second line. + // Then switch one off through its own switch on the signal's page -- the user's path now that an acquired + // signal is not in the list: a disabled signal must leave the plots, not sit there drawing nothing in a slot + // a live one could take. Late enough that a math probe armed behind this one has had its graphs installed -- + // the dependants are the point of the second line. g_timeout_add(2000, +[](gpointer u) -> gboolean { App* app = static_cast(u); if (app->model->channels().empty() || app->signal_list == nullptr) return G_SOURCE_REMOVE; const std::size_t signal = app->model->channels().front().signal; const std::string name = app->model->channels().front().name; GtkWidget* tick = nullptr; - for (GtkWidget* row_widget = gtk_widget_get_first_child(app->signal_list); - row_widget != nullptr && tick == nullptr; - row_widget = gtk_widget_get_next_sibling(row_widget)) { - // The tick sits a box inside the row, which is the shape `signal_tick_visible()` walks too. - for (GtkWidget* box = gtk_widget_get_first_child(row_widget); box != nullptr && tick == nullptr; - box = gtk_widget_get_next_sibling(box)) { - for (GtkWidget* w = gtk_widget_get_first_child(box); w != nullptr; - w = gtk_widget_get_next_sibling(w)) { - if (!GTK_IS_CHECK_BUTTON(w)) continue; - if (GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(w), "udpscope-signal")) != signal) continue; - tick = w; - break; + std::string signal_name; + { + std::lock_guard lock(app->model->mutex()); + const std::vector& sigs = app->model->signals(); + if (signal < sigs.size()) signal_name = sigs[signal].name; + } + if (!signal_name.empty() && signal_page_ensure(*app, signal_name)) { + // The page's "Acquire this signal" check is the first check button in it. + const std::function first_check = [&](GtkWidget* root) -> GtkWidget* { + for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr; c = gtk_widget_get_next_sibling(c)) { + if (GTK_IS_CHECK_BUTTON(c)) return c; + if (GtkWidget* deeper = first_check(c)) return deeper; } - } + return nullptr; + }; + tick = first_check(app->signal_pages[signal_name]); } if (tick == nullptr) { - std::fprintf(stderr, "[enable-all-probe] no acquire tick found for signal %zu\n", signal); + std::fprintf(stderr, "[enable-all-probe] no acquire tick found on the page of signal %zu\n", signal); std::fflush(stderr); return G_SOURCE_REMOVE; } @@ -4314,7 +4317,6 @@ gboolean exit_probe_step1(gpointer user) { g_signal_emit_by_name(a->trigger_edit, "clicked"); // and back to the view } a->dbg_trigger_locked = a->trigger_locked ? 1 : 0; - a->dbg_signals_locked = a->signals_locked ? 1 : 0; a->dbg_trigger_body_visible = a->trigger_body != nullptr && gtk_widget_get_visible(a->trigger_body) ? 1 : 0; // The summary's text comes from the sync that wrote it: the container is a grid of key/value rows, diff --git a/src/ui_signal_page.cpp b/src/ui_signal_page.cpp index 9e29707..4b09670 100644 --- a/src/ui_signal_page.cpp +++ b/src/ui_signal_page.cpp @@ -65,10 +65,9 @@ void fill(App& app, const std::string& name, GtkWidget* page) { const SignalInfo sig = all[index]; const bool enabled = app.model->signal_enabled(index); const bool plotted = signal_plotted(app, sig.name); - const bool unlocked = !app.signals_locked; // Back to the list: a page reached from a list row has no other way home. - GtkWidget* back = text_button("All signals", page); + GtkWidget* back = text_button("Back to signals", page); gtk_widget_set_halign(back, GTK_ALIGN_START); gtk_widget_add_css_class(back, "flat"); g_signal_connect(back, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { @@ -96,8 +95,6 @@ void fill(App& app, const std::string& name, GtkWidget* page) { g_object_set_data(G_OBJECT(enable), "udpscope-signal", GSIZE_TO_POINTER(index)); g_signal_connect(enable, "notify::active", G_CALLBACK(on_signal_switch), &app); gtk_widget_set_visible(enable, sig.math || app.waveform_name.empty()); - // The signal lock is the list's lock too: the page may not do what the locked list refuses. - gtk_widget_set_sensitive(enable, unlocked); gtk_box_append(GTK_BOX(page), enable); // Colour. @@ -110,7 +107,7 @@ void fill(App& app, const std::string& name, GtkWidget* page) { GtkWidget* colour_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); gtk_box_append(GTK_BOX(colour_row), chip); GtkWidget* pick = text_button("Change colour...", colour_row); - gtk_widget_set_sensitive(pick, plotted && unlocked); + gtk_widget_set_sensitive(pick, plotted); gtk_widget_set_tooltip_text(pick, plotted ? "the colour this signal is plotted in" : "on no plot, so there is no trace to colour"); tag(pick, index); @@ -119,7 +116,7 @@ void fill(App& app, const std::string& name, GtkWidget* page) { }), &app); GtkWidget* reset = text_button("Default colour", colour_row); - gtk_widget_set_sensitive(reset, unlocked && app.rows->signal_colors().count(sig.name) != 0); + gtk_widget_set_sensitive(reset, app.rows->signal_colors().count(sig.name) != 0); tag(reset, index); g_signal_connect(reset, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { outlive_the_rebuild(GTK_WIDGET(b)); @@ -184,12 +181,10 @@ void fill(App& app, const std::string& name, GtkWidget* page) { GtkWidget* more = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); if (sig.math) { GtkWidget* edit = text_button("Edit graph...", more); - gtk_widget_set_sensitive(edit, unlocked); tag(edit, index); g_signal_connect(edit, "clicked", G_CALLBACK(on_math_edit_clicked), &app); GtkWidget* del = text_button("Delete", more); gtk_widget_add_css_class(del, "destructive-action"); - gtk_widget_set_sensitive(del, unlocked); tag(del, index); g_signal_connect(del, "clicked", G_CALLBACK(on_math_delete_clicked), &app); } else { @@ -197,7 +192,6 @@ void fill(App& app, const std::string& name, GtkWidget* page) { tag(info, index); g_signal_connect(info, "clicked", G_CALLBACK(on_info_clicked), &app); GtkWidget* cal = text_button("Calibrate...", more); - gtk_widget_set_sensitive(cal, unlocked); gtk_widget_set_tooltip_text(cal, "gain and offset, applied at ingest"); tag(cal, index); g_signal_connect(cal, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { @@ -337,20 +331,6 @@ gboolean signal_page_probe(gpointer user) { plot_on_row0_disabled = gtk_widget_get_sensitive(gtk_widget_get_first_child(rows_box_on)) ? 0 : 1; } - // The signal lock: with it on, the page's acquire tick is insensitive after the sync (the lock change - // re-runs it through rebuild_signal_panels); restored afterwards. - int locked_enable_sensitive = -1; - const bool was_locked = a.signals_locked; - a.signals_locked = true; - signal_pages_sync(a); - page_it = a.signal_pages.find(name); - if (page_it != a.signal_pages.end()) { - GtkWidget* enable_now = find_descendant(page_it->second, [](GtkWidget* w) { return GTK_IS_CHECK_BUTTON(w); }); - if (enable_now != nullptr) locked_enable_sensitive = gtk_widget_get_sensitive(enable_now) ? 1 : 0; - } - a.signals_locked = was_locked; - signal_pages_sync(a); - int removed = -1; page_it = a.signal_pages.find(name); GtkWidget* off = page_it == a.signal_pages.end() @@ -381,8 +361,15 @@ gboolean signal_page_probe(gpointer user) { slideover_open(a, "signals"); const int back_to_list = slideover_page(a) == "signals" ? 1 : 0; - // Activating the signal's row in the list opens its page (the path a click takes). + // Activating the signal's row in the list opens its page (the path a click takes). The list proposes what + // can be added, so the signal has a row only while it is not acquired: switch it off for the leg and back + // on after it. (With more than six acquired signals the rows list the acquired ones too.) slideover_close(a); + const bool was_enabled = a.model->signal_enabled(index); + if (was_enabled) { + a.model->set_signal_enabled(index, false); + rebuild_signal_panels(a); + } int row_opens = -1; for (GtkWidget* r = gtk_widget_get_first_child(a.signal_list); r != nullptr; r = gtk_widget_get_next_sibling(r)) { if (GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(r), "udpscope-signal")) == index && GTK_IS_LIST_BOX_ROW(r)) { @@ -391,6 +378,11 @@ gboolean signal_page_probe(gpointer user) { break; } } + if (was_enabled) { + slideover_close(a); + a.model->set_signal_enabled(index, true); + rebuild_signal_panels(a); + } // Pruning: a math channel's page, open, then the channel deleted; the sync must close the panel and drop // the page. Left at -1 when no channel can be added (nothing acquired to build it from). @@ -447,10 +439,10 @@ gboolean signal_page_probe(gpointer user) { } std::fprintf(stderr, "[signalpage] name=%s open=%d pages=%d enable_visible=%d plot_buttons=%d plot_on_row0=%d " - "plot_on_row0_disabled=%d locked_enable_sensitive=%d " + "plot_on_row0_disabled=%d " "removed=%d btn_row0=%d back_to_list=%d row_opens=%d pruned=%d other_page_kept=%d rows=%d\n", name.c_str(), open, pages, enable_visible, plot_buttons, plot_on_row0, plot_on_row0_disabled, - locked_enable_sensitive, removed, btn_row0, back_to_list, row_opens, pruned, + removed, btn_row0, back_to_list, row_opens, pruned, other_page_kept, a.rows->row_count()); return G_SOURCE_REMOVE; } diff --git a/src/ui_signals.cpp b/src/ui_signals.cpp index 5b7c392..ddbc2d6 100644 --- a/src/ui_signals.cpp +++ b/src/ui_signals.cpp @@ -167,7 +167,7 @@ int math_index_for_signal(App& app, const std::string& name) { /// The `MATH` boundary in the one channel list, carrying the `+` that creates a channel. Not /// selectable and not activatable: it is a heading, and a heading that highlights under the /// pointer reads as a row you can plot. -GtkWidget* make_math_divider(App& app, bool with_add) { +GtkWidget* make_math_divider(App& app) { GtkWidget* row = gtk_list_box_row_new(); gtk_list_box_row_set_selectable(GTK_LIST_BOX_ROW(row), FALSE); gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), FALSE); @@ -181,16 +181,12 @@ GtkWidget* make_math_divider(App& app, bool with_add) { gtk_widget_set_halign(label, GTK_ALIGN_START); gtk_widget_set_hexpand(label, TRUE); gtk_box_append(GTK_BOX(box), label); - // Locked: the heading stays, because the list it divides is the one being read, but the `+` that - // creates a channel does not. A `+` on a locked list is a button that opens the editor the lock - // exists to keep shut. - if (with_add) { - GtkWidget* add = gtk_button_new_from_icon_name("list-add-symbolic"); - gtk_widget_add_css_class(add, "flat"); - gtk_widget_set_tooltip_text(add, "add a math channel"); - g_signal_connect(add, "clicked", G_CALLBACK(on_math_add_clicked), &app); - gtk_box_append(GTK_BOX(box), add); - } + // The `+` that creates a channel: the "new math channel" entry of a page that proposes what can be added. + GtkWidget* add = gtk_button_new_from_icon_name("list-add-symbolic"); + gtk_widget_add_css_class(add, "flat"); + gtk_widget_set_tooltip_text(add, "add a math channel"); + g_signal_connect(add, "clicked", G_CALLBACK(on_math_add_clicked), &app); + gtk_box_append(GTK_BOX(box), add); gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box); return row; } @@ -257,6 +253,14 @@ void on_math_delete_clicked(GtkButton* button, gpointer user) { apply_math_channels(*a); } +bool signal_badges_overflowed(App& app, const std::vector& signals) { + std::size_t enabled = 0; + for (std::size_t i : listed_signals(signals)) { + if (app.model->signal_enabled(i)) ++enabled; + } + return enabled > kMaxSignalBadges; +} + std::vector listed_signals(const std::vector& signals) { std::vector out; for (std::size_t i = 0; i < signals.size(); ++i) { @@ -310,12 +314,20 @@ void rebuild_signal_list(App& app, const std::vector& signals) { bool math_divider_done = false; // Streamed signals, counted as they are drawn: the dump reads `rows == signals + 1 + math`. std::size_t plain = 0; + std::size_t math_rows = 0; + // The page proposes what can be *added*: an acquired signal already has a badge, so it is not listed -- + // unless the badges have overflowed into one "N signals" badge, when the rows are the only way to reach + // each signal's page. + const bool overflowed = signal_badges_overflowed(app, signals); for (std::size_t i : listed_signals(signals)) { - // Locked: a row per signal that is *on*, which is the list a user reads rather than the list they - // configure. Turning one on is what the pane's [edit] button is for. - if (app.signals_locked && !app.model->signal_enabled(i)) continue; + if (!overflowed && app.model->signal_enabled(i)) continue; rows.push_back(i); } + // Nothing addable: say so rather than leave the math entry alone under the hint. Not before the stream + // has declared any signal -- "every signal is acquired" would be untrue of no signals at all. + if (app.signals_empty != nullptr) { + gtk_widget_set_visible(app.signals_empty, rows.empty() && !listed_signals(signals).empty()); + } for (std::size_t i : rows) { const SignalInfo& sig = signals[i]; @@ -323,9 +335,13 @@ void rebuild_signal_list(App& app, const std::vector& signals) { // `+` that creates the first one. if (sig.math && !math_divider_done) { math_divider_done = true; - gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app, !app.signals_locked)); + gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app)); + } + if (sig.math) { + ++math_rows; + } else { + ++plain; } - if (!sig.math) ++plain; GtkWidget* row = gtk_list_box_row_new(); gtk_widget_add_css_class(row, "udpscope-compact"); GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); @@ -339,7 +355,7 @@ void rebuild_signal_list(App& app, const std::vector& signals) { chip, !app.model->signal_enabled(i) ? "not acquired" : plotted ? "the colour this signal is plotted in -- click to change it" : "on no plot, so there is no trace to colour"); - if (plotted && !app.signals_locked) { + if (plotted) { // A gesture on the label rather than a `GtkButton` around it: a button brings its own // padding, and padding on this row is what made the list twice as tall once already (see // this function's note). The chip also has to stay exactly one disc wide, or the names @@ -373,9 +389,7 @@ void rebuild_signal_list(App& app, const std::vector& signals) { gtk_widget_set_visible(enable, sig.math || app.waveform_name.empty()); g_object_set_data(G_OBJECT(enable), "udpscope-signal", GSIZE_TO_POINTER(i)); g_signal_connect(enable, "notify::active", G_CALLBACK(on_signal_switch), &app); - // Locked: the switch decides what is acquired, so it is a configuration control and it goes with - // the rest of them. - if (!app.signals_locked) gtk_box_append(GTK_BOX(box), enable); + gtk_box_append(GTK_BOX(box), enable); GtkWidget* name = gtk_label_new(sig.name.c_str()); gtk_widget_set_hexpand(name, true); @@ -459,9 +473,7 @@ void rebuild_signal_list(App& app, const std::vector& signals) { gtk_menu_button_set_popover(GTK_MENU_BUTTON(more), popover); gtk_widget_add_css_class(more, "flat"); gtk_widget_set_tooltip_text(more, "information and calibration"); - // Locked: the menu holds calibration, deletion and a rename, which are exactly what this mode is - // not for. The popover is left unattached, so nothing can reach it. - if (!app.signals_locked) gtk_box_append(GTK_BOX(box), more); + gtk_box_append(GTK_BOX(box), more); gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box); // Activating the row opens the signal's page; the row resolves its identity the way its widgets do. @@ -473,9 +485,18 @@ void rebuild_signal_list(App& app, const std::vector& signals) { // Appended when there are no math channels at all: the `+` is the only way to create the first // one, so it cannot be conditional on one already existing. if (!math_divider_done) { - gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app, !app.signals_locked)); + gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app)); } app.dbg_signal_plain = plain; + app.dbg_signal_math_rows = math_rows; + // The proposal as the model states it, to compare with the rows built: the signals not acquired, or every + // listed signal while the badges are overflowed. + app.dbg_signal_addable = 0; + for (std::size_t i : listed_signals(signals)) { + if (overflowed || !app.model->signal_enabled(i)) ++app.dbg_signal_addable; + } + app.dbg_signal_empty_label = + app.signals_empty != nullptr && gtk_widget_get_visible(app.signals_empty) != FALSE ? 1 : 0; app.dbg_signal_rows = 0; for (GtkWidget* c = gtk_widget_get_first_child(list); c != nullptr; c = gtk_widget_get_next_sibling(c)) { @@ -608,10 +629,7 @@ GtkWidget* build_signal_panel(App& app) { // The hint line under it, which is where the "signals_config" hint joins: the heading already // carries one, and the rule the layer follows is a hint beside the thing it explains. app.signals_note = GTK_WIDGET(gtk_builder_get_object(ui, "signals_note")); - app.signals_edit = GTK_WIDGET(gtk_builder_get_object(ui, "signals_edit")); - if (app.signals_edit != nullptr) { - g_signal_connect(app.signals_edit, "clicked", G_CALLBACK(on_pane_edit_clicked), &app); - } + app.signals_empty = GTK_WIDGET(gtk_builder_get_object(ui, "signals_empty")); if (box == nullptr || app.signal_list == nullptr) { // A missing id is a mistake in the `.ui`: the panel comes up empty rather than taking the window with // it, and `[udpscope] signals panel:` reads the difference. diff --git a/src/ui_tour.cpp b/src/ui_tour.cpp index 9085d63..d698849 100644 --- a/src/ui_tour.cpp +++ b/src/ui_tour.cpp @@ -458,9 +458,10 @@ constexpr TourStep kTourSteps[] = { "The dock beside the plot: the cursors, then the measurements. The buttons in the bar above " "show and hide it.", &App::side_box}, - {"signals", "signals", "The signals", - "Every signal the source declares, each with the switch that acquires it. The + badge opens " - "this list; a signal has to be switched on before it can be plotted, measured or searched.", + {"signals", "signals", "Adding a signal", + "What can be added: the signals the source declares that are not acquired yet, each with the " + "tick that acquires it. The + badge opens this list; a signal has to be acquired before it can " + "be plotted, measured or searched, and its badge then appears in the bar.", &App::signal_list}, {"status", nullptr, "The status line", "The stream's own report, once a second: the display mode, the rate arriving, the window on " From d8f82bd6108f09774268faaf925e0e51f9c9f6ad Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 02:14:37 +0100 Subject: [PATCH 076/127] docs: source badge name only, no signals lock, the Add-a-signal page Co-Authored-By: Claude Opus 4.6 --- README.md | 19 +++--- docs/claude/architecture.md | 2 +- docs/claude/invariants-gtk-and-chrome.md | 4 +- docs/claude/invariants-plot-and-layout.md | 6 +- docs/claude/invariants-session-and-export.md | 6 +- docs/claude/probe-readings.md | 62 +++++++++++--------- docs/claude/verification.md | 7 ++- help/C/org.udpscope.app/overview.page | 2 +- 8 files changed, 59 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 8f3f433..cba3458 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,13 @@ src/ui_signal_page.cpp the per-signal slide-over pages (`signal:`) **The window.** The plot is the window. Under it sits a fixed-height **badge bar** (36 px). Its left part is the **signals**: one badge per *enabled* signal (a dot in the signal's colour, filled while the -signal is plotted, and its name), then a `+` badge that opens the full signal list (`g` does the same). +signal is plotted, and its name), then a `+` badge that opens the **Add a signal** page (`g` does the same). That page proposes only what can be +*added*: the streamed signals that are not acquired and the math channels that are not computed, each row with +the tick that acquires it (the row then leaves the page and the signal's badge appears), plus the `MATH` +divider's `+` for a new math channel; when every signal is acquired a dim "every signal is acquired" line +stands in for the list. While the bar has overflowed into the "N signals" badge the page lists the acquired +signals too, since their rows are then the only way to each signal's page. There is no signal lock: a +signal's page and the list always allow acquire, colour, calibration, the math editor and delete. A signal badge opens that signal's own page in the slide-over -- colour, calibration and the *Plot on N* buttons -- and is also the drag handle: drag it onto a plot to draw the signal there, or onto another badge to reorder them. Past **six** signal badges the bar shows a single **"N signals"** badge in their @@ -112,7 +118,7 @@ place (it opens the list, and `+` is hidden): the E-1608's 8 analog inputs and c otherwise. The signal badges sit in a scroller, so the bar's minimum width, and with it the window's, never depends on how many signals there are. The badge order is kept **for the session only**; it is not written to the session file. The right-hand end carries two words-only badges: **Trigger** (`off`, `armed`, `capturing`, `triggered` -or `disarmed`) and **Source** (the kind's name and `connected`/`disconnected`; a read-out, not a button, +or `disarmed`) and **Source** (the kind's name only -- the link state is the status bar's; a read-out, not a button, for a source with no controls of its own, and for a file). A badge opens the **slide-over**: a 360 px panel laid over the plot's right edge, opaque, with its own title, a *Close* button and one scroller per page. The Trigger badge opens the trigger form; the Source badge opens the source kind's own panels @@ -2937,11 +2943,10 @@ now — they are listed so they are not mistaken for anything else: tabs did not fit 320 px. It is **one dock** now, with no tab switcher: the cursors and the measurements in one scroller, shown from the start and insensitive until the stream has signals; `m` scrolls it to the cursors; the **signal list** is the `signals` slide-over page (`g`, or the `+` badge). The **trigger form** and a **source's own - controls** are pages of the slide-over, reached from the badge bar. Both panes also have a **locked** mode, which 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 - to switch to the full pane, and back. Dragging a signal's badge onto a plot still works while locked -- - filling plots is not configuration. A signal's badge is dragged onto a plot, or onto another badge to + controls** are pages of the slide-over, reached from the badge bar. The trigger pane also has a **locked** mode, which is what a returning user gets: it + shows one line saying what it is set to and offers no control that changes the configuration -- its + heading has an `[edit]` button to switch to the full pane, and back. The signals have no lock: the + signals page only proposes what can be added, and the signal pages always allow editing. A signal's badge is dragged onto a plot, or onto another badge to reorder the badges (the order is kept for the session only); the slide-over closes for the length of the drag so there is something under the pointer to drop onto. A **first run** (no session file for that source) comes up unlocked, because configuring the host is what a first run is for. Each pane's heading also carries its **own switch**, the same control as the diff --git a/docs/claude/architecture.md b/docs/claude/architecture.md index 10319af..7446c30 100644 --- a/docs/claude/architecture.md +++ b/docs/claude/architecture.md @@ -123,7 +123,7 @@ overlay is built (the tour's overlay wraps the whole window, so its dim rectangl too); the trigger form is registered as a page by `build_trigger_panel`; `build_source_panels()` registers the source pages; and `build_badge_bar()` runs *after* all of them, because the Source badge needs to know the first page. -Per-tick state (the Trigger word, the Source name and `connected`, the open marks) is written by +Per-tick state (the Trigger word, the Source name, the open marks) is written by `sync_badge_bar()`, called from `tick()` and from `slideover_open`/`slideover_close`; it writes a label only when its text changed. Nothing in the badge bar or the slide-over may be read from the producer thread. diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md index deb5551..2fa3653 100644 --- a/docs/claude/invariants-gtk-and-chrome.md +++ b/docs/claude/invariants-gtk-and-chrome.md @@ -255,8 +255,8 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear `gtk_box_reorder_child_after` and rewrites `App::badge_order` from the widgets, and **never rebuilds the badges** -- the dragged badge and the target whose `drop` emission is running must both survive it. A foreign drag whose string is not a signal index is refused. `[sigbadges]` reads the controllers, the payload and the reorder; `pane drag:` reads the close. -- **A stock icon name has to be one *Adwaita* has, and a blank button is the failure mode.** The two - panes' `[edit]` buttons are `document-edit-symbolic` (the pencil: edit) and `object-select-symbolic` +- **A stock icon name has to be one *Adwaita* has, and a blank button is the failure mode.** The + trigger pane's `[edit]` button is `document-edit-symbolic` (the pencil: edit) and `object-select-symbolic` (the check: done). The done one was `emblem-ok-symbolic`, which breeze ships and **Adwaita does not** — so the button that leaves configuration mode was blank, which no other reading could see: the button worked, the layout was unchanged, and only `pane edits:` (the two names and diff --git a/docs/claude/invariants-plot-and-layout.md b/docs/claude/invariants-plot-and-layout.md index 52cbe9b..1b962d5 100644 --- a/docs/claude/invariants-plot-and-layout.md +++ b/docs/claude/invariants-plot-and-layout.md @@ -116,8 +116,8 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear - **The badge bar has a fixed height and sits outside the overlay.** `badge_bar` is 36 px (`height-request`) between `plot_overlay` and the status line, so the plot above it never moves when a badge's text changes (`bar_h` = `bar_h_after` in `[badges]`). This is "layout must not depend on - content" for the new row: a badge's state is a word from a short fixed set and the Source badge's name - is a kind's display name, so what changes with the stream is the row's *width* (the bar has room to + content" for the new row: the Trigger badge's state is a word from a short fixed set and the Source badge + is a kind's display name only (no connection state: that is the status bar's), so what changes with the stream is the row's *width* (the bar has room to spare on the right) and never its height. The badge labels are not ellipsized: a name long enough to need it would be a defect of the kind's `display_name()`. Rows that appear by trigger condition appear inside the slide-over's scroller, never in the bar. @@ -127,7 +127,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear (the E-1608: eight inputs and the counter) would push the window's minimum width past a laptop screen. Past `kMaxSignalBadges` (6) the row is replaced by one "N signals" badge, which also stands for the `+`. The bar's height stays 36 px in every one of these states (`bar_h_fixed` in `[sigbadges]`), and - `bar_min_w` reads the same with one signal, nine signals and the overflow (329 on the E-1608, 336 on UDPS: + `bar_min_w` reads the same with one signal, nine signals and the overflow (244 on the E-1608, 251 on UDPS: the Source badge of each kind reads differently). A change that makes it grow with the signal count has taken the scroller out of the measurement. - **The dock's width is fixed too** (`side_column` `width-request` 300, the split view's 320 px floor diff --git a/docs/claude/invariants-session-and-export.md b/docs/claude/invariants-session-and-export.md index 99a2eec..23a3727 100644 --- a/docs/claude/invariants-session-and-export.md +++ b/docs/claude/invariants-session-and-export.md @@ -13,9 +13,9 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear (`rebuild_trigger_signals()` picks up `App::pending_trigger_channel`, or `apply_trigger_layout()` resolves it itself when they are already there) — a name that resolved to nothing would silently move the trigger to channel 0. -- **A session that loaded is what locks the two panes.** Locked is the returning user's mode: the - trigger pane shows one compact line, the signal list only the signals that are on, and neither offers - a control that changes the configuration. A *first run* — no session file for that source — never +- **A session that loaded is what locks the trigger pane.** Locked is the returning user's mode: the + trigger pane shows one compact line and offers no control that changes the configuration (the signals + have no lock). A *first run* — no session file for that source — never reaches `apply_layout()` and stays in configuration mode, which is what a first run is for. The mode is a mode and not a setting: it is deliberately not persisted, so a scope reopened tomorrow starts where a returning user expects to start. **Dragging a signal onto a plot is not configuration and survives diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 143253c..717ee86 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -1706,8 +1706,8 @@ library measures and draws the rect in tests of its own. ``` [udpscope] pane drag: slide-over closed for the drag=1 [udpscope] pane edits: locked=document-edit-symbolic(ok) config=object-select-symbolic(ok) -[udpscope] panes: trigger=locked signals=locked body_visible=0 summary='signal: square | condition: window, falling | level: 0.3 .. 0.6 | mode: single | noise band: 0.2 | pre-trigger: 20 % | hold-off: 1000 ms' -[udpscope] panes: trigger=config signals=config body_visible=1 summary='' +[udpscope] panes: trigger=locked body_visible=0 summary='signal: square | condition: window, falling | level: 0.3 .. 0.6 | mode: single | noise band: 0.2 | pre-trigger: 20 % | hold-off: 1000 ms' +[udpscope] panes: trigger=config body_visible=1 summary='' [udpscope] panes: the same line as the probe found it: 'mode: stream (trigger off)' ``` @@ -1720,18 +1720,18 @@ or more than six, where the badges are replaced by the "N signals" overflow). It `sidebar hidden for the drag=1 restored after it=1` pair, which tested `row_drag_sidebar()` -- the list rows were the drag handle and the overlaid sidebar had to step aside; both are gone. -**`pane edits:`** is the two `[edit]` buttons in each mode, by name and verdict, and it exists because of +**`pane edits:`** is the trigger pane's `[edit]` button in each mode, by name and verdict, and it exists because of a real defect: the "done" icon was `emblem-ok-symbolic`, which **Adwaita does not have** (breeze does), so the button that leaves configuration mode was blank — and a blank icon button is invisible in every other reading, exactly as `toolbar icons:` describes for the toolbar. The probe clicks into configuration mode and back, so one line carries both names. **Both must read `(ok)`**, and the pair is `document-edit-symbolic` (the pencil: edit) and `object-select-symbolic` (the check: done). -Both panes have two modes. **Locked** is what a returning user gets — the trigger pane shows the one line -in `summary`, the signal list shows only the signals that are on, and neither offers a control that -changes the configuration — and **config** is the full pane, which is what a *first run* gets, because a -session file that loaded is what "we have been here before" means. Each heading's `[edit]` button flips -its pane. +The trigger pane has two modes. **Locked** is what a returning user gets — the pane shows the one line +in `summary` and offers no control that changes the configuration — and **config** is the full pane, which +is what a *first run* gets, because a session file that loaded is what "we have been here before" means. +The heading's `[edit]` button flips it. (The signals have no lock: the badges and the signal pages are +direct manipulation, and `panes:` no longer carries a `signals=` field.) - **With trigger mode off the summary is one line**: `mode: stream (trigger off)`. The four lines below describe a trigger that is not running, so quoting them would describe nothing. The dump carries the line @@ -1765,9 +1765,8 @@ its pane. `body_visible=0` is a locked pane showing nothing at all, which is the defect this reads for. - **`body_visible=0`** is the settings being off screen, which is what makes the pane a couple of hundred pixels instead of 580 in `panels:`. -- **The locked signal list drops the rows of the signals that are off**, so `rows` counts what is on: - `signals panel: … rows=3 signals=2 divider=1` is two on signals and the `MATH` divider, not four - signals. Its `+` goes with them — a `+` on a locked list opens the editor the lock exists to keep shut. +- **The signals list has no lock.** It lists what can be *added* (see `signals panel:`), and the signal + pages always allow acquire, colour, calibrate, math edit and delete. ## `panel switches:` and the trigger panel's default sensitivity @@ -2305,19 +2304,26 @@ prints without a probe: `depth`, the retention (`kept`/`bytes`/`budget`/`dropped ## `signals panel:` ``` -[udpscope] signals panel: labels=2 list=1 rows=5 signals=4 math=0 divider=1 -[udpscope] signals panel: labels=2 list=1 rows=9 signals=4 math=4 divider=1 +[udpscope] signals panel: labels=3 list=1 rows=5 signals=4 math=0 divider=1 drag_sources=0 addable=4 addable_ok=1 empty_label=0 +[udpscope] signals panel: labels=3 list=1 rows=2 signals=1 math=0 divider=1 drag_sources=0 addable=1 addable_ok=1 empty_label=0 +[udpscope] signals panel: labels=3 list=1 rows=1 signals=0 math=0 divider=1 drag_sources=0 addable=0 addable_ok=1 empty_label=1 ``` -The signals panel's frame read from the window, and in the same line the *shape* of the one channel list: -the heading and the hint the `.ui` gives it, the list the rows go in, how many rows that list holds -(`rows`), how many of them are streamed signals (`signals`), how many math channels the app has (`math`), -and `divider`, which is 1 when `rows == signals + 1 + math` and 0 otherwise. **`drag_sources` must be +The signals panel's frame read from the window, and in the same line the *shape* of the one list. The page +is "Add a signal": it proposes only what can be added, so the list holds the streamed signals that are not +acquired (`signals`), the math channels that are not computed (`math`), and the `MATH` divider with its `+` +(`divider`, 1 when `rows == signals + 1 + math`, 0 otherwise). An acquired signal has a badge in the bar and +is not listed -- **unless the badge bar has overflowed** (more than 6 enabled signals, the "N signals" badge), +when the acquired ones are listed too, since the bar no longer shows them. `addable` is the same count +worked out from the model (unacquired listed signals, or all of them when overflowed) and `addable_ok=1` is +`signals + math == addable`; `0` is a list filter that drifted from the rule. `empty_label=1` is the dim +"every signal is acquired" label shown, which must happen exactly when `addable=0` and the stream declares +signals (the divider row stays, so a math channel can still be created). **`drag_sources` must be `0`**: dragging a signal onto a plot is the signal *badge's* job now (see `[sigbadges]`, which counts the badges' own sources), and the list's rows only open the signal's page; a non-zero reading is a second, competing -drag handle on the list. `labels=1` is a `.ui` that lost -one of the two labels — counted over the panel's subtree *above* the list, because the rows' own name -and unit labels are the code's and the heading sits in a heading row beside the pane's `[edit]` button — `list=0` one that lost the list -- both of which otherwise show as a panel quietly +drag handle on the list. `labels=2` is a `.ui` that lost +one of the three labels (heading, hint, the empty-state label) — counted over the panel's subtree *above* the list, because the rows' own name +and unit labels are the code's — `list=0` one that lost the list -- both of which otherwise show as a panel quietly missing a piece -- and **`divider=0` is the merged list that lost its `+`**, the one control that can create a math channel. The first line above is a run with no math channels at all: the divider is there anyway, because a `+` that only appeared once a channel existed could never create the first one. `panels: … @@ -2640,17 +2646,16 @@ left the caller's `false` alone would read `dock_shown=0 default=1 ok=0`. The pr ## `[signalpage]` ``` -[signalpage] name=sine open=1 pages=1 enable_visible=1 plot_buttons=1 plot_on_row0=1 plot_on_row0_disabled=1 locked_enable_sensitive=0 removed=1 btn_row0=1 back_to_list=1 row_opens=1 pruned=1 other_page_kept=1 rows=1 +[signalpage] name=sine open=1 pages=1 enable_visible=1 plot_buttons=1 plot_on_row0=1 plot_on_row0_disabled=1 removed=1 btn_row0=1 back_to_list=1 row_opens=1 pruned=1 other_page_kept=1 rows=1 ``` `UDPSCOPE_PROBE_SIGNAL_PAGES_MS` builds and drives the page of the first streamed signal. `open=1` (the slide-over shows `signal:`), `pages=1` (one page in `App::signal_pages`), `enable_visible=1` (the acquire tick is shown in live mode; `0` in a file view), `plot_buttons=` equal to `rows=` (one "Plot on" button per figure row), `plot_on_row0=1` (`assign_signal_to_row()` returned true and the signal is plotted, from a state where the probe first took it off every plot), `plot_on_row0_disabled=1` (after the page refilled with the signal on plot 1, that plot's "Plot on" button is insensitive, "already on this plot"; `0` is a button that would put the signal in a second slot of -the same plot), `locked_enable_sensitive=0` (with `signals_locked` set and `signal_pages_sync()` run, the page's Acquire/Compute tick is insensitive; `1` is a page that bypasses -the signal lock -- the same goes for a math channel's Edit graph and Delete, which the probe does not read; the probe restores the lock), `removed=1` -(the page's "Remove from plots" took it off), `btn_row0=1` (the "Plot on 1" button, the user's path, put it back), `back_to_list=1` ("All signals" -target), `row_opens=1` (activating the list row opens the page), and `pruned=1`: a math channel's page was opened, its **Delete** button clicked, and +the same plot), `removed=1` +(the page's "Remove from plots" took it off), `btn_row0=1` (the "Plot on 1" button, the user's path, put it back), `back_to_list=1` ("Back to signals" +target), `row_opens=1` (the probe switches the signal off so it is addable, activates its list row and the page opens; the signal is then switched back on), and `pruned=1`: a math channel's page was opened, its **Delete** button clicked, and the slide-over closed and the page was dropped by `signal_pages_sync()`. `pruned=-1` means no math channel could be added; `0` is a stale page. `other_page_kept=1`: with the `trigger` page open, a signal page that is not on show was pruned (a math channel added, its page built, the channel removed) and the slide-over was still on `trigger`, both by `slideover_page()` and by the stack's visible child; `0` is `signal_pages_sync()` parking the stack on the signals list while the title and the badge marker still name `trigger`; `-1` when there is no `trigger` page or no math channel could be added. @@ -2658,14 +2663,14 @@ signals list while the title and the badge marker still name `trigger`; `-1` whe ## `[sigbadges]` ``` -[sigbadges] enabled=4 badges=4 overflow=0 overflow_text=- plus_visible=1 dot_plotted=4 bar_h=36 bar_h_fixed=1 bar_min_w=336 opens=1 plus_opens=1 +[sigbadges] enabled=4 badges=4 overflow=0 overflow_text=- plus_visible=1 dot_plotted=4 bar_h=36 bar_h_fixed=1 bar_min_w=251 opens=1 plus_opens=1 ``` `UDPSCOPE_PROBE_SIGNAL_BADGES_MS` reads the signal badges. `badges` must equal `enabled` (the enabled, listed signals) while `enabled` is at most 6 (`kMaxSignalBadges`); beyond that `badges=0 overflow=1 overflow_text= signals plus_visible=0` (the "N signals" badge replaces the badges and the "+"). `dot_plotted` is the number of badges whose signal is on a plot (its dot is then the trace colour, not the unplotted grey). `bar_h` is the bar's allocated height and `bar_h_fixed=1` that neither it nor its natural height moved while one signal was switched off and on again (`rebuild_signal_panels()` each time), and that the badge count followed; `0` is a bar whose height depends on its content. -`bar_min_w` is only a record: the scroller must keep it the same however many signals there are (it reads 336 with the UDPS mock's 4 and with the overflow forced on, and 329 against the E-1608 mock with one signal and with nine -- the overflow, `enabled=9 badges=0 overflow=1 overflow_text=9 signals plus_visible=0 bar_h=36 bar_h_fixed=1`; a value that grows with the signal count is a scroller that has stopped constraining the bar). `opens=1`: clicking the +`bar_min_w` is only a record: the scroller must keep it the same however many signals there are (it reads 251 with the UDPS mock's 4 and with the overflow forced on, and 244 against the E-1608 mock with one signal and with nine -- the overflow, `enabled=9 badges=0 overflow=1 overflow_text=9 signals plus_visible=0 bar_h=36 bar_h_fixed=1`; a value that grows with the signal count is a scroller that has stopped constraining the bar). `opens=1`: clicking the first signal badge opens `signal:` with the badge marked open and a second click closes it (`-1` when overflowed, there being no signal badges); `plus_opens=1` the same for the "+" (the overflow badge when overflowed) and the `signals` page. @@ -2697,8 +2702,7 @@ any is a badge that does not say its panel is open, or one that stays marked aft **Line 2** is the Source badge, and every field has a value it must read: -- `name=` the source kind's name and `state=` `connected`/`disconnected`, as the badge shows them. `connected` against a live mock; a badge stuck on - `disconnected` is the status not reaching the badge. +- `name=` the source kind's name, as the badge shows it. The badge carries no link state (the status bar says it); there is no `state=` field. - `pages=` the number of slide-over pages the source kind contributes (`source-`): `0` for UDPS, `2` for the E-1608 (`source-ao`, `source-dio`). `can_target=0` with `pages=0` is the UDPS badge being a read-out rather than a button; `can_target=1` with `pages>0` is the button being live. `can_target=1` with `pages=0` is a dead button that opens nothing. diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 7c749b1..25f5d9f 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -472,7 +472,7 @@ precedent, not an exception. a sensitivity, a visibility or a value needs no open page. **The source kind's panels are slide-over pages too** (`source-`: the E-1608's `source-ao` and `source-dio`, Flat UDP's, the probe kind's), so the same rule holds for them. The probe appends a second - `[badges]` line for the Source badge: `[badges] source name= state= pages= + `[badges]` line for the Source badge: `[badges] source name= pages= switch_visible=<0|1> can_target=<0|1> second_page= second_ok=<0|1> open_marked=<0|1|-1> closed_unmarked=1`. `pages` is the number of source pages, `switch_visible` is the segmented switch being on screen after the first source page was opened (`1` for the E-1608's two pages, `0` for one page @@ -1035,8 +1035,9 @@ stages into one is the failure mode**: a probe that rebuilt the buffer at 11.5 s immediately would read `bare=0` on every search leg, which looks exactly like a broken comparator. **Why 4 s and not 3.** `enable_all_probe`'s last act is a timeout of its own, `ENABLE_ALL_MS + 2000` -- 3 s -under this recipe -- which walks `signal_list` for the front channel's acquire tick and clicks it. A probe -that installs a math graph at that same instant rebuilds the signal panel under it, and the off-step then +under this recipe -- which opens the front channel's signal page and clicks its "Acquire this signal" tick (an +acquired signal is not in the Add-a-signal list, so the list cannot be walked). A probe +that installs a math graph at that same instant rebuilds the signal panels under it, and the off-step then emits `active` on a freed `GtkCheckButton`: `GLib-GObject-CRITICAL: instance with invalid (NULL) class pointer`, with the backtrace in `enable_all_probe()::{lambda}`. It is a real fragility of that probe's lookup and not of the feature, and it is why every documented probe time is offset from `ENABLE_ALL_MS + 2000`, not diff --git a/help/C/org.udpscope.app/overview.page b/help/C/org.udpscope.app/overview.page index fd605c2..9f95d4a 100644 --- a/help/C/org.udpscope.app/overview.page +++ b/help/C/org.udpscope.app/overview.page @@ -127,7 +127,7 @@ and port.

-

Enable a signal in the signal list. The plot stays empty for the first +

Enable a signal in the Add a signal page (the + badge). The plot stays empty for the first fraction of a second while the first window fills — the buffer holds only what has arrived.

From 616cde5b136bfa0aa292b569c015bcdbb8b19277 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 02:38:12 +0100 Subject: [PATCH 077/127] badges: hide the Source badge when the source has no panel of its own A Source pill that opens nothing (UDPS, Flat UDP, files) was noise. It is now shown only while the slide-over has a source page; the [badges] source line gains visible=. Co-Authored-By: Claude Opus 4.6 --- README.md | 7 ++++--- docs/claude/architecture.md | 4 +++- docs/claude/invariants-plot-and-layout.md | 6 +++--- docs/claude/probe-readings.md | 11 ++++++----- docs/claude/verification.md | 7 ++++--- src/ui_app.hpp | 2 +- src/ui_badges.cpp | 22 +++++++++++++++++----- src/ui_tour.cpp | 6 +++--- 8 files changed, 41 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index cba3458..60ef547 100644 --- a/README.md +++ b/README.md @@ -118,10 +118,11 @@ place (it opens the list, and `+` is hidden): the E-1608's 8 analog inputs and c otherwise. The signal badges sit in a scroller, so the bar's minimum width, and with it the window's, never depends on how many signals there are. The badge order is kept **for the session only**; it is not written to the session file. The right-hand end carries two words-only badges: **Trigger** (`off`, `armed`, `capturing`, `triggered` -or `disarmed`) and **Source** (the kind's name only -- the link state is the status bar's; a read-out, not a button, -for a source with no controls of its own, and for a file). A badge opens the **slide-over**: a 360 px +or `disarmed`) and **Source** (the kind's name only -- the link state is the status bar's). **Source is shown only when the source +has panels of its own** (the E-1608's); for UDPS, Flat UDP and a file there is nothing for it to open, so it is hidden +(the bar keeps its height; the kind's name is in the status bar). A badge opens the **slide-over**: a 360 px panel laid over the plot's right edge, opaque, with its own title, a *Close* button and one scroller per -page. The Trigger badge opens the trigger form; the Source badge opens the source kind's own panels +page. The Trigger badge opens the trigger form; the Source badge (when shown) opens the source kind's own panels (the E-1608's *Analog out* and *Digital I/O*, behind a segmented switch at the top of the panel). It closes with a second click on its badge, with `Esc`, with *Close*, or with a click on the plot; it never resizes the plot. The right-hand **dock** (the existing sidebar, 320 px) holds what is left of the old diff --git a/docs/claude/architecture.md b/docs/claude/architecture.md index 7446c30..9487074 100644 --- a/docs/claude/architecture.md +++ b/docs/claude/architecture.md @@ -113,7 +113,9 @@ touching the data path. **Source** badges). `sync_signal_badges()` rebuilds the signal badges (from `rebuild_signal_panels()`); their order is `App::badge_order`, in memory only. A badge is a flat button whose `page` is the slide-over page it toggles; the Source badge's page is the first `source-*` page, and a kind with none (UDPS, Flat UDP, a - file) leaves it a read-out with `can_target` off. + file) leaves it **hidden** (the user asked for hide here, not the usual disable): `sync_badge_bar()` sets its visibility, + pick and focus from `slideover.source_pages` being non-empty every tick, so anything that indexes the badge + (the tour's `badges` step targets the whole bar, `[badges]`) must tolerate it being absent. - **The dock** is the `AdwOverlaySplitView` sidebar (`side_box` > `dock_scroller` > `dock_page`): the cursors and the measurements only, in one scroller (the signal list is the `signals` page). It holds no tab switcher and no revealer, and it does not hold a source kind's panels (those are slide-over pages). diff --git a/docs/claude/invariants-plot-and-layout.md b/docs/claude/invariants-plot-and-layout.md index 1b962d5..89c4ac3 100644 --- a/docs/claude/invariants-plot-and-layout.md +++ b/docs/claude/invariants-plot-and-layout.md @@ -117,7 +117,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear (`height-request`) between `plot_overlay` and the status line, so the plot above it never moves when a badge's text changes (`bar_h` = `bar_h_after` in `[badges]`). This is "layout must not depend on content" for the new row: the Trigger badge's state is a word from a short fixed set and the Source badge - is a kind's display name only (no connection state: that is the status bar's), so what changes with the stream is the row's *width* (the bar has room to + (shown only when the kind has source pages; hidden otherwise) is a kind's display name only (no connection state: that is the status bar's), so what changes with the stream is the row's *width* (the bar has room to spare on the right) and never its height. The badge labels are not ellipsized: a name long enough to need it would be a defect of the kind's `display_name()`. Rows that appear by trigger condition appear inside the slide-over's scroller, never in the bar. @@ -127,8 +127,8 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear (the E-1608: eight inputs and the counter) would push the window's minimum width past a laptop screen. Past `kMaxSignalBadges` (6) the row is replaced by one "N signals" badge, which also stands for the `+`. The bar's height stays 36 px in every one of these states (`bar_h_fixed` in `[sigbadges]`), and - `bar_min_w` reads the same with one signal, nine signals and the overflow (244 on the E-1608, 251 on UDPS: - the Source badge of each kind reads differently). A change that makes it grow with the signal count has + `bar_min_w` reads the same with one signal, nine signals and the overflow (244 on the E-1608, 124 on UDPS: + the Source badge is on screen for the E-1608 and hidden for UDPS, and its text differs by kind). A change that makes it grow with the signal count has taken the scroller out of the measurement. - **The dock's width is fixed too** (`side_column` `width-request` 300, the split view's 320 px floor and cap), and it holds one scroller with the cursors and the measurements. A diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 717ee86..96e35cd 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -2663,14 +2663,14 @@ signals list while the title and the badge marker still name `trigger`; `-1` whe ## `[sigbadges]` ``` -[sigbadges] enabled=4 badges=4 overflow=0 overflow_text=- plus_visible=1 dot_plotted=4 bar_h=36 bar_h_fixed=1 bar_min_w=251 opens=1 plus_opens=1 +[sigbadges] enabled=4 badges=4 overflow=0 overflow_text=- plus_visible=1 dot_plotted=4 bar_h=36 bar_h_fixed=1 bar_min_w=124 opens=1 plus_opens=1 ``` `UDPSCOPE_PROBE_SIGNAL_BADGES_MS` reads the signal badges. `badges` must equal `enabled` (the enabled, listed signals) while `enabled` is at most 6 (`kMaxSignalBadges`); beyond that `badges=0 overflow=1 overflow_text= signals plus_visible=0` (the "N signals" badge replaces the badges and the "+"). `dot_plotted` is the number of badges whose signal is on a plot (its dot is then the trace colour, not the unplotted grey). `bar_h` is the bar's allocated height and `bar_h_fixed=1` that neither it nor its natural height moved while one signal was switched off and on again (`rebuild_signal_panels()` each time), and that the badge count followed; `0` is a bar whose height depends on its content. -`bar_min_w` is only a record: the scroller must keep it the same however many signals there are (it reads 251 with the UDPS mock's 4 and with the overflow forced on, and 244 against the E-1608 mock with one signal and with nine -- the overflow, `enabled=9 badges=0 overflow=1 overflow_text=9 signals plus_visible=0 bar_h=36 bar_h_fixed=1`; a value that grows with the signal count is a scroller that has stopped constraining the bar). `opens=1`: clicking the +`bar_min_w` is only a record: the scroller must keep it the same however many signals there are (it reads 124 with the UDPS mock's 3 (the Source badge being hidden there) and with the overflow forced on, and 244 against the E-1608 mock with one signal and with nine -- the overflow, `enabled=9 badges=0 overflow=1 overflow_text=9 signals plus_visible=0 bar_h=36 bar_h_fixed=1`; a value that grows with the signal count is a scroller that has stopped constraining the bar). `opens=1`: clicking the first signal badge opens `signal:` with the badge marked open and a second click closes it (`-1` when overflowed, there being no signal badges); `plus_opens=1` the same for the "+" (the overflow badge when overflowed) and the `signals` page. @@ -2691,7 +2691,7 @@ gesture's threshold, the slide-over closing under it and the pointer-driven drop ``` [badges] table_bad=0 trigger_state=off bar_h=36 bar_h_after=36 fixed=1 open_marked=1 page_open=1 closed_unmarked=1 -[badges] source name=UDPStreamer state=connected pages=0 switch_visible=0 can_target=0 second_page=- second_ok=-1 open_marked=-1 closed_unmarked=1 panel_min_w=0 +[badges] source name=UDPStreamer pages=0 visible=0 switch_visible=0 can_target=0 second_page=- second_ok=-1 open_marked=-1 closed_unmarked=1 panel_min_w=0 ``` `UDPSCOPE_PROBE_BADGES_MS` reads the badge bar under the plot. **Line 1** is the Trigger badge: `table_bad=0` (the rows of the 16-input @@ -2700,12 +2700,13 @@ the same, and at least 36 px, before and after the `trigger` page was opened and moves), `open_marked=1` / `page_open=1` / `closed_unmarked=1` (the `udpscope-badge-open` class follows the slide-over's page, both ways; `0` on any is a badge that does not say its panel is open, or one that stays marked after it closed). -**Line 2** is the Source badge, and every field has a value it must read: +**Line 2** is the Source badge, which is **hidden when the source has no pages** (UDPS, Flat UDP, a file), and every field has a value it must read: - `name=` the source kind's name, as the badge shows it. The badge carries no link state (the status bar says it); there is no `state=` field. - `pages=` the number of slide-over pages the source kind contributes (`source-`): `0` for UDPS, `2` for the E-1608 (`source-ao`, `source-dio`). - `can_target=0` with `pages=0` is the UDPS badge being a read-out rather than a button; `can_target=1` with `pages>0` is the button being live. + `can_target=0` with `pages=0` is the UDPS badge being untargetable, which goes with it being hidden; `can_target=1` with `pages>0` is the button being live. `can_target=1` with `pages=0` is a dead button that opens nothing. +- `visible=` the badge being on screen: **`0` with `pages=0`** (hidden, by design: nothing to open), `1` with `pages>0`. `visible=1` with `pages=0` is a dead pill that was supposed to be hidden; `visible=0` with pages is a source panel nothing can open. - `switch_visible=` the segmented switch being on screen after the first source page was opened: `1` with two or more pages, `0` with one or none. A switch shown for a single page is a control that cannot change anything; hidden with two, the second panel is unreachable. - `second_page=` / `second_ok=` what `slideover_page()` read after the switch selected the second page, and whether it is that page. **`second_ok=1` with diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 25f5d9f..8a07d37 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -473,12 +473,13 @@ precedent, not an exception. **The source kind's panels are slide-over pages too** (`source-`: the E-1608's `source-ao` and `source-dio`, Flat UDP's, the probe kind's), so the same rule holds for them. The probe appends a second `[badges]` line for the Source badge: `[badges] source name= pages= - switch_visible=<0|1> can_target=<0|1> second_page= second_ok=<0|1> open_marked=<0|1|-1> + visible=<0|1> switch_visible=<0|1> can_target=<0|1> second_page= second_ok=<0|1> open_marked=<0|1|-1> closed_unmarked=1`. `pages` is the number of source pages, `switch_visible` is the segmented switch being on screen after the first source page was opened (`1` for the E-1608's two pages, `0` for one page or none), `second_page` is what `slideover_page()` read after the switch selected the second page - (`second_ok=1` when it is that page), `can_target=0` with `pages=0` is the UDPS badge being a read-out and - `open_marked=-1` is that no-page case (there is nothing to open). + (`second_ok=1` when it is that page), `visible=0` with `pages=0` is the UDPS badge being **hidden** (a kind with no source page + has nothing for it to open; `can_target=0` goes with it) and `open_marked=-1` is that no-page case (there is + nothing to open); `visible=1` with `pages>0`. - `UDPSCOPE_PROBE_SOURCE_KINDS=2000` with `UDPSCOPE_EXIT_AFTER_MS=5000`, run as `--source probe --source-opt rate=500` -- the source-plugin path end to end. The variable's presence also registers the test-only `probe` kind (a synthetic sine with one panel), so it is the only way a run sees that kind; its value is the delay in ms. diff --git a/src/ui_app.hpp b/src/ui_app.hpp index a9322ee..2f2603e 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -160,7 +160,7 @@ struct BadgeBar { GtkWidget* signals_slot = nullptr; ///< left; empty in step 1, step 2 fills it GtkWidget* system_slot = nullptr; ///< right: Trigger, Source Badge trigger; - Badge source; ///< opens the first source page; a read-out when the source has none + Badge source; ///< opens the first source page; hidden when the source has none GtkWidget* signals_row = nullptr; ///< the box inside the scroller: signal badges, then the "+" /// One per shown enabled signal; empty when overflowed. `unique_ptr`: each button's "clicked" handler holds /// a pointer to its `Badge`, which must not move when the vector does. diff --git a/src/ui_badges.cpp b/src/ui_badges.cpp index 485a84b..19ad510 100644 --- a/src/ui_badges.cpp +++ b/src/ui_badges.cpp @@ -173,13 +173,16 @@ void build_badge_bar(App& app) { bb.signals_slot = app.badge_signals; bb.system_slot = app.badge_system; make_badge(app, bb.trigger, bb.system_slot, "Trigger", "trigger"); - // The first source page is where the badge goes; a source with none (UDPS) has nothing to open, so its - // badge is a read-out: not a target, not focusable, but still on screen. + // The first source page is where the badge goes; a source with none (UDPS, Flat UDP, a file) has nothing to + // open, so its badge is hidden (`sync_badge_bar` keeps it in step with the slide-over's source pages) and, for + // as long as it is, neither a target nor focusable. Hidden, not insensitive: a dead "Source" pill that does + // nothing was only noise. The bar's height is fixed, so the row loses width and never height. const std::string first = app.source_panels.empty() ? std::string() : app.source_panels.front().name; make_badge(app, bb.source, bb.system_slot, "Source", first); if (first.empty()) { gtk_widget_set_can_target(bb.source.button, FALSE); gtk_widget_set_focusable(bb.source.button, FALSE); + gtk_widget_set_visible(bb.source.button, FALSE); } // The link state is the status bar's to say; the badge names the source and opens its pages. The empty // state label is hidden, as the "+" badge's is, rather than leaving a gap in the pill. @@ -278,6 +281,14 @@ void sync_badge_bar(App& app) { set_open(sb->button, slideover_page(app) == sb->page); } if (bb.source.button == nullptr) return; + // Shown only while there is a source page to open. The slide-over's page list is the truth, so a page that is + // registered later brings the badge back, and its focus and pick with it. + const bool has_source_pages = !app.slideover.source_pages.empty(); + if ((gtk_widget_get_visible(bb.source.button) != FALSE) != has_source_pages) { + gtk_widget_set_visible(bb.source.button, has_source_pages); + gtk_widget_set_can_target(bb.source.button, has_source_pages); + gtk_widget_set_focusable(bb.source.button, has_source_pages); + } // The source's name; the link state is the status bar's. A file has no kind worth naming. const bool file_mode = app.replaying || !app.model->config().open_waveform.empty(); std::string name = "Source"; @@ -322,7 +333,8 @@ gboolean badges_probe(gpointer user) { bad, gtk_label_get_text(GTK_LABEL(a->badges.trigger.state)), h_closed, h_after, h_closed == h_after && h_closed >= 36 ? 1 : 0, marked_open ? 1 : 0, page_open ? 1 : 0, marked_closed ? 1 : 0); - // The Source badge: its read-out, and the segmented switch of a source with several panels. + // The Source badge: shown only with a source page (`visible=`; hidden, the page-dependent fields read -1 or 0), + // and the segmented switch of a source with several panels. const std::string first = a->badges.source.page; bool switch_visible = false; int second_ok = -1; // -1: there is no second page to select (one source page, or none) @@ -348,11 +360,11 @@ gboolean badges_probe(gpointer user) { } slideover_close(*a); std::fprintf(stderr, - "[badges] source name=%s pages=%zu switch_visible=%d can_target=%d second_page=%s " + "[badges] source name=%s pages=%zu visible=%d switch_visible=%d can_target=%d second_page=%s " "second_ok=%d open_marked=%d closed_unmarked=%d panel_min_w=%d\n", gtk_label_get_text(GTK_LABEL(a->badges.source.name)), a->slideover.source_pages.size(), - switch_visible ? 1 : 0, gtk_widget_get_can_target(a->badges.source.button) ? 1 : 0, + gtk_widget_get_visible(a->badges.source.button) ? 1 : 0, switch_visible ? 1 : 0, gtk_widget_get_can_target(a->badges.source.button) ? 1 : 0, second_page.empty() ? "-" : second_page.c_str(), second_ok, first.empty() ? -1 : marked ? 1 : 0, gtk_widget_has_css_class(a->badges.source.button, "udpscope-badge-open") ? 0 : 1, widest); diff --git a/src/ui_tour.cpp b/src/ui_tour.cpp index d698849..d77a640 100644 --- a/src/ui_tour.cpp +++ b/src/ui_tour.cpp @@ -446,9 +446,9 @@ constexpr TourStep kTourSteps[] = { &App::area_overlay}, {"badges", nullptr, "The badges", "Setup is reached by the thing it configures. A badge per signal opens that signal's colour, " - "calibration and plot; the + opens the full list; Trigger and Source open their own forms. Drag a " - "signal badge onto a plot to draw it there, or onto another badge to reorder. Click again, press " - "Esc or click the plot to close the panel.", + "calibration and plot; the + opens the full list; Trigger opens its form, and so does Source when " + "the source has controls of its own. Drag a signal badge onto a plot to draw it there, or onto " + "another badge to reorder. Click again, press Esc or click the plot to close the panel.", &App::badge_bar}, {"trigger", "trigger", "The trigger", "Describe an event -- a signal, a condition, a level and the window around it -- and arm it: " From 034c82f7d781009fd46e8d1a1ca8743c00e12a6d Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 02:47:04 +0100 Subject: [PATCH 078/127] Budget the sample buffer in samples per signal, under a hard memory limit --buffer-samples (k/M/G) replaces --buffer-mb: the request is rounded down to a power of two for every signal and the shared time ring. A separate memory limit (--memory-limit-mb, a Preferences row, default 8 GiB) halves the plan until the real ring bytes fit and says so on stderr and in the status line. The per-source key buffer_mb becomes buffer_samples; an old buffer_mb is ignored. Co-Authored-By: Claude Opus 4.6 --- src/config.cpp | 39 +++++++++++-- src/config.hpp | 24 +++++--- src/layout_store.cpp | 12 ++-- src/layout_store.hpp | 11 ++-- src/main.cpp | 6 +- src/model.cpp | 133 +++++++++++++++++++++++++++++------------- src/model.hpp | 82 +++++++++++++++++++++----- src/settings.cpp | 5 ++ src/settings.hpp | 6 ++ src/source_dialog.cpp | 100 ++++++++++++++++++++++--------- src/status_text.cpp | 53 +++++++++++++++-- src/status_text.hpp | 23 +++++++- src/ui.cpp | 53 ++++++++++++++++- src/ui/preferences.ui | 23 +++++++- src/ui/source-form.ui | 12 ++-- src/ui_layout.cpp | 12 ++-- src/ui_prefs.cpp | 30 +++++++++- src/ui_prefs.hpp | 1 + src/ui_probe.cpp | 9 +-- src/ui_status.cpp | 16 ++++- 20 files changed, 516 insertions(+), 134 deletions(-) diff --git a/src/config.cpp b/src/config.cpp index 73ab661..eba38d1 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -39,6 +39,25 @@ double parse_double(const char* name, const std::string& v) { } } +/// A count with an optional decimal suffix: `250000`, `64k`, `100M`, `1.5G`. k, M and G are 10^3, 10^6 +/// and 10^9 -- a *sample* count, not a byte size -- and either case is accepted. +std::uint64_t parse_count(const char* name, const std::string& v) { + std::string digits = v; + double scale = 1.0; + if (!digits.empty()) { + switch (digits.back()) { + case 'k': case 'K': scale = 1.0e3; break; + case 'm': case 'M': scale = 1.0e6; break; + case 'g': case 'G': scale = 1.0e9; break; + default: break; + } + if (scale != 1.0) digits.pop_back(); + } + const double d = parse_double(name, digits) * scale; + if (!(d >= 1.0) || d > 1.0e12) fail(std::string(name) + " out of range: " + v); + return static_cast(d + 0.5); +} + } // namespace SourceConfig Config::source_config() const { @@ -70,7 +89,11 @@ void print_usage(std::FILE* out, const char* argv0) { " --source NAME source kind (%s; default udps). --server/--port/\n" " --multicast/--interface configure udps\n" " --source-opt K=V a setting for the source kind; repeatable\n" - " --buffer-mb MB size of each of the two sample buffers (default 200)\n" + " --buffer-samples N samples kept per acquired signal, e.g. 100M or 250000 (k, M\n" + " and G are decimal; default 10M). Rounded down to a power\n" + " of two: 100M gives 67108864\n" + " --memory-limit-mb MB hard ceiling on the sample rings, all signals together\n" + " (default 8192); the request is halved until it fits\n" " --max-window SEC largest time window the toolbar offers (default 600)\n" " --holdoff SEC default trigger hold-off (default 0.010)\n" " --recv-buffer BYTES SO_RCVBUF for the receiver socket (default 8388608)\n" @@ -125,10 +148,16 @@ Config parse_args(int argc, char** argv) { } else if (a == "--source-opt" || a.rfind("--source-opt=", 0) == 0) { const std::string v = value_of(argc, argv, i, "--source-opt"); if (!c.source_opts.parse_assignment(v)) fail("--source-opt must be key=value: " + v); - } else if (a == "--buffer-mb" || a.rfind("--buffer-mb=", 0) == 0) { - c.buffer_mb = parse_double("--buffer-mb", value_of(argc, argv, i, "--buffer-mb")); - c.buffer_mb_specified = true; - if (c.buffer_mb < 1.0) fail("--buffer-mb must be at least 1"); + } else if (a == "--buffer-samples" || a.rfind("--buffer-samples=", 0) == 0) { + c.buffer_samples = parse_count("--buffer-samples", value_of(argc, argv, i, "--buffer-samples")); + c.buffer_samples_specified = true; + if (c.buffer_samples < 128) fail("--buffer-samples must be at least 128"); + } else if (a == "--memory-limit-mb" || a.rfind("--memory-limit-mb=", 0) == 0) { + const double mb = + parse_double("--memory-limit-mb", value_of(argc, argv, i, "--memory-limit-mb")); + if (!(mb >= 1.0) || mb > 1.0e9) fail("--memory-limit-mb must be 1..1000000000 (MiB)"); + c.memory_limit_mb = static_cast(mb); + c.memory_limit_specified = true; } else if (a == "--max-window" || a.rfind("--max-window=", 0) == 0) { c.max_window_s = parse_double("--max-window", value_of(argc, argv, i, "--max-window")); if (c.max_window_s <= 0.0) fail("--max-window must be positive"); diff --git a/src/config.hpp b/src/config.hpp index 7144a0b..58a5f42 100644 --- a/src/config.hpp +++ b/src/config.hpp @@ -11,7 +11,7 @@ namespace udpscope { /// Everything the command line can say. Defaults are the ones the spec names -/// (200 MB per buffer, 10 ms trigger hold-off) unless the user overrides them. +/// (10 M samples per signal, 10 ms trigger hold-off) unless the user overrides them. struct Config { std::string server = "127.0.0.1"; ///< IPv4 dotted quad of the stream source. std::uint16_t port = 8000; ///< UDP port (unicast) or TCP port (multicast). @@ -40,13 +40,21 @@ struct Config { std::string session_host() const; std::uint16_t session_port() const; - /// Size of *each* of the two sample buffers, in megabytes. The application - /// always allocates two identical buffers (spec item 1). - double buffer_mb = 200.0; - /// Whether `--buffer-mb` was given. A stored configuration's buffer size fills in only when the - /// flag was *absent*: an explicit flag beats a saved setting, and "not asked" is not the same - /// question as "asked for 200". - bool buffer_mb_specified = false; + /// The sample budget: how many samples to keep for **every acquired signal**, and for the shared + /// time ring. The rings are powers of two, so the request is rounded *down* to one (`buffer_ticks()` + /// in model.hpp) -- "up to" this many -- and the UI shows both figures. 10 M asks for 8 388 608. + std::uint64_t buffer_samples = 10'000'000; + /// Whether `--buffer-samples` was given. A stored configuration's sample budget fills in only when + /// the flag was *absent*: an explicit flag beats a saved setting, and "not asked" is not the same + /// question as "asked for 10 M". + bool buffer_samples_specified = false; + + /// The hard ceiling on the ring storage, in MiB, for the whole application rather than per source. + /// `plan_partition()` halves the rings until they fit under it, so it can clip `buffer_samples`. + /// Default 8 GiB; the preferences carry it between runs and `--memory-limit-mb` overrides it. + std::uint64_t memory_limit_mb = 8192; + /// Whether `--memory-limit-mb` was given, so the stored preference only fills the gap. + bool memory_limit_specified = false; /// Largest time window the toolbar offers, in seconds. The buffer partition is /// sized to hold at least this much time (spec: decimation). diff --git a/src/layout_store.cpp b/src/layout_store.cpp index 502f9aa..c6987f9 100644 --- a/src/layout_store.cpp +++ b/src/layout_store.cpp @@ -328,7 +328,7 @@ std::vector known_sources() { s.port = port; s.config = name; s.comment = st.comment; - s.buffer_mb = st.buffer_mb; + s.buffer_samples = st.buffer_samples; s.relative_time = st.relative_time; s.capture_packets = st.capture_packets; s.multicast = st.multicast; @@ -347,7 +347,7 @@ std::vector known_sources() { // session file per probe run and every one holds `signals = ["", "", "", ""]`. One that // carries a *setting* is worth a row even before it has ever streamed -- that is how a host is // set up from the chooser's form, and the settings are the whole point of editing one. - const bool configured = s.signals > 0 || s.buffer_mb > 0.0 || !st.comment.empty() || + const bool configured = s.signals > 0 || s.buffer_samples > 0 || !st.comment.empty() || st.relative_time || st.capture_packets > 0; if (!configured) continue; struct stat fb {}; @@ -583,9 +583,11 @@ bool load_layout(const std::string& path, LayoutState& out) { if (SourceConfig::valid_key(k)) st.source_opts.set(k, strip_quote(value)); } else if (key == "comment") { st.comment = strip_quote(value); - } else if (key == "buffer_mb") { + } else if (key == "buffer_samples") { try { - st.buffer_mb = std::stod(value); + // A negative or absurd count is "not stored", not a request: `stoull` wraps a minus sign. + const unsigned long long n = value.find('-') == std::string::npos ? std::stoull(value) : 0; + st.buffer_samples = n >= 128 && n <= 1000000000000ull ? n : 0; } catch (...) { } } else if (key == "relative_time") { @@ -768,7 +770,7 @@ bool save_layout(const std::string& path, const LayoutState& state) { o << "trigger_density_tau = " << state.trigger.density_tau_s << "\n"; o << "trigger_density_factor = " << state.trigger.density_factor << "\n"; if (!state.comment.empty()) o << "comment = " << toml_escape(state.comment) << "\n"; - if (state.buffer_mb > 0.0) o << "buffer_mb = " << state.buffer_mb << "\n"; + if (state.buffer_samples > 0) o << "buffer_samples = " << state.buffer_samples << "\n"; o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n"; if (state.capture_packets > 0) o << "capture_packets = " << state.capture_packets << "\n"; for (const auto& row : state.rows) { diff --git a/src/layout_store.hpp b/src/layout_store.hpp index 38264d7..fff8a93 100644 --- a/src/layout_store.hpp +++ b/src/layout_store.hpp @@ -139,9 +139,12 @@ struct LayoutState { /// The startup settings the configuration carries, applied when the chooser opens it -- which is /// why they live here and not only on the command line: two sources on one machine want different /// buffers and different clocks, and retyping the flags to look at the second one is how a - /// setting gets forgotten. A zero `buffer_mb` and an empty `capture_packets` mean "not stored": - /// the command line's value stands, so an old file cannot silently shrink a buffer. - double buffer_mb = 0.0; + /// setting gets forgotten. A zero `buffer_samples` and an empty `capture_packets` mean "not stored": + /// the command line's value stands, so an old file cannot silently shrink a buffer. The budget is + /// samples per acquired signal (the memory limit is the application's, not a source's). A file from + /// before that unit carries `buffer_mb`, which is not read: it reads as "not stored" and the + /// default stands, rather than a megabyte count being taken for a sample count. + std::uint64_t buffer_samples = 0; bool relative_time = false; std::uint32_t capture_packets = 0; }; @@ -162,7 +165,7 @@ struct KnownSource { std::string config; std::string comment; ///< the configuration's note, shown beside its name /// The startup settings this configuration carries, so the chooser can apply them. - double buffer_mb = 0.0; + std::uint64_t buffer_samples = 0; bool relative_time = false; std::uint32_t capture_packets = 0; }; diff --git a/src/main.cpp b/src/main.cpp index c6be5c6..7f16a52 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -24,7 +24,11 @@ int main(int argc, char** argv) { // The preferences fill in what the command line did not decide. `--relative-time` is an explicit // instruction for this run, so a preference can only turn the mode on, never off -- which is also // why this is read here, before the buffers are planned: the mode changes how they are filled. - if (udpscope::load_settings().relative_time) cfg.relative_time = true; + const udpscope::Settings prefs = udpscope::load_settings(); + if (prefs.relative_time) cfg.relative_time = true; + // The memory limit is the same kind of statement, the other way round: the flag is the explicit + // instruction for this run and the preference only fills in when it was absent. + if (!cfg.memory_limit_specified) cfg.memory_limit_mb = static_cast(prefs.memory_limit_mb); } catch (const std::exception& e) { std::fprintf(stderr, "udpscope: %s\n", e.what()); return 2; diff --git a/src/model.cpp b/src/model.cpp index 765459f..7535a9d 100644 --- a/src/model.cpp +++ b/src/model.cpp @@ -3,6 +3,8 @@ #include "model.hpp" +#include "status_text.hpp" // `format_samples()` / `format_bytes()`, for the one line a clipped plan prints + #include #include #include @@ -40,12 +42,24 @@ constexpr std::uint64_t kMathCostSample = 1024; } // namespace -Partition plan_partition(const std::vector& channel_dt, double buffer_bytes, - double target_span_s, bool with_abs_time) { +u64 buffer_ticks(u64 requested_samples) { + // 2^40 slots is already 8 TiB of clock alone: a request past it is nonsense and the cap keeps + // `bytes_for()` far from overflowing. + constexpr u64 kMaxTicks = u64(1) << 40; + return std::min(pow2_floor(std::max(requested_samples, 128)), kMaxTicks); +} + +u64 planned_bytes(u64 ticks, std::size_t channels, bool with_abs_time) { + return ticks * sizeof(f64) * (with_abs_time ? 2u : 1u) + ticks * sizeof(f32) * channels; +} + +Partition plan_partition(const std::vector& channel_dt, u64 samples_per_signal, + u64 max_bytes, double target_span_s, bool with_abs_time) { Partition p; const std::size_t n = channel_dt.size(); p.stride.assign(n, 1); p.value_capacity.assign(n, 0); + p.requested_samples = samples_per_signal; if (n == 0) return p; // The fastest channel drives the shared time base: every other channel is @@ -69,25 +83,13 @@ Partition plan_partition(const std::vector& channel_dt, double buffer_by p.abs_time = with_abs_time; - const u64 budget_bytes = static_cast(std::max(1.0, buffer_bytes)); - // Weighted in *bytes*, not in slots: the shared clock is f64 and the values are f32 - // (see `BufferStore::time()`), so a time slot costs twice a value sample and the two - // 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(sizeof(f64)) * (with_abs_time ? 2.0 : 1.0); // the time slice(s) - for (u64 s : p.stride) weight += static_cast(sizeof(f32)) / static_cast(s); - - const u64 slots = static_cast(static_cast(budget_bytes) / weight); - u64 time_capacity = pow2_floor(std::max(slots, 128)); - if (time_capacity == 0) { - throw std::runtime_error("buffer too small for one time-base sample per channel"); - } - - // The target span sets a floor, not a ceiling: the rest of the budget buys a - // longer ring, which is strictly more history to scroll back through. Growth - // stops before the total would exceed the budget. A channel's slots are rounded - // *up* on the power of two `RingBuffer` will round them to anyway, so every - // channel covers at least the time ring's span. + // The ring is a power of two, so the user's count is a ceiling and the plan takes the largest + // power of two under it. The budget is in *samples* now: it is the memory limit, not the request, + // that is in bytes -- and a time slot (f64) and a value sample (f32) are not the same size, which + // is why the limit below is weighed with `bytes_for()` and not as a count of slots. + // + // A channel's slots are rounded *down* on the power of two `RingBuffer` will round them to + // anyway, so every channel covers at most the time ring's span. // Floor, not ceiling: a channel's window must not be *longer* than the time ring // it reads its stamps from. Rounding up (with a stride that is not a power of two) // gave a channel more samples than the shared timeline covers, and the oldest of @@ -103,19 +105,24 @@ Partition plan_partition(const std::vector& channel_dt, double buffer_by for (u64 s : p.stride) total += capacity_for(ticks, s) * sizeof(f32); return total; }; - // `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; + u64 time_capacity = buffer_ticks(samples_per_signal); + const u64 wanted_ticks = time_capacity; + // The memory limit halves the request until the rings fit, never the other way: a bigger plan than + // asked for is not "up to". + while (time_capacity > 128 && bytes_for(time_capacity) > max_bytes) time_capacity /= 2; + if (bytes_for(time_capacity) > max_bytes) { + throw std::runtime_error("memory limit too small for the sample rings of " + + std::to_string(n) + " channels"); } + p.memory_limited = time_capacity < wanted_ticks; p.time_capacity = time_capacity; for (std::size_t i = 0; i < n; ++i) { p.value_capacity[i] = capacity_for(p.time_capacity, p.stride[i]); } - // Only now, with the ring as long as the budget can buy, decimate the shared clock + // Only now, with the ring as long as the request and the memory limit allow, decimate the shared clock // enough for the requested window to fit in it: deciding this earlier would over- - // decimate by whatever factor the growth loop was about to add. A power of two keeps + // decimate by whatever the ring's final length would have made unnecessary. A power of two keeps // the ring capacities (themselves powers of two) in step and lets the producer's group // test be a mask (`Acquisition::acc_mask_`) rather than a modulo -- a modulo on a stride // this function chose at runtime is a real 64-bit division, which the test *was* paying @@ -178,6 +185,7 @@ void BufferStore::acquire_all() { // --- ScopeModel ------------------------------------------------------------- ScopeModel::ScopeModel(const Config& cfg) : cfg_(cfg) { + memory_limit_bytes_.store(cfg.memory_limit_mb * 1024u * 1024u, std::memory_order_relaxed); trigger_.holdoff_s = cfg.trigger_holdoff_s; } @@ -567,22 +575,16 @@ void ScopeModel::rebuild_locked() { holes_.clear(); ++plan_generation_; - std::vector dts; - dts.reserve(channels_.size()); - for (const ChannelInfo& c : channels_) dts.push_back(c.dt); - - if (dts.empty()) { + if (channels_.empty()) { part_ = Partition{}; store_.reset(); return; } - const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0, - target_span_.load(std::memory_order_relaxed), - cfg_.relative_time); + const Partition want = plan_locked(target_span_.load(std::memory_order_relaxed)); // A plan that describes the storage already allocated is cleared, not rebuilt. Allocating - // a store is what costs -- its rings are value-initialised, so it touches the whole - // `--buffer-mb` -- and most structural changes do not move the plan at all: a calibration + // a store is what costs -- its rings are value-initialised, so it touches all of the + // planned rings -- and most structural changes do not move the plan at all: a calibration // keeps the channel set, and so does leaving an event for the live view. Startup asked for // sixteen of these and eight of them planned the same buffer twice. Clearing has the same // meaning (`BufferStore::reset()`): the history is gone either way, which is why @@ -601,6 +603,17 @@ void ScopeModel::rebuild_locked() { // the defect the line exists to name (it was fifteen of them at startup). std::fprintf(stderr, "[udpscope] buffer allocated: %zu channels, %.3g s span, %.0f ms\n", channels_.size(), part_.span_s, double(now_ns() - t0) / 1.0e6); + if (part_.memory_limited) { + // The one line that says the request was not honoured, and why: a smaller ring than the source + // form promised is otherwise explained nowhere. + std::fprintf(stderr, + "[udpscope] buffer limited to %s samples per signal (%s requested) by the %s " + "memory limit; the rings take %s\n", + format_samples(part_.time_capacity).c_str(), + format_samples(part_.requested_samples).c_str(), + format_bytes(memory_limit_bytes()).c_str(), + format_bytes(part_.bytes()).c_str()); + } std::fflush(stderr); } @@ -608,6 +621,47 @@ void ScopeModel::rebuild_locked() { prepare_math_locked(); } +Partition ScopeModel::plan_locked(double seconds) const { + std::vector dts; + dts.reserve(channels_.size()); + for (const ChannelInfo& c : channels_) dts.push_back(c.dt); + return plan_partition(dts, cfg_.buffer_samples, memory_limit_bytes(), seconds, cfg_.relative_time); +} + +bool ScopeModel::set_memory_limit_bytes(std::uint64_t bytes, bool replan) { + std::lock_guard lock(mutex_); + const std::uint64_t previous = memory_limit_bytes_.load(std::memory_order_relaxed); + if (bytes == previous) return false; + if (channels_.empty() || !replan) { + memory_limit_bytes_.store(bytes, std::memory_order_relaxed); + return false; + } + memory_limit_bytes_.store(bytes, std::memory_order_relaxed); + Partition want; + try { + want = plan_locked(target_span_.load(std::memory_order_relaxed)); + } catch (const std::exception& e) { + memory_limit_bytes_.store(previous, std::memory_order_relaxed); + std::fprintf(stderr, "[udpscope] memory limit %s refused: %s\n", format_bytes(bytes).c_str(), + e.what()); + std::fflush(stderr); + return false; + } + if (store_ && part_.same_storage_as(want)) { + // The same rings: only what the plan *says* about itself moved. Written in place, because the + // producer reads `part_` under the lock we hold and nothing here reallocates it. + part_.requested_samples = want.requested_samples; + part_.memory_limited = want.memory_limited; + return false; + } + std::fprintf(stderr, "[udpscope] memory limit %s -> %s: buffer re-planned\n", + format_bytes(previous).c_str(), format_bytes(bytes).c_str()); + std::fflush(stderr); + rebuild_locked(); + generation_.fetch_add(1, std::memory_order_release); + return true; +} + bool ScopeModel::set_target_span(double seconds) { if (!(seconds > 0.0)) return false; std::lock_guard lock(mutex_); @@ -616,10 +670,7 @@ bool ScopeModel::set_target_span(double seconds) { // Re-plan only if the factor moves. The planner is cheap, so ask it rather than // reimplementing its arithmetic here -- two expressions for the same decision is how // the buffer and the view end up disagreeing about the stored rate. - std::vector dts; - dts.reserve(channels_.size()); - for (const ChannelInfo& c : channels_) dts.push_back(c.dt); - const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0, seconds, cfg_.relative_time); + const Partition want = plan_locked(seconds); if (want.time_stride == part_.time_stride) return false; std::fprintf(stderr, "[udpscope] window %.4f s -> %.4f s: decimation %llu -> %llu, stored rate " diff --git a/src/model.hpp b/src/model.hpp index 88e4def..23daf4c 100644 --- a/src/model.hpp +++ b/src/model.hpp @@ -104,9 +104,27 @@ struct Partition { /// True when a second f64 ring parallel to the clock was planned and paid for in the /// byte budget. Relative mode only -- in stream mode `time()` already *is* the absolute - /// clock and a copy would cost ~22% of the user's `--buffer-mb` for nothing. + /// clock and a copy would cost ~22% of the user's sample budget for nothing. bool abs_time = false; + /// The samples-per-signal the user asked for, as given (not rounded). `time_capacity` is what + /// the plan actually holds: the request rounded down to a power of two, then halved further + /// while the rings did not fit the memory limit. These two are readings, not storage -- they are + /// not in `same_storage_as()` -- so a re-plan that only moves them does not drop history. + u64 requested_samples = 0; + /// True when the memory limit, and not the power-of-two rounding, made `time_capacity` smaller than + /// the request: the one case the UI has to explain. + bool memory_limited = false; + + /// The bytes the rings of this plan occupy: the time ring (twice in relative mode) plus every + /// value ring. This is the figure to quote and to budget against; a count of the user's + /// request is not, because the rings round and the limit can clip. + u64 bytes() const noexcept { + u64 total = time_capacity * sizeof(f64) * (abs_time ? 2u : 1u); + for (u64 c : value_capacity) total += c * sizeof(f32); + return total; + } + /// Seconds between two time-base slots (the sample interval of every channel). double slot_dt() const noexcept { return base_dt * static_cast(time_stride); } /// The rate samples are actually stored at, in samples per second. @@ -137,13 +155,24 @@ struct Partition { } }; -/// Plans a partition for `channel_dt` cadences within `buffer_bytes`, covering at least -/// `target_span_s` seconds of stream -- decimating (with the ingest filter) by whatever -/// power of two that takes. Throws std::runtime_error when the budget cannot hold even -/// one sample per channel. `with_abs_time` adds a second f64 per time slot to the budget -/// and sets `Partition::abs_time`. -Partition plan_partition(const std::vector& channel_dt, double buffer_bytes, - double target_span_s, bool with_abs_time = false); +/// The ring length a request of `requested_samples` per signal buys: the largest power of two not +/// above it (at least 128). The rings are powers of two, so the user's number is a ceiling. +u64 buffer_ticks(u64 requested_samples); + +/// What `ticks` slots cost for `channels` unit-stride channels: the f64 time ring (twice with the +/// absolute clock) plus one f32 ring per channel. The planner's own byte formula, for a caller +/// that has no `Partition` yet (the source form, the probes). +u64 planned_bytes(u64 ticks, std::size_t channels, bool with_abs_time); + +/// Plans a partition for `channel_dt` cadences: `samples_per_signal` slots for the time ring and for +/// every channel, rounded down to a power of two, then halved while the rings (`planned_bytes()`) +/// exceed `max_bytes` -- the memory limit. Covers at least `target_span_s` seconds of stream, +/// decimating (with the ingest filter) by whatever power of two that takes. Throws +/// std::runtime_error when even 128 slots do not fit `max_bytes`. `with_abs_time` adds a second f64 +/// per time slot to the budget and sets `Partition::abs_time`. `Partition::memory_limited` says +/// whether the limit clipped the request. +Partition plan_partition(const std::vector& channel_dt, u64 samples_per_signal, + u64 max_bytes, double target_span_s, bool with_abs_time = false); /// One of the two identical sample buffers: a shared time-base ring plus one /// value ring and one `ChannelBuffer` per channel. @@ -187,7 +216,7 @@ class BufferStore { /// /// Why it exists: constructing a store *is* its cost. A ring is a /// `vector>`, which is value-initialised, so building one touches every - /// byte of the user's `--buffer-mb` -- about 300 ms per GB, nearly all of it first-touch + /// byte of the planned buffer (`--buffer-samples`) -- about 300 ms per GB, nearly all of it first-touch /// page faults. Clearing is a count store per ring. Most structural changes (a /// calibration, a re-enable of what was just disabled, going back to live) re-plan to the /// *same* partition, and paying a second full allocation for them is what made a large @@ -546,13 +575,13 @@ class ScopeModel { std::uint64_t persist_dropped() const noexcept { return persist_dropped_.load(std::memory_order_relaxed); } - /// The bytes persistence may retain, which is the ring's own byte budget (`--buffer-mb`): the - /// snapshots are copies *of* what the rings hold, so holding as much again as the buffer does is - /// the honest ceiling for a display of it -- and it is the bound that cannot be driven into an - /// OOM by a long window on a fast stream, whatever depth the panel asks for. + /// The bytes persistence may retain, which is the rings' own planned byte total + /// (`Partition::bytes()`): the snapshots are copies *of* what the rings hold, so holding as much + /// again as the buffer does is the honest ceiling for a display of it -- and it is the bound that + /// cannot be driven into an OOM by a long window on a fast stream, whatever depth the panel asks + /// for. Zero until a stream has planned a buffer. Read under `mutex()`, like `partition()`. std::size_t persist_budget_bytes() const noexcept { - const int mb = cfg_.buffer_mb > 0 ? cfg_.buffer_mb : 0; - return static_cast(mb) * 1024u * 1024u; + return static_cast(part_.bytes()); } /// Sets how many acquisitions the persistence display keeps, clamped to `kMaxPersistDepth`, and @@ -671,6 +700,22 @@ class ScopeModel { /// `mutex()` itself; do not call it holding the lock. /// Returns true when the buffer was rebuilt. bool set_target_span(double seconds); + + /// The memory limit the planner honours, in bytes. Starts as `Config::memory_limit_mb` and is the + /// one planner input that changes while the application runs (the preferences). Atomic so the + /// status and the probes can read it without the lock. + std::uint64_t memory_limit_bytes() const noexcept { + return memory_limit_bytes_.load(std::memory_order_relaxed); + } + /// Changes the memory limit and re-plans. The buffer is rebuilt -- and the history dropped -- only + /// when the new limit changes the *storage* (`Partition::same_storage_as()`): a limit that still + /// leaves room for the plan updates `Partition::memory_limited` in place and costs nothing. + /// Returns true when the buffer was rebuilt. Takes `mutex()` itself; do not call it holding the + /// lock. A limit too small for even 128 slots per channel is refused (returns false, keeps the old + /// limit) and said on stderr. + /// With `replan` false the limit is only stored, for the next plan: a file or a replayed event + /// lives in the buffer, and re-planning it would clear what the user is looking at. + bool set_memory_limit_bytes(std::uint64_t bytes, bool replan = true); double target_span() const noexcept { return target_span_.load(std::memory_order_relaxed); } /// Trigger state for the UI, published by the acquisition thread. Atomics @@ -734,7 +779,14 @@ class ScopeModel { /// so a window change that moves the decimation re-prepares the filters exactly as an edit does. void prepare_math_locked(); + /// Plans the partition for the current channels at `seconds` of window with the current sample + /// budget and memory limit. The one place the planner's inputs are assembled, so the re-plan on a + /// window change, a structural change and a memory-limit change cannot disagree. Throws what + /// `plan_partition()` throws. Caller holds `mutex()`. + Partition plan_locked(double seconds) const; + Config cfg_; + std::atomic memory_limit_bytes_{0}; std::vector signals_; std::vector channels_; // --- math channels -------------------------------------------------------- diff --git a/src/settings.cpp b/src/settings.cpp index 53fd6a8..c9bbdf8 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -3,6 +3,7 @@ #include "settings.hpp" +#include #include #include #include @@ -64,6 +65,9 @@ Settings load_settings() { s.event_cache_max = std::max(0, std::atoi(value.c_str())); } else if (key == "relative_time") { s.relative_time = parse_bool(value, s.relative_time); + } else if (key == "memory_limit_mb") { + const long mb = std::strtol(value.c_str(), nullptr, 10); + if (mb >= 16) s.memory_limit_mb = static_cast(std::min(mb, 1000000000L)); } else if (key == "theme") { if (value == "auto" || value == "light" || value == "dark") s.theme = value; } else if (key == "contextual_help") { @@ -83,6 +87,7 @@ bool save_settings(const Settings& settings) { out << "event_cache_dir = \"" << settings.event_cache_dir << "\"\n"; out << "event_cache_max = " << settings.event_cache_max << "\n"; out << "relative_time = " << (settings.relative_time ? "true" : "false") << "\n"; + out << "memory_limit_mb = " << settings.memory_limit_mb << "\n"; out << "theme = \"" << settings.theme << "\"\n"; // The two help keys are the *app's* memory rather than the dialog's: the contextual-help rows -- // the Help menu's and the source chooser's own -- write the first, and the first-run tour writes the diff --git a/src/settings.hpp b/src/settings.hpp index 2dbd672..a302949 100644 --- a/src/settings.hpp +++ b/src/settings.hpp @@ -30,6 +30,12 @@ struct Settings { /// buffers are planned before any stream is known, so it takes effect on the next start (the /// preferences dialog says so, like the configurations dialog does). bool relative_time = false; + /// The hard ceiling on the sample rings' memory, in MiB, for the whole application rather than per + /// source (`--memory-limit-mb` overrides it for one run). The planner halves a source's sample + /// budget until the rings fit under it. Unlike the cache directory and relative time it applies as + /// it is changed: a running scope re-plans its buffer (and so drops its history) when the new limit + /// changes the rings. + int memory_limit_mb = 8192; /// "auto", "light" or "dark": what the interface should look like. Kept as the three names libadwaita /// uses, so the file says what the setting is rather than what an enum happens to be numbered. std::string theme = "auto"; diff --git a/src/source_dialog.cpp b/src/source_dialog.cpp index 70a1c71..84753dc 100644 --- a/src/source_dialog.cpp +++ b/src/source_dialog.cpp @@ -7,7 +7,9 @@ #include "help_window.hpp" #include "layout_store.hpp" +#include "model.hpp" // `buffer_ticks()` / `planned_bytes()`: what a request becomes #include "settings.hpp" +#include "status_text.hpp" // `format_samples()` and friends #include "source/registry.hpp" // The app's shared chrome -- `ui_impl::menu_button()`/`menu_toggle()` and `install_css()` -- so the // chooser's help menu is the *same* menu the window's Help view is, not a second one that merely @@ -21,6 +23,7 @@ #include #include +#include #include #include #include @@ -47,7 +50,7 @@ void apply_source(Config& cfg, const KnownSource& src) { // The configuration's own startup settings. They are read *before* the model exists, which is the // only moment a buffer size can be applied at all -- and the reason they are stored per // configuration rather than only passed on the command line. - if (src.buffer_mb > 0.0) cfg.buffer_mb = src.buffer_mb; + if (src.buffer_samples > 0) cfg.buffer_samples = src.buffer_samples; // The list's own copy of the clock, but only where nothing newer has said: with the preferences // open from this dialog a user can state relative time a minute before clicking the row, and that // statement must survive the click that follows it. @@ -59,8 +62,9 @@ void apply_source(Config& cfg, const KnownSource& src) { void report_sources(const std::vector& known) { std::fprintf(stderr, "[sources] %zu known:", known.size()); for (const KnownSource& s : known) { - std::fprintf(stderr, " %s:%u/%s plots=%d signals=%d buffer=%.6g relative=%d capture=%u", - s.host.c_str(), s.port, s.config.c_str(), s.rows, s.signals, s.buffer_mb, + std::fprintf(stderr, " %s:%u/%s plots=%d signals=%d samples=%llu relative=%d capture=%u", + s.host.c_str(), s.port, s.config.c_str(), s.rows, s.signals, + static_cast(s.buffer_samples), s.relative_time ? 1 : 0, s.capture_packets); if (!s.comment.empty()) std::fprintf(stderr, " note='%s'", s.comment.c_str()); if (!s.multicast.empty()) std::fprintf(stderr, " multicast=%s", s.multicast.c_str()); @@ -405,9 +409,7 @@ void chooser_populate(Chooser& c) { std::string sub = std::to_string(s.rows) + (s.rows == 1 ? " plot, " : " plots, ") + std::to_string(s.signals) + (s.signals == 1 ? " signal" : " signals"); - if (s.buffer_mb > 0.0) { - sub += ", buffer " + std::to_string(static_cast(s.buffer_mb)) + " MB"; - } + if (s.buffer_samples > 0) sub += ", buffer " + format_samples(s.buffer_samples) + " samples"; if (s.relative_time) sub += ", relative time"; if (s.capture_packets > 0) { sub += ", capture " + std::to_string(s.capture_packets) + " packets"; @@ -442,6 +444,32 @@ void chooser_populate(Chooser& c) { } } +/// The form's sample budget as a count: the spin is in millions, and a count is what is stored. +std::uint64_t form_buffer_samples(const Chooser& c) { + const double m = adw_spin_row_get_value(c.form_buffer); + return static_cast(std::max(128.0, std::round(m * 1.0e6))); +} + +/// Says under the "Samples per signal" row what the number will become: the rings are powers of two, +/// so the request is a ceiling, and the memory limit can lower it further. The limit is judged for +/// *one* signal here -- the channel count is not known until a stream declares it -- so a clip shown +/// is certain, and the status line says what a real stream got. The cost line gives the two figures +/// the user can multiply by their own signal count. +void update_buffer_note(Chooser& c) { + if (c.form_buffer == nullptr) return; + const std::uint64_t requested = form_buffer_samples(c); + const bool relative = adw_switch_row_get_active(c.form_relative) != FALSE; + const std::uint64_t limit = c.cfg->memory_limit_mb * 1024u * 1024u; + const std::uint64_t wanted = buffer_ticks(requested); + std::uint64_t used = wanted; + while (used > 128 && planned_bytes(used, 1, relative) > limit) used /= 2; + const std::string note = + describe_buffer(requested, used, used < wanted, limit) + ". The rings take " + + format_bytes(used * sizeof(double) * (relative ? 2 : 1)) + " for the clock and " + + format_bytes(used * sizeof(float)) + " per signal."; + adw_action_row_set_subtitle(ADW_ACTION_ROW(c.form_buffer), note.c_str()); +} + void form_close(Chooser& c) { // The form's own `closed` handler clears the pointer, so this is the only place that has to know how // an `AdwDialog` closes. @@ -497,7 +525,7 @@ void on_form_connect(GtkButton*, gpointer user) { const std::string iface = udps ? scfg.get("interface") : std::string(); std::string name = gtk_editable_get_text(GTK_EDITABLE(c.form_name)); const std::string note = gtk_editable_get_text(GTK_EDITABLE(c.form_note)); - const double buffer_mb = adw_spin_row_get_value(c.form_buffer); + const std::uint64_t buffer_samples = form_buffer_samples(c); const bool relative = adw_switch_row_get_active(c.form_relative) != FALSE; if (name.empty()) name = "default"; @@ -513,7 +541,7 @@ void on_form_connect(GtkButton*, gpointer user) { } // The startup settings. Applied here because this runs *before* `ScopeModel` exists, which is the // only moment a buffer size can be applied at all -- and stored, so the next run gets them back. - c.cfg->buffer_mb = buffer_mb; + c.cfg->buffer_samples = buffer_samples; c.cfg->relative_time = relative; LayoutState st; @@ -523,7 +551,7 @@ void on_form_connect(GtkButton*, gpointer user) { load_layout(configuration_path(c.edit_host, c.edit_port, c.edit_config), st); } st.comment = note; - st.buffer_mb = buffer_mb; + st.buffer_samples = buffer_samples; st.relative_time = relative; st.multicast = group; st.interface_addr = iface; @@ -541,9 +569,9 @@ void on_form_connect(GtkButton*, gpointer user) { c.saved_config = name; c.saved_note = note; std::fprintf(stderr, - "[sources] configuration %s: %s:%u/%s note='%s' buffer=%.6g relative=%d\n", + "[sources] configuration %s: %s:%u/%s note='%s' samples=%llu relative=%d\n", editing ? "saved" : "created", host.c_str(), port, name.c_str(), note.c_str(), - buffer_mb, relative ? 1 : 0); + static_cast(buffer_samples), relative ? 1 : 0); std::fflush(stderr); } else { std::fprintf(stderr, "[sources] configuration write failed: %s:%u/%s\n", host.c_str(), port, @@ -624,12 +652,24 @@ void open_form(Chooser& c, int edit_index = -1) { // the rows' subtitles, so an empty field still says what empty means. gtk_editable_set_text(GTK_EDITABLE(c.form_name), src != nullptr ? src->config.c_str() : "default"); gtk_editable_set_text(GTK_EDITABLE(c.form_note), src != nullptr ? src->comment.c_str() : ""); - adw_spin_row_set_value(c.form_buffer, src != nullptr && src->buffer_mb > 0.0 - ? src->buffer_mb - : std::max(1.0, c.cfg->buffer_mb)); + adw_spin_row_set_value(c.form_buffer, double(src != nullptr && src->buffer_samples > 0 + ? src->buffer_samples + : c.cfg->buffer_samples) / + 1.0e6); + update_buffer_note(c); + g_signal_connect(c.form_buffer, "notify::value", G_CALLBACK(+[](GObject*, GParamSpec*, gpointer user) { + update_buffer_note(*static_cast(user)); + }), + &c); adw_switch_row_set_active(c.form_relative, (src != nullptr ? src->relative_time : c.cfg->relative_time) ? TRUE : FALSE); + // The relative clock doubles the time ring, so the note under the sample budget follows it. + g_signal_connect(c.form_relative, "notify::active", G_CALLBACK(+[](GObject*, GParamSpec*, gpointer user) { + update_buffer_note(*static_cast(user)); + }), + &c); + update_buffer_note(c); // The dropdown lists the registered kinds. A new host starts on the kind the command line named // (UDPS by default); an edit starts on the configuration's own kind and cannot change it -- @@ -701,10 +741,11 @@ void report_pick(const Chooser& c) { // size would look identical here otherwise, and the buffer is the one of them a user notices // only after a long capture has been sized wrong. std::fprintf(stderr, - "[sources] picked %s:%u multicast=%s interface=%s buffer=%.6g relative=%d " + "[sources] picked %s:%u multicast=%s interface=%s samples=%llu relative=%d " "capture=%u config=%s note='%s'\n", c.cfg->server.c_str(), c.cfg->port, c.cfg->multicast.c_str(), - c.cfg->interface_addr.c_str(), c.cfg->buffer_mb, c.cfg->relative_time ? 1 : 0, + c.cfg->interface_addr.c_str(), + static_cast(c.cfg->buffer_samples), c.cfg->relative_time ? 1 : 0, c.cfg->capture_packets, (c.saved_config.empty() ? (c.known.empty() ? "-" : c.known.front().config) : c.saved_config) @@ -748,6 +789,10 @@ void open_chooser_preferences(Chooser& c) { // stored configuration, and it works in both directions -- turning it on *and* off. c.cfg->relative_time = s.relative_time; c.cfg->relative_time_specified = true; + // The memory limit is read by the model when it is built, which is after this returns, so the + // chooser's own change is the newest word on it for the run about to start. + c.cfg->memory_limit_mb = static_cast(s.memory_limit_mb); + c.cfg->memory_limit_specified = true; if (!written) { std::fprintf(stderr, "[sources] prefs: could not write %s\n", settings_path().c_str()); std::fflush(stderr); @@ -1171,7 +1216,7 @@ gboolean pick_probe(gpointer user) { c.form_cfg.write(sc); gtk_editable_set_text(GTK_EDITABLE(c.form_name), "probe-host"); gtk_editable_set_text(GTK_EDITABLE(c.form_note), "made by the new-host probe"); - adw_spin_row_set_value(c.form_buffer, 32); + adw_spin_row_set_value(c.form_buffer, 0.5); adw_switch_row_set_active(c.form_relative, TRUE); // 9/9 for UDPS: the form's row titles are mnemonics (`Source _type`, `_Server`, `_Port`, ...). The // total follows the kind's row count: five rows are the chooser's, the rest the kind's own. @@ -1183,7 +1228,7 @@ gboolean pick_probe(gpointer user) { std::fprintf(stderr, "[sources] form mnemonics=%d/%zu\n", underlines, 5 + c.form_cfg.rows.size()); std::fprintf(stderr, - "[sources] form: name='%s' note='%s' buffer=%.6g relative=%d " + "[sources] form: name='%s' note='%s' samples_m=%.6g relative=%d " "connect_sensitive=%d\n", gtk_editable_get_text(GTK_EDITABLE(c.form_name)), gtk_editable_get_text(GTK_EDITABLE(c.form_note)), @@ -1216,14 +1261,14 @@ gboolean pick_probe(gpointer user) { // The reading is the *pre-fill*: an edit form that opens empty is the defect this asserts // against, and the button's label says which mode it is in. std::fprintf(stderr, - "[sources] edit form: name='%s' note='%s' buffer=%.6g relative=%d button='%s'\n", + "[sources] edit form: name='%s' note='%s' samples_m=%.6g relative=%d button='%s'\n", gtk_editable_get_text(GTK_EDITABLE(c.form_name)), gtk_editable_get_text(GTK_EDITABLE(c.form_note)), adw_spin_row_get_value(c.form_buffer), adw_switch_row_get_active(c.form_relative) ? 1 : 0, gtk_button_get_label(GTK_BUTTON(c.form_connect))); gtk_editable_set_text(GTK_EDITABLE(c.form_note), "edited by the probe"); - adw_spin_row_set_value(c.form_buffer, 16); + adw_spin_row_set_value(c.form_buffer, 0.25); adw_switch_row_set_active(c.form_relative, FALSE); // `UDPSCOPE_PROBE_EDITNAME=new-name` also renames it, which is a *move*: the old file must not // survive, or the chooser lists the same session twice. @@ -1263,16 +1308,17 @@ void apply_stored_settings(Config& cfg) { // Only where nothing newer was said: a flag the user typed, or the preferences they just applied, // wins -- because it is the more recent statement of intent, and the file is not a place to hide a // surprise from whoever just made it. - if (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) cfg.buffer_mb = st.buffer_mb; + if (!cfg.buffer_samples_specified && st.buffer_samples > 0) cfg.buffer_samples = st.buffer_samples; const bool relative = !cfg.relative_time_specified && st.relative_time; if (relative) cfg.relative_time = true; if (cfg.capture_packets == 0 && st.capture_packets > 0) cfg.capture_packets = st.capture_packets; - const bool applied = (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) || relative || + const bool applied = (!cfg.buffer_samples_specified && st.buffer_samples > 0) || relative || st.capture_packets > 0; if (applied) { std::fprintf(stderr, - "[sources] settings from configuration '%s': buffer=%.6g relative=%d capture=%u\n", - want.c_str(), cfg.buffer_mb, cfg.relative_time ? 1 : 0, cfg.capture_packets); + "[sources] settings from configuration '%s': samples=%llu relative=%d capture=%u\n", + want.c_str(), static_cast(cfg.buffer_samples), + cfg.relative_time ? 1 : 0, cfg.capture_packets); std::fflush(stderr); } } @@ -1330,9 +1376,9 @@ void loading_show(const Config& cfg) { head = "Connecting to " + cfg.server + ":" + std::to_string(cfg.port); if (!cfg.multicast.empty()) head += " (" + cfg.multicast + ")"; } - char buf[128]; - std::snprintf(buf, sizeof(buf), "planning the %.0f MB sample buffer", cfg.buffer_mb); - sub = buf; + sub = "planning the buffer: " + format_samples(buffer_ticks(cfg.buffer_samples)) + + " samples per signal, " + format_bytes(cfg.memory_limit_mb * 1024u * 1024u) + + " memory limit"; } else { const std::size_t slash = cfg.open_waveform.rfind('/'); head = "Opening " + (slash == std::string::npos ? cfg.open_waveform diff --git a/src/status_text.cpp b/src/status_text.cpp index b78c911..74efad5 100644 --- a/src/status_text.cpp +++ b/src/status_text.cpp @@ -41,6 +41,43 @@ std::string format_rate(double per_s) { return buf; } +std::string format_samples(std::uint64_t n) { + char buf[32]; + const double d = static_cast(n); + if (n >= 1000000000ull) { + std::snprintf(buf, sizeof(buf), "%.3g G", d / 1.0e9); + } else if (n >= 1000000ull) { + std::snprintf(buf, sizeof(buf), "%.3g M", d / 1.0e6); + } else if (n >= 1000ull) { + std::snprintf(buf, sizeof(buf), "%.3g k", d / 1.0e3); + } else { + std::snprintf(buf, sizeof(buf), "%llu", static_cast(n)); + } + return buf; +} + +std::string format_bytes(std::uint64_t bytes) { + char buf[32]; + const double d = static_cast(bytes); + if (bytes >= (1ull << 30)) { + std::snprintf(buf, sizeof(buf), "%.3g GiB", d / double(1ull << 30)); + } else if (bytes >= (1ull << 20)) { + std::snprintf(buf, sizeof(buf), "%.3g MiB", d / double(1ull << 20)); + } else if (bytes >= (1ull << 10)) { + std::snprintf(buf, sizeof(buf), "%.3g KiB", d / double(1ull << 10)); + } else { + std::snprintf(buf, sizeof(buf), "%llu B", static_cast(bytes)); + } + return buf; +} + +std::string describe_buffer(std::uint64_t requested, std::uint64_t used, bool memory_limited, + std::uint64_t memory_limit_bytes) { + std::string out = format_samples(requested) + " requested, " + format_samples(used) + " used"; + if (memory_limited) out += " (limited by the " + format_bytes(memory_limit_bytes) + " memory limit)"; + return out; +} + std::string format_glance(const StatusSnapshot& s) { std::string out = s.state_word; out += " · "; @@ -68,17 +105,25 @@ std::string format_detail(const StatusSnapshot& s) { char buf[1024]; std::snprintf(buf, sizeof(buf), "[%s] %s %s | rate %s | packets %llu frames %llu gaps %llu | %zu signals, " - "%zu channels | plot 1: %d trace(s), %zu samples | buffer %.1f MB, %.2f s " - "span, time x%llu", + "%zu channels | plot 1: %d trace(s), %zu samples | buffer %s samples/signal " + "(%s), %.2f s span, time x%llu", s.state_detail.c_str(), s.connected ? "connected" : "waiting", s.have_config ? "configured" : "-", s.rate_text.empty() ? "--" : s.rate_text.c_str(), static_cast(s.packets), static_cast(s.frames), static_cast(s.gaps), s.signals, s.channels, - s.plot1_traces, s.plot1_samples, s.buffer_mb, s.span_s, - static_cast(s.time_stride)); + s.plot1_traces, s.plot1_samples, format_samples(s.buffer_samples).c_str(), + s.buffer_bytes > 0 ? format_bytes(s.buffer_bytes).c_str() : "not planned", + s.span_s, static_cast(s.time_stride)); std::string out = buf; + // The request is quoted only where it differs from what was planned, and the limit only where it + // is the reason: a clean run says the figure once, and a clipped one says why in the same breath. + if (s.memory_limited) { + out += " | limited to " + format_samples(s.buffer_samples) + " samples by the " + + format_bytes(s.memory_limit_bytes) + " memory limit (" + + format_samples(s.buffer_requested) + " requested)"; + } // Nothing about resequencing is said at all while all six read zero, which is a stream that // never lost ordering: the field would otherwise be six zeros on every clean run, and this // string is read by eye out of the exit dump. diff --git a/src/status_text.hpp b/src/status_text.hpp index 48c2378..6d38077 100644 --- a/src/status_text.hpp +++ b/src/status_text.hpp @@ -41,7 +41,15 @@ struct StatusSnapshot { std::size_t channels = 0; int plot1_traces = 0; std::size_t plot1_samples = 0; - double buffer_mb = 0.0; + /// The sample budget as planned: slots per signal the rings hold (the request rounded down to a + /// power of two and clipped by the memory limit), what was asked for, and the bytes those rings + /// occupy -- 0 until a stream has planned a buffer. `memory_limited` says the limit, and not the + /// rounding, made `buffer_samples` smaller than the request. + std::uint64_t buffer_samples = 0; + std::uint64_t buffer_requested = 0; + std::uint64_t buffer_bytes = 0; + std::uint64_t memory_limit_bytes = 0; + bool memory_limited = false; double span_s = 0.0; std::uint64_t time_stride = 1; bool have_event = false; @@ -82,6 +90,19 @@ std::string format_detail(const StatusSnapshot& s); /// cannot track the stream. Moved here from ui.cpp, which still calls it. std::string format_rate(double hz); +/// A sample count as "67.1 M": `%.3g` of the count in the unit it reads shortest in (k, M, G; decimal, +/// like `--buffer-samples`). Under a thousand it is the bare number. +std::string format_samples(std::uint64_t n); + +/// A byte count in binary units, "168 MiB" / "8 GiB", `%.3g` so the width does not track the figure. +std::string format_bytes(std::uint64_t bytes); + +/// The sentence the source form, the status popover and the exit dump share for a sample budget: +/// "100 M requested, 67.1 M used", and " (limited by the 1 GiB memory limit)" when the limit clipped it. +/// `requested` is the user's number, `used` the planned slots per signal. +std::string describe_buffer(std::uint64_t requested, std::uint64_t used, bool memory_limited, + std::uint64_t memory_limit_bytes); + /// An elapsed time, stepping to a coarser unit rather than growing digits, so its width /// cannot track the stream. Moved here from ui.cpp. std::string format_elapsed(double seconds); diff --git a/src/ui.cpp b/src/ui.cpp index d8d8555..71808c9 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -4397,7 +4397,16 @@ gboolean tick(gpointer user) { snap.channels = channel_count; snap.plot1_traces = bound; snap.plot1_samples = samples; - snap.buffer_mb = double(model.config().buffer_mb); + { + // The planned buffer when there is one; before the first CONFIG there is no plan, and the + // request (rounded as the ring will round it) is the honest figure, with no bytes to quote. + const Partition& part = model.partition(); + snap.buffer_requested = model.config().buffer_samples; + snap.buffer_samples = part.time_capacity > 0 ? part.time_capacity : buffer_ticks(snap.buffer_requested); + snap.buffer_bytes = part.bytes(); + snap.memory_limit_bytes = model.memory_limit_bytes(); + snap.memory_limited = part.memory_limited; + } snap.span_s = span_s; snap.time_stride = time_stride; snap.have_event = app->last_latch_us != 0; @@ -5281,8 +5290,9 @@ void on_activate(GtkApplication* application, gpointer user) { if (app->waveform_name.empty()) { const Config& cfg = app->model->config(); char buf[192]; - std::snprintf(buf, sizeof(buf), "connecting to %s:%u -- planning the %.0f MB buffer", - cfg.server.c_str(), unsigned(cfg.port), cfg.buffer_mb); + std::snprintf(buf, sizeof(buf), "connecting to %s:%u -- planning the buffer: %s samples per signal", + cfg.server.c_str(), unsigned(cfg.port), + format_samples(buffer_ticks(cfg.buffer_samples)).c_str()); set_notice(*app, buf, 6); } } @@ -6081,6 +6091,43 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit static_cast(model.live().values(i).produced()), static_cast(model.live().channel(i).size())); } + // The sample budget as this run planned it, and the planner on fixed inputs. The first line is + // the live buffer: `used` is the request rounded down to a power of two and clipped by the memory + // limit, `bytes` the rings' real size. The second feeds `plan_partition()` three channels and a + // 100 M request three ways -- room to spare, a 1 GiB limit that must clip, a limit too small for + // even 128 slots -- so a change to the rounding, the halving or the byte formula reads here even + // when the run's own plan happens not to be clipped. + std::printf("[udpscope] buffer: requested=%llu used=%llu bytes=%llu limit=%llu limited=%d\n", + static_cast(model.config().buffer_samples), + static_cast(part.time_capacity), + static_cast(part.bytes()), + static_cast(model.memory_limit_bytes()), + part.memory_limited ? 1 : 0); + { + const std::vector dts(3, 1.0e-4); + const auto pow2 = [](unsigned long long v) { return v != 0 && (v & (v - 1)) == 0; }; + const Partition open = plan_partition(dts, 100000000ull, 16ull << 30, 0.0); + constexpr unsigned long long kGiB = 1ull << 30; + const Partition clip = plan_partition(dts, 100000000ull, kGiB, 0.0); + const Partition clip_rel = plan_partition(dts, 100000000ull, kGiB, 0.0, true); + bool thrown = false; + try { + (void)plan_partition(dts, 100000000ull, 1000, 0.0); + } catch (const std::exception&) { + thrown = true; + } + std::printf("[udpscope] planner: unclipped got=%llu pow2=%d limited=%d | clipped(1GiB) " + "got=%llu bytes=%llu fits=%d pow2=%d limited=%d | clipped_rel got=%llu " + "bytes=%llu fits=%d | too_small_throws=%d\n", + static_cast(open.time_capacity), + pow2(open.time_capacity) ? 1 : 0, open.memory_limited ? 1 : 0, + static_cast(clip.time_capacity), + static_cast(clip.bytes()), clip.bytes() <= kGiB ? 1 : 0, + pow2(clip.time_capacity) ? 1 : 0, clip.memory_limited ? 1 : 0, + static_cast(clip_rel.time_capacity), + static_cast(clip_rel.bytes()), + clip_rel.bytes() <= kGiB ? 1 : 0, thrown ? 1 : 0); + } // `frames_painted` is the area's, read from the snapshot taken before the window was // destroyed (the PlotArea wrapper dies with its widget): one figure, one render per // frame, so it is the same count for every row. diff --git a/src/ui/preferences.ui b/src/ui/preferences.ui index 3c84219..837b77a 100644 --- a/src/ui/preferences.ui +++ b/src/ui/preferences.ui @@ -13,7 +13,7 @@ show-apply-button is off here: the write is a few bytes to one file, so a keystroke is not worth batching). The two settings that cannot take effect immediately — the cache directory and relative time, both of which are planned before any source is known — say so in their subtitles instead of - being held back by a button. + being held back by a button. The memory limit does take effect at once: a running buffer re-plans. Nothing here carries a value: `ui_prefs.cpp` fills the rows from `Settings` when the dialog opens, and writes it back on every change. This file is the shape, that is the state. @@ -77,6 +77,27 @@
+ + + Sample buffer + + + _Memory limit (MB) + True + Hard ceiling on the memory of the sample rings, all signals together. A source asks for a number of samples per signal; if that does not fit under this limit it is halved until it does, and the status line says so. Applies at once: a running buffer is re-planned, which clears its history. + + + 16 + 1048576 + 256 + 1024 + 8192 + + + + + +
diff --git a/src/ui/source-form.ui b/src/ui/source-form.ui index b0f2797..1e44a44 100644 --- a/src/ui/source-form.ui +++ b/src/ui/source-form.ui @@ -85,15 +85,17 @@ - _Buffer MB + _Samples per signal (millions) True - sample buffer per source, applied when this source is opened + samples kept for every signal, rounded down to a power of two; applied when this source is opened + 3 - 1 - 65536 + 0.001 + 4096 1 - 64 + 10 + 10 diff --git a/src/ui_layout.cpp b/src/ui_layout.cpp index e3c66d6..122082f 100644 --- a/src/ui_layout.cpp +++ b/src/ui_layout.cpp @@ -103,7 +103,7 @@ LayoutState collect_layout(App& app) { // *startup* parameter -- the rings are allocated before any stream is known -- so a configuration // is the only place it can be remembered per source, and the chooser applies it on open. st.comment = app.config_comment; - st.buffer_mb = app.model->config().buffer_mb; + st.buffer_samples = app.model->config().buffer_samples; st.relative_time = app.model->config().relative_time; st.capture_packets = app.model->config().capture_packets; return st; @@ -135,8 +135,8 @@ void on_config_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) { // a configuration that appears to half-apply. const Config& cfg = app.model->config(); std::string later; - if (st.buffer_mb > 0.0 && st.buffer_mb != cfg.buffer_mb) { - later += "buffer " + std::to_string(static_cast(st.buffer_mb)) + " MB"; + if (st.buffer_samples > 0 && st.buffer_samples != cfg.buffer_samples) { + later += "buffer " + format_samples(st.buffer_samples) + " samples"; } if (st.relative_time != cfg.relative_time) { later += later.empty() ? "" : ", "; @@ -237,9 +237,7 @@ void config_dialog_refresh(ConfigDialog& d) { } std::string sub = std::to_string(st.rows.size()) + " plots, " + std::to_string(signals) + " signals"; - if (st.buffer_mb > 0.0) { - sub += ", buffer " + std::to_string(static_cast(st.buffer_mb)) + " MB"; - } + if (st.buffer_samples > 0) sub += ", buffer " + format_samples(st.buffer_samples) + " samples"; if (st.relative_time) sub += ", relative time"; GtkWidget* counts = gtk_label_new(sub.c_str()); gtk_widget_add_css_class(counts, "dim-label"); @@ -410,7 +408,7 @@ void apply_layout(App& app, const LayoutState& st) { // What the session had on, collected and enabled **once**, at the end of this function. // Each enable re-plans the partition, and a re-plan allocates -- and so touches -- the whole - // of `--buffer-mb`: seven signals one at a time is seven allocations before a single sample + // of the planned rings: seven signals one at a time is seven allocations before a single sample // is kept, which is the ten seconds of blank window a large buffer used to start with. The // math channels are not collected here: their enable state is the session's `math` and // `set_math_channels()` has already restored it, so listing them again could turn on one diff --git a/src/ui_prefs.cpp b/src/ui_prefs.cpp index 8a87fa9..5a7852e 100644 --- a/src/ui_prefs.cpp +++ b/src/ui_prefs.cpp @@ -4,6 +4,7 @@ #include "ui_prefs.hpp" #include "event_store.hpp" // `default_events_dir()`, so the row shows the path the cache uses +#include "status_text.hpp" // `format_bytes()` #include "ui_app.hpp" // the application's half of the contract, at the bottom of this file #include @@ -36,6 +37,7 @@ Settings read_rows(const PrefsForm& f) { s.event_cache_dir = gtk_editable_get_text(GTK_EDITABLE(f.dir_row)); s.event_cache_max = static_cast(adw_spin_row_get_value(f.keep_row)); s.relative_time = adw_switch_row_get_active(f.relative_row) != FALSE; + s.memory_limit_mb = static_cast(adw_spin_row_get_value(f.memory_row)); s.theme = theme_name(adw_combo_row_get_selected(f.theme_row)); // The row shows the *effective* directory, so the default is written out in full rather than // described. Leaving it at that default is not a choice to pin it: the file keeps "unset", and the @@ -63,6 +65,7 @@ void fill_rows(PrefsForm& f, const Settings& s) { (s.event_cache_dir.empty() ? default_events_dir() : s.event_cache_dir).c_str()); adw_spin_row_set_value(f.keep_row, s.event_cache_max < 0 ? 0.0 : s.event_cache_max); adw_switch_row_set_active(f.relative_row, s.relative_time ? TRUE : FALSE); + adw_spin_row_set_value(f.memory_row, s.memory_limit_mb); adw_combo_row_set_selected(f.theme_row, s.theme == "light" ? 1 : (s.theme == "dark" ? 2 : 0)); f.filling = false; } @@ -141,6 +144,7 @@ void on_closed(AdwDialog*, gpointer user) { f.dir_row = nullptr; f.keep_row = nullptr; f.relative_row = nullptr; + f.memory_row = nullptr; f.theme_row = nullptr; f.browse = nullptr; f.parent = nullptr; @@ -155,12 +159,14 @@ void resolve_rows(PrefsForm& f) { f.keep_row = ADW_SPIN_ROW(gtk_builder_get_object(f.builder, "keep_row")); f.relative_row = ADW_SWITCH_ROW(gtk_builder_get_object(f.builder, "relative_row")); f.theme_row = ADW_COMBO_ROW(gtk_builder_get_object(f.builder, "theme_row")); + f.memory_row = ADW_SPIN_ROW(gtk_builder_get_object(f.builder, "memory_row")); if (f.dir_row == nullptr || f.keep_row == nullptr || f.relative_row == nullptr || - f.theme_row == nullptr) { + f.theme_row == nullptr || f.memory_row == nullptr) { f.dir_row = nullptr; f.keep_row = nullptr; f.relative_row = nullptr; f.theme_row = nullptr; + f.memory_row = nullptr; g_clear_object(&f.builder); } } @@ -214,6 +220,7 @@ void prefs_open(PrefsForm& form, GtkWidget* parent, const Settings& current, g_signal_connect(form.dir_row, "changed", G_CALLBACK(on_entry_changed), &form); g_signal_connect(form.keep_row, "notify::value", G_CALLBACK(on_row_notify), &form); g_signal_connect(form.relative_row, "notify::active", G_CALLBACK(on_row_notify), &form); + g_signal_connect(form.memory_row, "notify::value", G_CALLBACK(on_row_notify), &form); g_signal_connect(form.theme_row, "notify::selected", G_CALLBACK(on_row_notify), &form); if (GObject* reset = gtk_builder_get_object(form.builder, "reset_row"); reset != nullptr) { g_signal_connect(reset, "activated", G_CALLBACK(on_reset), &form); @@ -239,9 +246,29 @@ void prefs_probe_change(PrefsForm& form) { gtk_editable_set_text(GTK_EDITABLE(form.dir_row), "/tmp/udpscope-prefs-events"); adw_spin_row_set_value(form.keep_row, 7.0); adw_switch_row_set_active(form.relative_row, TRUE); + adw_spin_row_set_value(form.memory_row, 4096.0); adw_combo_row_set_selected(form.theme_row, 2); } +/// A changed memory limit reaches the running model. Only when it differs from the model's own (every +/// preference arrives through the same callback, and a theme change is not a re-plan), and without +/// re-planning while a file or a replayed event is what the buffer holds -- clearing those would +/// throw away what the user opened, so the limit is stored and the next plan honours it. +static void apply_memory_limit(App& app, int mb) { + if (app.model == nullptr || mb <= 0) return; + const std::uint64_t bytes = static_cast(mb) * 1024u * 1024u; + if (bytes == app.model->memory_limit_bytes()) return; + const bool holds_a_view = !app.waveform_name.empty() || app.replaying; + const bool rebuilt = app.model->set_memory_limit_bytes(bytes, !holds_a_view); + std::fprintf(stderr, "[udpscope] memory limit now %s (rebuilt=%d%s)\n", + format_bytes(bytes).c_str(), rebuilt ? 1 : 0, + holds_a_view ? ", applies to the next plan" : ""); + std::fflush(stderr); + if (rebuilt) { + set_notice(app, "memory limit " + format_bytes(bytes) + ": buffer re-planned, history cleared", 5); + } +} + /// The application's entry point: **Preferences…** in the overflow menu. Kept here rather than in /// `ui.cpp` with the rest of the chrome, because it is the application's half of *this* form's /// contract -- what a preference changing means to a running scope -- and reading it beside the form is @@ -249,6 +276,7 @@ void prefs_probe_change(PrefsForm& form) { void open_preferences(App& app) { prefs_open(app.prefs, app.window, app.settings, [&app](const Settings& s, bool written) { app.settings = s; + apply_memory_limit(app, s.memory_limit_mb); // Only a failure is announced. A change arrives once per keystroke in the directory row, and a // status line that flashed on each one would be the app shouting about its own bookkeeping. if (!written) set_notice(app, "could not write the preferences file", 4); diff --git a/src/ui_prefs.hpp b/src/ui_prefs.hpp index 69be0ae..927366a 100644 --- a/src/ui_prefs.hpp +++ b/src/ui_prefs.hpp @@ -62,6 +62,7 @@ struct PrefsForm { AdwEntryRow* dir_row = nullptr; AdwSpinRow* keep_row = nullptr; AdwSwitchRow* relative_row = nullptr; + AdwSpinRow* memory_row = nullptr; AdwComboRow* theme_row = nullptr; /// The folder chooser beside the directory row. A text field alone asks a user to know a path and /// type it correctly; the chooser is how they find one, and what it returns goes into that row -- so diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 228ffdd..22defae 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -1182,7 +1182,7 @@ gboolean math_probe(gpointer user) { // The one state `math_probe` structurally cannot reach: an edit of a graph whose ring has already // *wrapped*. Every edit that probe makes follows a structural change, which replans the buffer and // restarts every ring at absolute 0 -- and at `base == 0` the recompute's two renumbering bugs are -// both identities. Run it with a small `--buffer-mb` and give it time to saturate; `base=0` in its +// both identities. Run it with a small `--buffer-samples` and give it time to saturate; `base=0` in its // second line means the run was too short or the buffer too large to have read anything. int g_mathwrap_index = -1; double g_mathwrap_y_before = 0.0; @@ -1231,7 +1231,7 @@ gboolean mathwrap_probe_wait(gpointer user) { g_mathwrap_y_before = a->model->live().channel(idx).y_bounds().max; } } - // Polled rather than timed: the wrap is a function of the rate and of `--buffer-mb`, and a fixed + // Polled rather than timed: the wrap is a function of the rate and of `--buffer-samples`, and a fixed // delay that fell short would print a passing line having tested nothing. if (base == 0 && ++g_mathwrap_polls < 200) return G_SOURCE_CONTINUE; std::fprintf(stderr, "[mathwrap-probe] wrapped: base=%llu slots=%llu (want base > 0)\n", @@ -5384,9 +5384,10 @@ gboolean configs_probe(gpointer user) { const double noise_back = st.measures.empty() ? -1.0 : st.measures.front().options.noise_pct; const double hf_back = st.measures.empty() ? -1.0 : st.measures.front().options.hf_fc; std::fprintf(stderr, - "[configs] round trip read=%d comment='%s' buffer_mb=%.6g relative=%d " + "[configs] round trip read=%d comment='%s' buffer_samples=%llu relative=%d " "capture=%u plots=%zu signals=%d measures=%zu math=%zu noise=%.6g hf=%.6g\n", - read ? 1 : 0, st.comment.c_str(), st.buffer_mb, st.relative_time ? 1 : 0, + read ? 1 : 0, st.comment.c_str(), + static_cast(st.buffer_samples), st.relative_time ? 1 : 0, st.capture_packets, st.rows.size(), signals, st.measures.size(), st.math.size(), noise_back, hf_back); diff --git a/src/ui_status.cpp b/src/ui_status.cpp index 5dfbef5..038f959 100644 --- a/src/ui_status.cpp +++ b/src/ui_status.cpp @@ -106,9 +106,21 @@ void update_status_popover(App& app, const udpscope::StatusSnapshot& s) { std::snprintf(buf, sizeof(buf), "%zu signals · %zu channels", s.signals, s.channels); gtk_label_set_text(GTK_LABEL(app.popover_label[6]), buf); - std::snprintf(buf, sizeof(buf), "%.1f MB · %.0f s span · time x%llu", s.buffer_mb, s.span_s, - static_cast(s.time_stride)); + std::snprintf(buf, sizeof(buf), "%s samples/signal · %s · %.0f s span · time x%llu", + udpscope::format_samples(s.buffer_samples).c_str(), + s.buffer_bytes > 0 ? udpscope::format_bytes(s.buffer_bytes).c_str() : "not planned", + s.span_s, static_cast(s.time_stride)); gtk_label_set_text(GTK_LABEL(app.popover_label[7]), buf); + // The reason the figure is not the one asked for, where there is one: a smaller ring than the + // source form promised, with the limit that did it, as the label's tooltip. + gtk_widget_set_tooltip_text( + app.popover_label[7], + s.memory_limited + ? ("limited to " + udpscope::format_samples(s.buffer_samples) + " samples by the " + + udpscope::format_bytes(s.memory_limit_bytes) + " memory limit (" + + udpscope::format_samples(s.buffer_requested) + " requested)") + .c_str() + : nullptr); const std::string stored = udpscope::format_rate(s.stored_rate_hz); const std::string wire = udpscope::format_rate(s.declared_rate_hz); From 1627abcd4a519bc2f2a5bc875e576917928cab1d Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 02:49:21 +0100 Subject: [PATCH 079/127] docs: the sample budget in samples per signal, the memory limit and the planner probe lines Converts every --buffer-mb / buffer_mb mention to --buffer-samples / buffer_samples, records the unit change as a deviation (README, SPECS, CLAUDE.md), documents the buffer: and planner: dump lines and the new Preferences row, and updates the help pages and the help-shot script. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 7 +- README.md | 89 ++++++++++++------- SPECS.md | 15 +++- docs/claude/architecture.md | 5 +- docs/claude/build-and-run.md | 6 +- docs/claude/invariants-data-path.md | 6 +- docs/claude/invariants-math-channels.md | 2 +- docs/claude/invariants-measurements.md | 2 +- docs/claude/invariants-session-and-export.md | 6 +- docs/claude/invariants-trigger-and-pinning.md | 2 +- docs/claude/probe-readings.md | 64 +++++++++---- docs/claude/verification.md | 30 +++---- help/C/org.udpscope.app/overview.page | 2 +- help/C/org.udpscope.app/sessions.page | 6 +- help/C/org.udpscope.app/source.page | 2 +- help/C/org.udpscope.app/trigger.page | 2 +- help/C/org.udpscope.app/window.page | 9 +- todo.md | 2 +- tools/make-help-shots.sh | 2 +- 19 files changed, 166 insertions(+), 93 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 17e9a5d..4eabd46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co udpscope is a **GTK4 oscilloscope-style viewer for a UDPStreamer source**. It receives one data source -- a UDPStreamer stream through `deps/udpsclient` by default, or another registered -source kind (see `docs/claude/architecture.md`) -- buffers it in a large ring (default 200 MB), and draws it +source kind (see `docs/claude/architecture.md`) -- buffers it in a large ring (default 10 M samples per signal, under an 8 GiB memory limit), and draws it with `deps/plotter` — a figure of up to eight stacked rows sharing one x window. On top of the live view it provides the things a bench scope provides: a trigger with a pre/post event window, a pause that freezes the visible window, cursors, statistical measurements, @@ -19,6 +19,11 @@ Two documents bound the work and are **not** interchangeable with this one: - **`README.md`** documents what was actually *built*, including deliberate deviations from the spec and known limitations. **Read it before concluding something is a bug.** +**Deliberate deviation from `SPECS.md` line 1:** the sample buffer is budgeted in *samples per signal* +(`--buffer-samples`, rounded down to a power of two), not in megabytes, under a separate hard +`--memory-limit-mb` (also a Preferences row). There is no `--buffer-mb`; see README and `SPECS.md`'s +"Deviations from the spec". + Three things shape almost every change here, and the linked files expand on each: - **There are exactly two threads** — a producer that owns the data source and the GTK main diff --git a/README.md b/README.md index 60ef547..f181642 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,9 @@ It is used for exactly one widget — the shortcut overview is an `AdwShortcutsD GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and `AdwShortcutsDialog` is its replacement. -Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` -(size of the sample buffer, default 200), `--max-window` (largest time +Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-samples` +(samples kept per acquired signal, default 10M; see "The sample buffer is budgeted in samples" below), +`--memory-limit-mb` (hard ceiling on the ring storage, default 8192), `--max-window` (largest time window the toolbar offers, default 600), `--holdoff` (default trigger hold-off), `--recv-buffer`, `--plots` (the number of stacked plots to start with, 1..8; rows are added and removed from the **Layout editor** afterwards), `--relative-time` (see below), @@ -33,16 +34,36 @@ added and removed from the **Layout editor** afterwards), `--relative-time` (see `GSK_RENDERER` and `LIBGL_ALWAYS_SOFTWARE` before GTK initialises — see the SSH limitation below). -**What a large `--buffer-mb` costs at startup, and what the window says while it happens.** The -buffer's rings are value-initialised, so allocating one touches every byte of `--buffer-mb` --- about +**The sample buffer is budgeted in samples per signal, under a memory limit.** `--buffer-samples N` +(`250000`, `64k`, `100M`, `1.5G`; k, M and G are decimal, a *sample* count and not a byte size) is how +many samples to keep for **every acquired signal**, and for the shared time ring. A ring is a power of +two, so the request is rounded **down** to one --- "up to" --- and the UI shows both figures: the +default 10 M asks for, and gets, 8 388 608 per signal, and `--buffer-samples 100M` gets 67 108 864. What that +costs in bytes follows from the channel count (`ticks x 8` for the clock, doubled in relative mode, plus +`ticks x 4` per channel), and the status popover and the badge text quote the *planned* bytes rather than a +figure the user typed. The ceiling on those bytes is a separate application setting, +**`--memory-limit-mb`** (default 8192 MiB; also a *Memory limit* row in **Preferences**, which re-plans the +live buffer when changed). `plan_partition()` starts at the power-of-two floor of the request and +**halves** it until the rings fit under the limit, and throws only when 128 samples do not fit. A clipped +plan is never silent: stderr gets one line (`[udpscope] buffer limited to 8.39 M samples per signal (100 M +requested) by the 256 MiB memory limit; the rings take 192 MiB`), the status popover's buffer row names the +limit, and the source form's note says so, assuming one signal there because the channel count is not +known before a stream is. `--buffer-mb` and the stored `buffer_mb` key are gone, with no compatibility +shim: the flag is an unknown argument, and an old `buffer_mb` in a saved configuration is ignored (the +source falls back to the default until it is saved again as `buffer_samples`). **This is a deliberate +change of the budget's unit** (it was megabytes of ring, which made the retained history depend on the +channel count and on relative time); it is recorded under "Deviations from the spec" in `SPECS.md`. + +**What a large buffer costs at startup, and what the window says while it happens.** The +buffer's rings are value-initialised, so allocating one touches every byte of the planned rings --- about a second per gigabyte, mostly first-touch page faults --- and it happens on the producer thread holding the lock the frame tick also takes. It is therefore a real freeze and not a slow paint. Two things follow. First, a rebuild whose plan describes the storage already allocated **clears** the store instead of allocating a new one, which is what most structural changes are: startup used to ask -for fifteen allocations and now asks for two or three (3.36 s → 1.18 s at `--buffer-mb 1000`), and +for fifteen allocations and now asks for two or three (3.36 s → 1.18 s at a gigabyte of rings), and every enable of a signal is applied in one batch rather than re-planning per signal. Second, the window is **presented before** the buffer is planned and carries a notice naming what it is waiting -for --- `connecting to 127.0.0.1:8000 — planning the 4000 MB buffer` --- because the remaining second +for --- `connecting to 127.0.0.1:8000 — planning the buffer: 134 M samples per signal` --- because the remaining second or so at a multi-gigabyte buffer is unavoidable, and a blank window that says nothing is the part of it that reads as a hang. Each allocation also names itself on stderr (`[udpscope] buffer allocated: 4 channels, 839 s span, 74 ms`), so a run that prints a column of them @@ -740,8 +761,12 @@ The history cost of relative mode is measured rather than computed: | stream time (default) | **83.886 s** | | `--relative-time` | **41.943 s** | -Conditions: the mock at 100 kS/s, 4 channels at stride 1, the default `--buffer-mb 200`. -Relative mode retains **exactly half** the history — not the ~22% the original spec claimed, and +Conditions: the mock at 100 kS/s, 4 channels at stride 1, the old byte budget (`--buffer-mb 200`, since removed). **That +no longer happens**: the buffer is budgeted in samples per signal, so both modes retain the same span +(8 388 608 ticks at the default) and relative mode instead costs a third more *memory* (32 against 24 +bytes a tick with four channels; the `planner:` dump line in `docs/claude/probe-readings.md` reads it); only a memory limit that +bites clips it first. The paragraph below is the measurement that motivated it. +Under the old byte budget relative mode retained **exactly half** the history — not the ~22% the original spec claimed, and not the 75% a per-byte calculation suggests. The reason is the floor: relative mode keeps a second f64 ring for absolute time beside the stream clock's, so a tick's cost goes from `8 + 4×4 = 24` bytes to `16 + 4×4 = 32` bytes — a third more. But `Partition::time_capacity` is @@ -889,7 +914,7 @@ Three things worth knowing: replayed samples age out of the ring as the stream refills it. - **Opening an event, a comparison or going back to *Live* is one re-plan of the buffer**, not one per channel. Each of those switches rebuilds the signal set, and a rebuild that moves the plan - allocates --- and so touches --- the whole of `--buffer-mb`; enabling a comparison's eight channels + allocates --- and so touches --- the whole of the planned rings; enabling a comparison's eight channels one at a time was eight of those, which is most of what made the switch feel slow. They are now enabled in a single call, and a rebuild whose plan is unchanged clears the buffer instead of allocating a new one. @@ -923,15 +948,15 @@ Only `.npy` is read: it is the format the item asked for and the one that carrie export is for other tools. **A recording longer than the buffer can hold at its own rate is resampled, never cut short.** The -file is pushed into the same ring a stream fills, and that ring is sized by `--buffer-mb`: 20 s of ten -channels at 500 kS/s is 480 MB of rings, which a 200 MB buffer cannot hold. The loader therefore reads +file is pushed into the same ring a stream fills, and that ring is sized by `--buffer-samples`: 20 s of ten +channels at 500 kS/s is 10 M samples a channel, which the default 8.39 M cannot hold. The loader therefore reads the decimation the plan chose and pushes one stored sample per group of file rows, carrying the group's **mean** — the same anti-aliased filter the live ingest applies, so a long event opens as the whole recording at a lower sample rate instead of as a fraction of itself. It used to push every row regardless, which overran the ring and left the file as **its own tail**: a 20 s event opened showing only its last 4 s, with a correct-looking 20 s header line above it. The load line now names both numbers, `samples=31250 rows=1000000 decim=32`, so what is on screen is never in doubt; give the run a -larger `--buffer-mb` (the source's configuration carries one) to see such a file sample for sample. +larger `--buffer-samples` (the source's configuration carries one) to see such a file sample for sample. ## Named configurations @@ -953,18 +978,18 @@ measurements — **and the settings that can only be decided at startup**, which here rather than only on the command line: ```toml -comment = "5 kHz sine, trigger on Ch1, 8 MB is plenty" -buffer_mb = 8 +comment = "5 kHz sine, trigger on Ch1, 256 k samples is plenty" +buffer_samples = 262144 relative_time = false capture_packets = 0 ``` -The **buffer size** is the one that matters most: two sources on one machine rarely want the same +The **buffer size** (samples per signal) is the one that matters most: two sources on one machine rarely want the same ring, and the value is applied before the model exists, which is the only moment it can be — by whichever of the chooser's rows opened the session, **and by `apply_stored_settings()` when the source was named on the command line**. A saved host's settings are read either way: opening `udpscope --server 10.0.0.5 --port 8000` gets that source's stored buffer size, clock and capture -ring, and a flag the user actually typed wins over the file (`--buffer-mb`, `--relative-time`, +ring, and a flag the user actually typed wins over the file (`--buffer-samples`, `--relative-time`, `--capture-packets`) because it is the more recent statement of intent. Switching configurations *inside* a running app applies everything that can change live (layout, plots, math, measurements, enables) and says so in the status line about the rest (buffer, clock, packet capture): they take effect on the next @@ -1004,7 +1029,11 @@ applies as it is made — there is no Apply button, and a **Reset to defaults** dialog puts everything back — and the rows are the ones every GNOME application shows: a title, a subtitle under it where the setting needs explaining, and the control on the right. A key that is absent means its default, so the file only mentions what changed, and a missing file is a first run -rather than an error. The directory field shows the directory in use: the default +rather than an error. The **Sample buffer** group holds the *Memory limit (MB)* row (`memory_limit_mb`, +default 8192): the hard ceiling on the ring storage for the whole application, which the planner halves the +sample budget to fit. It is the one planner input that changes while the app runs, so changing it +re-plans the live buffer --- dropping history only if the storage actually changes --- except while a +recording or a replay is on screen, where it is stored for the next plan. The directory field shows the directory in use: the default is filled in rather than described, and typing it back leaves the setting unset, so it goes on following the cache directory instead of pinning one path for good. It can be typed, or picked with the folder button beside it, which writes what it found into the same field -- the field stays the one value, and @@ -1247,11 +1276,11 @@ allocates one `BufferStore` per CONFIG: one shared time-base ring plus one value ring per channel (N enabled signals -- see the limitation on multi-element signals below). The spec's second buffer is the latched `EventSnapshot`, which is already an immutable copy of the captured window (see "Trigger" below); a second live ring -bought nothing and cost half the memory budget, so `--buffer-mb 200` really is -200 MB. What it *would* buy is priced in the dump's `publish cost:` line — see +bought nothing and cost half the memory, so the planned bytes the status line quotes are +the whole of it. What it *would* buy is priced in the dump's `publish cost:` line — see "There is one live buffer, not two" under the review findings. -`plan_partition()` sizes the rings to cover `--max-window` seconds and then grows -them with the rest of the budget. When a channel's declared sample rate is lower +`plan_partition()` sizes the rings to the sample budget (rounded down to a power of two, halved +until it fits the memory limit) and then decimates the shared clock until `--max-window` seconds fit. When a channel's declared sample rate is lower than the fastest channel's, it is decimated by the integer ratio (`Partition::stride`); when the requested window does not fit the budget, the shared clock is resampled too (`Partition::time_stride`, an averaging decimation — see @@ -1437,8 +1466,8 @@ tick and 12.8 ms of draw a frame and 4.9 MB uploaded** on llvmpipe, and reads ** 5.6 ms and 1.0 MB** at the cap. **And it says what it costs.** A retained acquisition is a whole window copied out of the ring, so -the retention is bounded by two things: the depth, and **`--buffer-mb`** — persistence may hold at -most as much again as the buffer does, which is the bound a long window on a fast stream reaches +the retention is bounded by two things: the depth, and **the planned buffer** — persistence may hold at +most as many bytes again as the rings take (`persist_budget_bytes()`), which is the bound a long window on a fast stream reaches long before the eighth layer. The other bound is the plot's own: a layer takes a *free trace slot in the row that plots its signal*, layer by layer, so a row of four signals — the layout the app starts with — has room for one older acquisition of each, while a row per signal has room for all @@ -1654,7 +1683,7 @@ enabled only to feed a math channel. several, and there was no way to see which sources the app already knew about. The chooser lists one row per stored session: the server and port it was named after, how many plots and signals it held, and its multicast group when it had one, newest first. `New host…` is a four-field form — -server, port, multicast group, interface — and nothing more: `--buffer-mb` and the rest are tuning +server, port, multicast group, interface — and nothing more: `--buffer-samples` and the rest are tuning rather than "which source", and a form that asks for them is a form you have to read before using the app. Cancel exits before anything is allocated, because declining to choose a source is not a failure. @@ -1858,7 +1887,7 @@ fixes above bound *how often* and *how much per sample*; what is left is the ari scale: a pass is priced per measured **channel** (one fetch of the window's level-0 buckets, one statistics scan) and per **distinct share** in the pane (one level accumulator, walked over those buckets), and the window's own sample count multiplies both. Measured with the new -`[levels-probe]` sweep on the mock at a **15 s** window and `--buffer-mb 4000` (so `time_stride` +`[levels-probe]` sweep on the mock at a **15 s** window and `--buffer-samples 100M` (so `time_stride` stays 1 and the window holds 11.8 M samples, 184 k buckets), four signals, one row, on llvmpipe — `measure cost:`'s `each` per recompute, and the tick's mean: @@ -2001,9 +2030,9 @@ a sample and runs out on a clock. Widening only the clock keeps the cost where i 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 +budget in **bytes** against the memory limit (`sizeof(f64)` for the shared clock plus `sizeof(f32)` per +channel); the sample budget itself is a count of ticks, so a three-channel stream at +`--buffer-samples 10M` retains the same 8.39 M ticks 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. @@ -2646,7 +2675,7 @@ This reverses the earlier "the app does no decimation at all" decision, and keep reason: a naive stride is what made a 2 kHz sine on a 1 MHz clock plot as a full-amplitude 125 Hz sine — a plausible-looking waveform that was never in the data. The headless check is the same aliasing case with the filter in place: at -`--buffer-mb 1 --freq 2000` with a 60 s window the clock decimates by 16 to a 625 S/s +`--buffer-samples 65536 --freq 2000` with a 60 s window the clock decimates by 16 to a 625 S/s stored rate (312.5 Hz corner) and the channel dump reads `y=[-0.059, 0.079]`, roughly the boxcar's predicted 6%. A `y=[-1, 1]` there would mean the filter had been lost. @@ -2912,7 +2941,7 @@ now — they are listed so they are not mistaken for anything else: smaller than the stall: an A/B with and without a pause read `gaps=0` both ways, so what a second ring would buy is a smoother frame at a latch, not recovered packets. - **The buffer is allocated at the first CONFIG, not at initialisation.** - The spec's "N+1 split" needs N, which only a CONFIG gives; `--buffer-mb` is the + The spec's "N+1 split" needs N, which only a CONFIG gives; `--buffer-samples` is the *budget*, and the power-of-two ring rounding means the final allocation lands in `(budget/2, budget]` rather than exactly on it. Every CONFIG, enable/disable and calibration re-partitions (and drops the history) because the diff --git a/SPECS.md b/SPECS.md index 8609ae2..a4b5272 100644 --- a/SPECS.md +++ b/SPECS.md @@ -42,9 +42,18 @@ The following were built differently from what was asked for, each for a reason `README.md` states in full: -- **`todo.md` item 2 — relative timing.** Implemented as `--relative-time`. The retained history - in this mode is **41.943 s** against the default **83.886 s** (measured at 100 kS/s, 4 channels, - `--buffer-mb 200`): exactly half, because a second f64 ring for absolute time raises a tick's +- **The buffer is budgeted in samples per signal, not megabytes** (line 1 asked for "default 200MB + each, configurable as cli argument"). `--buffer-samples N` (default 10M, k/M/G suffixes) is the + number of samples kept for every acquired signal and for the shared time ring, rounded **down** to a + power of two ("up to"; 10M gives 8 388 608), and a separate application setting, `--memory-limit-mb` + (default 8192, also in Preferences), is the hard ceiling on the ring bytes, halving the sample budget + when the rings would not fit. The retained history therefore no longer depends on the channel count + or on relative time. There is no `--buffer-mb` and no compatibility shim; a stored `buffer_mb` is + ignored. The "2 identical buffers" were already a single ring plus latched snapshots (see below). +- **`todo.md` item 2 — relative timing.** Implemented as `--relative-time`. *As first built* (under + the old byte budget; superseded by the item above, where both modes keep the same span and only + the memory differs) the retained history in this mode was **41.943 s** against the default + **83.886 s** (measured at 100 kS/s, 4 channels, `--buffer-mb 200`): exactly half, because a second f64 ring for absolute time raises a tick's cost from 24 to 32 bytes — a third more — and `Partition::time_capacity` is floored to a power of two, so a third more weight costs a full halving. The design document and the implementation plan both put the cost at ~22%, which is wrong; the per-byte figure of 75% is wrong for the same diff --git a/docs/claude/architecture.md b/docs/claude/architecture.md index 9487074..6f9f31b 100644 --- a/docs/claude/architecture.md +++ b/docs/claude/architecture.md @@ -133,8 +133,9 @@ thread. ### Data path `ScopeModel` holds **one `BufferStore`** (`live()`): one shared time-base ring plus one -value ring per channel, sized by `plan_partition()` to cover `--max-window` seconds -within the byte budget. The producer always writes it, whatever the trigger is doing — +value ring per channel, sized by `plan_partition()`: `--buffer-samples` ticks (rounded down to a power of two) per ring, halved +until the real bytes fit `--memory-limit-mb` (`Partition::memory_limited` records a clip), and the shared +clock decimated until `--max-window` seconds fit. The producer always writes it, whatever the trigger is doing — that is what guarantees a full pre-trigger history on the *first* latch after arming. The spec's second buffer (item 1) is the latched `EventSnapshot`, which is already an immutable copy of the captured window; see README for why two live rings were dropped. diff --git a/docs/claude/build-and-run.md b/docs/claude/build-and-run.md index 4e78ed1..d915040 100644 --- a/docs/claude/build-and-run.md +++ b/docs/claude/build-and-run.md @@ -101,8 +101,10 @@ component, so its behaviour is unchanged. Options: `--source NAME` and the repeatable `--source-opt k=v` (the source kind and its settings; `--server`, `--port`, `--multicast` and `--interface` populate the UDPS entries of the same options), -`--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per -buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row +`--server`, `--port`, `--multicast`, `--interface`, `--buffer-samples` (samples +per acquired signal, default 10M, k/M/G suffixes, rounded down to a power of two), +`--memory-limit-mb` (hard ceiling on the ring bytes, default 8192; also a Preferences row), +`--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards), `--capture-packets N` (the raw-datagram ring of README's "Packet capture"; 1..100000, the value is required, and the popover button uses 1000 when the flag was absent). diff --git a/docs/claude/invariants-data-path.md b/docs/claude/invariants-data-path.md index aa1e1c1..1e439e3 100644 --- a/docs/claude/invariants-data-path.md +++ b/docs/claude/invariants-data-path.md @@ -20,7 +20,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear comparison on purpose: it is not a size, but it is what the slots *mean*, and keeping samples under a changed `base_dt` would re-date the whole ring. **Construction is the cost.** A `RingBuffer` holds a `std::vector>`, which is - value-initialised, so making a store touches every byte of `--buffer-mb` — about a second per + value-initialised, so making a store touches every byte of the planned rings — about a second per gigabyte, mostly first-touch page faults — while `reset()` is one relaxed store per ring plus an `invalidate()` and an epoch back to zero. It must leave nothing of the old history behind, which is why it clears the time ring, the absolute-time ring, every value ring, and both the published @@ -65,8 +65,8 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear trade and is why the status line's `rate` field is worth reading. - **A file is ingested too, so the loader decimates by the same factor.** `load_waveform_into_model()` sets the target span to the recording's own length and enables every column, and the plan that - comes back may well decimate: 20 s of 10 channels at 500 kS/s is 480 MB of rings, and the budget is - `--buffer-mb`. The loader therefore reads `partition().time_stride` **after** the enables and pushes + comes back may well decimate: 20 s of 10 channels at 500 kS/s is 10 M samples a channel, and the budget is + `--buffer-samples` (rounded down to a power of two, 8.39 M by default). The loader therefore reads `partition().time_stride` **after** the enables and pushes one slot per that many rows, carrying the group's mean at the group's mean stamp — the packet path's filter, written a second time because a file does not arrive in packets. Pushing every row instead overruns the ring and leaves the file as **its own tail**, with nothing to say so: a 20 s event diff --git a/docs/claude/invariants-math-channels.md b/docs/claude/invariants-math-channels.md index 4540b0f..a2d6ae5 100644 --- a/docs/claude/invariants-math-channels.md +++ b/docs/claude/invariants-math-channels.md @@ -334,7 +334,7 @@ the assertion: `finite` must be about `M` minus the hole share, and `finite=0` b before the ring first wraps** (`base == 0`, where the two numberings coincide), which is why it reads as intermittent and uptime-dependent. The dump's `math '': N samples vs M time slots` is the reading -- both numbers are `produced()`, so a mismatch *is* this defect -- but only on a run whose - ring has wrapped: give it a small `--buffer-mb`. `UDPSCOPE_PROBE_MATHWRAP_MS` is the probe that + ring has wrapped: give it a small `--buffer-samples`. `UDPSCOPE_PROBE_MATHWRAP_MS` is the probe that arranges exactly that, and it exists because **`math_probe` structurally cannot** reach this state: every edit that probe makes follows a structural change, which replans the buffer and restarts every ring at absolute 0, where both halves of this are identities. diff --git a/docs/claude/invariants-measurements.md b/docs/claude/invariants-measurements.md index 3e39764..03c0c9b 100644 --- a/docs/claude/invariants-measurements.md +++ b/docs/claude/invariants-measurements.md @@ -121,7 +121,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear - **A pass costs the window's buckets times the pane's *measured channels* and its *distinct levels*, and the window's own size multiplies all of it.** The cadence above bounds *how often*; this is *how much*, and it is the term a big time scale buys. Measured on the mock at - the user's scale -- a **15 s** window, `--buffer-mb 4000` (so `time_stride` stays 1 and the + the user's scale -- a **15 s** window, `--buffer-samples 100M` (so `time_stride` stays 1 and the window is 11.8 M samples, 184 k level-0 buckets), four signals, one row -- with `[levels-probe]` (see [verification.md](verification.md)), `measure cost:` reading `recomputes`/`each`/`covered`/`buckets`/`raw`: diff --git a/docs/claude/invariants-session-and-export.md b/docs/claude/invariants-session-and-export.md index 23a3727..27cc817 100644 --- a/docs/claude/invariants-session-and-export.md +++ b/docs/claude/invariants-session-and-export.md @@ -145,7 +145,7 @@ expression of the scheme, and the name is `sanitize()`d exactly as the host is, with a slash cannot escape the directory. A configuration carries the whole session *and* the settings that can only be decided before the -first socket: `buffer_mb`, `relative_time`, `capture_packets`. The startup chooser applies them +first socket: `buffer_samples`, `relative_time`, `capture_packets`. The startup chooser applies them (`apply_source()`), which works because `choose_source()` runs before `ScopeModel` is constructed — the one moment a buffer size can be applied at all. Switching configurations inside a running app applies what can change live and says in the status line that the rest takes effect on the @@ -156,9 +156,9 @@ A configuration's *startup* settings reach the model from two directions, and bo named with `--server`, which never reaches the chooser at all. Without the second, a saved host's buffer size, clock and capture ring were silently ignored — the layout still applied, because the app loads that separately once a CONFIG names the signals — so an edit to any of the three read as -one that never took effect. **A flag the user typed wins over the file** (`--buffer-mb`, +one that never took effect. **A flag the user typed wins over the file** (`--buffer-samples`, `--relative-time`, `--capture-packets`): the file fills in what the command line did not say, and -`Config::buffer_mb_specified` is what tells "not asked" from "asked for the default". +`Config::buffer_samples_specified` is what tells "not asked" from "asked for the default". The `active` file is written on every save and by the chooser's pick, so "which configuration was I on" survives a restart. A session written before configurations existed is **imported once** as diff --git a/docs/claude/invariants-trigger-and-pinning.md b/docs/claude/invariants-trigger-and-pinning.md index c56527e..021b414 100644 --- a/docs/claude/invariants-trigger-and-pinning.md +++ b/docs/claude/invariants-trigger-and-pinning.md @@ -356,7 +356,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear `snapshot_bytes()` is the honest cost of one -- both stamp arrays, every channel's `f32` samples and the LOD pyramids built for them, i.e. ~4.5 bytes per sample per channel plus 8 per sample of clock -- and `prune_persist_locked()` cuts the set to **both** bounds: the depth, and - `persist_budget_bytes()`, which is the ring's own `--buffer-mb`, because persistence is a copy + `persist_budget_bytes()`, which is the planned ring bytes (`Partition::bytes()`), because persistence is a copy *of* what the rings hold and holding as much again is the honest ceiling for a display of it. `kMaxPersistDepth` is 8 for the different reason the panel needs one: the layer there is already invisible (0.8 %) and seven is all the free slots a one-signal row has. The depth is not the only diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 96e35cd..bc8ce06 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -75,7 +75,7 @@ Two lines of the self-check output are assertions in disguise: a live axis narrower than the requested window is the same symptom from the other cause (see the `clamp_to_data()` invariant in [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)). Both need a *saturated* ring to - show up, so run them under `--buffer-mb 1`, which wraps in a few seconds. + show up, so run them under `--buffer-samples 65536`, which wraps in a few seconds. - `trigger: edge at X ... want [-a..b] got [-c..d] n=N` (stderr) — `got` must equal `want` and `N` must be exactly the window times the stream's real rate, plus one (2 s at 10 kS/s is `n=20001`, not 19999). `got` shorter than `want` on the *left* @@ -206,10 +206,10 @@ Two lines of the self-check output are assertions in disguise: decimation is for, and a `buffered` short of the window is the old "the control is capped to what the ring holds" behaviour coming back. `decim` must be a power of two, `stored × decim` must equal the wire rate, and `aa` must be exactly `stored / 2`. Run it - under `--buffer-mb 1`, which saturates in a few seconds and makes windows past ~6 s - decimate; at 200 MB nothing inside `--max-window` needs to. + under `--buffer-samples 65536`, which saturates in a few seconds and makes windows past ~6 s + decimate; at the default 10M nothing inside `--max-window` needs to. - **The anti-alias check** is the same probe run against a waveform above the stored - Nyquist: `--buffer-mb 1 --freq 2000` with the probe ending on a 60 s window gives + Nyquist: `--buffer-samples 65536 --freq 2000` with the probe ending on a 60 s window gives `decim=16`, a 625 S/s stored rate and a 312.5 Hz corner, and the channel dump's `ch 0 'sine' ... y=[-0.059, 0.079]` is the assertion — a boxcar of 16 at 10 kS/s passes 2 kHz at about 6%. **A `y=[-1, 1]` there means the filter is gone** and the 2 kHz sine has @@ -763,7 +763,7 @@ Two lines of the self-check output are assertions in disguise: it exists to answer: freezing a whole `BufferStore` copies nothing and inherits the pyramid a `ChannelBuffer` already maintains incrementally, so both `worst` figures would go to **0** — at the price of a second budget's worth of memory (README records that one live ring is why - `--buffer-mb 200` really is 200 MB) and a second per-sample write on the producer. What the line + the planned bytes are the whole memory cost) and a second per-sample write on the producer. What the line says about that trade is that the stall is real but **costs no packets**: an A/B of the same run with and without a pause read `gaps=0` both ways, the paused one with an 86 ms publish, so a second ring would buy a smoother frame at a latch rather than recovered data. (An earlier run @@ -776,7 +776,7 @@ Two lines of the self-check output are assertions in disguise: for the window**, one fetch and one scan per *measured channel*; **`L` is its price for the pane's shares**, one accumulator per distinct one, walked over the window's buckets; and the window's samples are the multiplier on both, so a sweep at one scale says nothing about - another. Measured at the user's scale (15 s window, `--buffer-mb 4000` -> `stride=1`, 11.8 M + another. Measured at the user's scale (15 s window, `--buffer-samples 100M` -> `stride=1`, 11.8 M samples in the window = 184 k buckets, four signals, mock at ~0.5 MS/s per signal, llvmpipe), with `UDPSCOPE_PROBE_LEVELS_CHANNELS` set: * `50`, 1 channel -> `1 window, 1 level`, `measure cost: 226 recomputes, 6.58 ms each`, `raw=0.1%`, @@ -1407,7 +1407,7 @@ popover row reads `failed: ` — one stderr line, no retry storm again [defaults] calibrated: 2.5 -1.25 model=2.5 -1.25 [configs] before=default active=default [configs] save-as=1 names=probe,default active=probe -[configs] round trip read=1 comment='written by the configs probe' buffer_mb=8 relative=0 capture=0 plots=1 signals=4 measures=0 math=0 noise=-1 hf=-1 +[configs] round trip read=1 comment='written by the configs probe' buffer_samples=10000000 relative=0 capture=0 plots=1 signals=4 measures=0 math=0 noise=-1 hf=-1 [configs] after forget names=default ``` `noise=` and `hf=` are the first measurement's band and corner as they came back from the file. The probe @@ -1422,7 +1422,7 @@ sets them to **7.5 and 250** before saving, so a key that never reached the file | `[defaults] trigger:` | `0.001 0.002 0 0.001 0 50 0.01 0 0 1` | the same, per trigger field — the ten spins in tree order: width, width2, level2, timeout, level, pre-trigger, hold-off, noise band, HF reject, persistence; a level of `-1e12` is a number nobody chose, and the trailing `1` is the persistence depth, whose default *is* the feature off | | `[configs] names` | gains `probe`, and loses it after `forget` | a save that writes somewhere the listing does not read, or a delete that leaves the file | | `[configs] active` | `probe` after the save-as | the `active` file not written, so the next start opens the wrong configuration | -| `[configs] round trip` | `read=1`, the note verbatim, `buffer_mb` non-zero | the note or the startup settings not persisted — a configuration that carries a layout but not the buffer size it was tuned with | +| `[configs] round trip` | `read=1`, the note verbatim, `buffer_samples` non-zero | the note or the startup settings not persisted — a configuration that carries a layout but not the buffer size it was tuned with | ## `[rowmenu]` @@ -1524,8 +1524,8 @@ actually use. ``` Printed by `apply_stored_settings()` when a source is named on the command line: the stored -configuration's startup settings, filled in where the command line was silent. A `buffer=` here -that disagrees with `--buffer-mb` is a flag being overruled by the file; a *missing* line, with a +configuration's startup settings, filled in where the command line was silent. A `samples=` here +that disagrees with `--buffer-samples` is a flag being overruled by the file; a *missing* line, with a configuration that carries settings, is the defect this exists for — a saved host opened with `--server` ran on the defaults, so an edit to its buffer or clock never took effect. @@ -2170,7 +2170,7 @@ of `S` signals shows `floor((8 - S) / S)` ages of each, so the default four-sign **`kept`/`bytes`/`dropped` are the *retention*, and they bound what those slots can draw.** `kept=3` of the 3 asked at `bytes=20998152` (21 MB: three acquisitions of a 0.5 s window at 500 kS/s over four channels, -~7 MB each) against `budget=209715200`, the ring's own `--buffer-mb`. `drawn=` is how many layer slots are +~7 MB each) against `budget=` the planned ring bytes (`persist_budget_bytes()`, `Partition::bytes()`). `drawn=` is how many layer slots are actually *bound* to a retained acquisition, and it is a different number from `placed=` exactly when the budget bit — which is the run to take next, on the fast mock with a long window: @@ -2182,7 +2182,7 @@ budget bit — which is the run to take next, on the fast mock with a long windo ``` `UDPSCOPE_PROBE_PERSIST_DEPTH=8 UDPSCOPE_PROBE_PERSIST_WINDOW=2` against the mock at 1.3 MS/s: one retained -acquisition is 72.8 MB, so the 200 MB budget keeps two and drops two — `kept` below the depth, `dropped` +acquisition is 72.8 MB, so the budget (the planned rings, ~201 MB for four channels at the default 10M) keeps two and drops two — `kept` below the depth, `dropped` non-zero, and **`drawn=4` against `placed=28`**: the layer slots exist and the oldest seven ages per row draw nothing at all. That is the memory bound as a reading, and `settled:` is the wait saying so (the retention can never reach the depth, so the probe reports what stopped growing instead of waiting out its cap — its @@ -2374,7 +2374,7 @@ refusal it may still hold the previous text. ## `[enable-all-probe]` and switching a signal off ``` -[enable-all-probe] 4 channel(s) enabled and assigned to plots, notice='connecting to 127.0.0.1:21023 -- planning the 200 MB buffer' +[enable-all-probe] 4 channel(s) enabled and assigned to plots, notice='connecting to 127.0.0.1:21023 -- planning the buffer: 8.39 M samples per signal' [enable-all-probe] switched 'sine' off: traces_left=0 (want 0) notice=''sine' is off: 1 trace left the plots' ``` @@ -2383,7 +2383,7 @@ earliest thing that reads the status bar. It is set as the window is presented - CONFIG can take the model's mutex, so before anything can block -- and it names the host and the buffer being planned, because that is the window's only answer to "why is nothing happening yet". An empty `notice` here is the window that used to come up silent while a large buffer was allocated: -at `--buffer-mb 4000` that is the dialog disappearing and then over a second of a frozen, blank +at `--buffer-samples 100M` that is the dialog disappearing and then over a second of a frozen, blank window. (The buffer is allocated on the producer thread under `ScopeModel::mutex()`, which the frame tick also takes, so the freeze is real and cannot be papered over in the UI -- see the `[udpscope] buffer allocated:` line below.) @@ -2452,6 +2452,34 @@ rate whenever data arrives at all: Neither can be read off the engine's own fields, which is why the store's pair is here. +## `[udpscope] buffer:` and `[udpscope] planner:` + +``` +[udpscope] buffer: requested=10000000 used=8388608 bytes=167772160 limit=8589934592 limited=0 +[udpscope] planner: unclipped got=67108864 pow2=1 limited=0 | clipped(1GiB) got=33554432 bytes=671088640 fits=1 pow2=1 limited=1 | clipped_rel got=33554432 bytes=939524096 fits=1 | too_small_throws=1 +``` + +Printed by the exit dump (`src/ui.cpp`), next to the per-channel `produced` lines. The first is the live +plan: `requested` is `Config::buffer_samples`, `used` is `Partition::time_capacity` (the request rounded +down to a power of two, then halved under the memory limit), `bytes` is `Partition::bytes()` (the rings' +real size -- 8 388 608 ticks x (8 + 3 x 4) = 167 772 160 for the mock's three channels) and `limit` is +`memory_limit_bytes()`. **`used` must be a power of two no larger than `requested`'s floor, `bytes` must be +<= `limit`, and `limited=1` only when `used` is below that floor.** A run with `--buffer-samples 100M +--memory-limit-mb 256` reads `used=8388608 limited=1` and prints the `buffer limited to ...` stderr line; +`used=0 bytes=0` means no plan was made yet (no channel enabled -- add `UDPSCOPE_ENABLE_ALL_MS`). + +The second line is not about the run: it feeds `plan_partition()` three channels and a 100 M request on +fixed inputs, so a change to the rounding, the halving or the byte formula reads here even when the run's +own plan is not clipped. `unclipped` (16 GiB limit) must give the pow2 floor of 100 M, **67108864**, with +`pow2=1 limited=0`. `clipped(1GiB)` must give a smaller power of two whose `bytes` are <= 1 GiB +(**33554432**, 671 088 640 bytes: 67 108 864 would need 1 342 177 280), `fits=1 pow2=1 limited=1`. +`clipped_rel` is the same in relative time, where the clock ring is doubled, so a tick costs 8 more bytes: +the same 33 554 432 ticks but 939 524 096 bytes, still `fits=1`. `too_small_throws=1` is a 1000-byte limit +refused (it cannot hold 128 ticks). A `fits=0` is the halving loop stopping early; `limited=0` on the clipped +case is the clip not being reported to the UI; `too_small_throws=0` is a planner that allocates past the limit. +The Preferences *Memory limit* row is not probed: change it by hand and read the new `buffer allocated:` line +and the status popover. + ## `[udpscope] buffer allocated:` ``` @@ -2460,7 +2488,7 @@ Neither can be read off the engine's own fields, which is why the store's pair i Not a probe — it is printed by `ScopeModel::rebuild_locked()` in every build, because this is the one operation in the application whose duration a user can see with no instrument: the store's rings are -`std::vector>` and so **value-initialised**, which touches the whole `--buffer-mb` +`std::vector>` and so **value-initialised**, which touches the whole planned ring storage (roughly a second per GB, mostly first-touch page faults), and it runs on the producer thread holding `ScopeModel::mutex()` — which the frame tick also takes. So the line's `ms` is a frozen window, not bookkeeping. @@ -2469,7 +2497,7 @@ bookkeeping. rebuild whose plan describes the storage already allocated clears the store instead (`Partition::same_storage_as()` → `BufferStore::reset()`) and prints nothing, so a startup or a mode change that reads more than one or two of these is a re-plan that should have been coalesced. Startup -asked for **fifteen**; it now asks for two or three. A run at `--buffer-mb 4000` measured those +asked for **fifteen**; it now asks for two or three. A run at `--buffer-samples 100M` measured those coalesced allocations at 1148 ms each and a max frame tick of 1186 ms — the same number, which is the evidence that the freeze *is* this call and not something downstream of it. @@ -2495,8 +2523,8 @@ while the edit runs) and never falls to 0, which is what a rebuild would do. Eve [mathwrap-probe] after edit: produced=72400 slots=72400 (want equal) y=[-0.5000,0.5000] (want +-0.5, was +-2.0000) ``` -`UDPSCOPE_PROBE_MATHWRAP_MS`, and it must be run with a **small `--buffer-mb`** and given time to -saturate it: `--buffer-mb 1` against `tools/mock_udpstreamer.py --rate 20000 --batch 200`, with +`UDPSCOPE_PROBE_MATHWRAP_MS`, and it must be run with a **small `--buffer-samples`** and given time to +saturate it: `--buffer-samples 65536` against `tools/mock_udpstreamer.py --rate 20000 --batch 200`, with `UDPSCOPE_PROBE_MATHWRAP_MS=1500 UDPSCOPE_EXIT_AFTER_MS=25000`, reads the lines above. It installs one `Gain` graph of 2 over the mock's unit sine, polls (100 ms) until the ring has **wrapped**, and only then edits the gain to 0.5 — the same channel list with one node changed, which is the in-place recompute and diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 8a07d37..719ce17 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -46,7 +46,7 @@ when only `src/` changed, so its `-Woverlength-strings` does not reappear. A third reading every run offers for free: **how many `[udpscope] buffer allocated:` lines it printed.** The line is not behind a probe — it is in every build — and one line is one *real* change of buffer plan, each of which blocks the main thread for the time it takes to touch the whole -`--buffer-mb`. A normal run prints two or three. Fifteen at startup is the defect it was added for; +planned buffer. A normal run prints two or three. Fifteen at startup is the defect it was added for; see [probe-readings.md](probe-readings.md). That second baseline used to be "exactly two, `gtk_widget_get_height` and `gtk_widget_get_width`, @@ -342,7 +342,7 @@ precedent, not an exception. *tick*, one frame after the ask: a `g_timeout` was the first version and it drifted past the paused/resumed boundary under load. The probe then asks for a **zoom-out** ten times the current span and reports the span the next frame produced: **`limited` must be 1**, i.e. `span <= window`. Run it with - a ring that holds *more* than the window (`--buffer-mb 64` past the window's own seconds) — + a ring that holds *more* than the window (`--buffer-samples 2M` past the window's own seconds) — that is the state the old `max(retained, window_s_)` bound leaked in, and it showed up here as `span=2.502550 window=2.000000 limited=0` against `span=2.000000` after the fix. @@ -617,7 +617,7 @@ as distinct from the fault injection below, which exercises ingest: `Gain` graph over the mock's sine, waits for the ring to **saturate**, and only then edits the gain, which is the one state `UDPSCOPE_PROBE_MATH_MS` cannot reach (each of its edits follows a structural change, and a replan restarts every ring at absolute 0). It therefore needs a **small buffer and a long - run**: `--buffer-mb 1` against `--rate 20000 --batch 200`, with + run**: `--buffer-samples 65536` against `--rate 20000 --batch 200`, with `UDPSCOPE_PROBE_MATHWRAP_MS=1500 UDPSCOPE_EXIT_AFTER_MS=25000`. `base > 0` on its second line is the precondition — **`base=0` means the run tested nothing** — and the third line's `produced == slots` and `y=±0.5` are the two assertions; both are in [probe-readings.md](probe-readings.md). @@ -625,7 +625,7 @@ as distinct from the fault injection below, which exercises ingest: note, through `save_configuration_as()` (the same entry point the dialog's button uses), then reads it back off disk. Reading: `save-as=1`; `names=` gains `probe`; `active=probe`; and the round trip must carry the note **and** the startup settings — - `comment='written by the configs probe' buffer_mb=8 relative=0 capture=0`. A `buffer_mb=0` there + `comment='written by the configs probe' buffer_samples=10000000 relative=0 capture=0`. A `buffer_samples=0` there is the per-source buffer size not being written, which is the one setting a user notices only after a capture has been sized wrong. Run it with a throwaway `XDG_CONFIG_HOME`; the probe deletes its own configuration at the end (`after forget names=…`). @@ -681,9 +681,9 @@ as distinct from the fault injection below, which exercises ingest: `[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25` — **the dialog must show the signal's stored calibration**, and the two halves must match. - The **chooser**, display-free: `UDPSCOPE_PROBE_SOURCES=1` prints `[sources] N known:` with one - entry per configuration (`host:port/name … buffer= relative= note='…'`), and + entry per configuration (`host:port/name … samples= relative= note='…'`), and `UDPSCOPE_PROBE_PICK_MS=800` opens the chooser, picks the first row and prints - `[sources] picked host:port … buffer=64 relative=0 capture=0 config=… note='…'` — the settings the + `[sources] picked host:port … samples=2097152 relative=0 capture=0 config=… note='…'` — the settings the configuration carried, into the `Config` the model is built from. **With no `--server` and no probe the chooser blocks in its own nested loop** (no frame to end and no dump), so a chooser run is always this probe. It also prints the dialog's frame *and its own help layer*: @@ -764,7 +764,7 @@ as distinct from the fault injection below, which exercises ingest: python3 -c "import numpy as np; n=1000000; t=-4.0+np.arange(n)/50000.0; \ a=np.zeros(n, dtype=[('time','` — then the load line above, with no socket involved. - **A `--server` launch reads the stored configuration.** With a throwaway `XDG_CONFIG_HOME` - holding `127.0.0.1_8101/bench.toml` (`buffer_mb = 64`, `relative_time = true`, + holding `127.0.0.1_8101/bench.toml` (`buffer_samples = 2097152`, `relative_time = true`, `capture_packets = 500`, `active` = `bench`), `./build/udpscope --server 127.0.0.1 --port 8101` - must print `[sources] settings from configuration 'bench': buffer=64 relative=1 capture=500`, the - `relative time:` line, and a `span=` of about 209 s (64 MB at 10 kS/s) — **not** the 200 MB - default's ~655 s. The same command with `--buffer-mb 8` must read `buffer=8` and `span=26.214s`: + must print `[sources] settings from configuration 'bench': samples=2097152 relative=1 capture=500`, the + `relative time:` line, and a `span=` of about 209 s (2 097 152 ticks at 10 kS/s) — **not** the 10M default's ~839 s. The same command with + `--buffer-samples 262144` must read `samples=262144` and `span=26.214s`: **an explicit flag wins over the file**. Without this, the settings a host was edited to have were ignored whenever the address came from the command line. - The chooser's **form**, with `UDPSCOPE_PROBE_PICK_MS` scheduling it: `UDPSCOPE_PROBE_NEWHOST=host:port` fills it and prints - `[sources] form: name='probe-host' note='…' buffer=32 relative=1 connect_sensitive=1`, then - `[sources] configuration created: host:port/probe-host … buffer=32 relative=1` and a + `[sources] form: name='probe-host' note='…' samples_m=0.5 relative=1 connect_sensitive=1`, then + `[sources] configuration created: host:port/probe-host … samples=500000 relative=1` and a `picked … config=probe-host`; a following `UDPSCOPE_PROBE_SOURCES=1` must list it - (`buffer=32 relative=1 note='…'`) — a configuration the form wrote that the chooser does not + (`samples=500000 relative=1 note='…'`) — a configuration the form wrote that the chooser does not list cannot be edited next time. `UDPSCOPE_PROBE_EDITHOST=host:port` opens the same form on that - configuration and prints `[sources] edit form: name='…' note='…' buffer=… relative=… button='Save'` + configuration and prints `[sources] edit form: name='…' note='…' samples_m=… relative=… button='Save'` — **the pre-fill is the assertion**: an edit form that opens empty cannot edit anything — then saves and reports the new values. `UDPSCOPE_PROBE_EDITNAME=new-name` renames it as well: one `known:` row afterwards, with the old file gone. diff --git a/help/C/org.udpscope.app/overview.page b/help/C/org.udpscope.app/overview.page index 9f95d4a..bd95f8b 100644 --- a/help/C/org.udpscope.app/overview.page +++ b/help/C/org.udpscope.app/overview.page @@ -18,7 +18,7 @@

udpscope plots a live UDPStreamer source. One window holds up to eight rows of plots, each with up to four 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 drawing thread reads whatever that ring holds when a frame + --buffer-samples; 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 diff --git a/help/C/org.udpscope.app/sessions.page b/help/C/org.udpscope.app/sessions.page index f677cac..5a37359 100644 --- a/help/C/org.udpscope.app/sessions.page +++ b/help/C/org.udpscope.app/sessions.page @@ -41,7 +41,7 @@ they open.

A configuration also carries the settings that can only be decided when the buffers are - allocated: the buffer size (--buffer-mb), relative time, and the + allocated: the samples per signal (--buffer-samples), relative time, and the packet capture's ring size. Opening a configuration from the chooser applies all three; switching inside a running app applies the rest and says in the status line that these take effect on the next start.

@@ -57,8 +57,8 @@

What a configuration persists: the time window, the rows and each row's slot signals, the calibration, the trace colours, which signals were enabled, the math - channels with their graphs, the measurements pane — and the startup settings: buffer - size, relative time and the packet capture's ring size.

+ channels with their graphs, the measurements pane — and the startup settings: samples + per signal, relative time and the packet capture's ring size.

Signal identity is the name throughout, because a new CONFIG renumbers the channels.

A file written by a run against a source whose streamer was down would name no diff --git a/help/C/org.udpscope.app/source.page b/help/C/org.udpscope.app/source.page index 956bdc4..7ed3bf8 100644 --- a/help/C/org.udpscope.app/source.page +++ b/help/C/org.udpscope.app/source.page @@ -28,7 +28,7 @@ stored configuration (host:port · name, its note, and the settings it carries), newest first, plus a New host… form that takes the address and the configuration it belongs to — its name and note, the - buffer size, and whether time is relative or absolute. A row's pencil + samples per signal, and whether time is relative or absolute. A row's pencil button opens the same form on that configuration, pre-filled, and changes to its name or address move it (the old file is deleted).

diff --git a/help/C/org.udpscope.app/trigger.page b/help/C/org.udpscope.app/trigger.page index 19f89b1..60f0f75 100644 --- a/help/C/org.udpscope.app/trigger.page +++ b/help/C/org.udpscope.app/trigger.page @@ -117,7 +117,7 @@ layout the app starts with — has room for one older acquisition of each of them; a row per signal has room for all seven.

Memory. A retained acquisition is a whole window copied out of the ring, - so persistence may hold at most as much again as the buffer (--buffer-mb) does. + so persistence may hold at most as much again as the buffer takes (see --buffer-samples and --memory-limit-mb). On a long window at a high sample rate that bound is reached before the eighth layer.

diff --git a/help/C/org.udpscope.app/window.page b/help/C/org.udpscope.app/window.page index 2192cff..9d2e245 100644 --- a/help/C/org.udpscope.app/window.page +++ b/help/C/org.udpscope.app/window.page @@ -56,11 +56,10 @@
Example -

1 MS/s with 200 MB of buffer. A 2 s window stores every sample - (time x1). Widen it to 600 s and the stride becomes x16: 625 S/s - stored, anti-aliased at 312.5 Hz. A 2 kHz sine seen through that window reads - y=[-0.059, 0.079] — attenuated to about 6%, not folded down to a - full-amplitude 125 Hz one.

+

1 MS/s with the default buffer of 8 388 608 samples per signal (8.39 s). A 2 s window + stores every sample (time x1). Widen it to 600 s and the stride becomes + x128: 7.8 kS/s stored, anti-aliased at 3.9 kHz. A 5 kHz sine seen through that + window is attenuated by the filter, not folded down to a full-amplitude low-frequency one.

diff --git a/todo.md b/todo.md index a2a464a..1069716 100644 --- a/todo.md +++ b/todo.md @@ -9,7 +9,7 @@ - user is acquiring a signal with 1MSps and 100_000 samples per udp frame - last frame as number and previous in this case a 100_000 indexes must be skipped - time is simply: `(( - ) * - ) / ` (or something like that) -- [x] (3) ~~named configurations~~ **done**: one directory per source holding `.toml` per configuration plus `active`; a configuration carries a note and the startup settings (`buffer_mb`, `relative_time`, `capture_packets`); the chooser lists them and the menu's _Configurations…_ opens/saves/deletes them. +- [x] (3) ~~named configurations~~ **done**: one directory per source holding `.toml` per configuration plus `active`; a configuration carries a note and the startup settings (`buffer_samples`, `relative_time`, `capture_packets`); the chooser lists them and the menu's _Configurations…_ opens/saves/deletes them. - [x] (4) ~~optional udp frame storage for debugging of strange behaviours~~ **done**: `--capture-packets N`; see README and the design note in `docs/superpowers/specs/` for the ring file, the popover toggle and `tools/capture_to_pcap.py`. - [x] (5) ~~open exported binary waveforms to view/analyse~~ **done**: `--open FILE` and the chooser's **Waveforms** page (the files opened before, newest first, plus _Open…_); the `.npy`'s dtype names the columns, so a load restores the signals, the samples and the axis the export had, and every instrument works on it — see README and `docs/claude/invariants-data-path.md`. - [x] (6) ~~imporve right click menu on the plots~~ **done**: the plot's context menu uses the main menu's row style (`menu_button` + `.udpscope-menu`, asserted by `UDPSCOPE_PROBE_ROWMENU_MS`: uniform `label_x=26`, `h=18`, the shortcut flush right), and deleting a signal is a **Remove signal** submenu listing that plot's traces plus _All traces on this plot_. diff --git a/tools/make-help-shots.sh b/tools/make-help-shots.sh index e6419b6..6b49ec3 100755 --- a/tools/make-help-shots.sh +++ b/tools/make-help-shots.sh @@ -126,7 +126,7 @@ pick_other_window() { start_app() { # LOGFILE ENVS... ; extra args via APP_ARGS local log=$1; shift # shellcheck disable=SC2086 - env "$@" "$app" --server 127.0.0.1 --port "$port" --buffer-mb 16 ${APP_ARGS:-} >"$log" 2>&1 & + env "$@" "$app" --server 127.0.0.1 --port "$port" --buffer-samples 512k ${APP_ARGS:-} >"$log" 2>&1 & app_pid=$! } From a7a1609d48a3a6547d008eaadebf0bd4a728a73e Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 05:19:19 +0100 Subject: [PATCH 080/127] docs: plan for the compact readouts dock (step 3 of the redesign) --- .../plans/2026-11-03-ui-readouts-dock.md | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-03-ui-readouts-dock.md diff --git a/docs/superpowers/plans/2026-11-03-ui-readouts-dock.md b/docs/superpowers/plans/2026-11-03-ui-readouts-dock.md new file mode 100644 index 0000000..661f930 --- /dev/null +++ b/docs/superpowers/plans/2026-11-03-ui-readouts-dock.md @@ -0,0 +1,107 @@ +# Compact readouts dock -- step 3 of the badge-bar redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The dock shows the cursors and the measurements as compact, read-only readouts (about 280 px wide, always on screen); the full cursors panel and the full measurements panel (mode, reset, add, statistics, edit) become the `cursors` and `measure` slide-over pages, reached by an "Edit" text button in the dock, by the `m` key, and by clicking a measurement's dock line. + +**Architecture:** A new unit `src/ui_dock.cpp` (spec: `ReadoutsDock`) builds the dock's "Readouts" section: a cursors block (a small grid mirroring `App::cursors_lines`) and a measurements block (one line per `App::measures`). It owns no formatting: `update_cursors_pane()` and `update_measures()` already compute the strings, and they now write them to the page's widget *and* to the dock's, from the one string. `cursors_panel` and the measures panel are *reparented* (as the trigger form and the signal list were) into slide-over pages with ids `cursors` and `measure`. The dock's old contents (the two full panels) leave it. + +**Tech Stack:** C++20, GTK4, libadwaita >= 1.8, CMake. No unit-test suite: verification is the app's own headless self-check (`xvfb-run`, mock sources) -- extend `src/ui_probe.cpp` probes, never a separate harness. + +Spec: `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md` ("Right dock", "Slide-over panel", rollout step 3). Earlier plans: `2026-11-02-ui-badges-dock-shell.md` (step 1), `2026-11-03-ui-badges-signals.md` (step 2). Branch: `feature/ui-badges-dock` (continue on it). + +## Global Constraints + +Copied from the spec and the repository's rules; every task includes them. + +- **Right dock (spec, verbatim):** "Fixed width, about 280 px; always on screen, shown but insensitive until the stream has signals ('disable, do not hide'). It stays an `AdwOverlaySplitView` sidebar ... **Readouts** (top): the active cursor values and measurement values, read-only, one compact line per value, through the one formatter both panes share (`invariants-readouts-and-cursors.md`). An 'Edit' affordance opens the slide-over's `measure` or `cursors` page; clicking a measurement row opens that measurement's editor." +- **One formatter:** a readout quotes a stored sample and never a value between two (`docs/claude/invariants-readouts-and-cursors.md`); the dock and the page show the *same string*, set from the same variable in the same call -- no second `format_measure()` call site for the same value. +- **Layout must not depend on content** (`invariants-plot-and-layout.md`): the dock has a fixed width (280 px: `min-sidebar-width` = `max-sidebar-width` in `main-window.ui`), every dock label is ellipsized with a fixed `width-chars`/`max-width-chars`, so a long unit or value never widens the dock; the badge bar keeps its fixed height; the slide-over overlays and never resizes the plot. +- **A `GtkOverlay` child is picked wherever its allocation is**: this step adds no overlay child. +- Page ids: `trigger`, `signal:`, `signals`, `measure`, `cursors`, `events`, `source-`. This step adds `measure` and `cursors`. One slide-over page at a time; each page has its own scroller (`slideover_add_page` provides it); the open page is not saved. +- "Disable, do not hide": with the cursors off, the dock's cursor block stays on screen with `--` cells (the existing blanking in `update_cursors_pane`), insensitive; an empty measurement list shows one dim line, never nothing. +- Text buttons only for new controls ("Edit", not an icon). Existing icon buttons that move into the pages are not changed in this step. +- The widget-lifetime trap (`invariants-gtk-and-chrome.md`): a click handler that calls `rebuild_measures_pane()` / `rebuild_cursors_pane()` destroys widgets it may have been emitted from; hold the emitter with `outlive_the_rebuild(widget)` first. The dock's lines are rebuilt by those two functions, so a click on a dock line must not be handled by a widget that the rebuild destroys synchronously (open the dialog from the handler, rebuild later). +- Every new source file carries the SPDX MIT header (`// SPDX-License-Identifier: MIT` / `// SPDX-FileCopyrightText: 2026 Martino Ferrari`). +- Baselines: a clean build emits exactly **one** warning (the icons' `-Woverlength-strings`), an incremental build **zero**; the dump's `gtk warnings:` reads `0`; no `Gtk-CRITICAL`/`Gtk-WARNING`. +- Verification runs are short and serial; kill every mock you start (`tools/mock_udpstreamer.py` port 8114, `tools/mock_e1608.py` port 54211); seed a tour-seen config so the first-run tour does not close slide-over pages (see `.superpowers/sdd/task-5-report.md` for commands). Do not re-run to chase cosmetics. +- Do not push, merge or amend. Commit per task on `feature/ui-badges-dock`. + +## Deliberate step-3 deviations from the spec (state them in the final report) + +- **The measurement editor stays a dialog window** (`open_measure_dialog`): the `measure` page holds the list, Add, the Statistics switch and each row's edit button, which open that dialog. Moving the dialog's form into the slide-over is not needed for the readouts and is not done here. +- The `measure` and `cursors` pages hold the existing panels unchanged (heading, mode dropdown, reset, switch, grid / add, statistics, rows), so their content is not re-designed. +- Dock cursor lines carry the same cells as the page's grid (`who`, A, B, delta), at a smaller size; they are not reduced to the time line only, because the modes that read per-row values would then have no readout on screen. + +## File Structure + +| File | Create/Modify | Responsibility | +| --- | --- | --- | +| `src/ui_dock.cpp` | Create | The dock's Readouts section: build, rebuild of the cursor lines and the measurement lines, the Edit buttons, the click-to-edit on a measurement line, the `[dock]` probe line. | +| `CMakeLists.txt` | Modify | Add `src/ui_dock.cpp` next to `src/ui_badges.cpp`. | +| `src/ui_app.hpp` | Modify | `App::DockCursorLine`, `App::dock_cursor_lines`, `App::dock_measure_labels`, `App::dock_readouts` widgets; declarations. | +| `src/ui_cursors.cpp` | Modify | `update_cursors_pane()` writes each cell's string to the page and to the dock; the rebuild calls the dock's rebuild. | +| `src/ui_measure.cpp` | Modify | `update_measures()` writes each reading to the page's label and to the dock's; `rebuild_measures_pane()` rebuilds the dock lines. | +| `src/ui.cpp` | Modify | Pages `cursors` / `measure` registered; panels removed from `dock_page`; `dock_show_section()` removed; `ShowMeasure` opens the page; dock assembly. | +| `src/ui/main-window.ui` | Modify | Dock width 280 (`min/max-sidebar-width`), `side_column` `width-request` 260, comments. | +| `src/ui_tour.cpp`, `src/ui_context_help.cpp` | Modify | Steps / hints that pointed at the cursors and measurements panels in the dock now open the page (as the signals step does). | +| `src/ui_probe.cpp` | Modify | Probes that walked, measured or scrolled the panels while they lived in the dock; the `dock:` readings. | +| `README.md`, `docs/claude/*.md` | Modify | Task 3. (`USER-GUIDE.md` and the Mallard manual are spec step 4.) | + +--- + +### Task 1: The compact readouts in the dock (panels still in the dock) + +The Readouts section is added at the top of the dock, above the existing full panels, so the app stays whole: nothing is removed yet. Both read from the same strings. + +**Files:** +- Create: `src/ui_dock.cpp` +- Modify: `CMakeLists.txt`, `src/ui_app.hpp`, `src/ui_cursors.cpp`, `src/ui_measure.cpp`, `src/ui.cpp` (dock assembly, ~line 5161), `src/ui_probe.cpp` (a `[dock]` line in an existing probe) + +**Interfaces:** +- Produces (declared in `ui_app.hpp`, defined in `ui_dock.cpp`): + - `GtkWidget* build_readouts_dock(App& app);` -- the Readouts box (a vertical `GtkBox`: a "Readouts" heading; a cursors block = a row with the "Cursors" label and an "Edit" `GtkButton` (text), then a `GtkGrid` of dock cursor lines; a measurements block = a row with the "Measurements" label and an "Edit" button, then a vertical box of lines). Stores `app.dock_cursor_grid`, `app.dock_measures_box`, `app.dock_cursors_edit`, `app.dock_measures_edit`. + - `void dock_rebuild_cursors(App& app);` -- called at the end of `rebuild_cursors_pane()`: rebuilds `app.dock_cursor_lines` (one `DockCursorLine {GtkWidget *label,*a,*b,*d;}` per `app.cursors_lines`, same order) in `dock_cursor_grid`. The first row is the same header `cursors_header()` writes (`who`/A/B/delta), small. + - `void dock_rebuild_measures(App& app);` -- called from `rebuild_measures_pane()` after the page's rows: one line per `app.measures` (signal chip, type name ellipsized, value label), pushing the value label to `app.dock_measure_labels` (same index as `app.measure_labels`). With no measurements: one dim line "no measurements". + - `void dock_cursor_cell(App& app, std::size_t line, const std::string& a, const std::string& b, const std::string& d);` and `void dock_measure_value(App& app, std::size_t i, const std::string& text);` -- set the dock's labels; no-ops when the index is out of range (mid-rebuild). +- Consumes: `App::cursors_lines`, `App::measures`, `App::measure_labels`, `measure_color()`, `chip_markup()`, `signal_color_index()`, `open_measure_dialog(App&, int)`, `slideover_open(App&, const std::string&)` (page ids used in Task 2; in this task the Edit buttons call it and the page may not exist yet -- `slideover_open` of an unknown id is a no-op; Task 2 registers the pages). + +- [ ] **Step 1: Dock widgets and rebuild functions.** Create `ui_dock.cpp` with the functions above. Fixed sizes: dock label `width-chars`/`max-width-chars` of 9 for value cells, ellipsize end, CSS class `caption` and `numeric` (check `ui_widgets.cpp` for the existing CSS before adding any). The whole Readouts box is the first child of `app.dock_page` (insert before the cursors panel with `gtk_box_prepend`). +- [ ] **Step 2: One string, two widgets.** In `update_cursors_pane()` (`ui_cursors.cpp`) replace the `put(line.a, va)` etc. by a helper that sets the page cell and calls `dock_cursor_cell(app, index, va, vb, vd)`; do the same for the blanking branch (`"--"`). In `update_measures()` (`ui_measure.cpp`, the two `gtk_label_set_text(label, ...)` sites that also write `app.measure_text[i]`) call `dock_measure_value(app, i, text)` from the same variable. Never format twice. +- [ ] **Step 3: Rebuild hooks.** `rebuild_cursors_pane()` (ui.cpp ~1564) ends with `dock_rebuild_cursors(app)`; `rebuild_measures_pane()` calls `dock_rebuild_measures(app)` before its final `update_measures(...)`. Both are no-ops until `build_readouts_dock` ran (null guards). +- [ ] **Step 4: Assemble.** In `ui.cpp` where the dock is assembled (after `gtk_box_append(dock_page, measures_panel)`), prepend `build_readouts_dock(*app)` and add its separator. The Edit buttons call `slideover_open(app, "cursors")` / `slideover_open(app, "measure")`; clicking a measurement line (a `GtkGestureClick` on the line box, not on the value label which is selectable) calls `open_measure_dialog(app, i)` with the index stored in the widget data, as `measures_row`'s edit button does. +- [ ] **Step 5: Probe.** Add to the existing cursors probe (find it with `grep -n "cursors" src/ui_probe.cpp`) and measures probe a reading proving the dock agrees with the page: print `dock cursors: lines= page_lines= same_text=<0|1>` and `dock measures: lines= page_rows= same_text=<0|1>` where `same_text` compares every dock label's text to the page label's text. Also `dock_w=` and assert it does not exceed the sidebar width. Run with 3 measurements and the cursors on in `Both` mode (cursors mode set via `rows->set_cursor_mode`). +- [ ] **Step 6: Build, run the probes against `tools/mock_udpstreamer.py`, verify `same_text=1`, `gtk warnings: 0`.** +- [ ] **Step 7: Commit** (`git add src/ui_dock.cpp CMakeLists.txt src/ui_app.hpp src/ui_cursors.cpp src/ui_measure.cpp src/ui.cpp src/ui_probe.cpp`; message `ui: compact cursor and measurement readouts at the top of the dock`). + +--- + +### Task 2: The `cursors` and `measure` pages; the dock is only readouts + +**Files:** +- Modify: `src/ui.cpp`, `src/ui/main-window.ui`, `src/ui_app.hpp`, `src/ui_probe.cpp`, `src/ui_dock.cpp` + +**Interfaces:** +- Consumes: Task 1's `build_readouts_dock`, `slideover_add_page(App&, id, title, widget)`, `slideover_toggle(App&, id)`. +- Produces: slide-over pages `cursors` (title "Cursors", content `app.cursors_panel`) and `measure` (title "Measurements", content the measures panel); `AppAction::ShowMeasure` toggles the `measure` page; `dock_show_section()` and its declaration/uses are deleted. + +- [ ] **Step 1: Register the pages** next to the `trigger` and `signals` registrations (`ui.cpp` ~5156): `slideover_add_page(*app, "cursors", "Cursors", app->cursors_panel)` and `slideover_add_page(*app, "measure", "Measurements", measures_panel)`. Remove both `gtk_box_append(dock_page, ...)` of the panels and the separator between them; the dock keeps only `build_readouts_dock` (and, later, step 4's events). +- [ ] **Step 2: Keys and actions.** `AppAction::ShowMeasure` -> `slideover_toggle(app, "measure")`. Delete `dock_show_section()`. Make sure the tour/help code does not call it (`grep -n dock_show_section src`). +- [ ] **Step 3: Dock width.** `main-window.ui`: `min-sidebar-width`/`max-sidebar-width` 280, `side_column` `width-request` 260; update its comments. +- [ ] **Step 4: Badge/header state.** The page ids join the "open" bookkeeping: `slideover_page(app)` reports them; nothing in the badge bar marks them (no badge). The header's Cursors toggle still mirrors the `cursors_switch` inside the page (both exist). +- [ ] **Step 5: Probes.** Fix every probe that read the panels' allocation, sensitivity or scroll position while they sat in the dock (`dock_section_probe`, the `dock:` reading in the layout dump, the `cursors=... in the toolbar, dock=..., reset=... in the cursors pane` line, any that walks `dock_page`): they open the page first or read the widget tree without allocation. Add `[readouts] pages cursors=<0|1> measure=<0|1> dock_children= dock_w=` (pages registered; dock holds only the readouts box; width 280). Re-baseline `.superpowers/sdd/ui-signals-baseline-udps.txt` only where this step explains the difference. +- [ ] **Step 6: Run** the UDPS and E-1608 probe runs serially; `gtk warnings: 0`; Esc closes the pages; the measure Add and the measurement dialog still work from the page (the existing measure probes). +- [ ] **Step 7: Commit** (`ui: the cursors and measurements panels are slide-over pages; the dock is the readouts`). + +--- + +### Task 3: Tour, context help, docs + +**Files:** +- Modify: `src/ui_tour.cpp`, `src/ui_context_help.cpp`, `README.md`, `docs/claude/{architecture,verification,probe-readings,invariants-plot-and-layout,invariants-readouts-and-cursors,invariants-gtk-and-chrome}.md`, `help/C/*.page` mentions of the cursors / measurements panels in the dock or the side panel (the full Mallard sweep and `USER-GUIDE.md` are step 4) + +- [ ] **Step 1: Tour.** Steps pointing at `cursors_panel`/`cursors_head`/`measures_head`/`side_box` now target the dock's readouts, or open the `cursors` / `measure` page through the existing "tour opens a page" mechanism (`invariants-gtk-and-chrome.md`), as the signals step does. Rewrite the step text: readouts in the dock, "Edit" opens the page. +- [ ] **Step 2: Context help.** Hints attached to `cursors_head` / `measures_head` live in the pages: show only while the page is open, or re-attach to the dock block headings -- whichever the existing layer supports without a second code path (follow how `signals_head` was handled). Run the help-figures probe and the tour probe (`UDPSCOPE_PROBE_*` in `verification.md`) and keep their lines sane. +- [ ] **Step 3: Docs.** README (layout, keys, the dock, readouts, deviations listed above), `architecture.md` (chrome section), `verification.md` and `probe-readings.md` (new `[dock]`/`[readouts]` lines, changed `dock:` readings), `invariants-readouts-and-cursors.md` (the dock and the page share one string: rule and why), `invariants-plot-and-layout.md` (dock width 280, ellipsized cells). +- [ ] **Step 4: Run** the tour/help-figure probes and one E-1608 run; confirm baselines (build 0 warnings, `gtk warnings: 0`). +- [ ] **Step 5: Commit** (`docs: the readouts dock, the cursors and measurement pages`). From a0fc0762d54da36702d1fd5f90f86f479255ca3b Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 05:22:41 +0100 Subject: [PATCH 081/127] ui: compact cursor and measurement readouts at the top of the dock Both views are written from the same variable at the same call site, so the dock cannot disagree with the full panels. Co-Authored-By: Claude Sonnet 4.6 --- CMakeLists.txt | 1 + src/ui.cpp | 39 ++++---- src/ui_app.hpp | 28 ++++++ src/ui_cursors.cpp | 19 +++- src/ui_dock.cpp | 229 +++++++++++++++++++++++++++++++++++++++++++++ src/ui_measure.cpp | 5 +- src/ui_probe.cpp | 51 ++++++++++ 7 files changed, 350 insertions(+), 22 deletions(-) create mode 100644 src/ui_dock.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index eaeff16..042baa2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -269,6 +269,7 @@ add_executable(udpscope src/ui_status.cpp src/ui_measure.cpp src/ui_cursors.cpp + src/ui_dock.cpp src/ui_math.cpp src/ui_layout.cpp src/ui_source.cpp diff --git a/src/ui.cpp b/src/ui.cpp index 71808c9..512aeee 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -1353,6 +1353,22 @@ bool visible_samples(App& app, std::size_t channel, std::vector& time, +/// The chip of a measurement's signal, in the colour every other view names that signal with (the row and +/// the dock's line both). The signal list is the producer's to rebuild, so it is read under the mutex. +std::string measure_chip_markup(App& app, const std::string& signal) { + std::lock_guard lock(app.model->mutex()); + const std::vector& signals = app.model->signals(); + int color_index = -1; + for (std::size_t i = 0; i < signals.size(); ++i) { + if (signals[i].name == signal) { + color_index = signal_color_index(signals, i); + break; + } + } + return chip_markup(color_index >= 0 ? app.rows->signal_color(signal, color_index) + : RowSet::unplotted_color()); +} + /// How often the measurements pane is recomputed while the window is still moving: a readout, /// where the honest currency is the window's own settling, not the frame rate. Ten a second is /// past the point a number can be read, and it makes the cost independent of the framerate. @@ -1380,22 +1396,7 @@ GtkWidget* measures_row(App& app, std::size_t index, const Measure& m) { // The signal's own chip beside it, in the palette colour every other view names that signal // with, so the row says *what* is being measured as well as by which measurement. GtkWidget* sig_chip = gtk_label_new(""); - { - std::lock_guard lock(app.model->mutex()); - const std::vector& signals = app.model->signals(); - int color_index = -1; - for (std::size_t i = 0; i < signals.size(); ++i) { - if (signals[i].name == m.signal) { - color_index = signal_color_index(signals, i); - break; - } - } - gtk_label_set_markup(GTK_LABEL(sig_chip), - chip_markup(color_index >= 0 - ? app.rows->signal_color(m.signal, color_index) - : RowSet::unplotted_color()) - .c_str()); - } + gtk_label_set_markup(GTK_LABEL(sig_chip), measure_chip_markup(app, m.signal).c_str()); gtk_widget_set_tooltip_text(sig_chip, m.signal.c_str()); gtk_box_append(GTK_BOX(box), sig_chip); GtkWidget* kind = gtk_label_new(measure_type_name(m.type)); @@ -1618,6 +1619,7 @@ void rebuild_cursors_pane(App& app) { } } } + dock_rebuild_cursors(app); } @@ -5161,12 +5163,17 @@ void on_activate(GtkApplication* application, gpointer user) { gtk_box_append(GTK_BOX(app->dock_page), app->cursors_panel); gtk_box_append(GTK_BOX(app->dock_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); gtk_box_append(GTK_BOX(app->dock_page), measures_panel); + // The compact readouts above the full panels (which move to slide-over pages later), with their own + // separator: the first child of the dock. + gtk_box_prepend(GTK_BOX(app->dock_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + gtk_box_prepend(GTK_BOX(app->dock_page), build_readouts_dock(*app)); // The cursors start off, so their readout starts insensitive -- stated here by the one place that // knows the default, and the *body* and not the panel, because the panel's heading carries the // switch that turns it back on. The trigger's body is deliberately *not* disabled: a trigger is // configured before it is switched on, so its settings have to stay editable while it is off. gtk_widget_set_sensitive(app->cursors_body, FALSE); + gtk_widget_set_sensitive(app->dock_cursor_grid, FALSE); // The arm controls are the one part of the trigger pane that needs trigger mode running: both of them, // and the compact line's visibility, are stated from the toggle by `sync_arm_buttons()`. sync_arm_buttons(*app); diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 2f2603e..1d300fe 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -590,6 +590,24 @@ struct App { int slot = -1; }; std::vector cursors_lines; + /// The dock's compact copy of the cursors table (`src/ui_dock.cpp`): one line per `cursors_lines` + /// entry, same order, written from the same strings by `update_cursors_pane()`. + struct DockCursorLine { + GtkWidget* label = nullptr; + GtkWidget* a = nullptr; + GtkWidget* b = nullptr; + GtkWidget* d = nullptr; + }; + std::vector dock_cursor_lines; + /// The Readouts section at the top of the dock, and the parts of it the rebuilds and the Edit buttons + /// reach. + GtkWidget* dock_readouts = nullptr; + GtkWidget* dock_cursor_grid = nullptr; + GtkWidget* dock_measures_box = nullptr; + GtkWidget* dock_cursors_edit = nullptr; + GtkWidget* dock_measures_edit = nullptr; + /// One value label per measurement, in `measures` order (the dock's copy of `measure_labels`). + std::vector dock_measure_labels; /// The measurements the user set up, and what they last computed. The list is the state; /// the results are recomputed on a throttle (the numbers are window statistics, and the /// window scrolls every frame -- 60 recomputations a second of every sample is not a @@ -1756,6 +1774,16 @@ void rebuild_measures_pane(App& app); void on_cursor_mode_changed(GObject* obj, GParamSpec*, gpointer user); App::CursorLine cursors_line(App& app, const char* who, plot::Color chip, bool has_chip); GtkWidget* cursors_header(App& app); +/// The compact readouts at the top of the dock (`src/ui_dock.cpp`). The strings are the pages' own: the +/// pages' writers call the `dock_*` setters from the same variable, so a number is formatted once. +GtkWidget* build_readouts_dock(App& app); +void dock_rebuild_cursors(App& app); +void dock_rebuild_measures(App& app); +void dock_cursor_cell(App& app, std::size_t line, const std::string& a, const std::string& b, + const std::string& d); +void dock_measure_value(App& app, std::size_t i, const std::string& text); +/// The signal chip of a measurement's row, in the colour every other view names that signal with. +std::string measure_chip_markup(App& app, const std::string& signal); std::string row_unit(App& app, int row); std::string slot_unit(App& app, int row, int slot); std::uint32_t bound_slot_mask(App& app); diff --git a/src/ui_cursors.cpp b/src/ui_cursors.cpp index aa86065..b604b61 100644 --- a/src/ui_cursors.cpp +++ b/src/ui_cursors.cpp @@ -19,10 +19,14 @@ void update_cursors_pane(App& app) { // A readout quotes a stored sample; with the instruments off there is no sample it quotes, so // the cells blank rather than keeping the last ones. `"--"` is the same spelling an // unavailable cell already uses -- two spellings of one state in one grid would be the defect. - for (const App::CursorLine& line : app.cursors_lines) { - if (line.a != nullptr) gtk_label_set_text(GTK_LABEL(line.a), "--"); - if (line.b != nullptr) gtk_label_set_text(GTK_LABEL(line.b), "--"); - if (line.d != nullptr) gtk_label_set_text(GTK_LABEL(line.d), "--"); + // The dock's copy is written from the same literal, in the same loop. + const std::string blank = "--"; + for (std::size_t i = 0; i < app.cursors_lines.size(); ++i) { + const App::CursorLine& line = app.cursors_lines[i]; + if (line.a != nullptr) gtk_label_set_text(GTK_LABEL(line.a), blank.c_str()); + if (line.b != nullptr) gtk_label_set_text(GTK_LABEL(line.b), blank.c_str()); + if (line.d != nullptr) gtk_label_set_text(GTK_LABEL(line.d), blank.c_str()); + dock_cursor_cell(app, i, blank, blank, blank); } return; } @@ -41,7 +45,8 @@ void update_cursors_pane(App& app) { const RowSet::CursorMode mode = app.rows->cursor_mode(); const bool rulers = mode == RowSet::CursorMode::Horizontal || mode == RowSet::CursorMode::Both; const bool crossings = mode == RowSet::CursorMode::Mix; - for (const App::CursorLine& line : app.cursors_lines) { + for (std::size_t index = 0; index < app.cursors_lines.size(); ++index) { + const App::CursorLine& line = app.cursors_lines[index]; if (line.a == nullptr) continue; // One measurement per cell, each with the unit its own axis declares: the pane is a // readout beside a plot, where the values are the point. @@ -85,6 +90,8 @@ void update_cursors_pane(App& app) { put(line.a, va); put(line.b, vb); put(line.d, vd); + // The dock's compact line takes the same three strings, from these variables. + dock_cursor_cell(app, index, va, vb, vd); } } @@ -144,6 +151,8 @@ void on_cursors_toggled(GtkToggleButton* button, gpointer user) { // cursors back on -- and a control inside an insensitive parent cannot be clicked. gtk_widget_set_sensitive(app->cursors_reset, on); if (app->cursors_body != nullptr) gtk_widget_set_sensitive(app->cursors_body, on); + // The dock's copy follows: switched off it stays on screen, insensitive, reading `--`. + if (app->dock_cursor_grid != nullptr) gtk_widget_set_sensitive(app->dock_cursor_grid, on); if (!on) update_cursors_pane(*app); if (on) { app->rows->reset_cursors(); diff --git a/src/ui_dock.cpp b/src/ui_dock.cpp new file mode 100644 index 0000000..cbe05d9 --- /dev/null +++ b/src/ui_dock.cpp @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +// The compact readouts at the top of the dock: a read-only copy of the cursors table and of the +// measurement values, in a fixed width, with an Edit button for each that opens the full panel. +// +// The strings are not formatted here. `update_cursors_pane()` and `update_measures()` format each value +// once and hand the same variable to the page's label and to `dock_cursor_cell()` / `dock_measure_value()`, +// so the two views cannot disagree. Every label is ellipsized at a fixed `width-chars`, so the dock's +// layout does not depend on what a value reads; the full text is the label's tooltip. + +#include "ui_app.hpp" + +#include + +#include +#include + +namespace udpscope { +namespace ui_impl { + +namespace { + +/// The width, in characters, of a value cell and of a `who` / type cell. Fixed, so the dock is as wide +/// with `--` as with `-123.456 mV`. +constexpr int kValueChars = 9; +constexpr int kNameChars = 8; + +GtkWidget* fixed_label(const char* text, int chars, GtkAlign halign) { + GtkWidget* l = gtk_label_new(text); + gtk_widget_set_halign(l, halign); + gtk_label_set_ellipsize(GTK_LABEL(l), PANGO_ELLIPSIZE_END); + gtk_label_set_width_chars(GTK_LABEL(l), chars); + gtk_label_set_max_width_chars(GTK_LABEL(l), chars); + gtk_widget_add_css_class(l, "caption"); + return l; +} + +/// Sets a value label's text and, with it, the tooltip that carries what the ellipsis cut. The text is +/// compared first so the per-frame write of an unchanged value costs nothing. +void set_value_text(GtkWidget* label, const std::string& text) { + if (label == nullptr) return; + if (std::strcmp(gtk_label_get_text(GTK_LABEL(label)), text.c_str()) == 0) return; + gtk_label_set_text(GTK_LABEL(label), text.c_str()); + gtk_widget_set_tooltip_text(label, text.c_str()); +} + +void on_edit_cursors(GtkButton*, gpointer user) { + slideover_open(*static_cast(user), "cursors"); +} + +void on_edit_measures(GtkButton*, gpointer user) { + slideover_open(*static_cast(user), "measure"); +} + +/// A click on a measurement's line opens that measurement's dialog, as the page row's `edit` button +/// does. The line carries its index (plus one, so that zero is "none") in its data. +void on_measure_line_pressed(GtkGestureClick* gesture, int n_press, double, double, gpointer user) { + if (n_press != 1) return; + GtkWidget* line = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(gesture)); + const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(line), "udpscope-measure-index")) - 1; + if (idx < 0) return; + open_measure_dialog(*static_cast(user), idx); +} + +/// A section's heading row: its title at the left, its Edit button at the right. +GtkWidget* section_head(const char* title, GtkWidget** edit_out, GCallback on_edit, App* app, + const char* tip) { + GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget* t = gtk_label_new(title); + gtk_widget_set_halign(t, GTK_ALIGN_START); + gtk_widget_set_hexpand(t, true); + gtk_widget_add_css_class(t, "caption-heading"); + gtk_box_append(GTK_BOX(row), t); + GtkWidget* edit = gtk_button_new_with_label("Edit"); + gtk_widget_add_css_class(edit, "flat"); + gtk_widget_set_tooltip_text(edit, tip); + g_signal_connect(edit, "clicked", on_edit, app); + gtk_box_append(GTK_BOX(row), edit); + *edit_out = edit; + return row; +} + +/// The same header `cursors_header()` writes into the page's table, small. +void dock_cursors_header(GtkGrid* grid) { + const char* texts[4] = {"", "A", "B", "delta"}; + for (int c = 0; c < 4; ++c) { + GtkWidget* l = fixed_label(texts[c], c == 0 ? kNameChars : kValueChars, + c == 0 ? GTK_ALIGN_START : GTK_ALIGN_END); + gtk_widget_add_css_class(l, "dim-label"); + gtk_grid_attach(grid, l, c, 0, 1, 1); + } +} + +} // namespace + +GtkWidget* build_readouts_dock(App& app) { + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_widget_set_margin_top(box, 6); + gtk_widget_set_margin_bottom(box, 6); + gtk_widget_set_margin_start(box, 6); + gtk_widget_set_margin_end(box, 6); + + GtkWidget* heading = gtk_label_new("Readouts"); + gtk_widget_set_halign(heading, GTK_ALIGN_START); + gtk_widget_add_css_class(heading, "heading"); + gtk_box_append(GTK_BOX(box), heading); + + gtk_box_append(GTK_BOX(box), section_head("Cursors", &app.dock_cursors_edit, + G_CALLBACK(on_edit_cursors), &app, + "the cursors' full panel")); + app.dock_cursor_grid = gtk_grid_new(); + gtk_grid_set_row_spacing(GTK_GRID(app.dock_cursor_grid), 2); + gtk_grid_set_column_spacing(GTK_GRID(app.dock_cursor_grid), 4); + gtk_box_append(GTK_BOX(box), app.dock_cursor_grid); + + gtk_box_append(GTK_BOX(box), section_head("Measurements", &app.dock_measures_edit, + G_CALLBACK(on_edit_measures), &app, + "the measurements' full panel")); + app.dock_measures_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); + gtk_box_append(GTK_BOX(box), app.dock_measures_box); + + app.dock_readouts = box; + // The tables are built from the pages' current lines, which exist already when the pages were built + // first; otherwise the pages' own rebuild fills them. + dock_rebuild_cursors(app); + dock_rebuild_measures(app); + return box; +} + +void dock_rebuild_cursors(App& app) { + if (app.dock_cursor_grid == nullptr) return; + GtkGrid* grid = GTK_GRID(app.dock_cursor_grid); + GtkWidget* child = gtk_widget_get_first_child(app.dock_cursor_grid); + while (child != nullptr) { + GtkWidget* next = gtk_widget_get_next_sibling(child); + gtk_grid_remove(grid, child); + child = next; + } + app.dock_cursor_lines.clear(); + dock_cursors_header(grid); + int at = 1; + for (const App::CursorLine& src : app.cursors_lines) { + App::DockCursorLine line; + line.label = fixed_label("", kNameChars, GTK_ALIGN_START); + if (src.label != nullptr) { + // The page's `who` cell, verbatim: a chip and a name as markup, or plain text. + const char* text = gtk_label_get_label(GTK_LABEL(src.label)); + if (gtk_label_get_use_markup(GTK_LABEL(src.label))) { + gtk_label_set_markup(GTK_LABEL(line.label), text); + } else { + gtk_label_set_text(GTK_LABEL(line.label), text); + } + const char* tip = gtk_widget_get_tooltip_text(src.label); + gtk_widget_set_tooltip_text(line.label, tip != nullptr ? tip : gtk_label_get_text(GTK_LABEL(src.label))); + } + gtk_grid_attach(grid, line.label, 0, at, 1, 1); + GtkWidget** cells[3] = {&line.a, &line.b, &line.d}; + for (int c = 0; c < 3; ++c) { + GtkWidget* v = fixed_label("", kValueChars, GTK_ALIGN_END); + gtk_widget_add_css_class(v, "numeric"); + gtk_label_set_selectable(GTK_LABEL(v), true); + gtk_grid_attach(grid, v, c + 1, at, 1, 1); + *cells[c] = v; + } + app.dock_cursor_lines.push_back(line); + ++at; + } +} + +void dock_rebuild_measures(App& app) { + if (app.dock_measures_box == nullptr) return; + GtkWidget* child = gtk_widget_get_first_child(app.dock_measures_box); + while (child != nullptr) { + GtkWidget* next = gtk_widget_get_next_sibling(child); + gtk_box_remove(GTK_BOX(app.dock_measures_box), child); + child = next; + } + app.dock_measure_labels.clear(); + if (app.measures.empty()) { + GtkWidget* none = gtk_label_new("no measurements"); + gtk_widget_set_halign(none, GTK_ALIGN_START); + gtk_widget_add_css_class(none, "caption"); + gtk_widget_add_css_class(none, "dim-label"); + gtk_box_append(GTK_BOX(app.dock_measures_box), none); + return; + } + for (std::size_t i = 0; i < app.measures.size(); ++i) { + const Measure& m = app.measures[i]; + GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + g_object_set_data(G_OBJECT(line), "udpscope-measure-index", + GINT_TO_POINTER(static_cast(i) + 1)); + GtkWidget* chip = gtk_label_new(""); + gtk_label_set_markup(GTK_LABEL(chip), measure_chip_markup(app, m.signal).c_str()); + gtk_widget_set_tooltip_text(chip, m.signal.c_str()); + gtk_box_append(GTK_BOX(line), chip); + GtkWidget* kind = fixed_label(measure_type_name(m.type), 12, GTK_ALIGN_START); + gtk_widget_set_hexpand(kind, true); + gtk_widget_set_tooltip_text(kind, (m.signal + "\n" + measure_type_name(m.type)).c_str()); + gtk_box_append(GTK_BOX(line), kind); + GtkWidget* value = fixed_label("--", kValueChars, GTK_ALIGN_END); + gtk_widget_add_css_class(value, "numeric"); + gtk_label_set_selectable(GTK_LABEL(value), true); + gtk_box_append(GTK_BOX(line), value); + // On the line and not on the value label, which is selectable and so keeps its own clicks. + GtkGesture* click = gtk_gesture_click_new(); + g_signal_connect(click, "pressed", G_CALLBACK(on_measure_line_pressed), &app); + gtk_widget_add_controller(line, GTK_EVENT_CONTROLLER(click)); + gtk_box_append(GTK_BOX(app.dock_measures_box), line); + app.dock_measure_labels.push_back(value); + } +} + +void dock_cursor_cell(App& app, std::size_t line, const std::string& a, const std::string& b, + const std::string& d) { + if (line >= app.dock_cursor_lines.size()) return; // mid-rebuild + const App::DockCursorLine& l = app.dock_cursor_lines[line]; + set_value_text(l.a, a); + set_value_text(l.b, b); + set_value_text(l.d, d); +} + +void dock_measure_value(App& app, std::size_t i, const std::string& text) { + if (i >= app.dock_measure_labels.size()) return; // mid-rebuild + set_value_text(app.dock_measure_labels[i], text); +} + +} // namespace ui_impl +} // namespace udpscope diff --git a/src/ui_measure.cpp b/src/ui_measure.cpp index 465a983..faa76eb 100644 --- a/src/ui_measure.cpp +++ b/src/ui_measure.cpp @@ -111,6 +111,7 @@ void rebuild_measures_pane(App& app) { gtk_box_append(GTK_BOX(app.measures_list), measures_row(app, i, app.measures[i])); app.measure_text.emplace_back(); } + dock_rebuild_measures(app); update_measures(app, /*force=*/true, /*accumulate=*/true); } @@ -581,7 +582,8 @@ void update_measures(App& app, bool force, bool accumulate) { // to measure *this* pass. The marks still fall back (below); `continue`ing out of the marks // was the other half of the rulers blinking off. app.measure_text[i] = "--"; - if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--"); + if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), app.measure_text[i].c_str()); + dock_measure_value(app, i, app.measure_text[i]); continue; } const App::MeasureWindow& w = *wp; @@ -615,6 +617,7 @@ void update_measures(App& app, bool force, bool accumulate) { } app.measure_text[i] = text; gtk_label_set_text(GTK_LABEL(label), text.c_str()); + dock_measure_value(app, i, text); } // One observation per recompute, and only when the pass produced one and the caller // asked for it. An invalid pass contributes nothing -- the marks' `measure_last_valid` diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 22defae..3892f12 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -4339,6 +4339,53 @@ gboolean exit_probe_step1(gpointer user) { return G_SOURCE_REMOVE; } +/// The dock's readouts against the pages they copy: every dock label's text must equal the page +/// label's (they are written from the same variable), and the Readouts box must fit the sidebar. +static int dock_width_fits(App& a, int* dock_w, int* side_w) { + *dock_w = a.dock_readouts != nullptr ? gtk_widget_get_width(a.dock_readouts) : -1; + *side_w = a.dock_scroller != nullptr ? gtk_widget_get_width(a.dock_scroller) : -1; + return *dock_w >= 0 && *side_w > 0 && *dock_w <= *side_w ? 1 : 0; +} + +static void dock_cursors_report(App& a, const char* what) { + int same = a.dock_cursor_lines.size() == a.cursors_lines.size() ? 1 : 0; + for (std::size_t i = 0; same != 0 && i < a.cursors_lines.size(); ++i) { + const App::CursorLine& p = a.cursors_lines[i]; + const App::DockCursorLine& d = a.dock_cursor_lines[i]; + const auto text = [](GtkWidget* w) { return std::string(gtk_label_get_text(GTK_LABEL(w))); }; + if (text(p.label) != text(d.label) || text(p.a) != text(d.a) || text(p.b) != text(d.b) || + text(p.d) != text(d.d)) { + same = 0; + } + } + int dock_w = 0; + int side_w = 0; + const int fits = dock_width_fits(a, &dock_w, &side_w); + std::fprintf(stderr, + "[cursors-probe] %-14s dock cursors: lines=%zu page_lines=%zu same_text=%d dock_w=%d " + "side_w=%d fits=%d dock_sensitive=%d\n", + what, a.dock_cursor_lines.size(), a.cursors_lines.size(), same, dock_w, side_w, fits, + a.dock_cursor_grid != nullptr && gtk_widget_get_sensitive(a.dock_cursor_grid) ? 1 : 0); +} + +static void dock_measures_report(App& a, const char* what) { + int same = a.dock_measure_labels.size() == a.measure_labels.size() ? 1 : 0; + for (std::size_t i = 0; same != 0 && i < a.measure_labels.size(); ++i) { + if (std::strcmp(gtk_label_get_text(GTK_LABEL(a.measure_labels[i])), + gtk_label_get_text(GTK_LABEL(a.dock_measure_labels[i]))) != 0) { + same = 0; + } + } + int dock_w = 0; + int side_w = 0; + const int fits = dock_width_fits(a, &dock_w, &side_w); + std::fprintf(stderr, + "[measures-probe] %-14s dock measures: lines=%zu page_rows=%zu same_text=%d dock_w=%d " + "side_w=%d fits=%d\n", + what, a.dock_measure_labels.size(), a.measure_labels.size(), same, dock_w, side_w, + fits); +} + void cursors_probe_report(App& a, const char* what) { static const char* const kModes[] = {"vertical", "horizontal", "both", "mix"}; const int mode = static_cast(a.rows->cursor_mode()); @@ -4381,6 +4428,7 @@ void cursors_probe_report(App& a, const char* what) { if (have_mark) { std::fprintf(stderr, "[cursors-probe] %-14s first mark at x=%.6f y=%.6g\n", what, mx, my); } + dock_cursors_report(a, what); std::fflush(stderr); } @@ -4649,10 +4697,12 @@ gboolean measures_probe_step(gpointer user) { // the count with nothing to attribute it to -- which is how a window that lagged the screen // read `0/6` with no way to tell a stale recompute from a mark placed correctly. g_timeout_add(250, measure_trace, a); + dock_measures_report(*a, "added"); measures_probe_next(*a, 1, 400); break; } case 1: + dock_measures_report(*a, "settled"); open_measure_dialog(*a, 0); // the edit path: this and the delete are what aborted measures_probe_next(*a, 2, 400); break; @@ -4717,6 +4767,7 @@ gboolean measures_probe_step(gpointer user) { std::fabs(restored - want) < 1.0e-9 ? 1 : 0); std::fflush(stderr); measure_dialog_report(*a, "final"); + dock_measures_report(*a, "final"); break; } } From 3d307a01856b4126af342b65bd7e4a3b59211851 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 06:24:52 +0100 Subject: [PATCH 082/127] ui: the cursors and measurements panels are slide-over pages; the dock is the readouts Co-Authored-By: Claude Sonnet 4.6 --- src/ui.cpp | 54 ++++++--------- src/ui/main-window.ui | 17 ++--- src/ui_app.hpp | 13 ++-- src/ui_dock.cpp | 5 +- src/ui_probe.cpp | 156 +++++++++++++++--------------------------- 5 files changed, 91 insertions(+), 154 deletions(-) diff --git a/src/ui.cpp b/src/ui.cpp index 512aeee..8d21db9 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -2866,20 +2866,6 @@ void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer open_row_menu(*app, row, x, y, widget); } -/// Scrolls the dock to a section. The dock is one scroller (`App::dock_scroller`) holding the cursors and -/// the measurements top to bottom, so "measure" is the cursors panel's offset in the page. Nothing is -/// hidden or removed: both stay in the page. An unknown name (the signal list is a slide-over page now, -/// not a dock section) is a no-op, so a key bound to a section a later version drops cannot blank anything. -void dock_show_section(App& app, const char* section) { - if (app.dock_scroller == nullptr || app.dock_page == nullptr || section == nullptr) return; - GtkAdjustment* adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(app.dock_scroller)); - if (std::strcmp(section, "measure") != 0 || app.cursors_panel == nullptr) return; - graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f); - graphene_point_t at; - if (!gtk_widget_compute_point(app.cursors_panel, app.dock_page, &zero, &at)) return; - gtk_adjustment_set_value(adj, at.y); -} - /// The dock is on when the user's sidebar button is in -- from the start, with or without signals; the /// two panel buttons appear as soon as a CONFIG describes any signal at all. void update_side_visibility(App& app) { @@ -3437,7 +3423,9 @@ gboolean tick(gpointer user) { } } // Captured every frame so the dump has the last live value: see `dbg_measures_pane_w`. - if (app->measures_list != nullptr) { + // Only while the `measure` page is on screen: a closed page has no allocation, and the dump should + // keep the last real size rather than 0. + if (app->measures_list != nullptr && gtk_widget_get_width(app->measures_list) > 0) { app->dbg_measures_pane_w = gtk_widget_get_width(app->measures_list); app->dbg_measures_pane_h = gtk_widget_get_height(app->measures_list); } @@ -3993,7 +3981,7 @@ gboolean tick(gpointer user) { // The one thing that has to be decided *per frame* about the dock: whether there is room for it // beside the plot. Collapsed, the split view puts it *over* the plot rather than giving it a share of // a width that is not there -- so a small window shows fewer pixels of plot, never a panel squeezed - // to nothing. `kSidebarRoom` is the sidebar's 320 plus the width below which the plot stops being + // to nothing. `kSidebarRoom` is the sidebar's 280 plus the width below which the plot stops being // worth looking at. if (app->side_view != nullptr && app->window != nullptr) { // The split view writes `show-sidebar` itself (at startup and when it collapses), so the wish is @@ -4656,7 +4644,7 @@ void run_app_action(App& app, AppAction action) { case AppAction::CursorsPane: toggle(app.cursors_toggle); break; - case AppAction::ShowMeasure: dock_show_section(app, "measure"); break; + case AppAction::ShowMeasure: slideover_toggle(app, "measure"); break; case AppAction::ShowSignals: { // The `+` badge is insensitive without signals; so is its shortcut. bool have_signals = false; @@ -5038,7 +5026,7 @@ void on_activate(GtkApplication* application, gpointer user) { // The size the window opens at, stated in code as well as in the `.ui`: `default-width` there is the // size it *asks* for, and a window whose content has a larger minimum opens at that minimum instead -- - // 756 px here, the plot column's own minimum plus the sidebar's 320. The old code set it explicitly; + // 756 px here, the plot column's own minimum plus the sidebar's 280. The old code set it explicitly; // this is where it went when the window moved into the file, and it is why the window came up small. gtk_window_set_default_size(GTK_WINDOW(app->window), 1280, 800); @@ -5140,15 +5128,16 @@ void on_activate(GtkApplication* application, gpointer user) { gtk_popover_set_has_arrow(GTK_POPOVER(app->status_popover), TRUE); } - // The right-hand side is the dock: one scroller, the cursors then the measurements. Every section is - // always there -- "go to Measure" scrolls to it instead of switching a tab, so the measurements are as - // live as the cursors above them. The signal list is a slide-over page (reached by the `+` badge). + // The right-hand side is the dock: one scroller holding only the compact read-only readouts (the + // cursors table and the measurements). The full Cursors and Measurements panels are slide-over pages + // (`cursors`, `measure`), live whether or not they are open: the pages hold the same widgets and the + // tick writes them either way. The signal list is a page too (reached by the `+` badge). // `side` and `dock_page`/`dock_scroller` are resolved above with the rest of the skeleton and checked // by the null guard; assign the member here. app->side_box = side; - // Built in the order the panels have always been (cursors, measurements, trigger, signals) and placed in - // the dock's: the trigger form goes to the slide-over (reached by the Trigger badge). + // Built in the order the panels have always been (cursors, measurements, trigger, signals); each is a + // page of the slide-over, reached by its badge, its key or the header's Cursors toggle. app->cursors_panel = build_cursors_panel(*app); GtkWidget* measures_panel = build_measures_panel(*app); app->trigger_panel = build_trigger_panel(*app); @@ -5160,13 +5149,14 @@ void on_activate(GtkApplication* application, gpointer user) { // `build_slideover`, and registered once. app->signal_panel = build_signal_panel(*app); slideover_add_page(*app, "signals", "Signals", app->signal_panel); - gtk_box_append(GTK_BOX(app->dock_page), app->cursors_panel); - gtk_box_append(GTK_BOX(app->dock_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); - gtk_box_append(GTK_BOX(app->dock_page), measures_panel); - // The compact readouts above the full panels (which move to slide-over pages later), with their own - // separator: the first child of the dock. - gtk_box_prepend(GTK_BOX(app->dock_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); - gtk_box_prepend(GTK_BOX(app->dock_page), build_readouts_dock(*app)); + // `c` / the header toggle drive the cursors themselves; this page is where they are configured and read + // in full. `m` toggles the measurements page. + gtk_widget_set_valign(app->cursors_panel, GTK_ALIGN_START); + gtk_widget_set_valign(measures_panel, GTK_ALIGN_START); + slideover_add_page(*app, "cursors", "Cursors", app->cursors_panel); + slideover_add_page(*app, "measure", "Measurements", measures_panel); + // The dock is the compact readouts alone (step 4 adds the events to it). + gtk_box_append(GTK_BOX(app->dock_page), build_readouts_dock(*app)); // The cursors start off, so their readout starts insensitive -- stated here by the one place that // knows the default, and the *body* and not the panel, because the panel's heading carries the @@ -5200,8 +5190,8 @@ void on_activate(GtkApplication* application, gpointer user) { // Two plots, in a paned so a comparison's divider can move: the primary takes everything while the // second is hidden, and the width is the user's once it is not. - // Nothing to state about the sidebar's size. It is the split view's: 320 px from the `.ui`, floored - // at 240, and never a number derived from the window -- which is what the removed + // Nothing to state about the sidebar's size. It is the split view's: 280 px from the `.ui` (min = max), + // and never a number derived from the window -- which is what the removed // `gtk_paned_set_position(paned, 940)` was, and why a window narrower than 940 px gave the sidebar // no width at all. diff --git a/src/ui/main-window.ui b/src/ui/main-window.ui index 1ecc6aa..ba7d785 100644 --- a/src/ui/main-window.ui +++ b/src/ui/main-window.ui @@ -21,7 +21,7 @@ What is *not* here is as deliberate as what is. Everything this file nests is fixed — the shape of the window does not depend on the stream, the trigger or the layout — while the subtrees that do are built by code and put in place: the events pane, into `events_divider`'s start side; the two plot - areas, into the split inside `plot_box`; the dock's panels (signals, cursors, measurements), appended to `dock_page`; and the + areas, into the split inside `plot_box`; the dock's readouts, appended to `dock_page`; and the status popover, attached to `status_info`. The header bar is not here either: it is the window's own titlebar, built by `build_toolbar()` @@ -40,22 +40,19 @@ end - + px - 320 - 320 + 280 + 280 - + vertical - 300 + 260 1 diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 1d300fe..90e8169 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -266,9 +266,9 @@ struct App { GtkWidget* trigger_panel = nullptr; GtkWidget* signal_panel = nullptr; GtkWidget* side_box = nullptr; - /// The dock: the sidebar's one scroller and the box inside it, which holds the cursors and - /// the measurements (`dock_show_section()` scrolls it). A feature's own on/off never - /// removes a section (it sets its panel's sensitivity instead). + /// The dock: the sidebar's one scroller and the box inside it, which holds only the compact readouts + /// (`build_readouts_dock()`). The full cursors and measurements panels are the slide-over's `cursors` + /// and `measure` pages. A feature's own on/off never removes a panel (it sets its sensitivity instead). GtkWidget* dock_page = nullptr; GtkWidget* dock_scroller = nullptr; GtkWidget* arm_button = nullptr; @@ -1609,8 +1609,8 @@ constexpr int kEventThumbH = 46; constexpr int kEventThumbMaxH = 104; inline int g_stats_phase = 0; inline std::vector g_stats_n_before; -/// The same for the dock-section probe: the accumulator counts recorded while the Measure section was -/// still scrolled into view, so the report can say whether any of them advanced after it scrolled out. +/// The same for the dock-section probe: the accumulator counts recorded while the `measure` page was +/// still open, so the report can say whether any of them advanced after it was closed. inline std::vector g_dock_section_n_before; inline double g_trigrenum_edge = 0.0; inline std::uint64_t g_trigrenum_latches = 0; @@ -1867,9 +1867,6 @@ void row_menu_fill(RowMenuState& st); GtkWidget* open_row_menu(App& app, int row, double x, double y, GtkWidget* parent, bool autohide = true); void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer user); gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, gpointer user); -/// Scrolls the dock to a section: "signals" (the top) or "measure" (the cursors panel, which the -/// measurements follow). An unknown name is a no-op. -void dock_show_section(App& app, const char* section); /// Reads each pane's own switch back off the header bar's button, and the other way round: the two are /// one control in two places, and `panel_switch_sync` keeps the write from looking like a click. void sync_panel_switches(App& app); diff --git a/src/ui_dock.cpp b/src/ui_dock.cpp index cbe05d9..c0a1693 100644 --- a/src/ui_dock.cpp +++ b/src/ui_dock.cpp @@ -22,9 +22,10 @@ namespace ui_impl { namespace { /// The width, in characters, of a value cell and of a `who` / type cell. Fixed, so the dock is as wide -/// with `--` as with `-123.456 mV`. +/// with `--` as with `-123.456 mV`; their sum (5 + 3 x 9 characters) is what keeps the readouts inside the +/// dock's 280 px. constexpr int kValueChars = 9; -constexpr int kNameChars = 8; +constexpr int kNameChars = 5; GtkWidget* fixed_label(const char* text, int chars, GtkAlign halign) { GtkWidget* l = gtk_label_new(text); diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 3892f12..51227ae 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -18,6 +18,7 @@ #include #include #include +#include namespace udpscope { namespace ui_impl { @@ -183,6 +184,18 @@ gboolean exit_probe_step3b(gpointer user) { a->dbg_dock_shown = sidebar_shown; a->dbg_dock_sensitive = gtk_widget_get_sensitive(a->side_box) ? 1 : 0; a->dbg_dock_w = a->dbg_side_w; + { + // The dock holds only the readouts box; the full cursors and measurements panels are slide-over pages, + // registered whether or not either is open. + int children = 0; + for (GtkWidget* c = gtk_widget_get_first_child(a->dock_page); c != nullptr; + c = gtk_widget_get_next_sibling(c)) { + ++children; + } + std::fprintf(stderr, "[readouts] pages cursors=%d measure=%d dock_children=%d dock_w=%d\n", + slideover_has_page(*a, "cursors") ? 1 : 0, slideover_has_page(*a, "measure") ? 1 : 0, + children, a->dbg_side_w); + } a->dbg_dock_scrollers = 0; for (GtkWidget* c = gtk_widget_get_first_child(a->side_box); c != nullptr; c = gtk_widget_get_next_sibling(c)) { @@ -1792,24 +1805,11 @@ gboolean stats_probe(gpointer user) { return G_SOURCE_REMOVE; } -/// Which section the dock's scroller is showing at its top: "measure" once it has been scrolled to the -/// measurements' heading or past it, "cursors" above that (the cursors panel is the dock's first section; -/// the signal list is a slide-over page). Read from the adjustment, so it is the state the user would see -/// and not the last call made. -static const char* dock_section_in_view(App& a) { - if (a.dock_scroller == nullptr || a.measures_head == nullptr || a.dock_page == nullptr) return "none"; - graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f); - graphene_point_t at; - if (!gtk_widget_compute_point(a.measures_head, a.dock_page, &zero, &at)) return "none"; - GtkAdjustment* adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(a.dock_scroller)); - return gtk_adjustment_get_value(adj) > 0.5 * at.y ? "measure" : "cursors"; -} - -/// Phase two of the dock-section probe: the accumulators must have advanced while the Measure section was -/// scrolled out of view. A statistics readout is *history*, so min/max/avg would gain a gap for exactly as -/// long as the section was off screen -- which is the one defect the design's "what runs on a hidden -/// page" rule is there to prevent, and the reason no frame-rate code may be gated on which section is in -/// view. (It was a tab once; the dock is one scroller, and the rule outlived the tabs.) +/// Phase two of the dock-section probe: the accumulators must have advanced while the `measure` page was +/// closed. A statistics readout is *history*, so min/max/avg would gain a gap for exactly as long as the +/// page was off screen -- which is the one defect the design's "what runs on a hidden page" rule is there +/// to prevent, and the reason no frame-rate code may be gated on which page is open. (It was a tab once, +/// then a dock section; the rule outlived both.) gboolean dock_section_probe_report(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; @@ -1824,34 +1824,27 @@ gboolean dock_section_probe_report(gpointer user) { first = false; } } - if (a->dock_page != nullptr) gtk_widget_set_size_request(a->dock_page, -1, -1); - if (a->cursors_panel != nullptr) gtk_widget_set_size_request(a->cursors_panel, -1, -1); std::fprintf(stderr, "[side-tab-probe] section=%s rows=%zu accums=%zu advanced=%" G_GUINT64_FORMAT " min_n=%" G_GUINT64_FORMAT "\n", - dock_section_in_view(*a), a->measures.size(), a->measure_stats.size(), advanced, min_n); + slideover_page(*a).empty() ? "none" : slideover_page(*a).c_str(), a->measures.size(), a->measure_stats.size(), advanced, min_n); std::fflush(stderr); return G_SOURCE_REMOVE; } -/// Phase one: four measurements on the first channel, statistics on, then scroll the dock *away* from the -/// Measure section and come back in two seconds to read what ran while it was not in view. (The probe -/// keeps its old environment variable, `UDPSCOPE_PROBE_SIDE_TAB_MS`, and its `[side-tab-probe]` tag.) +/// Phase one: four measurements on the first channel, statistics on, then the `measure` page opened for a +/// forced pass and *closed* again; it comes back in two seconds to read what ran while the page was not on +/// screen. (The probe keeps its old environment variable, `UDPSCOPE_PROBE_SIDE_TAB_MS`, and its +/// `[side-tab-probe]` tag.) Run it against a config that has seen the first-run tour, which would close the page. gboolean dock_section_probe(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; - // A dock whose content fits has nothing to scroll away from, and a tall window does that. The first - // pass makes the page taller than any window (a probe-only minimum height, undone in the report) and - // comes back once it has been laid out, so "scrolled out of view" is a state this run really reaches. - static bool padded = false; - if (!padded && a->dock_page != nullptr) { - padded = true; - gtk_widget_set_size_request(a->dock_page, -1, 3000); - // ... and the cursors panel (the dock's first section, above the measurements) taller than any window - // too, which is what pushes the measurements below the viewport at offset 0 (padding the page's end only - // makes it scrollable, and on a window taller than the section's y the section would stay in view). - // Undone in the report. - if (a->cursors_panel != nullptr) gtk_widget_set_size_request(a->cursors_panel, -1, 3000); + // The first pass only waits 400 ms: the enable-all probe switches signals on and off around this time, + // and a buffer rebuild between the baseline and the report would drop the accumulators and read as a + // gated pass. (It also gives the layout the pass this probe's own row assignment needs.) + static bool waited = false; + if (!waited) { + waited = true; g_timeout_add(400, dock_section_probe, a); return G_SOURCE_REMOVE; } @@ -1892,32 +1885,21 @@ gboolean dock_section_probe(gpointer user) { if (a->measures_stats_check != nullptr) { gtk_check_button_set_active(GTK_CHECK_BUTTON(a->measures_stats_check), TRUE); } - GtkAdjustment* dock_adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(a->dock_scroller)); - // From the top, is the measure section (the measurements' heading) below the viewport? Only then is scrolling to it a move *into* view and back out a move *away*; a - // section already in view at offset 0 would make the whole probe a reading of nothing. - gtk_adjustment_set_value(dock_adj, 0.0); - graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f); - graphene_point_t measure_at = GRAPHENE_POINT_INIT(-1.f, -1.f); - const bool located = - a->measures_head != nullptr && gtk_widget_compute_point(a->measures_head, a->dock_page, &zero, &measure_at); - const bool outside_at_top = located && static_cast(measure_at.y) >= gtk_adjustment_get_page_size(dock_adj); - dock_show_section(*a, "measure"); // lands on the cursors panel, the dock's top: a no-op scroll - if (located) gtk_adjustment_set_value(dock_adj, measure_at.y); // ... so go to the measurements themselves - // Where the dock went: `scrolled` is its offset and `reach` the most it can scroll. A reach of 0 would - // mean the content fits and "away from the section" is not a state this run can reach. - const double scrolled = gtk_adjustment_get_value(dock_adj); - const double reach = gtk_adjustment_get_upper(dock_adj) - gtk_adjustment_get_page_size(dock_adj); + // On the page: the section is where the user reads the measurements in full. + slideover_open(*a, "measure"); + const bool was_open = slideover_page(*a) == "measure"; update_measures(*a, /*force=*/true, /*accumulate=*/true); g_dock_section_n_before.clear(); for (const MeasureStatsAccum& s : a->measure_stats) g_dock_section_n_before.push_back(s.n); - // Away from the section the measurements are in: they are scrolled out of the viewport (the widgets - // stay mapped, only clipped), and the tick keeps calling `update_measures()` regardless. - gtk_adjustment_set_value(dock_adj, 0.0); + // Away from the page: it is closed (its widgets stay in the tree, unmapped), and the tick keeps calling + // `update_measures()` regardless. + slideover_close(*a); + const bool hidden = slideover_page(*a).empty(); std::fprintf(stderr, - "[side-tab-probe] section=%s accums=%zu baseline recorded (measure at scroll %.0f of %.0f, " - "outside_at_top=%d)\n", - dock_section_in_view(*a), g_dock_section_n_before.size(), scrolled, reach, - outside_at_top ? 1 : 0); + "[side-tab-probe] section=%s accums=%zu baseline recorded (measure page was_open=%d, " + "now closed=%d)\n", + slideover_page(*a).empty() ? "none" : slideover_page(*a).c_str(), + g_dock_section_n_before.size(), was_open ? 1 : 0, hidden ? 1 : 0); std::fflush(stderr); // Long enough for several throttled recomputes (one per 100 ms while the window moves). g_timeout_add(2000, dock_section_probe_report, a); @@ -4687,7 +4669,7 @@ gboolean measures_probe_step(gpointer user) { m.options.show_on_plot = measure_can_show_on_plot(m.type); a->measures.push_back(std::move(m)); } - dock_show_section(*a, "measure"); + slideover_open(*a, "measure"); // the page the probe reads and edits rebuild_measures_pane(*a); std::fprintf(stderr, "[measures-probe] added %zu measurements on '%s'\n", a->measures.size(), signal.c_str()); @@ -4849,7 +4831,7 @@ gboolean bandavg_probe_step(gpointer user) { m.options.show_on_plot = measure_can_show_on_plot(m.type); a->measures.push_back(std::move(m)); } - dock_show_section(*a, "measure"); + slideover_open(*a, "measure"); // the page the probe reads and edits rebuild_measures_pane(*a); g_timeout_add(250, measure_trace, a); bandavg_probe_next(*a, 1, 1500); @@ -6414,16 +6396,6 @@ gboolean defaults_probe(gpointer user) { gboolean keys_probe(gpointer user) { App* a = static_cast(user); - // `m` scrolls the dock, and a dock whose content fits has nothing to scroll: a run on a tall window - // would read 0->0 and prove nothing. So the first pass only makes the page taller than any window - // (a probe-only minimum height, undone below) and comes back once it has been laid out. - static bool padded = false; - if (!padded && a->dock_page != nullptr) { - padded = true; - gtk_widget_set_size_request(a->dock_page, -1, 3000); - g_timeout_add(400, keys_probe, a); - return G_SOURCE_REMOVE; - } std::string line = "[keys]"; const auto before_after = [&](const char* name, GtkWidget* button, AppAction action) { @@ -6459,39 +6431,23 @@ gboolean keys_probe(gpointer user) { before_after("t: trigger panel", a->trigger_toggle, AppAction::TriggerPanel); before_after("c: cursors", a->cursors_toggle, AppAction::CursorsPane); - // `m` and `g` scroll the dock: the reading is the scroller's offset before and after, and `m` must land - // on the cursors panel's own y (clamped to what the page can scroll, which is why the reachable maximum - // is printed beside it), `g` back on 0. + // `m` and `g` open a slide-over page: `m` the measurements, `g` the signal list, and a second press of + // either closes it again. The dock does not move (it holds only the readouts). const auto dock_adjustment = [a]() { return gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(a->dock_scroller)); }; const auto dock_value = [&]() { return gtk_adjustment_get_value(dock_adjustment()); }; - { + for (const auto& [key, action, page] : + {std::tuple{"m", AppAction::ShowMeasure, "measure"}, + {"g", AppAction::ShowSignals, "signals"}}) { const double before = dock_value(); - run_app_action(*a, AppAction::ShowMeasure); + run_app_action(*a, action); + const bool opened = slideover_page(*a) == page; const double after = dock_value(); - graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f); - graphene_point_t at = GRAPHENE_POINT_INIT(-1.f, -1.f); - if (!gtk_widget_compute_point(a->cursors_panel, a->dock_page, &zero, &at)) at.y = -1.f; - GtkAdjustment* adj = dock_adjustment(); - const double reach = gtk_adjustment_get_upper(adj) - gtk_adjustment_get_page_size(adj); - char buf[160]; - std::snprintf(buf, sizeof(buf), " m: dock %.0f->%.0f (cursors_y=%.0f reach=%.0f at_cursors=%d) |", before, - after, static_cast(at.y), reach, - std::fabs(after - std::min(static_cast(at.y), reach)) < 1.0 ? 1 : 0); - line += buf; - } - { - // `g` is the signal list's slide-over page now: it opens it, the dock does not move, and a second `g` - // closes it again. - const double before = dock_value(); - run_app_action(*a, AppAction::ShowSignals); - const bool opened = slideover_page(*a) == "signals"; - const double after = dock_value(); - run_app_action(*a, AppAction::ShowSignals); + run_app_action(*a, action); const bool closed = slideover_page(*a).empty(); char buf[96]; - std::snprintf(buf, sizeof(buf), " g: page=%d dock_still=%d closed=%d |", opened ? 1 : 0, + std::snprintf(buf, sizeof(buf), " %s: page=%d dock_still=%d closed=%d |", key, opened ? 1 : 0, std::fabs(after - before) < 1.0 ? 1 : 0, closed ? 1 : 0); line += buf; } @@ -6512,21 +6468,17 @@ gboolean keys_probe(gpointer user) { } // An accel must be refused while text is being typed. Focus the window entry, send 'm' - // through the real handler, and require the dock not to scroll. + // through the real handler, and require no page to open. int refused = -1; if (a->window_entry != nullptr) { gtk_menu_button_popup(GTK_MENU_BUTTON(a->window_button)); gtk_widget_grab_focus(a->window_entry); - // The dock has to be somewhere other than where `m` would take it, or "did not scroll" proves nothing. - gtk_adjustment_set_value(dock_adjustment(), 0.0); - const double before_dock = dock_value(); + const std::string page_before = slideover_page(*a); const gboolean handled = on_app_key_pressed(nullptr, GDK_KEY_m, 0, static_cast(0), a); - const double after_dock = dock_value(); - refused = (handled == FALSE && before_dock == after_dock) ? 1 : 0; + refused = (handled == FALSE && slideover_page(*a) == page_before) ? 1 : 0; gtk_menu_button_popdown(GTK_MENU_BUTTON(a->window_button)); } - if (a->dock_page != nullptr) gtk_widget_set_size_request(a->dock_page, -1, -1); char tail[64]; std::snprintf(tail, sizeof(tail), " editable focused: 'm' refused=%d\n", refused); line += tail; From 38b31360ccc4e0efae1c6909fc5547e2cae30f31 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 06:29:32 +0100 Subject: [PATCH 083/127] ui: the cursors and measure pages are insensitive until the stream has signals Co-Authored-By: Claude Sonnet 4.6 --- src/ui.cpp | 31 ++++++++++++++++++++++++------- src/ui_app.hpp | 4 ++++ src/ui_probe.cpp | 12 ++++++++++-- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/ui.cpp b/src/ui.cpp index 8d21db9..77f96c8 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -2866,16 +2866,32 @@ void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer open_row_menu(*app, row, x, y, widget); } +/// The availability of everything that does nothing until the stream has signals: the dock and the two +/// slide-over pages that were once in it (Cursors, Measurements). "Disable, do not hide": a page may be +/// opened by its key before the first CONFIG, and shows its controls insensitive until then. The signal +/// list is read under the model's mutex, as the producer replaces it on a CONFIG. +bool update_signal_availability(App& app) { + bool have_signals = false; + { + std::lock_guard lock(app.model->mutex()); + have_signals = !app.model->signals().empty(); + } + if (app.side_box != nullptr) gtk_widget_set_sensitive(app.side_box, have_signals); + if (app.cursors_panel != nullptr) gtk_widget_set_sensitive(app.cursors_panel, have_signals); + if (app.measures_panel != nullptr) gtk_widget_set_sensitive(app.measures_panel, have_signals); + return have_signals; +} + /// The dock is on when the user's sidebar button is in -- from the start, with or without signals; the /// two panel buttons appear as soon as a CONFIG describes any signal at all. void update_side_visibility(App& app) { if (app.side_box == nullptr) return; - const bool have_signals = !app.model->signals().empty(); + const bool have_signals = update_signal_availability(app); // `sidebar_wanted` is the user's last word, which is why it is kept apart from the button's own state: // a swipe that hides the sidebar fires the button's handler (the binding is bidirectional), so a gesture // is a choice too. "Disable, do not hide": the dock is on screen from the start and does nothing until // the stream has signals, so the signals decide its *sensitivity* and never whether it is there. - gtk_widget_set_sensitive(app.side_box, have_signals); + // (`update_signal_availability()` above set it, and the two slide-over pages' with it.) gtk_widget_set_sensitive(app.sidebar_toggle, TRUE); const bool want_column = app.sidebar_wanted; // The split view owns the sidebar's visibility: it animates it, and when the window is too narrow it @@ -3990,7 +4006,7 @@ gboolean tick(gpointer user) { // whether the dock is there; having signals decides only whether it does anything ("disable, do not // hide"): a dock that is on screen from the start does not jump in when the first CONFIG arrives. const bool want = app->sidebar_wanted; - gtk_widget_set_sensitive(app->side_box, !app->model->signals().empty()); + update_signal_availability(*app); gtk_widget_set_sensitive(app->sidebar_toggle, TRUE); if (adw_overlay_split_view_get_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(app->side_view)) != want) { adw_overlay_split_view_set_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(app->side_view), want); @@ -5137,9 +5153,10 @@ void on_activate(GtkApplication* application, gpointer user) { app->side_box = side; // Built in the order the panels have always been (cursors, measurements, trigger, signals); each is a - // page of the slide-over, reached by its badge, its key or the header's Cursors toggle. + // page of the slide-over, reached by its badge or its key (the Cursors page by the dock's Edit button; + // the header's Cursors toggle only switches the cursors on and mirrors the switch inside the page). app->cursors_panel = build_cursors_panel(*app); - GtkWidget* measures_panel = build_measures_panel(*app); + app->measures_panel = build_measures_panel(*app); app->trigger_panel = build_trigger_panel(*app); // Top-aligned: the page's scroller gives the child the page's height, and a form that stretched to it // would spread its rows (or draw its frame) over empty space under the last one. @@ -5152,9 +5169,9 @@ void on_activate(GtkApplication* application, gpointer user) { // `c` / the header toggle drive the cursors themselves; this page is where they are configured and read // in full. `m` toggles the measurements page. gtk_widget_set_valign(app->cursors_panel, GTK_ALIGN_START); - gtk_widget_set_valign(measures_panel, GTK_ALIGN_START); + gtk_widget_set_valign(app->measures_panel, GTK_ALIGN_START); slideover_add_page(*app, "cursors", "Cursors", app->cursors_panel); - slideover_add_page(*app, "measure", "Measurements", measures_panel); + slideover_add_page(*app, "measure", "Measurements", app->measures_panel); // The dock is the compact readouts alone (step 4 adds the events to it). gtk_box_append(GTK_BOX(app->dock_page), build_readouts_dock(*app)); diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 90e8169..8f24657 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -521,6 +521,8 @@ struct App { GtkWidget* cursors_reset = nullptr; /// The cursors panel's root, kept because cursors-off disables it rather than hiding it. GtkWidget* cursors_panel = nullptr; + /// The measurements page's root: like `cursors_panel`, insensitive until the stream has signals. + GtkWidget* measures_panel = nullptr; /// The panes' *own* switches, in their header rows, and the panel bodies those switches disable. /// The switch and the header bar's button are one control with two places to click: each runs the /// same action, and `sync_panel_switches()` re-reads both from the toolbar's state after any @@ -1171,6 +1173,8 @@ struct App { /// not until the stream has signals), its width, and how many `GtkScrolledWindow`s it holds directly. int dbg_dock_shown = -1; int dbg_dock_sensitive = -1; + /// The Cursors and Measurements pages' sensitivity (1 only if both are), read on the tick. + int dbg_pages_sensitive = -1; int dbg_dock_w = -1; int dbg_dock_scrollers = -1; int dbg_trigger_sensitive = 0; diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 51227ae..ab465d6 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -192,9 +192,17 @@ gboolean exit_probe_step3b(gpointer user) { c = gtk_widget_get_next_sibling(c)) { ++children; } - std::fprintf(stderr, "[readouts] pages cursors=%d measure=%d dock_children=%d dock_w=%d\n", + // Both pages follow the dock's availability: insensitive without signals, sensitive with them. + a->dbg_pages_sensitive = a->cursors_panel != nullptr && a->measures_panel != nullptr && + gtk_widget_get_sensitive(a->cursors_panel) && + gtk_widget_get_sensitive(a->measures_panel) + ? 1 : 0; + std::fprintf(stderr, + "[readouts] pages cursors=%d measure=%d dock_children=%d dock_w=%d " + "pages_sensitive=%d signals=%d\n", slideover_has_page(*a, "cursors") ? 1 : 0, slideover_has_page(*a, "measure") ? 1 : 0, - children, a->dbg_side_w); + children, a->dbg_side_w, a->dbg_pages_sensitive, + a->model->signals().empty() ? 0 : 1); } a->dbg_dock_scrollers = 0; for (GtkWidget* c = gtk_widget_get_first_child(a->side_box); c != nullptr; From a2ce02db92774bd3fe69fe8811aaaf0d5cf6c9e0 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 06:37:57 +0100 Subject: [PATCH 084/127] ui: tour and help follow the readouts dock The dock tour stop describes the read-only readouts and their Edit buttons, and the cursors and measures hints point to the dock copy and the pages. Co-Authored-By: Claude Sonnet 4.6 --- src/ui_context_help.cpp | 8 ++++++-- src/ui_tour.cpp | 12 +++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/ui_context_help.cpp b/src/ui_context_help.cpp index 4ca19a9..1096255 100644 --- a/src/ui_context_help.cpp +++ b/src/ui_context_help.cpp @@ -63,6 +63,10 @@ struct HelpHint { /// The table, which is the whole of the curation: a new surface is a row, and the heading row it /// names must already exist in `App` (that is what `ui_app.hpp`'s help-layer fields are for). +/// +/// The trigger, cursors, measurements and signals headings live on slide-over pages, which exist +/// (unmapped) whether or not the page is open: their hints are attached all the time and drawn while +/// the page is on screen, so the dock needs no hint of its own and the layer has one code path. constexpr HelpHint kHelpHints[] = { {"plot", "The plot", "Every signal you have switched on is drawn here, one row per group of traces, with all rows " @@ -77,12 +81,12 @@ constexpr HelpHint kHelpHints[] = { {"cursors", "The cursors", "Two movable instruments read against the window on screen: two verticals for time, two " "horizontals for the value axis, or the two crossings of each trace. What every plotted row " - "reads at the cursor is listed in this pane.", + "reads at the cursor is listed in this page; the dock keeps a read-only copy.", "C", &App::cursors_head}, {"measures", "The measurements", "Readings over the visible window: average, RMS, top, peak to peak, amplitude, frequency, a " "threshold count and more. Statistics under a reading track how it has varied since the last " - "reset, without the plot having to be still.", + "reset, without the plot having to be still. The dock lists the values; Edit opens this page.", "", &App::measures_head}, {"math", "Math channels", "A channel computed from the streamed ones in a small node graph: sources, gains, filters and " diff --git a/src/ui_tour.cpp b/src/ui_tour.cpp index d77a640..bb73c84 100644 --- a/src/ui_tour.cpp +++ b/src/ui_tour.cpp @@ -39,12 +39,12 @@ namespace ui_impl { namespace { /// The card's width. Wide enough for a comfortable line of the paragraph, narrow enough to fit -/// beside the plot on the documented window -- at 756 px the dock is 320 and a card wider than +/// beside the plot on the documented window -- at 756 px the dock is 280 and a card wider than /// that is a card that has to cover its own target. It is a *floor* on the box, not a cap on the /// paragraph: the wrapped label is capped by `max_width_chars` so the natural width never exceeds it. constexpr int kCardWidth = 280; /// The gap between the hole and whatever is placed beside it. Eight and not twelve: the dock -/// is 320 px, and it has to hold the card and both gaps. +/// is 280 px, and it has to hold the card and both gaps. constexpr int kGap = 8; /// Places an overlay child of the tour overlay at an exact rectangle. `GtkOverlay` positions an @@ -455,8 +455,10 @@ constexpr TourStep kTourSteps[] = { "the view then freezes on the first event that matches, however rare it is.", &App::trigger_body}, {"side", nullptr, "The dock", - "The dock beside the plot: the cursors, then the measurements. The buttons in the bar above " - "show and hide it.", + "The dock beside the plot is a read-only copy of the cursors and of every measurement, in a " + "fixed width. Edit, beside each heading, opens that instrument's full panel over the plot; a " + "click on a measurement opens its dialog. The side-panel button in the bar above shows and hides " + "the dock.", &App::side_box}, {"signals", "signals", "Adding a signal", "What can be added: the signals the source declares that are not acquired yet, each with the " @@ -502,7 +504,7 @@ void start_tour(App& app) { app.settings.tour_seen = true; (void)save_settings(app.settings); } - // The main window has no page stack (the dock is one scroller): its pages are the slide-over's, and + // The main window has no page stack (the dock holds only the readouts): its pages are the slide-over's, and // the view's `on_page` hook opens one (or closes the panel for a stop with no page), replacing the // `page_stack` mechanism the chooser keeps. app.tour.on_page = tour_open_page; From ce3cc97b91bd70c493caeb9536ded449d58e94e7 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 06:37:57 +0100 Subject: [PATCH 085/127] docs: the readouts dock, the cursors and measurement pages Co-Authored-By: Claude Sonnet 4.6 --- README.md | 76 +++++++++++++------ docs/claude/architecture.md | 22 ++++-- docs/claude/invariants-gtk-and-chrome.md | 31 ++++++-- docs/claude/invariants-plot-and-layout.md | 19 +++-- .../claude/invariants-readouts-and-cursors.md | 18 ++++- docs/claude/invariants-session-and-export.md | 2 +- docs/claude/probe-readings.md | 75 ++++++++++++------ docs/claude/verification.md | 29 ++++--- help/C/org.udpscope.app/cursors.page | 5 +- help/C/org.udpscope.app/measurements.page | 4 +- help/C/org.udpscope.app/overview.page | 12 +-- 11 files changed, 205 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index f181642..2eaf7ab 100644 --- a/README.md +++ b/README.md @@ -146,10 +146,21 @@ panel laid over the plot's right edge, opaque, with its own title, a *Close* but page. The Trigger badge opens the trigger form; the Source badge (when shown) opens the source kind's own panels (the E-1608's *Analog out* and *Digital I/O*, behind a segmented switch at the top of the panel). It closes with a second click on its badge, with `Esc`, with *Close*, or with a click on the plot; it never -resizes the plot. The right-hand **dock** (the existing sidebar, 320 px) holds what is left of the old -tabs in one scroller: the cursors and the measurements, shown from the start and insensitive until -the stream has signals (the signal list moved to the `signals` slide-over page). It is temporary -- see -"Review findings not (yet) implemented" for what the redesign still has to do with it. +resizes the plot. The right-hand **dock** (the sidebar, **280 px**, fixed) holds the **readouts**: a +compact, read-only copy of the cursors table (a header row `A B delta`, then one line per cursor +line) and of the measurements (one line each: the signal's colour chip, the type, the value). It is +shown from the start and insensitive until the stream has signals. Each of the two blocks has an +**Edit** button, which opens the full panel as a slide-over page -- `cursors` or `measure` -- over the +plot; `m` toggles the `measure` page, and a click on a measurement's line in the dock opens that +measurement's dialog. The header's **Cursors** toggle only switches the cursors on and off (it +mirrors the switch inside the `cursors` page); with the cursors off the dock's cursor lines are +insensitive, not hidden. Both pages are insensitive until the stream has signals, like the dock. The dock's +cells are a fixed number of characters wide and ellipsized, so the dock's width and height do not +depend on what a value reads: the **who** cell of a cursor line is 5 characters (the full text is its +tooltip), a value cell 9 (its tooltip is the full text). The dock and the pages show **one string**: +`update_cursors_pane()` and `update_measures()` format each value once and hand that variable to the +page's label and to the dock's, so the two cannot disagree. The remaining part of the redesign is +listed under "Review findings not (yet) implemented". Threading: one producer thread owns the data source (the UDPStreamer client is the built-in one) and calls `poll()`; the GTK main thread owns every widget and the GL contexts. @@ -1229,7 +1240,11 @@ decides which steps exist at all: a stop on a slide-over page is always kept, be reveals it, while one whose *pane is collapsed* (the dock, in a window too narrow to put it beside the plot) is dropped, because it is unmapped with its pane (a hidden page keeps its own *visible* flag true -- the widget's own flag, which is all `is_visible()` walks -- so the old test walked over -surfaces that were not on screen); a narrow window therefore walks four steps. The chooser's +surfaces that were not on screen); a narrow window therefore walks five steps. The dock's stop +describes the readouts and the **Edit** buttons; it names no slide-over page, so it closes the panel +like the plot's. The cursors and measurements pages have no stop of their own, and their contextual +help "(i)" hints are attached to the pages' headings the way the trigger's and the signals' are: they +are drawn while the page is open, and the dock has none. The chooser's tour works the same way over its own two pages: its tabs step shows the *Waveforms* page and stepping off it -- in either direction -- brings the *Sources* page back. @@ -1631,9 +1646,10 @@ it holds more than the window the bound became the whole ring, and a zoom-out wa view past the window into data the window was never set to show. A partly filled window simply has an empty left-hand side. -**The panel switches are on the toolbar, and the panes own their own controls.** Cursors, -Measurements and Math channels are icon toggles in the header bar rather than rows in the overflow -menu, and **Reset cursors** sits in the cursors pane beside the mode it resets (it acts on that +**The panel switches are on the toolbar, and the panes own their own controls.** (Since the +slide-over redesign the toolbar keeps only the **Cursors** toggle of the three icons named here, and it +only enables the cursors; the measurements are reached by `m` or the dock's Edit button, and the math +editor from the signal list.) **Reset cursors** sits in the cursors page beside the mode it resets (it acts on that pane's instruments and is only visible while they exist). The rows read, left to right: **window control, trigger, pause** — the trigger and the pause are read against the span on screen, so they sit with the control that sets it — then, on the right, **cursors, measurements, math, sidebar** and **the menu last**. With `HeaderBar::pack_end()` the @@ -2849,7 +2865,7 @@ reaches every widget). The full key table: | `Space` | pause / resume | | `t` | enable / disable trigger mode | | `c` | enable / disable the cursors | -| `m` | go to Measure (scroll the dock to the cursors and the measurements) | +| `m` | open or close the Measurements page (the `measure` slide-over page; the dock's Edit button for the cursors opens the `cursors` page) | | `g` | go to Signals (open or close the signal list, the `signals` slide-over page, also reached by the `+` badge) | | `l` | show / hide the layout editor | | `s` | show / hide the dock | @@ -2970,9 +2986,10 @@ now — they are listed so they are not mistaken for anything else: signal list -- in one scrolling column; with four of them open in a 320 px sidebar every section was short of room, and finding one meant scrolling past the others. The sidebar was then two text tabs (a **Measure** tab and a **Signals** tab, with a source kind's own tabs beside them), and four - tabs did not fit 320 px. It is **one dock** now, with no tab switcher: the cursors and the - measurements in one scroller, shown from the start and insensitive until the stream has signals; `m` - scrolls it to the cursors; the **signal list** is the `signals` slide-over page (`g`, or the `+` badge). The **trigger form** and a **source's own + tabs did not fit 320 px. It is **one dock** now, with no tab switcher: the **readouts** (a compact, + read-only copy of the cursors and of the measurements, each block with an **Edit** button), shown from + the start and insensitive until the stream has signals; the full cursors and measurements panels are the + `cursors` and `measure` slide-over pages (`m` toggles `measure`); the **signal list** is the `signals` slide-over page (`g`, or the `+` badge). The **trigger form** and a **source's own controls** are pages of the slide-over, reached from the badge bar. The trigger pane also has a **locked** mode, which is what a returning user gets: it shows one line saying what it is set to and offers no control that changes the configuration -- its heading has an `[edit]` button to switch to the full pane, and back. The signals have no lock: the @@ -2987,19 +3004,28 @@ now — they are listed so they are not mistaken for anything else: toggle hides a pane any more: cursors-off **disables** its readout and leaves it in place, and trigger-off leaves the trigger form fully editable -- a trigger is configured *before* it is switched on, and the locked view says `stream (trigger off)` instead of the settings -- and the measurements' and math panes' toolbar toggles are gone with the panes. -- **The badge bar / slide-over / dock redesign is in progress; this is its first step, and it deviates - from `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md` in three known ways.** - (1) The dock keeps the sidebar's **320 px**, not the spec's ~280: the panels it holds were built for - 320, and the width changes when they become compact readouts (step 3). (2) The session file gains the - dock's *shown* state only, not a width -- the dock's width is fixed, so there is nothing to persist - -- and does not store the open slide-over page. (3) The **Trigger badge** shows `off`, `armed` and - `triggered` as the spec says, and two more words, `capturing` (a post-trigger window is filling) and - `disarmed` (trigger on, not armed), so the label never claims a state the engine is not in. The signal badges, the - signal pages and the drag (step 2) are done. Still to - come, in the spec's order: the compact readouts (3), the events - section in the dock, the deletion of the left pane and the Events toggle, and `USER-GUIDE.md` and the - manual's figures (4). Until then the events pane, the header's Events toggle and the manual still - describe the previous layout. +- **The badge bar / slide-over / dock redesign is in progress; steps 1-3 are done, and they deviate + from `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md` in these known ways.** + (1) The session file gains the dock's *shown* state only, not a width -- the dock's width is + fixed (**280 px**, as the spec says), so there is nothing to persist -- and does not store the open + slide-over page. (2) The **Trigger badge** shows `off`, `armed` and `triggered` as the spec says, and + two more words, `capturing` (a post-trigger window is filling) and `disarmed` (trigger on, not + armed), so the label never claims a state the engine is not in. (3) The **`cursors` and `measure` + pages hold the existing panels unchanged** (the cursors table with its mode, its Reset and its + switch; the measurement list with its `+`, statistics and per-row edit), so the pages carry the + panels' own controls, not a new form. (4) The **measurement editor stays a dialog**: the spec has the + measurement add/edit/remove inside the `measure` page, but the dialog (`open_measure_dialog()`) is + what the `+`, a row's edit button and a click on a dock line all open; the page lists the measurements + and hosts the dialog's entry points. (5) A **dock cursor line carries the full who / A / B / delta + cells** (not one value per line as the spec sketches), in a 5 + 3 x 9 character fixed-width grid: the + **who** cell ellipsizes at **5 characters** -- the width the 280 px pin allows; with 8 the readouts' + minimum width was 304 px and widened the sidebar -- and its tooltip carries the full text. (6) The dock and + the pages **share one string** for every value (see `docs/claude/invariants-readouts-and-cursors.md`). + The signal badges, the signal pages and the drag (step 2) are done. Still to come, in the spec's + order: the events section in the dock, the deletion of the left pane and the Events toggle, and + `USER-GUIDE.md` and the manual's figures (4). Until then the events pane, the header's Events toggle + and the manual (which still describes the side panel's tabs, and the cursors and measurements as + panes of it) still describe the previous layout. - **A plot holds at most eight signals** (`RowSet::kSlotsPerRow`), because a `Plot` cannot give up a trace and the slots are therefore created up front. - **One toggle drives both cursor orientations.** The vertical markers and the diff --git a/docs/claude/architecture.md b/docs/claude/architecture.md index 6f9f31b..549fa36 100644 --- a/docs/claude/architecture.md +++ b/docs/claude/architecture.md @@ -101,8 +101,11 @@ touching the data path. a `slideover_add_page(app, id, title, widget)`: the widget is wrapped in a `GtkScrolledWindow` of its own and registered under its id (`trigger`, `source-`, `signals` (the signal list) and `signal:` (one signal's page, `src/ui_signal_page.cpp`, created on demand by `signal_page_ensure()` - and pruned by `signal_pages_sync()`, which `rebuild_signal_panels()` ends with); `measure`, `cursors` - and `events` are reserved for later steps). One page is open at a time and `App::slideover.page` + and pruned by `signal_pages_sync()`, which `rebuild_signal_panels()` ends with), `cursors` (the cursors + panel, unchanged) and `measure` (the measurements panel, unchanged; `m` toggles it); `events` is reserved + for a later step). Both of those are registered whether or not they are open (a closed page's widgets + exist, unmapped), and both are insensitive until the stream has signals, by the same + `update_signal_availability()` call that sets the dock's sensitivity. One page is open at a time and `App::slideover.page` is its id, empty when closed. The open page is **not** a session value. Esc, *Close*, a second click on the badge and a click on the plot close it; the click-away is a capture-phase observer (see [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). With two or more `source-*` pages, an @@ -116,9 +119,18 @@ touching the data path. file) leaves it **hidden** (the user asked for hide here, not the usual disable): `sync_badge_bar()` sets its visibility, pick and focus from `slideover.source_pages` being non-empty every tick, so anything that indexes the badge (the tour's `badges` step targets the whole bar, `[badges]`) must tolerate it being absent. -- **The dock** is the `AdwOverlaySplitView` sidebar (`side_box` > `dock_scroller` > `dock_page`): the - cursors and the measurements only, in one scroller (the signal list is the `signals` page). It holds no tab switcher and no - revealer, and it does not hold a source kind's panels (those are slide-over pages). +- **The dock** is the `AdwOverlaySplitView` sidebar (`side_box` > `dock_scroller` > `dock_page`), **280 px** + and fixed. It holds one thing, the **Readouts** box (`src/ui_dock.cpp`, `build_readouts_dock()`): a + **Cursors** block (heading, an **Edit** button that opens the `cursors` page, a `GtkGrid` of a header row + `A B delta` and one line per page line) and a **Measurements** block (heading, **Edit** opening the + `measure` page, one line per measurement: chip, type, value; a click on a line opens that measurement's + dialog; no measurements shows one dim "no measurements" line). It is read-only and **owns no formatting**: + `update_cursors_pane()` and `update_measures()` hand the string they wrote to the page's label to + `dock_cursor_cell()` / `dock_measure_value()` as well, and `rebuild_cursors_pane()` / + `rebuild_measures_pane()` end by rebuilding the dock's lines to match (`dock_rebuild_cursors()`, + `dock_rebuild_measures()`). It holds no tab switcher, no revealer and none of the full panels, nor a source + kind's panels (those are slide-over pages). The window's measurement editor is a dialog + (`open_measure_dialog()`), not part of the `measure` page. **When each is built and when it is read.** `build_slideover()` runs in `on_activate` right after the tour overlay is built (the tour's overlay wraps the whole window, so its dim rectangles cover the slide-over diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md index 2fa3653..a7e43a1 100644 --- a/docs/claude/invariants-gtk-and-chrome.md +++ b/docs/claude/invariants-gtk-and-chrome.md @@ -50,7 +50,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear 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* (Fit, Export, Configurations) and the one editor that is not a pane (the layout editor): the signal list - is a section of the sidebar's dock, so there is nothing left to reveal or hide about it. The Arm/Disarm button in the trigger panel still has text. + is the `signals` slide-over page, so there is nothing left to reveal or hide about it. The Arm/Disarm button in the trigger panel still has text. - **Five 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 measurement ruler, and the stock names those buttons used to carry said something else @@ -225,13 +225,15 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear take room has to be hidden and not merely unrevealed; the revealer keeps the slide while the pane *is* open. The allocated width is how this was found -- the revealer's own measure said 0. **The sidebar is one dock now, and has no `GtkRevealer` left in it**: a single `GtkScrolledWindow` - (`dock_scroller`) holding the cursors and the measurements one under another. It + (`dock_scroller`) holding only the compact **readouts** (`src/ui_dock.cpp`). It replaced an `AdwViewSwitcher` over an `AdwViewStack`, whose four text tabs did not fit 320 px (and a source can add more); a source kind's own panels are slide-over pages, not dock sections. The reason is the one - this trap teaches: sections competing for one 320 px column each left every one of them short of room, and - a scroller gives each its natural height. `m` scrolls the dock to the cursors (`dock_show_section()`); the signal list is - not a dock section but the `signals` slide-over page, which `g` and the `+` badge open. A *feature's* on/off is no longer a visibility at all -- the - **cursors'** toggle sets its pane's **body** insensitive -- the readout has nothing to show with the + this trap teaches: full panels competing for one narrow column each left every one of them short of room. + The full cursors and measurements panels are now the `cursors` and `measure` **slide-over pages**, reached + by the readouts' **Edit** buttons, and `m` toggles `measure` (it no longer scrolls the dock; + `dock_show_section()` is gone); the signal list is the `signals` slide-over page, which `g` and the `+` badge + open. The header's Cursors toggle no longer opens anything: it only enables the cursors. A *feature's* on/off is no longer a visibility at all -- the + **cursors'** toggle sets its pane's **body** insensitive (and the dock's cursor grid beside it) -- the readout has nothing to show with the instruments off -- and the *body* and not the pane, because the pane's heading carries its own switch (see the next bullet). The **trigger's body is deliberately never disabled**: 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 @@ -243,7 +245,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear - **The dock is shown but insensitive until the stream has signals ("disable, do not hide").** `update_side_visibility()` and the tick's per-frame block set the column's visibility from the user's wish alone (`sidebar_wanted`) and the dock's *sensitivity* from `have_signals`; the toolbar's Dock button stays - sensitive throughout. The wish is persisted (`dock = true|false` in the session file) and a change of it + sensitive throughout. The `cursors` and `measure` **pages follow the same condition** through + `update_signal_availability()` (called from `update_side_visibility()` and the per-frame block), so a + page opened by `m` before the stream has signals is insensitive, not refused. `[readouts] ... pages_sensitive=` + reads it. The wish is persisted (`dock = true|false` in the session file) and a change of it schedules a save. `dock:` in the dump reads the result. - **The signal badge is the drag handle, and a drop handler only moves widgets.** Dragging a signal onto a plot starts on its badge in the badge bar (a `GtkDragSource` on the badge's button, payload the signal index as a @@ -584,6 +589,18 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps `ui_impl::hint_button()` reached either by the table's `context_help_sync()` or by `context_help_button()` for a window with no `App` (the source chooser) -- one button, two callers, so the two windows cannot drift into two affordances. +- **A hint on a slide-over page's heading is attached all the time and drawn only while the page is open.** + The trigger's, signals', cursors' and measurements' headings (`trigger_head`, `signals_head`, + `cursors_head`, `measures_head`) live on pages that exist, unmapped, whether or not they are open, so + `context_help_sync()` attaches their "(i)" like any other and the count (`hints=12`) does not depend on + which page is up. There is **no second code path** and the dock's readouts carry no hint of their own: the + pages' hints say what the dock shows too (the cursors' and measurements' bodies mention the read-only copy + and the Edit button). A probe that must *see* one drawn needs its page open, like every other reading off a page. +- **The tour has one stop for the readouts and none for the pages behind them.** The dock's stop + (`side`, target `side_box`, no `page`, so it closes the panel) describes the readouts, the **Edit** + buttons and the click on a measurement; the `cursors` and `measure` pages are reached from there rather than + walked. A stop for either would be one more `TourStop::page` (see the next bullet), and the plan's + length is asserted (`steps=6`, `steps=5` with the dock put away) -- a new stop changes both. - **The guided tour takes no grab, and that is the whole reason it is an overlay.** Four dim rectangles around the hole are ordinary widgets that swallow the clicks that land on them and nothing else; `Skip`, `Finish` and `Escape` hide them, and there is no popover, no gesture and no diff --git a/docs/claude/invariants-plot-and-layout.md b/docs/claude/invariants-plot-and-layout.md index 89c4ac3..2578344 100644 --- a/docs/claude/invariants-plot-and-layout.md +++ b/docs/claude/invariants-plot-and-layout.md @@ -12,9 +12,9 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear from the paned's own width, re-parenting the column, the property form of its children, size requests): the *mechanism* was the fault. It is an **`AdwOverlaySplitView`** now — what a utility sidebar is in GNOME, as against `AdwNavigationSplitView`, which is for navigation lists — with `min-sidebar-width` = - `max-sidebar-width` = 320 in **pixels**, so the width is its own at any window size, and `collapsed` (set + `max-sidebar-width` = 280 in **pixels**, so the width is its own at any window size, and `collapsed` (set from the window's width in `tick()`) to *overlay* the plot when there is no room beside it rather than - being squeezed. `[sidebar-probe]` reads both states: `window=756 sidebar=320` under a 2x scale is exactly + being squeezed. `[sidebar-probe]` reads both states: `window=756 sidebar=280` under a 2x scale is exactly the case that used to be zero. - **The sidebar's button is the only input; `AdwOverlaySplitView:show-sidebar` is an output.** The split view writes that property *itself* — at startup, and when it collapses — with no user involved, so reading @@ -29,7 +29,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear no-signals case must leave the wish alone, or a source connecting later finds a sidebar the user never closed. The wish is the session's `dock` key: a change of it (not a program's own write, and not a restore) schedules a save. - **`AdwOverlaySplitView:sidebar-width-fraction` is a 0..1 fraction of the window, whatever - `sidebar-width-unit` says.** Setting a pixel count there (320) is a `GObject` critical, and it silently + `sidebar-width-unit` says.** Setting a pixel count there (280) is a `GObject` critical, and it silently falls back to 0.25 — which reads as "the sidebar is mysteriously 560 px wide" on a wide window with no error anywhere the build would catch. Pin the width with `min-sidebar-width`/`max-sidebar-width`. - **A `GtkPaned`'s position is stated after both its sides are filled**, and that is a fact about the @@ -130,9 +130,16 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear `bar_min_w` reads the same with one signal, nine signals and the overflow (244 on the E-1608, 124 on UDPS: the Source badge is on screen for the E-1608 and hidden for UDPS, and its text differs by kind). A change that makes it grow with the signal count has taken the scroller out of the measurement. -- **The dock's width is fixed too** (`side_column` `width-request` 300, the split view's 320 px floor - and cap), and it holds one scroller with the cursors and the measurements. A - section's visibility is never what makes the dock taller; the scroller is what absorbs the extra. +- **The dock's width is fixed too, and it is the readouts' width that holds it there** (`side_column` + `width-request` 260, the split view's 280 px floor and cap). The dock holds only the compact readouts, so a + section's visibility is never what makes it taller; the scroller absorbs the rest. **The readouts' minimum + width is the sum of their cells' character widths, and it must stay under 280** (the Readouts box is 268: + 280 less its 6 px margins): a cursor line is the `who` cell (5 characters) plus three value cells (9 each), + every one ellipsized at that width (`width-chars` = `max-width-chars`), so a long value is cut and its + tooltip carries the text and the layout never depends on what a value reads. The `who` cell was 8 + characters, which made the readouts' minimum width 304 px; that pushed the sidebar to 304 px + (`dock_w=304`) and defeated the 280 pin. Widening any cell is the same + trap -- read `[readouts] ... dock_w=` (must be 280) and the `dock cursors:` / `dock measures:` `fits=`. - **Trace slots are identified by signal name, not channel index.** Enabling a signal or a new CONFIG renumbers the channel list, so an index would silently re-point a trace at another signal. diff --git a/docs/claude/invariants-readouts-and-cursors.md b/docs/claude/invariants-readouts-and-cursors.md index 45a9e9c..20c36bf 100644 --- a/docs/claude/invariants-readouts-and-cursors.md +++ b/docs/claude/invariants-readouts-and-cursors.md @@ -41,6 +41,22 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear window's **first sample** and never from history before the window, so a measurement never depends on data outside the window it reports on: the left-edge step transient is removed by the seeding, not by priming from earlier samples. +- **The dock's readouts and the pages' panels are one string, never two.** The dock (`src/ui_dock.cpp`) + shows a read-only copy of the cursors table and of the measurement values, and it must not format + anything: `update_cursors_pane()` and `update_measures()` format each value **once**, into a local, and + hand that same variable to the page's label **and** to `dock_cursor_cell()` / `dock_measure_value()` -- + on every branch, including the `"--"` blanking one (cursors off, no sample). A second formatter in the + dock would be two spellings of one reading that drift the first time `format_measure()` changes, and + the dock exists so that the two can be read side by side. The `who` cell is copied from the page's label + verbatim (markup chip and name), and the dock's lines are rebuilt from the page's whenever the page + rebuilds (`dock_rebuild_cursors()`, `dock_rebuild_measures()`), so the line counts agree by + construction. A cell is **ellipsized at a fixed character count** (who 5, values 9, measurement type 12) with + the full text in its tooltip: the dock is 280 px wide and its layout must not depend on what a value + reads, so a long value is cut and quoted whole in the tooltip rather than widening the column (the + `who` cell was 8 characters, which made the readouts 304 px wide and pushed the sidebar past its + pin). `[cursors-probe] ... dock cursors:` and `[measures-probe] ... dock measures:` assert it: `same_text=1` + compares `gtk_label_get_text()` (the untruncated text) of every dock label with the page's, and + `fits=1` that the Readouts box is no wider than the dock. - **`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 @@ -55,7 +71,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear 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`. - **With the instruments off the readout blanks, and the panel stays on screen.** Cursors-off makes the - pane *insensitive* rather than hiding it -- a panel you can still see is a panel you can read, and the + pane *insensitive* (the page's body, and the dock's cursor grid beside it) rather than hiding it -- a panel you can still see is a panel you can read, and the settings you are about to switch back on must not vanish while you look for them -- and every cell in it is written `"--"`, the spelling an unavailable cell already uses. A readout quotes a stored sample; with nothing bracketing one there is no sample to quote, and keeping the last values would be quoting diff --git a/docs/claude/invariants-session-and-export.md b/docs/claude/invariants-session-and-export.md index 27cc817..bb0b23a 100644 --- a/docs/claude/invariants-session-and-export.md +++ b/docs/claude/invariants-session-and-export.md @@ -31,7 +31,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear toggle; written as a bare top-level boolean (`measure_stats = true/false`), defaults to `false` when absent, and **`dock`** -- `LayoutState::dock_shown`, the Dock button's state (the user's last word on it, `App::sidebar_wanted`): a bare top-level boolean written where `side_tab` used to be, default `true`. - It is the **only** thing the dock persists -- its 320 px width is fixed, so there is nothing to store -- + It is the **only** thing the dock persists -- its 280 px width is fixed, so there is nothing to store -- and **the open slide-over page is deliberately not persisted** (a panel that opens by itself at start-up is a surprise, and a page id from another source kind may not exist). The parser ignores unknown keys (its key chain has no erroring final `else`), so a file written before the tabs became a dock diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index bc8ce06..f405ff7 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -29,7 +29,7 @@ Two lines of the self-check output are assertions in disguise: window slides over a round number. The companion `axes.w changes: N (last at frame F, side panel up=S)` line separates the two readings: `0`, or a handful near the start, is a settled layout; a count near the frame count is the twitch. Runs far longer than - the documented probes have shown one ~320 px step — the side panel's width — that + the documented probes have shown one ~320 px step — the side panel's width, when it was 320 px — that reverts; it is intermittent and unexplained, so read `changes` before calling a range the twitch bug. - `[trig-probe] ... trigger=on ... display=... epoch=...` (stderr, @@ -425,8 +425,8 @@ Two lines of the self-check output are assertions in disguise: — 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] rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] traces=[..]` - `side_w` is the sidebar column's width and **must be its own request (320), never 0**: it read 0 while - the divider sat at the window's right edge, which is a panel that is not there. It was 320 during the + `side_w` is the sidebar column's width and **must be its own request (280), never 0**: it read 0 while + the divider sat at the window's right edge, which is a panel that is not there. It was 280 during the first frames and 0 once the window had settled, so read it from a `placed` line rather than the first. (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 @@ -479,16 +479,30 @@ 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 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. -- `dock: shown=1 sensitive=1 w=320 scroller_pages=1` (in the exit dump, beside `panels:`) -- the sidebar's dock - as the last tick found it. It replaces the old `switcher:` line: there is no tab switcher, the sidebar is - one scroller (`dock_scroller`) holding the signal list, the cursors and the measurements. **`shown=1` +- `dock: shown=1 sensitive=1 w=280 scroller_pages=1` (in the exit dump, beside `panels:`) -- the sidebar's dock + as the last tick found it. There is no tab switcher: the sidebar is one scroller (`dock_scroller`) holding the + **readouts box only** -- a compact, read-only copy of the cursors and of the measurements, each with an Edit + button (`build_readouts_dock()` in `src/ui_dock.cpp`). The signal list, the full cursors table, the measurements + and the trigger are slide-over pages. **`shown=1` always** (the dock is on screen from the start; `shown=0` only when the user hid it with the Dock button, which a probe does deliberately); **`sensitive=0` only before the stream has signals** ("disable, do not hide") -- a run against a port with no sender reads `shown=1 sensitive=0`, a normal run `sensitive=1`; - `w=` is the sidebar's own width (320); **`scroller_pages=1`** counts the scrollers directly under the dock + `w=` is the sidebar's own width (**280**); **`scroller_pages=1`** counts the scrollers directly under the dock column and says there is one scroller and no stack. These are captured on the tick or at the exit probe: the dump runs after the window is destroyed. A source with extra panels adds *no* dock section: its panels are slide-over pages (`source-`), so the line reads the same on the E-1608 and the probe kind. +- `[readouts] pages cursors=1 measure=1 dock_children=1 dock_w=280 pages_sensitive=1 signals=1` (in the exit + dump; `dock_children` counts the direct children of the dock page) -- the two pages and the dock against the + stream. **`cursors=1 measure=1`**: both slide-over pages are registered, always and from the start (not when + the stream has signals); `0` is a page that was never added, and then the dock's Edit buttons and the `m` key + open nothing. **`dock_w=280`** is the sidebar's width, as for `dock:`. **`pages_sensitive` must equal + `signals`**: both pages are insensitive until the stream has signals ("disable, do not hide"; + `update_signal_availability()` writes them with the dock), so a run against a port with no sender reads + `pages_sensitive=0 signals=0`, a normal run `pages_sensitive=1 signals=1`. `pages_sensitive=1` with `signals=0` + is a page that can be opened and read with nothing to read; `pages_sensitive=0` with `signals=1` is a + stream whose pages never came back after the signal list arrived. **`dock_children=1`**: the dock + page holds the readouts box and nothing else (a second child is a section that crept back into the dock; 0 is a + dock that was built empty). - `help layer: contextual=on hints=12 ids={events plot status cursors measures trigger trigger_advanced events_record trigger_search signals signals_config window} icon=dialog-information-symbolic(ok) hint=12x12; tour=idle steps=6 step=0 auto=1 requested=0` (in the exit dump, beside `dock:`) — the help affordance layer as the last tick found it. **`hints=` must equal the number of ids**, and the ids are the surfaces the contextual help is on: eleven of the window @@ -988,7 +1002,7 @@ two also catch a row whose accelerator went missing. `settings=` must be a real as a placement bug, on the plot stop (the target is the whole content) and on the trigger stop at the documented 756 px window (the panel is 360 px of it, so the card has no side wide enough and goes flush left over the plot, overlapping the panel's edge by a few pixels), and on the `signals` stop for the same - reason (the dock beside it is 320 px and the card has no side wide enough). + reason (the dock beside it is 280 px and the card has no side wide enough). - `[toursurface] finished: running=0 slideover=none` -- the last Next ended the walk and **the panel is closed with it**. - `[toursurface] restarted at trigger: running=1 slideover=trigger` then @@ -999,8 +1013,8 @@ two also catch a row whose accelerator went missing. `settings=` must be a real is how a probe puts the dock away for good): the dock's own stop is dropped, **and the trigger and signals stops are kept** because the slide-over does not depend on the dock -- five stops remain (plot, badges, trigger, signals, status). -- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | c: cursors: 0->1 | m: dock 0->243 (cursors_y=243 reach=2256 at_cursors=1) | g: page=1 dock_still=1 closed=1 | … | esc: opened=1 handled=1 closed=1 idle_handled=0 | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** (`g` is not a pair: it opens the `signals` slide-over page and a second press closes it), **`refused` must be 1**, and the `esc:` reading must be `opened=1 handled=1 closed=1 idle_handled=0`. - The `m`/`g` readings are the dock scroller's vertical adjustment before and after (the probe pads the dock to 3000 px so there is something to scroll, and undoes it at the end): **`m` must land on `cursors_y`, the cursors panel's y in the dock (`at_cursors=1`), and `g` must read `page=1 dock_still=1 closed=1`** (the `signals` page opened, the dock's scroll untouched, a second press closing it), which is what says `m` scrolls to a section and `g` opens the list's page rather than toggling a pane; a `reach` of 0 would mean the dock fit its viewport and the probe's padding was lost; `t`/`c` read the toolbar toggle's own state, because those two enable a feature and the button is where that state is visible. **The `esc:` reading goes through `on_app_key_pressed()` with `GDK_KEY_Escape`**, the handler the window's key controller calls, and not through `slideover_escape()`: the probe opens the `trigger` slide-over page (`opened=1`), sends the key (`handled=1`: it was consumed, and `closed=1`: the page is gone), then sends it again with nothing open (`idle_handled=0`: Escape is left alone when there is nothing for it to close, so a dialog or popover under it still gets it). `handled=0` or `closed=0` is the slide-over's Escape never reaching the panel from the window's key path -- a handler that returns before the slide-over test, or a controller in the wrong phase -- which the `[slideover] esc_closes=1` reading (a direct call to `slideover_escape()`) cannot see. **Limit of the `m`/`g` readings:** the padding is 3000 px at the end of the page, so `reach` is non-zero on any window shorter than that and the key has somewhere to scroll, but it does not push the cursors panel out of the viewport -- on a window whose dock viewport is taller than `cursors_y` (a tall window) the section is already in view at offset 0 and `m` still scrolls to it; `at_cursors=1` is the key landing the section's top at the viewport's top, which is the reading, not "the section was hidden before". 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 `: : ->` — 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 | c: cursors: 0->1 | m: page=1 dock_still=1 closed=1 | g: page=1 dock_still=1 closed=1 | … | esc: opened=1 handled=1 closed=1 idle_handled=0 | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** (`m` and `g` are not pairs: each opens a slide-over page, `measure` and `signals`, and a second press closes it), **`refused` must be 1**, and the `esc:` reading must be `opened=1 handled=1 closed=1 idle_handled=0`. + The `m`/`g` readings are `page=P dock_still=D closed=C`: **all three must be 1 for both keys** -- `P` the page the key opens (`measure` for `m`, `signals` for `g`) is the slide-over's open page after the first press, `D` the dock scroller's vertical adjustment did not move (the dock holds only the readouts, so a key must not scroll it), and `C` a second press closes the page. This is what says `m` opens the measurements' page rather than scrolling the dock to a section (the old `m: dock 0->N (cursors_y=… at_cursors=1)` reading is gone with the dock sections), and that neither key toggles a pane. `m` does *not* turn the measurements on or off: the header Cursors toggle only enables cursors, and `m` only opens the page. `t`/`c` read the toolbar toggle's own state, because those two enable a feature and the button is where that state is visible. **The `esc:` reading goes through `on_app_key_pressed()` with `GDK_KEY_Escape`**, the handler the window's key controller calls, and not through `slideover_escape()`: the probe opens the `trigger` slide-over page (`opened=1`), sends the key (`handled=1`: it was consumed, and `closed=1`: the page is gone), then sends it again with nothing open (`idle_handled=0`: Escape is left alone when there is nothing for it to close, so a dialog or popover under it still gets it). `handled=0` or `closed=0` is the slide-over's Escape never reaching the panel from the window's key path -- a handler that returns before the slide-over test, or a controller in the wrong phase -- which the `[slideover] esc_closes=1` reading (a direct call to `slideover_escape()`) cannot see. 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 `: : ->` — 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). - `[color] '' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1)`, then `[color] override: trace=#123456 chip_agrees=1`, `[color] session: entries=1 rgb=#123456` and `[color] default: trace=#RRGGBB chip_agrees=1` (stderr, `UDPSCOPE_PROBE_COLOR_MS`) — **all three `chip_agrees` must be 1**, the override line's `trace` must be `#123456`, the session line must read `entries=1 rgb=#123456`, and the default line's `trace` must be back to the first line's. Run it with `UDPSCOPE_ENABLE_ALL_MS` ahead of it, or there is no bound trace and the probe prints `no bound trace to recolour` instead of asserting anything; on the standard mock the first line reads `'sine' at row 0 slot 0: trace=#4FA3FF`. Two things make this an assertion rather than a tautology. The colours are read out of **`plot::Plot::trace().color`** — what the renderer will stroke, reached through `Row::plot` *after* `relayout_areas()`, since a new colour is a new figure — and out of the chip's own `` markup, not by asking `RowSet::signal_color()` twice, which would pass against a chip that never reached the trace. And the override is `#123456`, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips `collect_layout()` → `save_layout()` → `load_layout()` on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit. - `[reseq-probe] reseq=N late=N stale=N overflows=N holes=N hole_slots=N backfilled=N gaps_refused=N open_holes=N bands=N bands_repaired=N nan_slots=N scanned=N trigger_in_hole=D` @@ -1791,11 +1805,26 @@ disagree. The two lines are the claim, in both directions: - `trigger panel sensitive …=0` is the *panel body*, not the panel: the heading row, with its switch, stays live. `1` here while trigger mode is off is the pane not being disabled at all. +## `dock cursors:` and `dock measures:` + +``` +[cursors-probe] dock cursors: lines=1 page_lines=1 same_text=1 dock_w=268 side_w=280 fits=1 dock_sensitive=1 +[measures-probe] dock measures: lines=13 page_rows=13 same_text=1 dock_w=268 side_w=280 fits=1 +``` + +These ride on the `[cursors-probe]` readings (every state line gets a `dock cursors:` beside it) and on the `[measures-probe]` readings (`added`, `settled`, `final`), and read the dock's compact copy against the page it copies. The dock never formats a value: `update_cursors_pane()` and `update_measures()` hand the same string to the page label and the dock label. + +- **`same_text=1` is the assertion**: every dock label (the cursors' who, A, B, delta cells; the measurements' values) reads the same text as the page's label in the same position. `same_text=0` is a second formatter, or a value written to one view only; the two panes must never disagree. The probe compares `gtk_label_get_text()`, so the who cell compares as the text a user reads, markup stripped. +- **`lines` must equal `page_lines` / `page_rows`**: one dock line per page line. A shorter dock is a rebuild that was not mirrored (`dock_rebuild_cursors()` / `dock_rebuild_measures()` run from the pages' rebuilds); `same_text` reads 0 with it, so this names the cause. +- **`fits=1`** (`dock_w <= side_w`, `side_w > 0`): the readouts box fits inside the sidebar scroller. `dock_w` reads **268** inside the 280 px sidebar (the box's own margins and the fixed label widths: who 5 characters, each value 9, a measurement's type 12). **A who cell at 8 characters made the box 304 px and `fits=0`**, which is the defeat of the pin: the fixed-width ellipsis is what makes the dock's layout independent of what a value reads, so a cell given more characters widens the dock for every run, not only for a long name. `side_w=-1` or `dock_w=-1` is a probe that ran before the dock was built. +- **`dock_sensitive=1`** (cursors only): the dock's cursor grid is live whenever the stream has signals; `0` before them ("disable, do not hide"), `0` with signals is the dock never coming back. +- On a session with no cursors line, or with no measurements, `lines=0 page_lines=0 same_text=1` is correct: the dock then shows `no measurements` for the measurements (one non-value label, not counted in `lines`). + ## `[sidebar-probe]` ``` -[sidebar-probe] beside window=756 sidebar=320 collapsed=1 shown=1 button=1(sens=1) -[sidebar-probe] regained window=756 sidebar=320 collapsed=1 shown=1 button=1(sens=1) +[sidebar-probe] beside window=756 sidebar=280 collapsed=1 shown=1 button=1(sens=1) +[sidebar-probe] regained window=756 sidebar=280 collapsed=1 shown=1 button=1(sens=1) ``` `UDPSCOPE_PROBE_SIDEBAR_MS` reads the sidebar in the two states a window can put it in, and against a hide @@ -1806,7 +1835,7 @@ and `regained` reads 400 ms later. - **`sidebar` must never be 0.** It was, for every window narrower than the 940 px the divider used to sit at, and the pane could only be found by dragging the divider back — the report this reading exists for. - It must read its own width (320) in *both* states: `collapsed` changes where the sidebar is, never how + It must read its own width (280) in *both* states: `collapsed` changes where the sidebar is, never how wide it is. - **`shown` and `button` must agree, always.** They are the panel and the toolbar button, and a button that says "shown" over a hidden panel is the report this reading exists for. `shown=1 button=1` on `regained` @@ -1822,24 +1851,22 @@ and `regained` reads 400 ms later. ## `[side-tab-probe]` ``` -[side-tab-probe] section=cursors accums=4 baseline recorded (measure at scroll 2732 of 2732, outside_at_top=1) -[side-tab-probe] section=cursors rows=4 accums=4 advanced=4 min_n=18 +[side-tab-probe] section=none accums=4 baseline recorded (measure page was_open=1, now closed=1) +[side-tab-probe] section=none rows=4 accums=4 advanced=4 min_n=18 ``` -`UDPSCOPE_PROBE_SIDE_TAB_MS` (the variable and the tag keep their pre-dock names; the function is `dock_section_probe`) is the one reading for the rule that **the statistics accumulator keeps running -while the Measure section is out of view**. It scrolls the dock *to* the measurements (`dock_show_section("measure")` lands on the cursors panel, the dock's top, so the probe then moves to the measurements' heading itself), forces one pass so there is a baseline, then scrolls back to the top (the cursors) and reads again two seconds later. To make the scroll observable the probe pads `dock_page` **and the cursors panel** to 3000 px each (probe only, both undone in the report), and it re-arms once after 400 ms so the first reading has the layout. +`UDPSCOPE_PROBE_SIDE_TAB_MS` (the variable and the tag keep their pre-dock names; the function is `dock_section_probe`) is the one reading for the rule that **the statistics accumulator keeps running while the measurements' page is closed**. The measurements are a slide-over page (`measure`), not a dock section, so the old scroll-the-dock set-up is gone. The probe opens the page, forces one pass so there is a baseline, *closes* the page (its widgets stay in the tree, unmapped) and reads again two seconds later. It re-arms once after 400 ms so the first reading has the layout. **Run it against a config that has seen the first-run tour**, which would close the page it opens (`tour_seen = true`). -- **`outside_at_top=1` is the precondition**: with the dock at offset 0, the Measure section (the measurements' heading, below the padded cursors panel) starts at or below the viewport's bottom edge, so the "away from the section" state really has the measurements out of view and `advanced=4` says something. `outside_at_top=0` means the probe's own set-up failed and the run proves nothing -- unpadded, the section's top sits a short way down and the dock's viewport is taller than that. **The limit is the 3000 px:** the guarantee holds for a dock viewport shorter than the padding (any real window); a viewport taller than the cursors panel's padding, about 3000 logical px, would read `0` again. - -- **`advanced=4` is the assertion**: every accumulator gained passes while its section was out of view, and +- **`was_open=1 closed=1` is the precondition**: the page really was open for the baseline and really is closed for the report, so `advanced=4` says something. `was_open=0` is the page never opening (the tour closed it, or the page is insensitive because the stream has no signals) and `closed=0` is `slideover_close()` not taking effect; either way the run proves nothing. `section=` is the slide-over's open page and **reads `none` on both lines**, because the page is closed. +- **`advanced=4` is the assertion**: every accumulator gained passes while the page was closed, and `min_n` (the smallest `n` seen) must be non-zero too. **`advanced=0` is the defect**: something gated the - accumulation on the visible section, so the readout would gain a gap in its min/max/avg for exactly as long as - the trigger was on screen. A `Gtk-CRITICAL` here is the other half of the same rule -- a label or an - allocation read on the unrealized page. + accumulation on the page being open, so the readout would gain a gap in its min/max/avg for exactly as long as + the page was shut. A `Gtk-CRITICAL` here is the other half of the same rule -- a label or an + allocation read on the unmapped page. - The probe enables a signal **and assigns it to a plot** itself, both deliberately. A signal starts disabled (enabling is what starts acquisition), and a channel with no trace leaves the live window *still* -- and a still window is the state `update_measures()` is *supposed* to skip, so a probe that left it that way would - read `advanced=0` for a reason that has nothing to do with the tab. + read `advanced=0` for a reason that has nothing to do with the page. - `rows` is the measurement count and `accums` the accumulator count; the two must agree, because one accumulator per measurement row is what makes `advanced` a statement about every row. diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 719ce17..d47097b 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -423,15 +423,24 @@ precedent, not an exception. pauses, reports again with `dn=` per measurement, then resets and reports a third time. Three phases: `phase=0`, `phase=1` (after pause) and `phase=2` (after Reset). Needs `UDPSCOPE_ENABLE_ALL_MS` ahead of it and `UDPSCOPE_PROBE_MEASURE_MS` to add measurements first. -- `UDPSCOPE_PROBE_SIDE_TAB_MS=3000` with `UDPSCOPE_EXIT_AFTER_MS=11000` — the measurements in the - dock's Measure section, a forced pass, then the dock scrolled back to its top (the - measurements out of view) and read again two seconds later. The probe pads the dock page and the cursors panel to - 3000 px each so the Measure section really starts below the viewport (`outside_at_top=1`) on any real window. **`advanced=4` - is the assertion** (every accumulator gained passes while its section was out of view); `advanced=0` is the - defect where the accumulation is gated on the visible section, which would leave a gap in min/max/avg for - exactly as long as the trigger was on screen. It enables *and plots* its own signal, so it needs no other - probe ahead of it. The reading's own entry is in `probe-readings.md`. (Probe name `dock_section_probe`; the - variable and the `[side-tab-probe]` tag keep their old names so existing scripts keep working.) +- `UDPSCOPE_PROBE_SIDE_TAB_MS=3000` with `UDPSCOPE_EXIT_AFTER_MS=11000` — the measurements while the + `measure` page is **closed**: it adds four measurements, opens the page for a forced pass (the baseline), + closes it, and reads the accumulators again two seconds later. **`advanced=` is the assertion** + (every accumulator gained passes while the page was off screen); `advanced=0` is the defect where the + accumulation is gated on what is visible, which would leave a gap in min/max/avg for exactly as long as the + page was shut. It enables *and plots* its own signal, so it needs no other probe ahead of it, and it + re-arms once after 400 ms (a buffer rebuild between baseline and report reads as a gated pass). It needs a + config that has seen the first-run tour, which would close the page it opens (see the next-but-one bullet). + The reading's own entry is in `probe-readings.md`. (Probe name `dock_section_probe`; the variable and the + `[side-tab-probe]` tag keep their old names so existing scripts keep working. The measurements used to be a + dock section the probe scrolled out of view, with padding; the dock now holds only readouts, so there is + no scroll and no padding.) +- `UDPSCOPE_PROBE_CURSORS_MS` and `UDPSCOPE_PROBE_MEASURES_MS` also print the dock's copy of what they read: + a `dock cursors:` line after every `[cursors-probe]` report and a `dock measures:` line at the + measures probe's added / settled / final stops. **`same_text=1` on every one** is the assertion that the + dock and the page show one string; `fits=1` that the Readouts box is no wider than the dock. Entries in + `probe-readings.md`. The exit chain also prints one `[readouts]` line (pages registered, the dock's one + child, the dock's width, the pages' sensitivity, whether the stream has signals) on every exit-dump run. - `UDPSCOPE_PROBE_DOCK_SESSION_MS=2500` with `UDPSCOPE_EXIT_AFTER_MS=8000` -- the session file's `dock` key, end to end: the dock hidden, saved and reloaded; shown again; and a file from the previous version (`side_tab = "measure"`, no `dock`) loaded without an error. Three `[dock-session]` lines, each `ok=1`; @@ -447,7 +456,7 @@ precedent, not an exception. read `click_inside_closes=0 still_open=1 click_outside_closes=1 esc_closes=1 closed=1`. For a screenshot add `UDPSCOPE_PROBE_SLIDEOVER_HOLD=1`, which leaves the page open after line 1 (run with `GSK_RENDERER=cairo` and `import -window root`): the panel must be drawn over the traces, opaque. -- **Slide-over and page probes (`SLIDEOVER`, `BADGES`, `SIGNAL_PAGES`, `SIGNAL_BADGES`) need a config that has already seen +- **Slide-over and page probes (`SLIDEOVER`, `BADGES`, `SIGNAL_PAGES`, `SIGNAL_BADGES`, `SIDE_TAB`, `KEYS`) need a config that has already seen the first-run tour**: a fresh `XDG_CONFIG_HOME` auto-starts the tour at about 1.5 s, and a tour stop with no page closes the slide-over under the probe. Seed it with `mkdir -p $D/udpscope && printf 'tour_seen = true\n' > $D/udpscope/settings.toml` (or one short throw-away run, which writes the key when the tour starts) and run with `XDG_CONFIG_HOME=$D`. Delete the diff --git a/help/C/org.udpscope.app/cursors.page b/help/C/org.udpscope.app/cursors.page index 6c9eda2..6d51f0b 100644 --- a/help/C/org.udpscope.app/cursors.page +++ b/help/C/org.udpscope.app/cursors.page @@ -25,8 +25,9 @@

Press the cursors button in the toolbar, or c.

-

The pane below the plot shows the readings and owns a Reset button. - Drag a marker to move it.

+

The readings are in the side panel (a read-only copy) and in the cursors panel, which + Edit beside Cursors opens over the plot and which owns a + Reset button. Drag a marker to move it.

The mode dropdown offers four modes: vertical (the shared time pair), diff --git a/help/C/org.udpscope.app/measurements.page b/help/C/org.udpscope.app/measurements.page index 9293d8d..5f0add6 100644 --- a/help/C/org.udpscope.app/measurements.page +++ b/help/C/org.udpscope.app/measurements.page @@ -28,10 +28,10 @@ How to reach it -

Press the measurements button in the toolbar, or m.

+

Press m, or Edit beside Measurements in the side panel.

-

The pane's + adds one; clicking a row opens the dialog where the signal, +

The panel's + adds one; clicking a row opens the dialog where the signal, the type, the threshold share or shares and the on-plot marks are set, with Save and Delete.

diff --git a/help/C/org.udpscope.app/overview.page b/help/C/org.udpscope.app/overview.page index bd95f8b..de4e796 100644 --- a/help/C/org.udpscope.app/overview.page +++ b/help/C/org.udpscope.app/overview.page @@ -88,11 +88,13 @@ The side panel -

Two tabs. Measure holds the cursors readout and the measurements - derived from it; Signals holds the trigger and the channel list, where a - math channel is a row of that one list under a MATH heading whose - + creates one. One row per channel: its colour, an enable switch, its name - and its unit. A channel's badge in the badge bar is dragged onto a plot row.

+

A read-only copy of the cursors and of the measurements; the Edit button + beside each heading opens that instrument's full panel over the plot. The channel + list and the trigger are panels of their own, opened from the badge bar and the + toolbar, where a math channel is a row of the one list under a MATH + heading whose + creates one. One row per channel: its colour, an enable + switch, its name and its unit. A channel's badge in the badge bar is dragged onto a + plot row.

The plot From b08494dd3677a38bcbe059c425557e3f6530394e Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 06:43:19 +0100 Subject: [PATCH 086/127] docs: plan for the events dock block and the left pane's removal (step 4 of the redesign) --- .../plans/2026-11-03-ui-events-dock.md | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-03-ui-events-dock.md diff --git a/docs/superpowers/plans/2026-11-03-ui-events-dock.md b/docs/superpowers/plans/2026-11-03-ui-events-dock.md new file mode 100644 index 0000000..ca7616b --- /dev/null +++ b/docs/superpowers/plans/2026-11-03-ui-events-dock.md @@ -0,0 +1,88 @@ +# Events in the dock, the left pane deleted -- Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Step 4 of the badges/dock redesign (spec `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md`): a compact Events section in the dock, the full events list and its actions on an `events` slide-over page, the left events pane / divider / header toggle deleted, and the Mallard manual, USER-GUIDE and remaining docs swept for the new layout. + +**Architecture:** The dock gets a third block under the readouts (`build_events_dock` in `src/ui_dock.cpp`): one compact row per `EventRecord` (title, time, small marker; no thumbnails), a "Manage" button, one click replays. The existing pane widgets (`events-pane.ui`: head with Live, Compare, Accumulate, the multi-select list with thumbnails) stop being a `GtkRevealer` beside the plot and become the content of the `events` slide-over page; the list keeps its builder, thumbnail cache and selection logic. Both views are rebuilt from the same `event_store->list()` by the same refresh entry point. + +**Tech Stack:** C++20, GTK4/libadwaita, the headless self-check probes (no unit tests). + +## Global Constraints + +- The dock is fixed at 280 px; dock event rows are ellipsized labels with fixed `width-chars`/`max-width-chars`; no thumbnails in the dock (layout must not depend on content). +- No new overlay child; the page uses `slideover_add_page(App&, id, title, widget)`; the page id is `events`. +- "Disable, do not hide": the dock's Events block stays on screen, insensitive, when unavailable. **Availability rule (deviation from the spec's "insensitive outside trigger mode", kept on purpose):** the existing `sync_events_availability` condition (`event_recording || replaying || events_cached`) -- a replay or a group leaves trigger mode, and a block that greys itself under the click that started it is the trap `invariants-trigger-and-pinning.md` records. Document the deviation in README and `docs/claude`. +- A handler that rebuilds destroys the widgets it was emitted from: use `outlive_the_rebuild(widget)`. `refresh_events_pane` must keep nulling `events_thumb` before it removes children. `update_group_buttons` keeps its `app.window == nullptr` teardown guard. +- `model->signals()` is read only under `model->mutex()`. +- The page content is the pane's `events_box`, never the `GtkRevealer` (an unrevealed revealer measures 0x0). +- Selection survives marking: `mark_events_on_screen` updates titles in place and now also updates the dock rows' marker; it never rebuilds the page list. +- Every new source file carries the SPDX MIT header (this plan adds none). +- Baselines: clean build = 1 warning (icons `-Woverlength-strings`), incremental = 0, `gtk warnings: 0`, no `Gtk-CRITICAL`. Probes need a tour-seen config for slide-over page legs (see `.superpowers/sdd/task-5-report.md`, `verification.md`). Mocks: `tools/mock_udpstreamer.py` (8114), `tools/mock_e1608.py` (54211); run serially, kill them. +- The session file has no events-pane field (`events_pane_width` is memory only); there is nothing to migrate. README's claim that the width is remembered across sessions is wrong and is removed. + +--- + +### Task 1: The Events block in the dock (the left pane still exists) + +Adds the dock block so the app stays whole; nothing is removed. + +**Files:** +- Modify: `src/ui_dock.cpp`, `src/ui_app.hpp`, `src/ui_events.cpp`, `src/ui.cpp` (dock assembly, availability sync, tick), `src/ui_probe.cpp`, `CMakeLists.txt` (only if a file is added; none expected) + +**Interfaces:** +- Produces (declared in `ui_app.hpp`, defined in `ui_dock.cpp`): + - `GtkWidget* build_events_dock(App& app);` -- vertical `GtkBox`: a row with the "Events" label and a "Manage" text `GtkButton` (calls `slideover_open(app, "events")`; a no-op until Task 2 registers the page), then a `GtkListBox` (`app.dock_events_list`, selection mode none, single-click activation) of compact rows, inside a `GtkScrolledWindow` with a fixed `min-content-height`/`max-content-height` (about 4 rows) and `vscrollbar-policy` automatic. Stores `app.dock_events_box`, `app.dock_events_list`, `app.dock_events_manage`. + - `void dock_rebuild_events(App& app);` -- clears and refills `dock_events_list` from `app.event_store->list()` (newest first, as the pane does): each row = a small marker label (`dock_event_marker`, see below), the title (ellipsized, same text as `event_row_title`), the time as the pane shows it (ellipsized, dim). With no records: one dim "no events yet" row. Attaches the `EventRecord` to the row as `"udpscope-event"` (same key as the pane). Null guard: a no-op before `build_events_dock` ran or when `app.event_store` is null. + - `void dock_mark_events(App& app);` -- updates each dock row's marker/title from the same data `mark_events_on_screen` uses (the event on screen and the group members), without rebuilding rows. + - `void dock_events_set_sensitive(App& app, bool available);` -- the one place the block's sensitivity is set; `sync_events_availability` calls it with its own `available` value. +- Row activation (single click): calls the existing `replay_event(app, record)` that `on_event_row_activated` (`ui_events.cpp:961`) calls, with the same argument it passes; do not duplicate the replay logic -- extract the shared body if `on_event_row_activated` has any. +- Consumes: `App::event_store`, `EventRecord`, `event_row_title()` (`ui_events.cpp:784`), `replay_event`, `mark_events_on_screen`, `slideover_open`. + +- [ ] **Step 1: Build the block.** Write `build_events_dock`, `dock_rebuild_events`, `dock_mark_events`, `dock_events_set_sensitive` in `ui_dock.cpp`. Marker: a single-character label -- the on-screen event gets a filled marker, the group members another, others none (read how `mark_events_on_screen` (`ui_events.cpp:796`) decides the on-screen/group titles and reuse its predicate; do not invent a second one). CSS: check `ui_widgets.cpp` for existing `.udpscope-event-*` classes and `dim-label` before adding any. +- [ ] **Step 2: Wire it.** Append `build_events_dock` to the dock after the readouts box (and a separator), in `ui.cpp` where `build_readouts_dock` is assembled. Call `dock_rebuild_events(app)` at the end of `refresh_events_pane`'s non-empty and empty paths (every place it finishes) **and** from `record_event` unconditionally (today's `record_event` refreshes the pane only if the revealer is revealed; the dock must follow every latch), and `dock_mark_events` from `mark_events_on_screen`. Call `dock_events_set_sensitive` from `sync_events_availability` (`ui_events.cpp:40-70`) with its `available` result, and once after building (insensitive until available). +- [ ] **Step 3: Probe.** In the existing events probe (`events_probe_next`, `UDPSCOPE_PROBE_EVENTS_MS`) print `dock events: rows= pane_rows= same_titles=<0|1> sensitive=<0|1> dock_w=` after at least two latched events (the probe already produces them), where `same_titles` compares each dock title to the pane row's title label (`"udpscope-event-title"`) in order, and again after a replay (`on_screen` marker present on exactly one dock row). Add one click on a dock row to the probe and print `dock replay: clicked=<0|1> replaying=<0|1>` (the dock row click starts a replay as the pane's activate does). +- [ ] **Step 4: Build; run** the events probe (UDPS mock), the readouts probes (regression), and check `dock_w` <= 280, `gtk warnings: 0`. +- [ ] **Step 5: Commit** (`git add src/ui_dock.cpp src/ui_app.hpp src/ui_events.cpp src/ui.cpp src/ui_probe.cpp`; message `ui: compact events block in the dock`). + +--- + +### Task 2: The `events` page; the left pane, divider and header toggle deleted + +**Files:** +- Modify: `src/ui_events.cpp`, `src/ui.cpp`, `src/ui/main-window.ui`, `src/ui/header-bar.ui`, `src/ui/events-pane.ui`, `src/ui_app.hpp`, `src/ui_trigger.cpp` (`:597-601`, `:723-724`), `src/ui_layout.cpp` (`:380` comment), `src/ui_widgets.cpp` (`:58-60` comment), `src/ui_probe.cpp`, `src/ui_dock.cpp` + +**Interfaces:** +- Consumes: Task 1's `build_events_dock`, `dock_rebuild_events`, `dock_mark_events`, `dock_events_set_sensitive`; `slideover_add_page`, `slideover_open`. +- Produces: slide-over page `events` (title "Events") whose content is `events_box` of `events-pane.ui`; `build_events_pane(App&)` now returns that box (not a revealer); `App::events_revealer`, `App::events_divider`, `App::events_pane_width`, `sync_events_divider`, the header `events_toggle` and its handler are gone; `events_cached`/`sync_events_availability` keep driving the dock block's sensitivity (and the page's: set `events_box` insensitive with it). + +- [ ] **Step 1: The page.** `events-pane.ui`: drop the `events_pane` revealer so `events_box` is the root (rename nothing else); keep `events_head`, `events_live`, `events_compare`, `events_accumulate`, `events_list`. `build_events_pane` stops creating `events_revealer`. Register the page next to the `cursors`/`measure` registrations in `ui.cpp`: `slideover_add_page(*app, "events", "Events", app->events_box)` (add the `events_box` member if absent). `record_event`'s refresh condition `gtk_revealer_get_child_revealed` becomes "the `events` page is open" (`slideover_page(app) == "events"`), because the dock refreshes independently; opening the page (the slide-over's open path or the Manage button) must refresh the list once so it is current -- put this in a `slideover_set_on_open`-style hook only if the slide-over already has one; otherwise refresh in the Manage button's handler and in `refresh_events_pane` callers that run while closed (cheap; the list is small) -- state in the report which you chose. +- [ ] **Step 2: Delete the pane.** `main-window.ui`: remove `events_divider`; `left_column` becomes the `side_view` content directly (keep every id inside it); fix the header comment (lines ~6, 23, 29). `ui.cpp`: remove `sync_events_divider`, its tick call, the `events_divider` creation (`~5201-5206`), the null guard (`~4998`), the toggle handler (`~4951-4959`) and the toolbar-order probe string entry (`~3010`); `header-bar.ui`: remove `events_toggle`; keep the `udpscope-events-symbolic` icon (used by `preferences.ui`). `ui_app.hpp`: remove the members and declarations (`events_divider`, `events_pane_width`, `events_revealer`, the toggle pointer). `sync_events_availability` loses the toggle/revealer branches, keeps computing `available`, and drives `dock_events_set_sensitive` plus the page's sensitivity. Fix the comments at `ui_trigger.cpp:597-601`, `:723-724`, `ui_layout.cpp:380`, `ui_widgets.cpp:58-60`. +- [ ] **Step 3: Probes.** Rewrite `events_probe_next` (`ui_probe.cpp:5590-5860`) to open the page with `slideover_open(*a, "events")` instead of clicking the toggle, then drive selection/Compare/Accumulate exactly as before on the page's list; delete the divider/revealer/`pane_w` reads; rewrite `report_events_availability` (`ui.cpp:5712-5724`) as `[events] available=<0|1> dock_sensitive=<0|1> page_sensitive=<0|1> page=` and the `events pane:` dump (`ui.cpp:6242-6255`) as `events pane: compare= accumulate= list= nested=` (no revealer). Any layout dump or toolbar probe naming the toggle is updated. The plot's width must no longer change when events exist: add `plot_w_stable=<0|1>` comparing the plot area's allocated width before and after the first latched event (it must read 1; the old pane squeezed it). +- [ ] **Step 4: Run** the events probe, the trigger probes, the layout/toolbar probes, the readouts probes, one E-1608 run; `gtk warnings: 0`, no `Gtk-CRITICAL`, in particular none from `events_thumb`, `update_group_buttons` at teardown, or the removed revealer. Re-baseline `.superpowers/sdd/ui-signals-baseline-udps.txt` only where this step explains a difference. +- [ ] **Step 5: Commit** (`git add` the modified files by name; message `ui: the events list is a slide-over page; the left pane, its divider and the header toggle are gone`). + +--- + +### Task 3: Tour, context help, docs/claude, README + +**Files:** +- Modify: `src/ui_tour.cpp`, `src/ui_context_help.cpp` (`:102-110`), `README.md` (97, 841-850, 883, 1032, 1194, 3025-3027), `docs/claude/{architecture,verification,probe-readings,invariants-gtk-and-chrome,invariants-trigger-and-pinning,invariants-plot-and-layout}.md` + +- [ ] **Step 1: Tour and help.** The `dock` tour stop text mentions the Events block (what it lists, that one click replays, that Manage opens the list). The context-help hints `events` (anchored to `&App::events_head`, now on a page) and `events_record` follow how the `cursors`/`measures` hints were handled in step 3 (anchor on the page heading, shown while the page is open; reword "the events pane"). Run the help-figures and tour probes. +- [ ] **Step 2: docs/claude.** `architecture.md` (chrome section: the dock now has readouts and events; page `events`; `events` no longer "reserved", ~line 105), `invariants-gtk-and-chrome.md` (paned/revealer traps at ~212-228 lose the events divider; teardown ~281 and `events_thumb` ~297, thumb cache ~309 now say "the page's list"), `invariants-trigger-and-pinning.md` (~441-453: the availability rule drives the dock block's sensitivity; the spec's "outside trigger mode" deviation and why), `invariants-plot-and-layout.md` (no events divider; the plot is never squeezed by events), `verification.md` (~724 and the events probe wording), `probe-readings.md` (`dock events:`, `dock replay:`, the new `[events]` and `events pane:` lines and what each must read; the old `pane_w=240` rows and the `[events]` table near 1660-1675 rewritten). +- [ ] **Step 3: README.** Layout/dock description, the header (no Events toggle), the events page, the single-click replay in the dock, deviations: availability rule instead of "outside trigger mode"; the dock list has no thumbnails (the page does); delete the false "remembers the width" claim; update the pending-removal note at ~3025-3027 (it is done). +- [ ] **Step 4: Run** the tour and help-figure probes, one UDPS and one E-1608 run; `gtk warnings: 0`. +- [ ] **Step 5: Commit** (code: `ui: tour and help describe the events block`; docs: `docs: events in the dock and on its page`). + +--- + +### Task 4: The Mallard manual and USER-GUIDE sweep for the whole redesign + +**Files:** +- Modify: `help/C/org.udpscope.app/*.page` (`trigger.page` 169-196 "Events pane on the left", the history button, "Drag the pane's edge"; `overview.page` incl. its layout description and any figure caption; `signals.page`, `math.page`, `sessions.page`, plus any page that mentions the side panel, the sidebar tabs ("Measure", "Signals" tab), the header's sidebar toggle, or the Events toggle), `docs/USER-GUIDE.md`, `README.md` leftovers + +- [ ] **Step 1: Find every stale mention.** `grep -rniE "side panel|sidebar|side pane|tab switcher|\bMeasure tab|Signals tab|events pane|history button|Events toggle|drag.*(edge|divider)" help/C docs/USER-GUIDE.md README.md src/ui_*.cpp` and read each hit in context; also check figures the manual embeds (`help/C/org.udpscope.app/figures/` and how they are generated -- `UDPSCOPE_*` help-figures probe in `verification.md`). List every hit and its disposition in the report. +- [ ] **Step 2: Rewrite** each page to the real UI: header bar (time window, Trigger, Pause, Cursors, Dock toggle, menu), the plot, the badge bar (signal badges, `+`, N signals, Trigger, Source), the slide-over pages (trigger, signals, signal:, cursors, measure, events, source), the dock (Readouts: cursors, measurements; Events). Read the code or `docs/claude/architecture.md` to be accurate; do not invent shortcuts (`m` toggles Measurements, `g` the signals page -- check `src/shortcuts.cpp`). Regenerate figures only if the repository already has a script that does so headlessly; otherwise say which figures remain stale. +- [ ] **Step 3: Validate** the Mallard pages if `yelp-check` / `xmllint` is available (`xmllint --noout help/C/org.udpscope.app/*.page`); build with `cmake --build build -j` (the help may be compiled/installed by a target). +- [ ] **Step 4: Commit** (`docs: the manual and the user guide describe the badge bar, the dock and the slide-over pages`). From baf1c73b21749cf94c01dd0eedb6891565705c65 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 06:48:35 +0100 Subject: [PATCH 087/127] ui: compact events block in the dock Co-Authored-By: Claude Sonnet 4.6 --- src/ui.cpp | 4 +- src/ui_app.hpp | 19 ++++++ src/ui_dock.cpp | 163 ++++++++++++++++++++++++++++++++++++++++++++++ src/ui_events.cpp | 32 +++++++-- src/ui_probe.cpp | 74 +++++++++++++++++++++ 5 files changed, 287 insertions(+), 5 deletions(-) diff --git a/src/ui.cpp b/src/ui.cpp index 77f96c8..7dcb73d 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -5172,8 +5172,10 @@ void on_activate(GtkApplication* application, gpointer user) { gtk_widget_set_valign(app->measures_panel, GTK_ALIGN_START); slideover_add_page(*app, "cursors", "Cursors", app->cursors_panel); slideover_add_page(*app, "measure", "Measurements", app->measures_panel); - // The dock is the compact readouts alone (step 4 adds the events to it). + // The dock: the compact readouts, and under them the compact events list. gtk_box_append(GTK_BOX(app->dock_page), build_readouts_dock(*app)); + gtk_box_append(GTK_BOX(app->dock_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + gtk_box_append(GTK_BOX(app->dock_page), build_events_dock(*app)); // The cursors start off, so their readout starts insensitive -- stated here by the one place that // knows the default, and the *body* and not the panel, because the panel's heading carries the diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 8f24657..51e4533 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -809,6 +809,11 @@ struct App { /// to. 240 by default: enough for the preview and the numbers beside it, and not a plot's worth. GtkWidget* events_divider = nullptr; int events_pane_width = 240; + /// The Events block of the dock: the box, its list (compact rows, one per `EventRecord`, the record + /// attached to each row as `udpscope-event`) and the Manage button. + GtkWidget* dock_events_box = nullptr; + GtkWidget* dock_events_list = nullptr; + GtkWidget* dock_events_manage = nullptr; /// The comparison's second plot: its own placeholder, area and `RowSet`, and the paned that holds /// the two side by side. `rows_b` exists only once the second area is built, and the area is hidden /// (so the paned gives it no room and no handle) unless a comparison is up. @@ -1786,6 +1791,20 @@ void dock_rebuild_measures(App& app); void dock_cursor_cell(App& app, std::size_t line, const std::string& a, const std::string& b, const std::string& d); void dock_measure_value(App& app, std::size_t i, const std::string& text); +/// The Events block under the readouts (`src/ui_dock.cpp`): compact rows (marker, title, time; no +/// thumbnails) from `event_store->list()`, newest first, and a Manage button for the `events` page. +/// `dock_rebuild_events()` follows every change of what is *cached*, `dock_mark_events()` every change +/// of what is *on screen* (in place, no rebuild), `dock_events_set_sensitive()` is the one writer of the +/// block's sensitivity. All three are no-ops before the block is built. +GtkWidget* build_events_dock(App& app); +void dock_rebuild_events(App& app); +void dock_mark_events(App& app); +void dock_events_set_sensitive(App& app, bool available); +/// A row's first line, and the two questions its mark is made of (`ui_events.cpp`): the one function +/// for the pane and the dock, so the two cannot disagree about which event is on screen. +std::string event_row_title(App& app, const EventRecord& r); +bool event_on_screen(const App& app, const EventRecord& r); +bool event_in_group(const App& app, const EventRecord& r); /// The signal chip of a measurement's row, in the colour every other view names that signal with. std::string measure_chip_markup(App& app, const std::string& signal); std::string row_unit(App& app, int row); diff --git a/src/ui_dock.cpp b/src/ui_dock.cpp index c0a1693..60da952 100644 --- a/src/ui_dock.cpp +++ b/src/ui_dock.cpp @@ -8,13 +8,20 @@ // once and hand the same variable to the page's label and to `dock_cursor_cell()` / `dock_measure_value()`, // so the two views cannot disagree. Every label is ellipsized at a fixed `width-chars`, so the dock's // layout does not depend on what a value reads; the full text is the label's tooltip. +// +// Below them, the Events block: one compact row per cached event (marker, title, time; no thumbnails), +// a Manage button for the full list, and one click on a row to replay it. Its rows are built from the +// same `event_store->list()` and the same `event_row_title()` as the events pane, so the two cannot +// disagree. #include "ui_app.hpp" #include #include +#include #include +#include namespace udpscope { namespace ui_impl { @@ -226,5 +233,161 @@ void dock_measure_value(App& app, std::size_t i, const std::string& text) { set_value_text(app.dock_measure_labels[i], text); } +namespace { + +/// The widths of an event row's three cells, in characters. Fixed, so the Events block is as wide with +/// one event as with thirty (the marker is one cell wide even when it is empty). +constexpr int kEventTitleChars = 18; +constexpr int kEventTimeChars = 8; + +/// The one-character mark of a row: filled for the event on screen, hollow for a member of the group on +/// screen, blank for the rest. Both questions are the ones `event_row_title()` asks (`ui_events.cpp`). +const char* dock_event_marker(const App& app, const EventRecord& r) { + if (event_on_screen(app, r)) return "\xE2\x97\x8F"; // U+25CF black circle + if (event_in_group(app, r)) return "\xE2\x97\x8B"; // U+25CB white circle + return " "; +} + +void on_manage_events(GtkButton*, gpointer user) { + slideover_open(*static_cast(user), "events"); +} + +/// One click on a row replays it, as the pane's activation does: `replay_event()` and then the in-place +/// re-mark. The record is copied first, because nothing it triggers may be allowed to free the row it +/// was read from. +void on_dock_event_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) { + App& app = *static_cast(user); + if (row == nullptr) return; + const auto* record = + static_cast(g_object_get_data(G_OBJECT(row), "udpscope-event")); + if (record == nullptr) return; + const EventRecord copy = *record; + replay_event(app, copy); + mark_events_on_screen(app); +} + +std::string event_clock(const EventRecord& r) { + const std::time_t t = static_cast(r.wall_time); + std::tm tm{}; + localtime_r(&t, &tm); + char clock[32]; + std::strftime(clock, sizeof(clock), "%H:%M:%S", &tm); + return clock; +} + +} // namespace + +GtkWidget* build_events_dock(App& app) { + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_widget_set_margin_top(box, 6); + gtk_widget_set_margin_bottom(box, 6); + gtk_widget_set_margin_start(box, 6); + gtk_widget_set_margin_end(box, 6); + + GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget* title = gtk_label_new("Events"); + gtk_widget_set_halign(title, GTK_ALIGN_START); + gtk_widget_set_hexpand(title, true); + gtk_widget_add_css_class(title, "heading"); + gtk_box_append(GTK_BOX(head), title); + GtkWidget* manage = gtk_button_new_with_label("Manage"); + gtk_widget_add_css_class(manage, "flat"); + gtk_widget_set_tooltip_text(manage, "the events' full list and actions"); + g_signal_connect(manage, "clicked", G_CALLBACK(on_manage_events), &app); + gtk_box_append(GTK_BOX(head), manage); + gtk_box_append(GTK_BOX(box), head); + + GtkWidget* list = gtk_list_box_new(); + gtk_list_box_set_selection_mode(GTK_LIST_BOX(list), GTK_SELECTION_NONE); + gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(list), true); + gtk_widget_add_css_class(list, "boxed-list"); + g_signal_connect(list, "row-activated", G_CALLBACK(on_dock_event_activated), &app); + + // A fixed height (about four rows), so the block is as tall with no events as with thirty and the + // readouts above it do not move when one is latched. + GtkWidget* scroller = gtk_scrolled_window_new(); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_min_content_height(GTK_SCROLLED_WINDOW(scroller), 112); + gtk_scrolled_window_set_max_content_height(GTK_SCROLLED_WINDOW(scroller), 112); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), list); + gtk_box_append(GTK_BOX(box), scroller); + + app.dock_events_box = box; + app.dock_events_list = list; + app.dock_events_manage = manage; + // Insensitive until the first availability sync says otherwise: on screen and greyed, never hidden. + gtk_widget_set_sensitive(box, FALSE); + dock_rebuild_events(app); + return box; +} + +void dock_rebuild_events(App& app) { + if (app.dock_events_list == nullptr || app.event_store == nullptr) return; + GtkListBox* list = GTK_LIST_BOX(app.dock_events_list); + gtk_list_box_remove_all(list); + const std::vector records = app.event_store->list(); + if (records.empty()) { + GtkWidget* none = gtk_label_new("no events yet"); + gtk_widget_set_halign(none, GTK_ALIGN_START); + gtk_widget_add_css_class(none, "caption"); + gtk_widget_add_css_class(none, "dim-label"); + gtk_widget_set_margin_start(none, 6); + gtk_widget_set_margin_top(none, 4); + gtk_widget_set_margin_bottom(none, 4); + GtkWidget* row = gtk_list_box_row_new(); + gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), none); + gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), false); + gtk_list_box_append(list, row); + return; + } + for (const EventRecord& r : records) { + GtkWidget* row = gtk_list_box_row_new(); + GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_widget_set_margin_start(line, 6); + gtk_widget_set_margin_end(line, 6); + gtk_widget_set_margin_top(line, 3); + gtk_widget_set_margin_bottom(line, 3); + + GtkWidget* marker = fixed_label(dock_event_marker(app, r), 1, GTK_ALIGN_CENTER); + gtk_box_append(GTK_BOX(line), marker); + const std::string text = event_row_title(app, r); + GtkWidget* name = fixed_label(text.c_str(), kEventTitleChars, GTK_ALIGN_START); + gtk_widget_set_hexpand(name, true); + gtk_widget_set_tooltip_text(name, text.c_str()); + gtk_box_append(GTK_BOX(line), name); + const std::string clock = event_clock(r); + GtkWidget* when = fixed_label(clock.c_str(), kEventTimeChars, GTK_ALIGN_END); + gtk_widget_add_css_class(when, "dim-label"); + gtk_box_append(GTK_BOX(line), when); + + g_object_set_data(G_OBJECT(row), "udpscope-event-marker", marker); + g_object_set_data(G_OBJECT(row), "udpscope-event-title", name); + g_object_set_data_full(G_OBJECT(row), "udpscope-event", new EventRecord(r), + [](gpointer p) { delete static_cast(p); }); + gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), line); + gtk_list_box_append(list, row); + } +} + +void dock_mark_events(App& app) { + if (app.dock_events_list == nullptr) return; + for (GtkWidget* row = gtk_widget_get_first_child(app.dock_events_list); row != nullptr; + row = gtk_widget_get_next_sibling(row)) { + const auto* record = + static_cast(g_object_get_data(G_OBJECT(row), "udpscope-event")); + GtkWidget* marker = GTK_WIDGET(g_object_get_data(G_OBJECT(row), "udpscope-event-marker")); + GtkWidget* name = GTK_WIDGET(g_object_get_data(G_OBJECT(row), "udpscope-event-title")); + // The "no events yet" placeholder carries none of the three: skipped. + if (record == nullptr || marker == nullptr || name == nullptr) continue; + gtk_label_set_text(GTK_LABEL(marker), dock_event_marker(app, *record)); + set_value_text(name, event_row_title(app, *record)); + } +} + +void dock_events_set_sensitive(App& app, bool available) { + if (app.dock_events_box == nullptr) return; + gtk_widget_set_sensitive(app.dock_events_box, available); +} + } // namespace ui_impl } // namespace udpscope diff --git a/src/ui_events.cpp b/src/ui_events.cpp index 8c087a9..6d441e3 100644 --- a/src/ui_events.cpp +++ b/src/ui_events.cpp @@ -52,6 +52,7 @@ void sync_events_availability(App& app) { // the button that shows it must not go with the switch. `events_cached` is refreshed once a second // from the tick -- the store's list is a directory read, not a per-frame question. const bool available = app.event_recording || app.replaying || app.events_cached; + dock_events_set_sensitive(app, available); gtk_widget_set_visible(app.events_toggle, available); if (!available) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.events_toggle), FALSE); @@ -132,11 +133,14 @@ bool record_event(App& app) { set_notice(app, "event cached: " + std::to_string(table.columns.size()) + " signals, " + std::to_string(table.rows()) + " samples", 3); - // Only while the pane is on screen: rebuilding every row per latch for a column nobody opened is - // work the tick owes nothing, and the toggle's own handler refreshes when it is asked for. + // The dock follows every latch; the pane's rows (with pictures) only while the pane is on screen: + // rebuilding them per latch for a column nobody opened is work the tick owes nothing, and the toggle's + // own handler refreshes when it is asked for. if (app.events_revealer != nullptr && gtk_revealer_get_child_revealed(GTK_REVEALER(app.events_revealer))) { refresh_events_pane(app); + } else { + dock_rebuild_events(app); } return true; } @@ -580,6 +584,7 @@ bool build_event_group(App& app, const std::vector& records, EventG // Fit on open, once: the group's own span, framed. // As above: the tick pins the axis to the group's own span and marks the trigger. sync_events_availability(app); + mark_events_on_screen(app); // the dock marks the group's members return true; } @@ -781,6 +786,17 @@ void attach_preview_zoom(GtkWidget* frame, const std::string& path) { /// screen. One function because two callers write it -- the row builder and the in-place /// re-mark -- and a mark that reads differently depending on which of them last ran would be /// worse than no mark. +bool event_on_screen(const App& app, const EventRecord& r) { + return std::string(app.waveform_name) == r.file; +} + +/// Whether the event is one of the members of the comparison or accumulation on screen. +bool event_in_group(const App& app, const EventRecord& r) { + if (app.group_kind == EventGroupKind::None) return false; + return std::any_of(app.group.begin(), app.group.end(), + [&](const EventRecord& g) { return g.file == r.file; }); +} + std::string event_row_title(App& app, const EventRecord& r) { const std::time_t t = static_cast(r.wall_time); std::tm tm{}; @@ -789,11 +805,12 @@ std::string event_row_title(App& app, const EventRecord& r) { std::strftime(clock, sizeof(clock), "%H:%M:%S", &tm); char title[160]; std::snprintf(title, sizeof(title), "%s %.3f s%s", clock, r.duration_s, - std::string(app.waveform_name) == r.file ? " (on screen)" : ""); + event_on_screen(app, r) ? " (on screen)" : ""); return title; } void mark_events_on_screen(App& app) { + dock_mark_events(app); if (app.events_list == nullptr) return; for (GtkWidget* row = gtk_widget_get_first_child(app.events_list); row != nullptr; row = gtk_widget_get_next_sibling(row)) { @@ -811,7 +828,7 @@ void mark_events_on_screen(App& app) { /// the whole pane describes whichever event happens to be newest rather than the one being read. The /// numbers are one line each: a list that has to load the samples to describe an event is a list that /// cannot show thirty of them. -void refresh_events_pane(App& app) { +static void rebuild_events_pane_list(App& app) { if (app.events_list == nullptr) return; while (GtkWidget* child = gtk_widget_get_first_child(app.events_list)) { gtk_list_box_remove(GTK_LIST_BOX(app.events_list), child); @@ -955,6 +972,13 @@ void refresh_events_pane(App& app) { } } +/// Both views of the cache, rebuilt from the same list: the pane's rows (with their pictures) and the +/// dock's compact ones. The dock follows even when the pane's list does not exist. +void refresh_events_pane(App& app) { + rebuild_events_pane_list(app); + dock_rebuild_events(app); +} + /// Clicking a row replays it: the same loader `--open` uses, on the event's file. The acquisition is /// stopped by `replay_event()` -- a live producer would overwrite the samples within a frame -- so the /// pane's own *Live* button is the way back. diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index ab465d6..f731011 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -5576,6 +5576,49 @@ void report_live_restored(App& a, const char* what) { std::fflush(stderr); } +/// The dock's Events block against the pane's list. `same_titles` compares each dock row's title label +/// with the pane row's, in order (both are rebuilt from the same list by `refresh_events_pane()`, and both +/// are marked by `mark_events_on_screen()`); `on_screen` counts the dock rows with the filled marker, and +/// `marked` is the index of the first one (-1 for none). +static void report_dock_events(App& a, const char* what) { + std::vector dock_titles; + std::vector pane_titles; + int on_screen = 0; + int marked = -1; + int members = 0; + const auto titles = [&members](GtkWidget* list, std::vector* out, int* filled, int* first) { + if (list == nullptr) return; + int i = 0; + for (GtkWidget* row = gtk_widget_get_first_child(list); row != nullptr; + row = gtk_widget_get_next_sibling(row), ++i) { + if (g_object_get_data(G_OBJECT(row), "udpscope-event") == nullptr) continue; + GtkWidget* name = GTK_WIDGET(g_object_get_data(G_OBJECT(row), "udpscope-event-title")); + if (name == nullptr) continue; + out->push_back(gtk_label_get_text(GTK_LABEL(name))); + GtkWidget* marker = GTK_WIDGET(g_object_get_data(G_OBJECT(row), "udpscope-event-marker")); + if (filled != nullptr && marker != nullptr && + std::strcmp(gtk_label_get_text(GTK_LABEL(marker)), "\xE2\x97\x8B") == 0) { + ++members; + } + if (filled != nullptr && marker != nullptr && + std::strcmp(gtk_label_get_text(GTK_LABEL(marker)), "\xE2\x97\x8F") == 0) { + ++*filled; + if (first != nullptr && *first < 0) *first = i; + } + } + }; + titles(a.dock_events_list, &dock_titles, &on_screen, &marked); + titles(a.events_list, &pane_titles, nullptr, nullptr); + std::fprintf(stderr, + "[events] dock events: rows=%zu pane_rows=%zu same_titles=%d sensitive=%d dock_w=%d " + "on_screen=%d marked=%d members=%d at=%s\n", + dock_titles.size(), pane_titles.size(), dock_titles == pane_titles ? 1 : 0, + a.dock_events_box != nullptr && gtk_widget_is_sensitive(a.dock_events_box) ? 1 : 0, + a.dock_events_box != nullptr ? gtk_widget_get_width(a.dock_events_box) : -1, on_screen, + marked, members, what); + std::fflush(stderr); +} + void events_probe_next(App& a, int step, guint delay_ms) { a.dbg_events_step = step; g_timeout_add(delay_ms, events_probe_step, &a); @@ -5762,6 +5805,24 @@ gboolean events_probe_step(gpointer user) { a->events_thumb != nullptr && gtk_widget_get_mapped(a->events_thumb) ? 1 : 0, rows_kept ? 1 : 0, selected_now, a->dbg_events_selected); std::fflush(stderr); + report_dock_events(*a, "after pane replay"); + // One click on a dock row, the gesture: a *second* event when there is one, so the marker has to + // move. Through `row-activated`, which is what a single click emits. + { + GtkListBox* dock = GTK_LIST_BOX(a->dock_events_list); + GtkListBoxRow* row = gtk_list_box_get_row_at_index(dock, 1); + if (row == nullptr) row = gtk_list_box_get_row_at_index(dock, 0); + const bool real = row != nullptr && g_object_get_data(G_OBJECT(row), "udpscope-event") != nullptr; + const std::string before = a->waveform_name; + if (real) g_signal_emit_by_name(dock, "row-activated", row); + // Already replaying (the pane's click above), so `replaying` alone cannot tell the click from the + // state it found: `waveform_changed` says the click opened the row's own event (1 when it was a + // second event, 0 when there was only the one to click again). + std::fprintf(stderr, "[events] dock replay: clicked=%d replaying=%d waveform_changed=%d\n", + real ? 1 : 0, a->replaying ? 1 : 0, before != a->waveform_name ? 1 : 0); + std::fflush(stderr); + report_dock_events(*a, "after dock click"); + } events_probe_next(*a, 2, 1200); return G_SOURCE_REMOVE; } @@ -5834,6 +5895,7 @@ gboolean events_group_probe_step(gpointer user) { // The live settings this leg will be read against, taken *here* rather than inherited from the // replay leg: a group is a second, independent way into a file view, and its way back has to put // back what was on screen when the button was pressed. + report_dock_events(*a, "two events latched"); capture_live_expectation(*a); if (a->events_compare != nullptr) g_signal_emit_by_name(a->events_compare, "clicked"); // Read one step later: a rebuilt row's traces are bound by the next *frame*, so a reading taken in @@ -5853,6 +5915,7 @@ gboolean events_group_probe_step(gpointer user) { group_visible_traces(*a, 0), group_visible_traces(*a, 1), a->rows->time_window(), a->event_recording ? 1 : 0, a->events_revealer != nullptr && gtk_widget_get_visible(a->events_revealer) ? 1 : 0); + report_dock_events(*a, "comparison up"); events_group_probe_next(*a, 46, 400); return G_SOURCE_REMOVE; } @@ -5985,6 +6048,17 @@ gboolean events_group_probe_step(gpointer user) { if (a->dbg_events_step == 61) { report_live_restored(*a, "after a group"); + // A single click on the *second* dock row now that the app is live again: a different event than the + // last (there are several by now), so the filled marker has to move to that row. + { + GtkListBox* dock = GTK_LIST_BOX(a->dock_events_list); + GtkListBoxRow* row = gtk_list_box_get_row_at_index(dock, 1); + const bool real = row != nullptr && g_object_get_data(G_OBJECT(row), "udpscope-event") != nullptr; + if (real) g_signal_emit_by_name(dock, "row-activated", row); + std::fprintf(stderr, "[events] dock replay: clicked=%d replaying=%d (second click, live before)\n", + real ? 1 : 0, a->replaying ? 1 : 0); + report_dock_events(*a, "second dock click"); + } return G_SOURCE_REMOVE; } From cb098eff75b528ef3769afc5fa285394c042e6c9 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 06:56:24 +0100 Subject: [PATCH 088/127] ui: cap and cheapen the dock's events rebuild Co-Authored-By: Claude Sonnet 4.6 --- src/ui_app.hpp | 5 +++ src/ui_dock.cpp | 90 ++++++++++++++++++++++++++++++++++++++--------- src/ui_events.cpp | 24 ++++++++----- src/ui_probe.cpp | 48 ++++++++++++++++++++----- 4 files changed, 133 insertions(+), 34 deletions(-) diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 51e4533..540dd22 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -1797,7 +1797,12 @@ void dock_measure_value(App& app, std::size_t i, const std::string& text); /// of what is *on screen* (in place, no rebuild), `dock_events_set_sensitive()` is the one writer of the /// block's sensitivity. All three are no-ops before the block is built. GtkWidget* build_events_dock(App& app); +/// The dock shows at most this many events (the newest), then one dim "... N more, see Manage" row. +inline constexpr std::size_t kDockEventsMax = 24; +/// The first lists the store itself; the second takes a list the caller already holds (so one refresh +/// reads the cache directory once, not twice). Both keep the list's vertical scroll across the rebuild. void dock_rebuild_events(App& app); +void dock_rebuild_events(App& app, const std::vector& records); void dock_mark_events(App& app); void dock_events_set_sensitive(App& app, bool available); /// A row's first line, and the two questions its mark is made of (`ui_events.cpp`): the one function diff --git a/src/ui_dock.cpp b/src/ui_dock.cpp index 60da952..23827e6 100644 --- a/src/ui_dock.cpp +++ b/src/ui_dock.cpp @@ -18,6 +18,7 @@ #include +#include #include #include #include @@ -238,7 +239,7 @@ namespace { /// The widths of an event row's three cells, in characters. Fixed, so the Events block is as wide with /// one event as with thirty (the marker is one cell wide even when it is empty). constexpr int kEventTitleChars = 18; -constexpr int kEventTimeChars = 8; +constexpr int kEventSeqChars = 8; /// The one-character mark of a row: filled for the event on screen, hollow for a member of the group on /// screen, blank for the rest. Both questions are the ones `event_row_title()` asks (`ui_events.cpp`). @@ -255,6 +256,11 @@ void on_manage_events(GtkButton*, gpointer user) { /// One click on a row replays it, as the pane's activation does: `replay_event()` and then the in-place /// re-mark. The record is copied first, because nothing it triggers may be allowed to free the row it /// was read from. +/// +/// Safety depends on `replay_event()` never rebuilding the dock's list (it must go through +/// `dock_mark_events()`, which rewrites labels in place): a rebuild here would free this very row, and +/// `row`, `record` and the list box's own emission of `row-activated` would all be dangling. The copy +/// guards the record only, not the row. If a replay ever has to refresh the cache's view, defer it. void on_dock_event_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) { App& app = *static_cast(user); if (row == nullptr) return; @@ -266,15 +272,6 @@ void on_dock_event_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) { mark_events_on_screen(app); } -std::string event_clock(const EventRecord& r) { - const std::time_t t = static_cast(r.wall_time); - std::tm tm{}; - localtime_r(&t, &tm); - char clock[32]; - std::strftime(clock, sizeof(clock), "%H:%M:%S", &tm); - return clock; -} - } // namespace GtkWidget* build_events_dock(App& app) { @@ -323,9 +320,47 @@ GtkWidget* build_events_dock(App& app) { void dock_rebuild_events(App& app) { if (app.dock_events_list == nullptr || app.event_store == nullptr) return; + dock_rebuild_events(app, app.event_store->list()); +} + +namespace { + +/// Puts the list's vertical scroll back after a refill. The adjustment's bounds are only updated at the +/// next allocation, so the value set at once is clamped against the old ones; an idle (which runs after the +/// frame that laid the new rows out) sets it again against the new bounds, where the clamp is exact. +void restore_dock_scroll(GtkWidget* list, double value) { + GtkWidget* scroller = gtk_widget_get_ancestor(list, GTK_TYPE_SCROLLED_WINDOW); + if (scroller == nullptr) return; + GtkAdjustment* adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(scroller)); + gtk_adjustment_set_value(adj, value); + struct Pending { + GtkAdjustment* adj; + double value; + }; + auto* p = new Pending{GTK_ADJUSTMENT(g_object_ref(adj)), value}; + g_idle_add_full( + G_PRIORITY_DEFAULT_IDLE, + [](gpointer d) -> gboolean { + auto* q = static_cast(d); + gtk_adjustment_set_value(q->adj, q->value); + g_object_unref(q->adj); + delete q; + return G_SOURCE_REMOVE; + }, + p, nullptr); +} + +} // namespace + +void dock_rebuild_events(App& app, const std::vector& records) { + if (app.dock_events_list == nullptr) return; GtkListBox* list = GTK_LIST_BOX(app.dock_events_list); + GtkWidget* scroller = gtk_widget_get_ancestor(app.dock_events_list, GTK_TYPE_SCROLLED_WINDOW); + const double scroll = + scroller != nullptr + ? gtk_adjustment_get_value(gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(scroller))) + : 0.0; gtk_list_box_remove_all(list); - const std::vector records = app.event_store->list(); if (records.empty()) { GtkWidget* none = gtk_label_new("no events yet"); gtk_widget_set_halign(none, GTK_ALIGN_START); @@ -340,7 +375,11 @@ void dock_rebuild_events(App& app) { gtk_list_box_append(list, row); return; } - for (const EventRecord& r : records) { + // The newest `kDockEventsMax` only: the list is newest first, and a block that builds four widgets per + // cached event is a cost that grows with a cache the user may have set to unbounded. + const std::size_t shown = std::min(records.size(), kDockEventsMax); + for (std::size_t i = 0; i < shown; ++i) { + const EventRecord& r = records[i]; GtkWidget* row = gtk_list_box_row_new(); GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); gtk_widget_set_margin_start(line, 6); @@ -355,10 +394,13 @@ void dock_rebuild_events(App& app) { gtk_widget_set_hexpand(name, true); gtk_widget_set_tooltip_text(name, text.c_str()); gtk_box_append(GTK_BOX(line), name); - const std::string clock = event_clock(r); - GtkWidget* when = fixed_label(clock.c_str(), kEventTimeChars, GTK_ALIGN_END); - gtk_widget_add_css_class(when, "dim-label"); - gtk_box_append(GTK_BOX(line), when); + // The latch's sequence number, as the pane's third line ends with it: the title already carries the + // clock, so this is the part of the row that is not in it. + const std::string seq = "#" + std::to_string(r.seq); + GtkWidget* num = fixed_label(seq.c_str(), kEventSeqChars, GTK_ALIGN_END); + gtk_widget_set_tooltip_text(num, ("latch " + seq).c_str()); + gtk_widget_add_css_class(num, "dim-label"); + gtk_box_append(GTK_BOX(line), num); g_object_set_data(G_OBJECT(row), "udpscope-event-marker", marker); g_object_set_data(G_OBJECT(row), "udpscope-event-title", name); @@ -367,6 +409,22 @@ void dock_rebuild_events(App& app) { gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), line); gtk_list_box_append(list, row); } + if (records.size() > shown) { + // A last row with no record (like the placeholder: `dock_mark_events()` skips it, and it does nothing + // when activated). Fixed text and the same ellipsis as the rest, so it cannot widen the dock. + const std::string more = "\xE2\x80\xA6 " + std::to_string(records.size() - shown) + " more, see Manage"; + GtkWidget* label = fixed_label(more.c_str(), kEventTitleChars + kEventSeqChars, GTK_ALIGN_START); + gtk_widget_add_css_class(label, "dim-label"); + gtk_widget_set_tooltip_text(label, more.c_str()); + gtk_widget_set_margin_start(label, 6); + gtk_widget_set_margin_top(label, 4); + gtk_widget_set_margin_bottom(label, 4); + GtkWidget* row = gtk_list_box_row_new(); + gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), label); + gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), false); + gtk_list_box_append(list, row); + } + restore_dock_scroll(app.dock_events_list, scroll); } void dock_mark_events(App& app) { diff --git a/src/ui_events.cpp b/src/ui_events.cpp index 6d441e3..596ee17 100644 --- a/src/ui_events.cpp +++ b/src/ui_events.cpp @@ -782,10 +782,8 @@ void attach_preview_zoom(GtkWidget* frame, const std::string& path) { GConnectFlags(0)); } -/// A row's first line: when the event was latched, how long it is, and whether it is the one on -/// screen. One function because two callers write it -- the row builder and the in-place -/// re-mark -- and a mark that reads differently depending on which of them last ran would be -/// worse than no mark. +/// Whether the event is the one on screen: the one question the title's "(on screen)" and the dock's +/// filled mark both ask. bool event_on_screen(const App& app, const EventRecord& r) { return std::string(app.waveform_name) == r.file; } @@ -797,6 +795,10 @@ bool event_in_group(const App& app, const EventRecord& r) { [&](const EventRecord& g) { return g.file == r.file; }); } +/// A row's first line: when the event was latched, how long it is, and whether it is the one on +/// screen. One function because two callers write it -- the row builder and the in-place +/// re-mark -- and a mark that reads differently depending on which of them last ran would be +/// worse than no mark. std::string event_row_title(App& app, const EventRecord& r) { const std::time_t t = static_cast(r.wall_time); std::tm tm{}; @@ -828,7 +830,7 @@ void mark_events_on_screen(App& app) { /// the whole pane describes whichever event happens to be newest rather than the one being read. The /// numbers are one line each: a list that has to load the samples to describe an event is a list that /// cannot show thirty of them. -static void rebuild_events_pane_list(App& app) { +static void rebuild_events_pane_list(App& app, std::vector& records) { if (app.events_list == nullptr) return; while (GtkWidget* child = gtk_widget_get_first_child(app.events_list)) { gtk_list_box_remove(GTK_LIST_BOX(app.events_list), child); @@ -838,8 +840,8 @@ static void rebuild_events_pane_list(App& app) { // the one that finds out. app.events_thumb = nullptr; if (app.event_store == nullptr) return; - // Not const: a record whose preview had to be redrawn learns its thumbnail's path here. - std::vector records = app.event_store->list(); + // `records` is the caller's list, not const: a record whose preview had to be redrawn learns its + // thumbnail's path here. if (records.empty()) { GtkWidget* empty = gtk_label_new("no events yet -- arm the trigger"); gtk_widget_add_css_class(empty, "dim-label"); @@ -975,8 +977,12 @@ static void rebuild_events_pane_list(App& app) { /// Both views of the cache, rebuilt from the same list: the pane's rows (with their pictures) and the /// dock's compact ones. The dock follows even when the pane's list does not exist. void refresh_events_pane(App& app) { - rebuild_events_pane_list(app); - dock_rebuild_events(app); + // One read of the cache directory for both views (none without a store, which the pane's rebuild + // and the dock's both treat as nothing to list). + std::vector records; + if (app.event_store != nullptr) records = app.event_store->list(); + rebuild_events_pane_list(app, records); + if (app.event_store != nullptr) dock_rebuild_events(app, records); } /// Clicking a row replays it: the same loader `--open` uses, on the event's file. The acquisition is diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index f731011..36e8d10 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -5579,14 +5579,19 @@ void report_live_restored(App& a, const char* what) { /// The dock's Events block against the pane's list. `same_titles` compares each dock row's title label /// with the pane row's, in order (both are rebuilt from the same list by `refresh_events_pane()`, and both /// are marked by `mark_events_on_screen()`); `on_screen` counts the dock rows with the filled marker, and -/// `marked` is the index of the first one (-1 for none). -static void report_dock_events(App& a, const char* what) { +/// `marked` is the index of the first one (-1 for none). `max_ok` is the cap: no more than +/// `kDockEventsMax` event rows and one "more" row. `expected_marked` is read after a replay of a record +/// the caller names by file (`expected_file`): 1 when exactly one dock row is filled and its record is that +/// one (record identity, not a count), 0 otherwise, -1 when the caller names none. +static void report_dock_events(App& a, const char* what, const std::string& expected_file = std::string()) { std::vector dock_titles; + std::string filled_file; + int dock_children = 0; std::vector pane_titles; int on_screen = 0; int marked = -1; int members = 0; - const auto titles = [&members](GtkWidget* list, std::vector* out, int* filled, int* first) { + const auto titles = [&members, &filled_file](GtkWidget* list, std::vector* out, int* filled, int* first) { if (list == nullptr) return; int i = 0; for (GtkWidget* row = gtk_widget_get_first_child(list); row != nullptr; @@ -5603,22 +5608,41 @@ static void report_dock_events(App& a, const char* what) { if (filled != nullptr && marker != nullptr && std::strcmp(gtk_label_get_text(GTK_LABEL(marker)), "\xE2\x97\x8F") == 0) { ++*filled; - if (first != nullptr && *first < 0) *first = i; + if (first != nullptr && *first < 0) { + *first = i; + filled_file = static_cast(g_object_get_data(G_OBJECT(row), "udpscope-event"))->file; + } } } }; + for (GtkWidget* row = a.dock_events_list != nullptr ? gtk_widget_get_first_child(a.dock_events_list) : nullptr; + row != nullptr; row = gtk_widget_get_next_sibling(row)) { + ++dock_children; + } titles(a.dock_events_list, &dock_titles, &on_screen, &marked); titles(a.events_list, &pane_titles, nullptr, nullptr); + // The dock shows the newest `kDockEventsMax` only: its titles are the pane's first ones, in order. + if (pane_titles.size() > kDockEventsMax) pane_titles.resize(kDockEventsMax); std::fprintf(stderr, "[events] dock events: rows=%zu pane_rows=%zu same_titles=%d sensitive=%d dock_w=%d " - "on_screen=%d marked=%d members=%d at=%s\n", + "on_screen=%d marked=%d members=%d max_ok=%d expected_marked=%d at=%s\n", dock_titles.size(), pane_titles.size(), dock_titles == pane_titles ? 1 : 0, a.dock_events_box != nullptr && gtk_widget_is_sensitive(a.dock_events_box) ? 1 : 0, a.dock_events_box != nullptr ? gtk_widget_get_width(a.dock_events_box) : -1, on_screen, - marked, members, what); + marked, members, dock_children <= static_cast(kDockEventsMax) + 1 ? 1 : 0, + expected_file.empty() ? -1 : (on_screen == 1 && filled_file == expected_file ? 1 : 0), what); std::fflush(stderr); } +/// The file of the record the Nth row of a list carries ("" for a row without one). +static std::string listed_file(GtkWidget* list, int index) { + if (list == nullptr) return std::string(); + GtkListBoxRow* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(list), index); + if (row == nullptr) return std::string(); + const auto* r = static_cast(g_object_get_data(G_OBJECT(row), "udpscope-event")); + return r != nullptr ? r->file : std::string(); +} + void events_probe_next(App& a, int step, guint delay_ms) { a.dbg_events_step = step; g_timeout_add(delay_ms, events_probe_step, &a); @@ -5805,7 +5829,9 @@ gboolean events_probe_step(gpointer user) { a->events_thumb != nullptr && gtk_widget_get_mapped(a->events_thumb) ? 1 : 0, rows_kept ? 1 : 0, selected_now, a->dbg_events_selected); std::fflush(stderr); - report_dock_events(*a, "after pane replay"); + // The pane replayed its own row 0; the dock's list is the same list, so the dock's filled marker has to + // be on the row whose record is that one. + report_dock_events(*a, "after pane replay", listed_file(a->events_list, 0)); // One click on a dock row, the gesture: a *second* event when there is one, so the marker has to // move. Through `row-activated`, which is what a single click emits. { @@ -5813,6 +5839,9 @@ gboolean events_probe_step(gpointer user) { GtkListBoxRow* row = gtk_list_box_get_row_at_index(dock, 1); if (row == nullptr) row = gtk_list_box_get_row_at_index(dock, 0); const bool real = row != nullptr && g_object_get_data(G_OBJECT(row), "udpscope-event") != nullptr; + const std::string clicked_file = + real ? static_cast(g_object_get_data(G_OBJECT(row), "udpscope-event"))->file + : std::string(); const std::string before = a->waveform_name; if (real) g_signal_emit_by_name(dock, "row-activated", row); // Already replaying (the pane's click above), so `replaying` alone cannot tell the click from the @@ -5821,7 +5850,7 @@ gboolean events_probe_step(gpointer user) { std::fprintf(stderr, "[events] dock replay: clicked=%d replaying=%d waveform_changed=%d\n", real ? 1 : 0, a->replaying ? 1 : 0, before != a->waveform_name ? 1 : 0); std::fflush(stderr); - report_dock_events(*a, "after dock click"); + report_dock_events(*a, "after dock click", clicked_file); } events_probe_next(*a, 2, 1200); return G_SOURCE_REMOVE; @@ -6054,10 +6083,11 @@ gboolean events_group_probe_step(gpointer user) { GtkListBox* dock = GTK_LIST_BOX(a->dock_events_list); GtkListBoxRow* row = gtk_list_box_get_row_at_index(dock, 1); const bool real = row != nullptr && g_object_get_data(G_OBJECT(row), "udpscope-event") != nullptr; + const std::string clicked_file = real ? listed_file(a->dock_events_list, 1) : std::string(); if (real) g_signal_emit_by_name(dock, "row-activated", row); std::fprintf(stderr, "[events] dock replay: clicked=%d replaying=%d (second click, live before)\n", real ? 1 : 0, a->replaying ? 1 : 0); - report_dock_events(*a, "second dock click"); + report_dock_events(*a, "second dock click", clicked_file); } return G_SOURCE_REMOVE; } From 8c866300d8cb1fd803752fdf4ac1fa98fdd41c00 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 07:07:57 +0100 Subject: [PATCH 089/127] ui: the events list is a slide-over page; the left pane, its divider and the header toggle are gone Co-Authored-By: Claude Sonnet 4.6 --- src/ui.cpp | 67 +++++++------------ src/ui/events-pane.ui | 111 +++++++++++++++---------------- src/ui/header-bar.ui | 19 +----- src/ui/main-window.ui | 150 ++++++++++++++++++++---------------------- src/ui_app.hpp | 25 +++---- src/ui_dock.cpp | 13 ++-- src/ui_events.cpp | 111 +++++++++---------------------- src/ui_layout.cpp | 2 +- src/ui_probe.cpp | 79 +++++++++++----------- src/ui_signals.cpp | 3 - src/ui_trigger.cpp | 10 +-- src/ui_widgets.cpp | 4 +- 12 files changed, 244 insertions(+), 350 deletions(-) diff --git a/src/ui.cpp b/src/ui.cpp index 7dcb73d..8b479a8 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -74,7 +74,6 @@ namespace ui_impl { /// fill, the bar does not. // --- persisted layout ------------------------------------------------------- -/// Defined with the events pane; the trigger toggle reveals it. // Defined below; the configurations dialog opens a session with them, and it sits above all three. @@ -3007,8 +3006,7 @@ void note_panel_controls(App& app) { // widget tree walked in child order *is* the layout order, so this is the reading order of the // controls the user sees -- the only way to check an arrangement without a screenshot. const std::pair named[] = { - {app.trigger_toggle, "trigger"}, {app.events_toggle, "events"}, - {app.pause_button, "pause"}, + {app.trigger_toggle, "trigger"}, {app.pause_button, "pause"}, {app.cursors_toggle, "cursors"}, {app.sidebar_toggle, "sidebar"}, {app.menu_button, "menu"}, }; @@ -3631,7 +3629,6 @@ gboolean tick(gpointer user) { } // Outside the lock: it reads and writes the two plots' own windows, not the model. sync_compare_axes(*app); - sync_events_divider(*app); // A file on screen is an event, and gets what a held capture gets: the axis pinned to the event's own // `[-pre, +post]` and the trigger instant marked at x = 0. That *is* the constraint -- the time base is // the event's, so no gesture can take the view outside it -- and the window control reads the event's @@ -4932,12 +4929,10 @@ GtkWidget* build_toolbar(App& app) { app.menu_button = GTK_WIDGET(gtk_builder_get_object(ui, "menu_button")); app.sidebar_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "sidebar_toggle")); app.trigger_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_toggle")); - app.events_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "events_toggle")); app.pause_button = GTK_WIDGET(gtk_builder_get_object(ui, "pause_button")); app.cursors_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "cursors_toggle")); if (app.menu_button == nullptr || app.sidebar_toggle == nullptr || app.trigger_toggle == nullptr || - app.events_toggle == nullptr || app.pause_button == nullptr || - app.cursors_toggle == nullptr) { + app.pause_button == nullptr || app.cursors_toggle == nullptr) { // A missing id is a mistake in the `.ui`: the bar comes up with the controls it got rather than taking // the window with it, and `[udpscope] header:` reads whatever geometry is left. return bar; @@ -4947,17 +4942,6 @@ GtkWidget* build_toolbar(App& app) { // What each control *does*, which a file cannot hold. g_signal_connect(app.sidebar_toggle, "toggled", G_CALLBACK(on_sidebar_toggled), &app); g_signal_connect(app.trigger_toggle, "toggled", G_CALLBACK(on_trigger_toggled), &app); - // The event history is a *trigger* thing, and one click from the mode that produces it. - g_signal_connect(app.events_toggle, "toggled", G_CALLBACK(+[](GtkToggleButton* b, gpointer user) { - App* a = static_cast(user); - const bool open = gtk_toggle_button_get_active(b); - gtk_revealer_set_reveal_child(GTK_REVEALER(a->events_revealer), open); - // Through the one function that decides *both*: revealing the child alone leaves an - // invisible pane with no width, which is a button that appears to do nothing. - sync_events_availability(*a); - if (open) refresh_events_pane(*a); - }), - &app); g_signal_connect(app.pause_button, "toggled", G_CALLBACK(on_pause_toggled), &app); g_signal_connect(app.cursors_toggle, "toggled", G_CALLBACK(on_cursors_toggled), &app); return bar; @@ -4969,7 +4953,7 @@ void on_activate(GtkApplication* application, gpointer user) { install_css(); // The window's skeleton is a `.ui` file: the window, its two panes, the plot column with the status // line under it, and the dock's scroller. Everything that depends on the - // stream -- the events pane, the two plot areas, the dock's panels -- is built below and put + // stream -- the events page, the two plot areas, the dock's panels -- is built below and put // into the slots by id. g_clear_object(&app->window_ui); app->window_ui = gtk_builder_new_from_resource("/org/udpscope/ui/main-window.ui"); @@ -4977,7 +4961,6 @@ void on_activate(GtkApplication* application, gpointer user) { app->window = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "main_window")); GtkWidget* side_view = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_view")); app->side_view = side_view; - GtkWidget* events_divider = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "events_divider")); app->plot_box = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_box")); app->plot_split = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_split")); app->plot_overlay = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_overlay")); @@ -4995,7 +4978,7 @@ void on_activate(GtkApplication* application, gpointer user) { app->badge_system = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "badge_system")); // A missing id is the one mistake neither the build nor `GtkBuilder` sees, and every use below would // be a null pointer. - if (app->window == nullptr || side_view == nullptr || events_divider == nullptr || + if (app->window == nullptr || side_view == nullptr || app->plot_box == nullptr || app->plot_overlay == nullptr || app->plot_split == nullptr || app->status_badge == nullptr || app->status_label == nullptr || app->status_info_button == nullptr || @@ -5195,17 +5178,13 @@ void on_activate(GtkApplication* application, gpointer user) { // `build_source_panels()` has just registered: the Source badge opens the first of them. build_badge_bar(*app); - // The left side is the events pane *and* the plot, in one row: the pane is a column of its own - // (the item's "collapsable pane on the left"), revealed by trigger mode. - // A *paned*: the pane's width is the user's to choose, and the plot takes what is left. - // The events pane is the one subtree of the inner divider that is built: whether it exists at all - // depends on trigger mode. - gtk_paned_set_start_child(GTK_PANED(events_divider), build_events_pane(*app)); - // Once, now that both the button and the pane exist: trigger mode is off, so the pane is closed and - // its button is not on the bar -- stated by the one function that decides it rather than by whichever + // The full events list is the `events` page of the slide-over (the dock's Manage button opens it), so + // the plot is never squeezed by it. Once the page and the dock block both exist: nothing is cached yet, + // so both start insensitive -- stated by the one function that decides it rather than by whichever // widget happened to be built with which default. + build_events_pane(*app); + slideover_add_page(*app, "events", "Events", app->events_box); sync_events_availability(*app); - app->events_divider = events_divider; // Two plots, in a paned so a comparison's divider can move: the primary takes everything while the // second is hidden, and the width is the user's once it is not. @@ -5715,14 +5694,17 @@ int group_visible_traces(App& app, int row) { return visible; } +/// The events block's availability as one reading: the rule, the dock block, the page, and the slide-over's +/// open page. Every step that changes recording, replay or the cache prints all of them. void report_events_availability(App& a, const char* what) { - std::fprintf(stderr, "[events] %s: button_visible=%d pane=%d pane_w=%d tick=%d recording=%d\n", - what, - a.events_toggle != nullptr && gtk_widget_get_visible(a.events_toggle) ? 1 : 0, - a.events_revealer != nullptr && - gtk_revealer_get_reveal_child(GTK_REVEALER(a.events_revealer)) ? 1 : 0, - a.events_divider != nullptr ? gtk_paned_get_position(GTK_PANED(a.events_divider)) : -1, - signal_tick_visible(a), a.event_recording ? 1 : 0); + // `available` is the rule `sync_events_availability()` states; the dock block and the page must both + // follow it, and `page` is the slide-over's open page. + const bool available = a.event_recording || a.replaying || a.events_cached; + std::fprintf(stderr, "[events] %s: available=%d dock_sensitive=%d page_sensitive=%d page=%s\n", what, + available ? 1 : 0, + a.dock_events_box != nullptr && gtk_widget_get_sensitive(a.dock_events_box) ? 1 : 0, + a.events_box != nullptr && gtk_widget_get_sensitive(a.events_box) ? 1 : 0, + slideover_page(a).empty() ? "none" : slideover_page(a).c_str()); } @@ -6241,9 +6223,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit app.dbg_signal_empty_label); } { - // The events pane's frame, read from the window: the four widgets the `.ui` gives it, and the list - // being inside the revealer the file builds. A control that lost its id reads 0 here rather than - // showing as a pane missing a button, and `nested=0` is a list that is no longer in the pane at all. + // The events page's frame, read from the window: the widgets the `.ui` gives it, and the list + // being inside the box the file builds. A control that lost its id reads 0 here rather than + // showing as a page missing a button, and `nested=0` is a list that is no longer in the page at all. const auto inside = [](GtkWidget* w, GtkWidget* root) { if (w == nullptr) return 0; for (GtkWidget* p = w; p != nullptr; p = gtk_widget_get_parent(p)) { @@ -6251,10 +6233,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit } return 0; }; - std::printf("[udpscope] events pane: compare=%d accumulate=%d list=%d revealer=%d nested=%d\n", + std::printf("[udpscope] events pane: compare=%d accumulate=%d list=%d nested=%d\n", app.events_compare != nullptr ? 1 : 0, app.events_accumulate != nullptr ? 1 : 0, - app.events_list != nullptr ? 1 : 0, app.events_revealer != nullptr ? 1 : 0, - inside(app.events_list, app.events_revealer)); + app.events_list != nullptr ? 1 : 0, inside(app.events_list, app.events_box)); } std::printf("[udpscope] panels: side=%dx%d (visible=%d) signals=%dx%d row_h=%d " "(page_current=%d) trigger=%dx%d (sensitive=%d) " diff --git a/src/ui/events-pane.ui b/src/ui/events-pane.ui index bc3a04a..f72332a 100644 --- a/src/ui/events-pane.ui +++ b/src/ui/events-pane.ui @@ -2,8 +2,8 @@ - - slide-right + + vertical + 6 + 170 + 6 + 6 + 6 - - vertical + + horizontal 6 - 170 - 6 - 6 - 6 - - horizontal - 6 - - - <b>Events</b> - 1 - 1 - start - - - - - Live - flat - go back to the live stream after replaying a stored event - - + + <b>Events</b> + 1 + 1 + start - - horizontal - 6 - - - Compare - the two selected events in two plots side by side, with the axes synchronised - 1 - 0 - - - - - Accumulate - up to four selected events overlaid in the same traces, translucent, so where they agree the trace is brighter - 1 - 0 - - + + Live + flat + go back to the live stream after replaying a stored event + + + + + + + horizontal + 6 + + + Compare + the two selected events in two plots side by side, with the axes synchronised + 1 + 0 - - 1 - never - - - 0 - multiple - - + + Accumulate + up to four selected events overlaid in the same traces, translucent, so where they agree the trace is brighter + 1 + 0 + + + + + + + 1 + never + + + 0 + multiple diff --git a/src/ui/header-bar.ui b/src/ui/header-bar.ui index 1c21a9c..74248f7 100644 --- a/src/ui/header-bar.ui +++ b/src/ui/header-bar.ui @@ -2,8 +2,7 @@ @@ -39,18 +38,6 @@ pause acquisition - - - vertical - - - - - udpscope-events-symbolic - the event history: the triggered acquisitions kept in the cache, with a picture of each - 0 - - open-menu-symbolic diff --git a/src/ui/main-window.ui b/src/ui/main-window.ui index ba7d785..6817e15 100644 --- a/src/ui/main-window.ui +++ b/src/ui/main-window.ui @@ -3,8 +3,8 @@ @@ -68,96 +67,87 @@ - - horizontal - - 1 - 0 - - - vertical + + vertical + 1 + + + 1 + 1 - - + + vertical + 1 + 4 1 1 - - vertical - 1 - 4 - 1 - 1 - - - horizontal - 1 - - + + horizontal + 1 + + + + + + horizontal + 6 + 36 + 6 + 6 - - + horizontal 6 - 36 - 6 - 6 - - - horizontal - 6 - 1 - - - - - horizontal - 6 - end - - + 1 - + horizontal 6 - 2 - 2 - 6 - - - - - - - starting… - end - 1 - fill - - - - - help-about-symbolic - Stream and buffer detail - center - - + end + + + + + + + horizontal + 6 + 2 + 2 + 6 + + + + + + + starting… + end + 1 + fill + + + + + help-about-symbolic + Stream and buffer detail + center diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 540dd22..c419778 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -789,26 +789,18 @@ struct App { /// `event_recording` is the trigger panel's switch and defaults on -- the cache is only useful if /// it was already on when the event you wanted happened. std::unique_ptr event_store; - /// Whether the cache holds anything, refreshed once a second: what keeps the history button on the bar - /// for a source whose events are already written, even with recording switched off. + /// Whether the cache holds anything, refreshed once a second: what keeps the dock's Events block and the + /// `events` page live for a source whose events are already written, even with recording switched off. bool events_cached = false; bool event_recording = false; - /// The events pane: its list, the revealer that holds it, the recording switch (in the trigger - /// panel, where the trigger's options live), and `events_thumb` -- the picture of the newest + /// The `events` page of the slide-over: its content box, its list, the recording switch (in the + /// trigger panel, where the trigger's options live), and `events_thumb` -- the picture of the newest /// event. Every row carries the picture of *its* event; this is the first one's, which is the one - /// the probe measures and which the pane does not put anywhere special. - GtkWidget* events_revealer = nullptr; + /// the probe measures and which the page does not put anywhere special. + GtkWidget* events_box = nullptr; GtkWidget* events_list = nullptr; GtkWidget* events_thumb = nullptr; GtkWidget* events_record_switch = nullptr; - /// The toolbar's event-history toggle: the pane is opened *on demand* from here (the item's own - /// arrangement), and the toggle only exists while trigger mode does -- there are no events without - /// a trigger, and a button that can never have anything to show is a button spent on nothing. - GtkWidget* events_toggle = nullptr; - /// The paned that holds the events pane and the plot, and the width the divider was last dragged - /// to. 240 by default: enough for the preview and the numbers beside it, and not a plot's worth. - GtkWidget* events_divider = nullptr; - int events_pane_width = 240; /// The Events block of the dock: the box, its list (compact rows, one per `EventRecord`, the record /// attached to each row as `udpscope-event`) and the Manage button. GtkWidget* dock_events_box = nullptr; @@ -1228,6 +1220,9 @@ struct App { /// it keeps for a second latch (it is counting retries, not time). int dbg_events_step = 0; int dbg_events_wait = 0; + /// The plot area's allocated width before the first event was latched (the events probe), compared with + /// its width after: the events list is a slide-over page and no longer a column beside the plot. + int dbg_events_plot_w = 0; /// The event row the probe selected before it activated one, and how many rows were selected. /// Both are read back *after* the activation: a row pointer that is no longer the list's row 0, /// or a smaller count, says the activation rebuilt the list and so threw away the user's scroll @@ -1753,8 +1748,6 @@ void on_pause_toggled(GtkToggleButton* button, gpointer user); void search_jump(App& app, double t); void update_search_panel(App& app); void sync_events_availability(App& app); -/// Restates the events pane's divider position once a frame; see the definition. -void sync_events_divider(App& app); /// The preferences dialog: one window, opened from the overflow menu. `apply_theme()` is separate /// because the theme is the one preference that takes effect without a restart. void open_preferences(App& app); diff --git a/src/ui_dock.cpp b/src/ui_dock.cpp index 23827e6..4812eae 100644 --- a/src/ui_dock.cpp +++ b/src/ui_dock.cpp @@ -11,7 +11,7 @@ // // Below them, the Events block: one compact row per cached event (marker, title, time; no thumbnails), // a Manage button for the full list, and one click on a row to replay it. Its rows are built from the -// same `event_store->list()` and the same `event_row_title()` as the events pane, so the two cannot +// same `event_store->list()` and the same `event_row_title()` as the events page, so the two cannot // disagree. #include "ui_app.hpp" @@ -249,11 +249,16 @@ const char* dock_event_marker(const App& app, const EventRecord& r) { return " "; } +/// Opens the `events` page. The page's list is rebuilt only while the page is open (see `record_event()`), +/// so it is refreshed here, once, before the page shows: the slide-over has no on-open hook, and the list +/// is small. The button is not a child of either list, so the rebuild cannot free the widget it runs from. void on_manage_events(GtkButton*, gpointer user) { - slideover_open(*static_cast(user), "events"); + App& app = *static_cast(user); + refresh_events_pane(app); + slideover_open(app, "events"); } -/// One click on a row replays it, as the pane's activation does: `replay_event()` and then the in-place +/// One click on a row replays it, as the page list's activation does: `replay_event()` and then the in-place /// re-mark. The record is copied first, because nothing it triggers may be allowed to free the row it /// was read from. /// @@ -394,7 +399,7 @@ void dock_rebuild_events(App& app, const std::vector& records) { gtk_widget_set_hexpand(name, true); gtk_widget_set_tooltip_text(name, text.c_str()); gtk_box_append(GTK_BOX(line), name); - // The latch's sequence number, as the pane's third line ends with it: the title already carries the + // The latch's sequence number, as the page row's third line ends with it: the title already carries the // clock, so this is the part of the row that is not in it. const std::string seq = "#" + std::to_string(r.seq); GtkWidget* num = fixed_label(seq.c_str(), kEventSeqChars, GTK_ALIGN_END); diff --git a/src/ui_events.cpp b/src/ui_events.cpp index 596ee17..f719ca9 100644 --- a/src/ui_events.cpp +++ b/src/ui_events.cpp @@ -1,7 +1,9 @@ // SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari // -// The event cache, seen from the front end: the pane that lists it, the picture each row carries, the\n// replay, and the two group layouts (side-by-side comparison and accumulation) with the axis mirror\n// that keeps a comparison several plots in step. +// The event cache, seen from the front end: the slide-over page that lists it, the picture each row +// carries, the replay, and the two group layouts (side-by-side comparison and accumulation) with the +// axis mirror that keeps a comparison several plots in step. #include "ui_app.hpp" @@ -32,68 +34,19 @@ GtkWidget* make_compare_area(App& app) { return widget; } -/// Whether the event history is reachable at all, and the one place that decides it. It has exactly -/// two preconditions: trigger mode (there are no events without a trigger) and recording (a disabled -/// cache is empty, and an empty column beside the plot is what "off" must not look like). Both the -/// toolbar button and the pane follow from it, so they cannot disagree -- and turning recording off -/// while the pane is open takes the button and the pane with it rather than leaving a stale list. +/// Whether the event history is reachable at all, and the one place that decides it. Recording on (the +/// cache can still grow), an event being read (a replay), or something already in the cache (an event on +/// disk outlives the switch that wrote it) -- not trigger mode: a stored event being examined is a file, +/// and it has no trigger. The dock's Events block and the `events` page follow from it, so they cannot +/// disagree; both stay on screen and go insensitive ("disable, do not hide"), and turning recording off +/// while the page is open leaves the page up, greyed, rather than stale and live. +/// +/// `events_cached` is refreshed once a second from the tick -- the store's list is a directory read, not +/// a per-frame question. void sync_events_availability(App& app) { - if (app.events_toggle == nullptr) return; - // What the divider was last dragged to. Read here, on every change of state, because the close that - // follows is about to set the position to 0 and lose it. - if (app.events_divider != nullptr) { - const int at = gtk_paned_get_position(GTK_PANED(app.events_divider)); - if (at > 1) app.events_pane_width = at; - } - // Available while the cache can still grow (recording on) *or* while it is being read (an event on - // screen). Not trigger mode: a stored event being examined is a file, and it has no trigger -- if the - // pane's life depended on the trigger, opening one would close the list it was opened from. - // ... or whenever the cache *has* something: an event on disk outlives the switch that wrote it, and - // the button that shows it must not go with the switch. `events_cached` is refreshed once a second - // from the tick -- the store's list is a directory read, not a per-frame question. const bool available = app.event_recording || app.replaying || app.events_cached; dock_events_set_sensitive(app, available); - gtk_widget_set_visible(app.events_toggle, available); - if (!available) { - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.events_toggle), FALSE); - gtk_revealer_set_reveal_child(GTK_REVEALER(app.events_revealer), FALSE); - } - const bool open = - available && gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.events_toggle)); - // Visibility, not only `reveal_child`: an unrevealed `GtkRevealer` *measures* 0x0 and its box still - // hands it the leftover width, so the collapsed pane reserved an empty column beside the plot. - gtk_widget_set_visible(app.events_revealer, open); - // The position is what gives the pane its room (or none): a paned hands its start child at least its - // minimum unless the position says otherwise. - if (app.events_divider != nullptr) { - gtk_paned_set_position(GTK_PANED(app.events_divider), open ? app.events_pane_width : 0); - } -} - -/// Restates the divider's position, once a frame. -/// -/// `gtk_paned_set_position()` is clamped against the allocation the paned has *now*, and a click on -/// the history button flips the toggle in the same instant the pane becomes visible -- so the position -/// is clamped against a child that still measures zero, the pane opens with no width, and only the -/// next call (a replay's, seconds later) gives it one. Restating it per frame is idempotent, and a -/// position the user dragged is *learned* rather than fought: the width is read back whenever the pane -/// is open and the position is not the one we would set. -void sync_events_divider(App& app) { - if (app.events_divider == nullptr) return; - const bool open = - app.events_revealer != nullptr && gtk_widget_get_visible(app.events_revealer); - const int at = gtk_paned_get_position(GTK_PANED(app.events_divider)); - if (!open) { - if (at != 0) gtk_paned_set_position(GTK_PANED(app.events_divider), 0); - return; - } - if (at > 1 && at != app.events_pane_width) { - app.events_pane_width = at; // the user dragged it: that is the width now - return; - } - if (at != app.events_pane_width) { - gtk_paned_set_position(GTK_PANED(app.events_divider), app.events_pane_width); - } + if (app.events_box != nullptr) gtk_widget_set_sensitive(app.events_box, available); } /// Writes the held event into the cache: its table, a sidecar of what the list shows, and a @@ -133,11 +86,10 @@ bool record_event(App& app) { set_notice(app, "event cached: " + std::to_string(table.columns.size()) + " signals, " + std::to_string(table.rows()) + " samples", 3); - // The dock follows every latch; the pane's rows (with pictures) only while the pane is on screen: - // rebuilding them per latch for a column nobody opened is work the tick owes nothing, and the toggle's - // own handler refreshes when it is asked for. - if (app.events_revealer != nullptr && - gtk_revealer_get_child_revealed(GTK_REVEALER(app.events_revealer))) { + // The dock follows every latch; the page's rows (with pictures) only while the page is open: + // rebuilding them per latch for a list nobody opened is work the tick owes nothing, and the Manage + // button refreshes the list when it is asked for. + if (slideover_page(app) == "events") { refresh_events_pane(app); } else { dock_rebuild_events(app); @@ -269,7 +221,7 @@ bool replay_event(App& app, const EventRecord& record) { // left alone afterwards -- restated here and never per frame. // The axis is the event's own and the tick pins it there (`pin_event_window`): nothing to frame or // home here beyond the window control's own reading, which the tick keeps in step. - // The pane is available through a replay, and this is what keeps it open. + // The events block is available through a replay, and this is what keeps it so. sync_events_availability(app); set_notice(app, "replaying " + record.stem + " (" + std::to_string(record.signals) + " signals, " + @@ -825,9 +777,9 @@ void mark_events_on_screen(App& app) { } } -/// The events pane's rows, newest first: each row is the picture of *that* event beside the +/// The events page's rows, newest first: each row is the picture of *that* event beside the /// sidecar's numbers, because what tells one event from another is its shape, and one picture for -/// the whole pane describes whichever event happens to be newest rather than the one being read. The +/// the whole page describes whichever event happens to be newest rather than the one being read. The /// numbers are one line each: a list that has to load the samples to describe an event is a list that /// cannot show thirty of them. static void rebuild_events_pane_list(App& app, std::vector& records) { @@ -934,7 +886,7 @@ static void rebuild_events_pane_list(App& app, std::vector& records gtk_widget_set_halign(name, GTK_ALIGN_START); gtk_label_set_ellipsize(GTK_LABEL(name), PANGO_ELLIPSIZE_END); // A cap, not only an ellipsis: `max-width-chars` bounds a label's *natural* width, and the row's - // natural width is the pane's minimum -- which is the floor the divider cannot go below. + // natural width is the page's minimum -- which is the floor the list cannot go below. gtk_label_set_max_width_chars(GTK_LABEL(name), 22); gtk_widget_add_css_class(name, "udpscope-event-title"); gtk_box_append(GTK_BOX(box), name); @@ -1001,28 +953,27 @@ void on_event_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) { mark_events_on_screen(app); } -/// The pane itself: a collapsible column on the left, a header and the list of events -- each row -/// carrying its own picture. Built once with the rest of the window, and opened on demand from the -/// toolbar's history button, which is itself only there while trigger mode and recording are both -/// on (`sync_events_availability()`). +/// The events list: a header and the list of events, each row carrying its own picture. Built once with +/// the rest of the window and returned as the content of the slide-over's `events` page, which the +/// dock's Manage button opens. GtkWidget* build_events_pane(App& app) { - // The pane's frame -- the revealer that slides it in, the header with the way back to the live stream, the - // two actions that read the selection, and the list inside its scroller -- is a UI file. The rows are not: + // The list's frame -- the header with the way back to the live stream, the two actions that read the + // selection, and the list inside its scroller -- is a UI file. The rows are not: // there is one per latched event, and the whole list is rebuilt whenever the cache changes. The two // buttons' sensitivity is `update_group_buttons()`'s, which is why the file only starts them insensitive. GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/events-pane.ui"); - app.events_revealer = GTK_WIDGET(gtk_builder_get_object(ui, "events_pane")); + app.events_box = GTK_WIDGET(gtk_builder_get_object(ui, "events_box")); app.events_compare = GTK_WIDGET(gtk_builder_get_object(ui, "events_compare")); app.events_accumulate = GTK_WIDGET(gtk_builder_get_object(ui, "events_accumulate")); GtkWidget* live = GTK_WIDGET(gtk_builder_get_object(ui, "events_live")); app.events_list = GTK_WIDGET(gtk_builder_get_object(ui, "events_list")); // The heading row, where the contextual help's "?" joins (see `ui_context_help.cpp`). app.events_head = GTK_WIDGET(gtk_builder_get_object(ui, "events_head")); - if (app.events_revealer == nullptr || app.events_compare == nullptr || + if (app.events_box == nullptr || app.events_compare == nullptr || app.events_accumulate == nullptr || live == nullptr || app.events_list == nullptr) { - // A missing id is a mistake in the `.ui`: the pane comes up empty rather than taking the window with it, + // A missing id is a mistake in the `.ui`: the page comes up empty rather than taking the window with it, // and `[udpscope] events pane:` reads the difference. - return app.events_revealer != nullptr ? app.events_revealer : gtk_revealer_new(); + return app.events_box != nullptr ? app.events_box : gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); } // What the pane *does*, which a file cannot hold: the two group actions read the selection, the list @@ -1039,7 +990,7 @@ GtkWidget* build_events_pane(App& app) { update_group_buttons(*static_cast(user)); }), &app); - return app.events_revealer; + return app.events_box; } /// The recording switch, which lives here because it is a trigger option and not a pane option: it diff --git a/src/ui_layout.cpp b/src/ui_layout.cpp index 122082f..4ba5e04 100644 --- a/src/ui_layout.cpp +++ b/src/ui_layout.cpp @@ -377,7 +377,7 @@ void apply_trigger_layout(App& app, const LayoutState::Trigger& t) { } write_trigger_widgets(app); // The state is applied by *pressing the same control a user would*: the handler is what arms, tells - // the events button, greys the body and syncs the pane's own switch. Deliberately outside the + // the dock's events block, greys the body and syncs the page's own switch. Deliberately outside the // `updating_panels` window `apply_layout()` uses for the panels, because a toggle set inside it // would emit `toggled` into a handler that returns at once and nothing would happen. if (app.trigger_toggle != nullptr) { diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 36e8d10..265084d 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -5653,12 +5653,15 @@ gboolean events_probe_step(gpointer user) { if (a->window == nullptr) return G_SOURCE_REMOVE; if (a->dbg_events_step == 0) { - // Before anything: streaming, no trigger, recording **off** (the default) -- so no button, no - // pane, and nothing written to disk that the user did not ask to have written. + // Before anything: streaming, no trigger, recording **off** (the default) -- so the dock's block and + // the page are insensitive (on screen, not hidden), and nothing is written to disk that the user did + // not ask to have written. The plot's width is taken here, with no event yet: `plot_w_stable` compares + // it with the width after the first latch. + a->dbg_events_plot_w = a->area_widget != nullptr ? gtk_widget_get_width(a->area_widget) : -1; report_events_availability(*a, "live, recording off"); - // The three clicks a user makes: recording on in the trigger panel, trigger mode on, then the - // history button (which is only there once the first two have happened -- that is the item's rule, - // and this is where it is asserted). + // The clicks a user makes: recording on in the trigger panel, trigger mode on, then the dock's + // Manage button (the `events` page is reachable at any time; it is insensitive until something is + // recorded or cached, and this is where that rule is asserted). gtk_switch_set_active(GTK_SWITCH(a->events_record_switch), TRUE); // An 80% pre-trigger share, so the event that latches is *not* symmetric: the window a click // opens must be the event's own span, and with a symmetric event the live window would look @@ -5671,27 +5674,19 @@ gboolean events_probe_step(gpointer user) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE); } report_events_availability(*a, "after trigger on"); - if (a->events_toggle != nullptr) { - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->events_toggle), TRUE); - } - // Read straight after the click, before anything else can call the availability rule: this is the - // reading that says the *button* opens the pane, and it is the one a later replay used to mask. - report_events_availability(*a, "after history button"); - // ... and again a few frames later, still with no replay in the way: this is the *click alone* + slideover_open(*a, "events"); + // Read straight after the open, before anything else can call the availability rule. + report_events_availability(*a, "after events page opened"); + // ... and again a few frames later, still with no replay in the way: this is the *open alone* // path a user takes, which a later replay's own availability call used to mask. events_probe_next(*a, 5, 500); return G_SOURCE_REMOVE; } if (a->dbg_events_step == 5) { - GtkWidget* box = a->events_revealer != nullptr - ? gtk_revealer_get_child(GTK_REVEALER(a->events_revealer)) - : nullptr; - std::fprintf(stderr, "[events] click alone: pane=%d pane_w=%d pane_box=%d\n", - a->events_revealer != nullptr && gtk_widget_get_visible(a->events_revealer) ? 1 : 0, - a->events_divider != nullptr ? gtk_paned_get_position(GTK_PANED(a->events_divider)) - : -1, - box != nullptr ? gtk_widget_get_width(box) : -1); + std::fprintf(stderr, "[events] open alone: page=%s page_box=%d\n", + slideover_page(*a).empty() ? "none" : slideover_page(*a).c_str(), + a->events_box != nullptr ? gtk_widget_get_width(a->events_box) : -1); events_probe_next(*a, 1, 2500); return G_SOURCE_REMOVE; } @@ -5748,11 +5743,15 @@ gboolean events_probe_step(gpointer user) { } std::fprintf(stderr, " newest: stem=%s dur=%.6f pre=%.6f post=%.6f edge=%.6f signals=%zu samples=%zu " - "rate=%.6g seq=%llu thumb_bytes=%ld recorded=%d pane=%d", + "rate=%.6g seq=%llu thumb_bytes=%ld recorded=%d page=%d", r.stem.c_str(), r.duration_s, r.pre_s, r.post_s, r.edge, r.signals, r.samples, r.rate_hz, static_cast(r.seq), thumb_bytes, - a->event_recording ? 1 : 0, - gtk_revealer_get_reveal_child(GTK_REVEALER(a->events_revealer)) ? 1 : 0); + a->event_recording ? 1 : 0, slideover_page(*a) == "events" ? 1 : 0); + // The first latch must not have moved the plot: the list is a slide-over page over the plot's + // edge, and the dock is a fixed column, so nothing about an event changes the plot's width. + const int plot_w_now = a->area_widget != nullptr ? gtk_widget_get_width(a->area_widget) : -1; + std::fprintf(stderr, " plot_w_stable=%d plot_w=%d/%d", plot_w_now == a->dbg_events_plot_w ? 1 : 0, + a->dbg_events_plot_w, plot_w_now); } std::fprintf(stderr, "\n"); std::fflush(stderr); @@ -5799,18 +5798,14 @@ gboolean events_probe_step(gpointer user) { const int selected_now = int(g_list_length(still)); g_list_free(still); std::fprintf(stderr, - "[events] activated row0: replaying=%d pane=%d pane_w=%d pane_box=%d tick=%d " + "[events] activated row0: replaying=%d page=%s page_box=%d tick=%d " "window=%.3f axis=[%.3f,%.3f] marker=%.3f " "thumbnail=%d thumb_size=%dx%d thumb_visible=%d rows_kept=%d selected=%d/%d\n", a->replaying ? 1 : 0, - a->events_revealer != nullptr && gtk_widget_get_visible(a->events_revealer) ? 1 : 0, - a->events_divider != nullptr ? gtk_paned_get_position(GTK_PANED(a->events_divider)) - : -1, - // The pane's *rendered* width: the position is what we asked for, this is what the - // pane actually got -- a divider at 240 with a zero-width pane is still no pane. - a->events_revealer != nullptr && gtk_revealer_get_child(GTK_REVEALER(a->events_revealer)) != nullptr - ? gtk_widget_get_width(gtk_revealer_get_child(GTK_REVEALER(a->events_revealer))) - : -1, + slideover_page(*a).empty() ? "none" : slideover_page(*a).c_str(), + // The page's *rendered* width: what it actually got -- an open page with a zero-width + // list is still no list. + a->events_box != nullptr ? gtk_widget_get_width(a->events_box) : -1, signal_tick_visible(*a), a->rows->time_window(), a->rows->row(0).plot != nullptr ? a->rows->row(0).plot->x_range(a->rows->row(0).axis_x).min @@ -5873,9 +5868,9 @@ gboolean events_probe_step(gpointer user) { std::fprintf(stderr, "[events] live again: replaying=%d waveform=%d\n", a->replaying ? 1 : 0, a->waveform_name.empty() ? 0 : 1); report_live_restored(*a, "after replay"); - // The pane's *other* precondition, through the real switch, in both trigger states: disabling - // recording has to take the button and the pane with it, and arming the trigger afterwards must not - // bring back a pane over an empty cache. + // The block's *other* precondition, through the real switch, in both trigger states: disabling + // recording with an empty cache has to grey the dock's block and the page, and arming the trigger + // afterwards must not wake them over an empty cache. gtk_switch_set_active(GTK_SWITCH(a->events_record_switch), FALSE); report_events_availability(*a, "recording off, trigger off"); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE); @@ -5901,11 +5896,11 @@ gboolean events_group_probe_step(gpointer user) { return G_SOURCE_REMOVE; } a->dbg_events_wait = 0; - // Open the pane first: its list is only rebuilt while the pane is *up*, so a selection made against - // a collapsed pane selects rows that are not there. - if (a->events_toggle != nullptr && - !gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->events_toggle))) { - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->events_toggle), TRUE); + // Open the page first: its list is only rebuilt while the page is *open* (or by the Manage button), so + // a selection made against a closed page selects rows that are not current. + if (slideover_page(*a) != "events") { + refresh_events_pane(*a); + slideover_open(*a, "events"); } // The selection *is* the input, so select the two newest rows the way a user does and press the // button: `on_group_clicked()` reads the list, not a parameter. @@ -5937,13 +5932,13 @@ gboolean events_group_probe_step(gpointer user) { std::lock_guard lock(a->model->mutex()); std::fprintf(stderr, "[events] compare: kind=%d rows=%d channels=%zu samples0=%llu traces0=%d traces1=%d " - "window=%.3f recording=%d pane=%d\n", + "window=%.3f recording=%d page=%s\n", static_cast(a->group_kind), a->rows->row_count(), a->model->channels().size(), static_cast(a->model->live().values(0).produced()), group_visible_traces(*a, 0), group_visible_traces(*a, 1), a->rows->time_window(), a->event_recording ? 1 : 0, - a->events_revealer != nullptr && gtk_widget_get_visible(a->events_revealer) ? 1 : 0); + slideover_page(*a).empty() ? "none" : slideover_page(*a).c_str()); report_dock_events(*a, "comparison up"); events_group_probe_next(*a, 46, 400); return G_SOURCE_REMOVE; diff --git a/src/ui_signals.cpp b/src/ui_signals.cpp index ddbc2d6..c2bf88c 100644 --- a/src/ui_signals.cpp +++ b/src/ui_signals.cpp @@ -656,9 +656,6 @@ GtkWidget* build_signal_panel(App& app) { return box; } -/// The pane's availability as one reading: the history button on the bar, the pane revealed, and the -/// switch that decides half of it. Both the button and the pane follow trigger mode *and* recording, -/// so every step that changes either prints all three. /// Whether the signal list's first row still carries its acquire tick. A file view has nothing to /// acquire -- the samples are stored -- so the tick goes; in live mode it is the control that decides /// what is acquired at all. diff --git a/src/ui_trigger.cpp b/src/ui_trigger.cpp index 67217de..64da317 100644 --- a/src/ui_trigger.cpp +++ b/src/ui_trigger.cpp @@ -594,10 +594,10 @@ void on_trigger_toggled(GtkToggleButton* button, gpointer user) { // unreachable exactly when a user wanted to change them.) What that costs is the reading: the // `trigger panel sensitive` line is now a regression guard, and a `0` there means the // configuration has been locked out again. - // The event-history button is part of trigger mode and the pane it opens is *on demand*; leaving - // trigger mode takes the button away and collapses the pane, so going back to streaming is not a - // column of stale events beside the plot. Recording is the other half of that answer, so this is - // `sync_events_availability()`'s call and not the trigger's alone. + // The dock's Events block and the `events` page follow what is recorded or cached, not trigger mode + // alone: leaving trigger mode does not empty the cache, and a replay is not in trigger mode at all. + // Recording is the other half of that answer, so this is `sync_events_availability()`'s call and not + // the trigger's alone. sync_events_availability(*app); // Arming is a *runtime* control: with trigger mode off there is no engine to arm, and the arm buttons // are the one part of this pane that is not a setting. (The body itself stays live -- see above.) Both @@ -721,7 +721,7 @@ GtkWidget* build_trigger_panel(App& app) { gtk_switch_set_active(GTK_SWITCH(app.events_record_switch), app.event_recording); gtk_widget_set_tooltip_text(app.events_record_switch, "keep every triggered acquisition in the event cache, with a " - "picture of each; the history button goes with it"); + "picture of each; the dock's Events block and page follow it"); g_signal_connect(app.events_record_switch, "notify::active", G_CALLBACK(on_events_record_toggled), &app); // Not `row_for()`: that expands its child, which is what a spin button wants and what a switch must not diff --git a/src/ui_widgets.cpp b/src/ui_widgets.cpp index cb5931a..8bd5f54 100644 --- a/src/ui_widgets.cpp +++ b/src/ui_widgets.cpp @@ -55,8 +55,8 @@ void install_css() { "popover.udpscope-menu button label { min-height: 0; }\n" "popover.udpscope-menu image { min-width: 12px; min-height: 0; }\n" "popover.udpscope-menu separator { margin: 4px 0; }\n" - // The events pane: the row's time is what a person scans for, so it is the one thing in the - // column that is not dim; the thumbnail gets an edge, or a dark plot on a dark pane has none. + // The events page: the row's time is what a person scans for, so it is the one thing in the + // list that is not dim; the thumbnail gets an edge, or a dark plot on a dark panel has none. ".udpscope-event-title { font-weight: bold; }\n" ".udpscope-event-thumb { border: 1px solid alpha(currentColor, 0.15); border-radius: 4px; }\n" "row.udpscope-compact button { min-height: 20px; min-width: 20px; padding: 0 3px; }\n" From 794df9b4d000c24849d260e7db51242acdba587e Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 07:11:34 +0100 Subject: [PATCH 090/127] ui: one way to open the events page, and stale wording after the pane's removal Co-Authored-By: Claude Sonnet 4.6 --- src/ui.cpp | 5 ++--- src/ui_app.hpp | 6 ++++++ src/ui_dock.cpp | 9 +++------ src/ui_events.cpp | 31 +++++++++++++++++++------------ src/ui_probe.cpp | 19 ++++++++++--------- 5 files changed, 40 insertions(+), 30 deletions(-) diff --git a/src/ui.cpp b/src/ui.cpp index 8b479a8..46cb210 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -4187,7 +4187,7 @@ gboolean tick(gpointer user) { next_channels = now_c + G_TIME_SPAN_SECOND; app->last_channels_meas = now_c; // The cache's own question, on the same cadence as the channel dump: a directory read per second, - // and the button's availability re-decided only when the answer changed. + // and the dock block's and the events page's availability re-decided only when the answer changed. if (app->event_store != nullptr) { const bool cached = !app->event_store->list().empty(); if (cached != app->events_cached) { @@ -5708,8 +5708,7 @@ void report_events_availability(App& a, const char* what) { } -/// Walks the whole event-cache loop the way a user does: trigger mode on (the real toggle, which is -/// what reveals the pane), a latch recorded automatically, the cache listed, the newest event +/// Walks the whole event-cache loop the way a user does: trigger mode on (the real toggle), a latch recorded automatically, the cache listed, the newest event /// replayed through the loader `--open` uses, and *Live* taken back. What it reports is what each /// step must be able to do -- a cache nothing can replay is a directory of files, and a replay with /// no way back is a one-way door. diff --git a/src/ui_app.hpp b/src/ui_app.hpp index c419778..9a94b0d 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -1945,6 +1945,12 @@ GdkTexture* scale_texture(GdkTexture* source, int width, int height); EventThumbCache* event_thumb_cache(App& app); GdkTexture* event_preview_texture(App& app, const std::string& path); void refresh_events_pane(App& app); +/// Opens the `events` page. **Every route that opens that page must use this** and never `slideover_open()` +/// directly: the page's list is rebuilt only while the page is open (see `record_event()`), and the +/// slide-over has no on-open hook, so the list is refreshed here, once, before the page shows. The refresh +/// is skipped when the page is already open -- it is current then, and a rebuild would drop the +/// Compare/Accumulate selection and the scroll position. Returns what `slideover_open()` returns. +bool open_events_page(App& app); /// Re-marks which row is the event currently on screen, **without rebuilding the list**. /// /// The mark is the only thing about a row that a replay changes -- the picture and the numbers diff --git a/src/ui_dock.cpp b/src/ui_dock.cpp index 4812eae..b68ab6b 100644 --- a/src/ui_dock.cpp +++ b/src/ui_dock.cpp @@ -249,13 +249,10 @@ const char* dock_event_marker(const App& app, const EventRecord& r) { return " "; } -/// Opens the `events` page. The page's list is rebuilt only while the page is open (see `record_event()`), -/// so it is refreshed here, once, before the page shows: the slide-over has no on-open hook, and the list -/// is small. The button is not a child of either list, so the rebuild cannot free the widget it runs from. +/// Opens the `events` page through `open_events_page()` (which refreshes the list only if it is not +/// already open). The button is not a child of either list, so the rebuild cannot free the widget it runs from. void on_manage_events(GtkButton*, gpointer user) { - App& app = *static_cast(user); - refresh_events_pane(app); - slideover_open(app, "events"); + open_events_page(*static_cast(user)); } /// One click on a row replays it, as the page list's activation does: `replay_event()` and then the in-place diff --git a/src/ui_events.cpp b/src/ui_events.cpp index f719ca9..6703e41 100644 --- a/src/ui_events.cpp +++ b/src/ui_events.cpp @@ -622,7 +622,7 @@ EventThumbCache* event_thumb_cache(App& app) { // Created on first use and owned by the list. Nothing used to create it, and a missing cache is // not a slow path here but a *blank* one: `event_preview_texture()` answers from the cache or not // at all, so every row came up without a picture -- which is what the previews were reported for. - // Owned by the list because nothing else has the pane's lifetime: textures of a list that no + // Owned by the list because nothing else has the events page's lifetime: textures of a list that no // longer exists are so much held memory. cache = new EventThumbCache(); g_object_set_data_full(G_OBJECT(app.events_list), "udpscope-event-thumbs", cache, @@ -707,7 +707,7 @@ GdkTexture* event_preview_texture(App& app, const std::string& path) { /// /// Through GTK's own tooltip rather than a popover of ours, for lifetime: a tooltip is shown, placed, /// dismissed and destroyed by GTK, and it takes no pointer grab -- a popover per row would be a -/// parented widget to unparent on every rebuild of the pane, and a grab to lose (see the GTK +/// parented widget to unparent on every rebuild of the page, and a grab to lose (see the GTK /// invariants on both). The texture is decoded in the handler and dropped with the tooltip's picture, /// so a hover costs nothing once it is over and the row cache keeps holding only the small one. gboolean on_preview_query_tooltip(GtkWidget*, int, int, gboolean, GtkTooltip* tooltip, gpointer user) { @@ -842,7 +842,7 @@ static void rebuild_events_pane_list(App& app, std::vector& records // to grow and the text keeps the rest of the width. gtk_widget_set_size_request(frame, kEventThumbW, kEventThumbH); // `halign` START, not the default FILL: a filling widget is given whatever the row has left over, - // which made the picture as wide as the pane and cropped by the height it was pinned to. + // which made the picture as wide as the page and cropped by the height it was pinned to. gtk_widget_set_halign(frame, GTK_ALIGN_START); gtk_widget_set_valign(frame, GTK_ALIGN_CENTER); gtk_widget_add_css_class(frame, "udpscope-event-thumb"); @@ -870,7 +870,7 @@ static void rebuild_events_pane_list(App& app, std::vector& records ++unreadable; } gtk_box_append(GTK_BOX(line), frame); - // The newest event is the first row: what the probe measures, and nothing the pane treats + // The newest event is the first row: what the probe measures, and nothing the page treats // specially. if (app.events_thumb == nullptr) app.events_thumb = shot; @@ -926,10 +926,11 @@ static void rebuild_events_pane_list(App& app, std::vector& records } } -/// Both views of the cache, rebuilt from the same list: the pane's rows (with their pictures) and the -/// dock's compact ones. The dock follows even when the pane's list does not exist. +/// Both views of the cache, rebuilt from the same list: the events page's rows (with their pictures) and the +/// dock block's compact ones. The dock follows even when the page's list does not exist. To *open* the page +/// use `open_events_page()`, which calls this only when the page is not already open. void refresh_events_pane(App& app) { - // One read of the cache directory for both views (none without a store, which the pane's rebuild + // One read of the cache directory for both views (none without a store, which the page's rebuild // and the dock's both treat as nothing to list). std::vector records; if (app.event_store != nullptr) records = app.event_store->list(); @@ -937,9 +938,14 @@ void refresh_events_pane(App& app) { if (app.event_store != nullptr) dock_rebuild_events(app, records); } +bool open_events_page(App& app) { + if (slideover_page(app) != "events") refresh_events_pane(app); + return slideover_open(app, "events"); +} + /// Clicking a row replays it: the same loader `--open` uses, on the event's file. The acquisition is /// stopped by `replay_event()` -- a live producer would overwrite the samples within a frame -- so the -/// pane's own *Live* button is the way back. +/// page's own *Live* button is the way back. void on_event_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) { App& app = *static_cast(user); if (row == nullptr) return; @@ -976,7 +982,7 @@ GtkWidget* build_events_pane(App& app) { return app.events_box != nullptr ? app.events_box : gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); } - // What the pane *does*, which a file cannot hold: the two group actions read the selection, the list + // What the page *does*, which a file cannot hold: the two group actions read the selection, the list // replays an event on a double click, and the header's button goes back to the live stream. g_signal_connect(app.events_compare, "clicked", G_CALLBACK(on_group_clicked), &app); g_signal_connect(app.events_accumulate, "clicked", G_CALLBACK(on_group_clicked), &app); @@ -993,9 +999,10 @@ GtkWidget* build_events_pane(App& app) { return app.events_box; } -/// The recording switch, which lives here because it is a trigger option and not a pane option: it -/// decides whether latches are written at all, and the pane that lists them must not be the only -/// place to reach the control that stops them. Flipping it re-decides the pane's availability. +/// The recording switch, which lives here because it is a trigger option and not an events-page option: it +/// decides whether latches are written at all, and the page that lists them must not be the only +/// place to reach the control that stops them. Flipping it re-decides the availability of the dock's +/// Events block and of the page. void on_events_record_toggled(GObject* o, GParamSpec*, gpointer user) { App& app = *static_cast(user); app.event_recording = gtk_switch_get_active(GTK_SWITCH(o)); diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 265084d..8b884a7 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -5674,7 +5674,7 @@ gboolean events_probe_step(gpointer user) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE); } report_events_availability(*a, "after trigger on"); - slideover_open(*a, "events"); + open_events_page(*a); // Read straight after the open, before anything else can call the availability rule. report_events_availability(*a, "after events page opened"); // ... and again a few frames later, still with no replay in the way: this is the *open alone* @@ -5747,11 +5747,15 @@ gboolean events_probe_step(gpointer user) { r.stem.c_str(), r.duration_s, r.pre_s, r.post_s, r.edge, r.signals, r.samples, r.rate_hz, static_cast(r.seq), thumb_bytes, a->event_recording ? 1 : 0, slideover_page(*a) == "events" ? 1 : 0); - // The first latch must not have moved the plot: the list is a slide-over page over the plot's - // edge, and the dock is a fixed column, so nothing about an event changes the plot's width. + } + // The first latch must not have moved the plot: the list is a slide-over page over the plot's + // edge, and the dock is a fixed column, so nothing about an event changes the plot's width. Both + // widths must be real (> 0): two unrealised -1s are equal and would read as stable. + { const int plot_w_now = a->area_widget != nullptr ? gtk_widget_get_width(a->area_widget) : -1; - std::fprintf(stderr, " plot_w_stable=%d plot_w=%d/%d", plot_w_now == a->dbg_events_plot_w ? 1 : 0, - a->dbg_events_plot_w, plot_w_now); + std::fprintf(stderr, " plot_w_stable=%d plot_w=%d/%d", + (plot_w_now > 0 && plot_w_now == a->dbg_events_plot_w) ? 1 : 0, a->dbg_events_plot_w, + plot_w_now); } std::fprintf(stderr, "\n"); std::fflush(stderr); @@ -5898,10 +5902,7 @@ gboolean events_group_probe_step(gpointer user) { a->dbg_events_wait = 0; // Open the page first: its list is only rebuilt while the page is *open* (or by the Manage button), so // a selection made against a closed page selects rows that are not current. - if (slideover_page(*a) != "events") { - refresh_events_pane(*a); - slideover_open(*a, "events"); - } + open_events_page(*a); // The selection *is* the input, so select the two newest rows the way a user does and press the // button: `on_group_clicked()` reads the list, not a parameter. GtkListBox* list = GTK_LIST_BOX(a->events_list); From 9db5e4a49c24855aea74b65ec3f4738ffe331530 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 07:15:38 +0100 Subject: [PATCH 091/127] ui: tour and help describe the events block The dock tour stop names the Events block (one click replays, Manage opens the list) and the event-history and record hints point at the page and the dock instead of a left pane. Co-Authored-By: Claude Sonnet 4.6 --- src/ui_context_help.cpp | 12 +++++++----- src/ui_tour.cpp | 9 +++++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/ui_context_help.cpp b/src/ui_context_help.cpp index 1096255..7d5211a 100644 --- a/src/ui_context_help.cpp +++ b/src/ui_context_help.cpp @@ -64,7 +64,7 @@ struct HelpHint { /// The table, which is the whole of the curation: a new surface is a row, and the heading row it /// names must already exist in `App` (that is what `ui_app.hpp`'s help-layer fields are for). /// -/// The trigger, cursors, measurements and signals headings live on slide-over pages, which exist +/// The trigger, cursors, measurements, events and signals headings live on slide-over pages, which exist /// (unmapped) whether or not the page is open: their hints are attached all the time and drawn while /// the page is on screen, so the dock needs no hint of its own and the layer has one code path. constexpr HelpHint kHelpHints[] = { @@ -100,14 +100,16 @@ constexpr HelpHint kHelpHints[] = { "measurement editor. A signal's badge, dragged onto a plot, draws it there.", "G", &App::signals_head}, {"events", "The event history", - "Every triggered acquisition the cache keeps, with a picture of each. Open one to read it as a " - "file, or select two or four and have them compared in two plots or overlaid in one.", + "Every triggered acquisition the cache keeps, with a picture of each. A double click on one " + "replays it, or select two or four and have them compared in two plots or overlaid in one. The " + "dock keeps a short list of the newest, where one click replays; its Manage button opens this " + "page.", "", &App::events_head}, {"events_record", "Recording events", "With this on, every acquisition the trigger latches is written to the event cache -- the " "samples around it and a picture of it -- so an event can be read again long after it has left " - "the live buffer. It is the switch beside it, and the same one the events pane shows the result " - "of.", + "the live buffer. It is the switch beside it, and the same one the dock's Events block and the " + "events page show the result of.", "", &App::trigger_record_row}, {"trigger_advanced", "The trigger's finer settings", "Pre-trigger share is how much of the event window is kept *before* the crossing, and hold-off " diff --git a/src/ui_tour.cpp b/src/ui_tour.cpp index bb73c84..d967bd6 100644 --- a/src/ui_tour.cpp +++ b/src/ui_tour.cpp @@ -456,9 +456,10 @@ constexpr TourStep kTourSteps[] = { &App::trigger_body}, {"side", nullptr, "The dock", "The dock beside the plot is a read-only copy of the cursors and of every measurement, in a " - "fixed width. Edit, beside each heading, opens that instrument's full panel over the plot; a " - "click on a measurement opens its dialog. The side-panel button in the bar above shows and hides " - "the dock.", + "fixed width, with the recorded events below them: one row per latched event, newest first, and " + "one click on a row replays it. Edit, beside each heading, opens that instrument's full panel " + "over the plot, Manage opens the full list of events; a click on a measurement opens its " + "dialog. The side-panel button in the bar above shows and hides the dock.", &App::side_box}, {"signals", "signals", "Adding a signal", "What can be added: the signals the source declares that are not acquired yet, each with the " @@ -504,7 +505,7 @@ void start_tour(App& app) { app.settings.tour_seen = true; (void)save_settings(app.settings); } - // The main window has no page stack (the dock holds only the readouts): its pages are the slide-over's, and + // The main window has no page stack (the dock holds the readouts and the events block): its pages are the slide-over's, and // the view's `on_page` hook opens one (or closes the panel for a stop with no page), replacing the // `page_stack` mechanism the chooser keeps. app.tour.on_page = tour_open_page; From c4b79da05790d213f7932153b1128521748001a8 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 07:19:33 +0100 Subject: [PATCH 092/127] docs: events in the dock and on its page The dock's Events block, the events slide-over page, the availability-rule deviation, the probe lines and the removal of the left pane are described; the false remembered-width claim is gone. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 79 +++++++++++++------ docs/claude/architecture.md | 28 +++++-- docs/claude/invariants-gtk-and-chrome.md | 31 +++++--- docs/claude/invariants-plot-and-layout.md | 12 ++- docs/claude/invariants-trigger-and-pinning.md | 27 ++++--- docs/claude/probe-readings.md | 75 ++++++++++++------ docs/claude/verification.md | 59 ++++++++------ 7 files changed, 206 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index 2eaf7ab..b421921 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ switches trigger mode on, waits for an event, then leaves trigger mode the way t "Go live" button does, closes the window, and prints on stdout the status line in 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 -state of the dock and the events pane. +state of the dock, and the events page's controls (present and nested in the page). 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 @@ -146,8 +146,8 @@ panel laid over the plot's right edge, opaque, with its own title, a *Close* but page. The Trigger badge opens the trigger form; the Source badge (when shown) opens the source kind's own panels (the E-1608's *Analog out* and *Digital I/O*, behind a segmented switch at the top of the panel). It closes with a second click on its badge, with `Esc`, with *Close*, or with a click on the plot; it never -resizes the plot. The right-hand **dock** (the sidebar, **280 px**, fixed) holds the **readouts**: a -compact, read-only copy of the cursors table (a header row `A B delta`, then one line per cursor +resizes the plot. The right-hand **dock** (the sidebar, **280 px**, fixed) holds the **readouts** and, under +them, the **Events** block (below): the readouts are a compact, read-only copy of the cursors table (a header row `A B delta`, then one line per cursor line) and of the measurements (one line each: the signal's colour chip, the type, the value). It is shown from the start and insensitive until the stream has signals. Each of the two blocks has an **Edit** button, which opens the full panel as a slide-over page -- `cursors` or `measure` -- over the @@ -159,8 +159,12 @@ cells are a fixed number of characters wide and ellipsized, so the dock's width depend on what a value reads: the **who** cell of a cursor line is 5 characters (the full text is its tooltip), a value cell 9 (its tooltip is the full text). The dock and the pages show **one string**: `update_cursors_pane()` and `update_measures()` format each value once and hand that variable to the -page's label and to the dock's, so the two cannot disagree. The remaining part of the redesign is -listed under "Review findings not (yet) implemented". +page's label and to the dock's, so the two cannot disagree. The **Events** block is a heading with a +**Manage** button over a short list of the cached events, newest first -- one compact row each, never a +picture -- and one click on a row replays that event (see "Captured events"). The full list, with the +pictures and the comparison actions, is the `events` slide-over page that **Manage** opens. There is no +events pane beside the plot and no Events button in the header: events never take any of the plot's width. +The rest of the redesign is listed under "Review findings not (yet) implemented". Threading: one producer thread owns the data source (the UDPStreamer client is the built-in one) and calls `poll()`; the GTK main thread owns every widget and the GL contexts. @@ -838,20 +842,36 @@ time to look at is still there: 20261009-123250-0001.bmp # a picture of it, drawn from the samples ``` -The **left pane** is opened on demand from the toolbar's event-history button, is collapsed by -default, and its **divider can be dragged**: it starts at 240 pixels and remembers the width you last -chose, and while it is closed it takes no room at all. Both the button and the pane exist while **recording is on**, or while an event is on -screen: with the cache disabled and nothing being examined there is nothing to show, and a visible -empty column beside the plot is what *off* must not look like. Recording is switched in the **trigger -panel**, with the trigger's other options rather than in the pane whose contents it decides, and it is -**off by default** -- writing every latch to disk is the user's decision about their disk. +Events are read in two places. The **dock's Events block** lists them compactly -- one row per event, newest +first, at most **24** with a dim "... N more, see Manage" row after them: a marker (a filled circle on the +event on screen, a hollow one on each member of the comparison or accumulation on screen), the time and +duration, and the event's sequence number (`#7`). **One click on a row replays it.** The block's **Manage** +button opens the **`events` page**, a slide-over page over the plot with the full list (below), the *Live* +button and the two group actions. The page replaces the left pane that used to sit beside the plot behind a +header button and a draggable divider; both are gone, and so is the toolbar's Events button -- opening the +list no longer resizes the plot at all. + +The block and the page exist all the time and are **insensitive, not hidden**, when there is nothing to show: +they are available while **recording is on**, while an event is on screen (a replay or a group), or while +the cache already holds events. **Deviation from the redesign spec,** which has the block insensitive +"outside trigger mode": a replay or a group *leaves* trigger mode (a file has no edge to wait for), so a block +that went grey outside trigger mode would grey itself under the very click that started the replay. Trigger +mode is therefore not one of the conditions. Recording is switched in the **trigger panel**, with the +trigger's other options rather than in the list whose contents it decides, and it is **off by default** -- +writing every latch to disk is the user's decision about their disk. (The session file keeps nothing about +this list: there is no remembered width, because there is no pane to size.) + +The dock follows every latch; the page's list is refreshed per latch only while it is open, and rebuilt when +**Manage** opens it. **The dock list has no thumbnails** -- its rows are fixed-width, ellipsized cells, so the +dock's size does not depend on what an event is called -- the page has them, and its rows are replayed with a +**double** click, because on the page a single click builds the selection that *Compare* and *Accumulate* read. Each row carries **its own picture** beside the numbers -- time, duration, signal count, rate -- 138 -pixels wide whatever the pane's width, and as tall as that event's own proportions make it: the +pixels wide whatever the page's width, and as tall as that event's own proportions make it: the preview is one 44-pixel strip per signal, so a one-signal event is a short row and a six-signal one a tall one (capped, so that a row stays a row). **Hovering it shows the whole preview at full size**, through a tooltip -- the small picture says *which* event this is, the zoom says what is in it. -Clicking a row **replays** it: the same loader `--open` uses, on the event's file. The click **leaves +Double-clicking a row on the page (or clicking one in the dock) **replays** it: the same loader `--open` uses, on the event's file. The click **leaves the list alone** -- your scroll position and the pair you had selected for a comparison both survive it, because marking which event is on screen rewrites the rows' titles in place instead of rebuilding the list. **The layout does @@ -862,7 +882,7 @@ rearrange the user's work under a click. What changes is the data, and the trigg file has no edge to wait for. Cursors, measurements, math channels and another export all work on a stored event. **Live** takes you back to the stream. -Two buttons under the pane's header read the **selection**: +Two buttons under the page's header read the **selection**: - *Compare* puts the two selected events in **two plots side by side**, each with **the layout you already have** -- the same rows, the same signals in the same slots, the same colours -- one showing @@ -880,8 +900,8 @@ model whose channel count no longer matches the stream), the trigger is left, an A replay leaves trigger mode, because a file has no edge to wait for -- an armed trigger keeps a *frozen capture* on screen (`display_mode()` stays `Held` while the model holds a snapshot), and a -held display draws that snapshot rather than the file that was just loaded. The events pane stays -open through it: it is available while a stored event is on screen. +held display draws that snapshot rather than the file that was just loaded. The dock's Events block and +the `events` page stay available through it: they are available while a stored event is on screen. **Going back to *Live* puts the live view back as it was.** Opening a file changes two things that are not the user's choice -- the time window becomes the event's own span, because that is what @@ -912,7 +932,7 @@ Three things worth knowing: - **The picture is decoded synchronously** (`gdk_texture_new_from_filename()` into `gtk_picture_set_paintable()`), not through `gtk_picture_set_filename()`: that path uses the image loader's sandboxed, asynchronous decoder, which here fails silently -- the picture keeps a - paint-able and an allocation, so a probe reports success while the pane shows an empty rectangle. + paint-able and an allocation, so a probe reports success while the page shows an empty rectangle. It is then **scaled once to the row's size** and cached by file: a `GtkPicture`'s natural size is its content, so the stored 480-pixel preview given straight to a row claims the row's whole width and squeezes the text out of it -- a size request on the picture is a floor, not a cap. @@ -929,8 +949,8 @@ Three things worth knowing: one at a time was eight of those, which is most of what made the switch feel slow. They are now enabled in a single call, and a rebuild whose plan is unchanged clears the buffer instead of allocating a new one. -- The switch in the pane's header stops the recording; the cache is only useful if it was already - on when the event you wanted happened, which is why it defaults to on. +- The switch in the trigger panel starts and stops the recording; the cache is only useful if it was + already on when the event you wanted happened, so switch it on before the event you are waiting for. ## Opening an exported waveform @@ -1241,8 +1261,10 @@ reveals it, while one whose *pane is collapsed* (the dock, in a window too narro plot) is dropped, because it is unmapped with its pane (a hidden page keeps its own *visible* flag true -- the widget's own flag, which is all `is_visible()` walks -- so the old test walked over surfaces that were not on screen); a narrow window therefore walks five steps. The dock's stop -describes the readouts and the **Edit** buttons; it names no slide-over page, so it closes the panel -like the plot's. The cursors and measurements pages have no stop of their own, and their contextual +describes the readouts and their **Edit** buttons and the Events block under them (one click on a row +replays it, **Manage** opens the list); it names no slide-over page, so it closes the panel like the plot's. +There is no stop for the `events` page, so nothing in the tour opens it; the contextual help's +event-history hint is attached to the page's heading the way the cursors' and measurements' are. The cursors and measurements pages have no stop of their own, and their contextual help "(i)" hints are attached to the pages' headings the way the trigger's and the signals' are: they are drawn while the page is open, and the dock has none. The chooser's tour works the same way over its own two pages: its tabs step shows the *Waveforms* page and @@ -3021,11 +3043,16 @@ now — they are listed so they are not mistaken for anything else: **who** cell ellipsizes at **5 characters** -- the width the 280 px pin allows; with 8 the readouts' minimum width was 304 px and widened the sidebar -- and its tooltip carries the full text. (6) The dock and the pages **share one string** for every value (see `docs/claude/invariants-readouts-and-cursors.md`). + (7) The **Events block and the `events` page are available** while recording is on, a replay is on screen, or + the cache holds events -- **not** "outside trigger mode" as the spec has it, because a replay or a group leaves + trigger mode and the block would grey itself under the click that started it. (8) The **dock's event rows carry no + thumbnails** (the page's do) and show a **hollow marker on the members of the group on screen**, which the page's + rows do not. (9) The dock lists at most 24 events; the page lists them all. The signal badges, the signal pages and the drag (step 2) are done. Still to come, in the spec's - order: the events section in the dock, the deletion of the left pane and the Events toggle, and - `USER-GUIDE.md` and the manual's figures (4). Until then the events pane, the header's Events toggle - and the manual (which still describes the side panel's tabs, and the cursors and measurements as - panes of it) still describe the previous layout. + order: `USER-GUIDE.md` and the manual's figures (4). The events section in the dock and the `events` + page are done, and the left events pane, its divider and the header's Events toggle are deleted. Until + the manual is swept, it (which still describes the side panel's tabs, and the cursors, measurements + and events as panes of it) still describes the previous layout. - **A plot holds at most eight signals** (`RowSet::kSlotsPerRow`), because a `Plot` cannot give up a trace and the slots are therefore created up front. - **One toggle drives both cursor orientations.** The vertical markers and the diff --git a/docs/claude/architecture.md b/docs/claude/architecture.md index 549fa36..c6d344e 100644 --- a/docs/claude/architecture.md +++ b/docs/claude/architecture.md @@ -102,10 +102,12 @@ touching the data path. own and registered under its id (`trigger`, `source-`, `signals` (the signal list) and `signal:` (one signal's page, `src/ui_signal_page.cpp`, created on demand by `signal_page_ensure()` and pruned by `signal_pages_sync()`, which `rebuild_signal_panels()` ends with), `cursors` (the cursors - panel, unchanged) and `measure` (the measurements panel, unchanged; `m` toggles it); `events` is reserved - for a later step). Both of those are registered whether or not they are open (a closed page's widgets - exist, unmapped), and both are insensitive until the stream has signals, by the same - `update_signal_availability()` call that sets the dock's sensitivity. One page is open at a time and `App::slideover.page` + panel, unchanged) and `measure` (the measurements panel, unchanged; `m` toggles it); `events` (the full events list, `events-pane.ui`'s `events_box`: the Live, Compare and Accumulate + controls and the multi-select list with its thumbnails; open it only through `open_events_page()`, which + refreshes the list first, because the slide-over has no on-open hook). They are registered whether or not + they are open (a closed page's widgets exist, unmapped); `cursors` and `measure` are insensitive until the + stream has signals, by the same `update_signal_availability()` call that sets the dock's sensitivity, and + `events` follows `sync_events_availability()` (below). One page is open at a time and `App::slideover.page` is its id, empty when closed. The open page is **not** a session value. Esc, *Close*, a second click on the badge and a click on the plot close it; the click-away is a capture-phase observer (see [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). With two or more `source-*` pages, an @@ -120,7 +122,7 @@ touching the data path. pick and focus from `slideover.source_pages` being non-empty every tick, so anything that indexes the badge (the tour's `badges` step targets the whole bar, `[badges]`) must tolerate it being absent. - **The dock** is the `AdwOverlaySplitView` sidebar (`side_box` > `dock_scroller` > `dock_page`), **280 px** - and fixed. It holds one thing, the **Readouts** box (`src/ui_dock.cpp`, `build_readouts_dock()`): a + and fixed. It holds two things, the **Readouts** box and, under a separator, the **Events** block (below). The Readouts box (`src/ui_dock.cpp`, `build_readouts_dock()`): a **Cursors** block (heading, an **Edit** button that opens the `cursors` page, a `GtkGrid` of a header row `A B delta` and one line per page line) and a **Measurements** block (heading, **Edit** opening the `measure` page, one line per measurement: chip, type, value; a click on a line opens that measurement's @@ -132,6 +134,22 @@ touching the data path. kind's panels (those are slide-over pages). The window's measurement editor is a dialog (`open_measure_dialog()`), not part of the `measure` page. + The **Events** block (`build_events_dock()`, also `ui_dock.cpp`) is a heading with a **Manage** button + (`open_events_page()`) over a short list (`dock_events_list`, 112 px, scrolled, no thumbnails): one compact + row per `EventRecord`, newest first, capped at `kDockEventsMax` (24) with a dim "... N more, see Manage" + row after them. A row's cells are a one-character marker (filled = the event on screen; hollow = a member + of the group on screen -- dock only, the page's rows do not mark members; blank otherwise), the title + (`event_row_title`, ellipsized, full text in the tooltip) and `#`; **one click replays** it + (`replay_event()`, the body of the page's double-click handler). Both views are rebuilt from one + `EventStore::list()` by `refresh_events_pane()`, but on different cadences: **the dock follows every + latch** (`record_event()` refreshes it unconditionally), while the page's list is refreshed per latch only + while the page is open and by `open_events_page()` otherwise. `mark_events_on_screen()` updates titles and + markers in place and never rebuilds either list. The block, and the page, are insensitive (never hidden) + unless `sync_events_availability()` says `event_recording || replaying || events_cached` -- a different + rule from the spec's (see [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)). + There is no events pane, divider or header toggle any more: the page is reached from Manage (or by a + caller of `open_events_page()`), and the plot is never squeezed by events. + **When each is built and when it is read.** `build_slideover()` runs in `on_activate` right after the tour overlay is built (the tour's overlay wraps the whole window, so its dim rectangles cover the slide-over too); the trigger form is registered as diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md index a7e43a1..dfaf725 100644 --- a/docs/claude/invariants-gtk-and-chrome.md +++ b/docs/claude/invariants-gtk-and-chrome.md @@ -210,9 +210,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear entry points in `source_dialog.cpp` (`chooser_tour_begin`, `chooser_tour_autostart`), and *that* walk is what a first run meets first -- see the tour bullets in the help-affordance section below. - **A `GtkPaned` hands its start child at least its minimum unless the position says otherwise.** - Putting the events pane in a paned is what makes its width the user's to drag -- but with the - position left alone, `shrink-start-child` or not, the pane's minimum is reserved as an empty column - while it is *closed*, and the plot pays for a pane nobody opened. The position is the only knob that + (The events pane that taught this is gone -- its list is a slide-over page now, with no divider -- but + the rule holds for any paned.) With the + position left alone, `shrink-start-child` or not, a closed side child's minimum is reserved as an empty + column, and the plot pays for a pane nobody opened. The position is the only knob that says "no room": store the width when it is closed (reading `gtk_paned_get_position()` *before* zeroing it) and set 0, restoring it when the pane opens. `gtk_paned_set_shrink_start_child(TRUE)` is still needed, or the minimum would clamp even a zero position. @@ -220,7 +221,8 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear = FALSE` makes both the revealer's minimum and its natural size zero -- and its box then hands it everything the expanding sibling beside it did not claim, so a *collapsed* pane reserved a 310-pixel empty column between the window edge and a plot drawn 630 pixels wide in a 1270-pixel - window. Every vertical revealer in the side panel hides this, because there the leftover goes down + window. (That pane was a revealer beside the plot; it is a slide-over page now, so no revealer sits in + a horizontal box any more.) Every vertical revealer in the side panel hid this, because there the leftover goes down instead of across. `gtk_widget_set_visible()` is what a layout honours, so anything that must not take room has to be hidden and not merely unrevealed; the revealer keeps the slide while the pane *is* open. The allocated width is how this was found -- the revealer's own measure said 0. @@ -278,8 +280,9 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear clicked — a switch that greyed out with its own pane could never turn it back on. That is why the sensitive target is the pane's **body** (`trigger_body`, `cursors_body`) and not the pane. - **A list box outlives the children of the pane it sits in, and the teardown order is not yours to - choose.** Tearing the window down finalizes the events pane's buttons, then the list box, and the - list's own `selected-rows-changed` then reaches a handler holding the freed pointers: four + choose.** Tearing the window down finalizes the events page's buttons, then its list box, and the + list's own `selected-rows-changed` then reaches a handler holding the freed pointers (`update_group_buttons()` + therefore keeps its `app.window == nullptr` guard): four `Gtk-CRITICAL`s from `gtk_widget_set_sensitive` and a read of memory nobody owns. `close-request` is *not* the hook -- `UDPSCOPE_EXIT_AFTER_MS` ends the self-check with `gtk_window_destroy()`, which does not emit it. The hook that does work is `App::window`: every teardown path clears it *before* the @@ -296,22 +299,26 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear - **A pointer to a widget inside a list survives the list being rebuilt.** `events_thumb` named a row's picture across `refresh_events_pane()`, which removes every row first: the next reader of that pointer got a `Gtk-CRITICAL` from `gtk_picture_get_paintable` on freed memory (the four criticals - read as "the pane is empty"). A rebuild has to null what it is about to destroy, not only rebuild it. + read as "the page is empty"). A rebuild has to null what it is about to destroy, not only rebuild it + (`rebuild_events_pane_list()`, the page half of `refresh_events_pane()`, nulls `events_thumb` before it removes the page list's children). A handler on a + row that rebuilds the list destroys the widget it was emitted from: `outlive_the_rebuild(widget)`. The dock's + rows are a second list built from the same records; the dock row's click handler is safe only because + `replay_event()` never rebuilds the dock list. - **`gtk_picture_set_filename()` can render nothing, silently.** It hands the file to the image loader's sandboxed, asynchronous decoder, which on this machine fails without a message: the picture keeps a *paint-able* and takes an allocation, so a probe that checks - `gtk_picture_get_paintable()` reads success while the pane shows an empty rectangle (the event - pane's preview did exactly that). `gdk_texture_new_from_filename()` decodes the same file on this + `gtk_picture_get_paintable()` reads success while the page shows an empty rectangle (the event + page's preview did exactly that). `gdk_texture_new_from_filename()` decodes the same file on this thread and `gtk_picture_set_paintable()` hands over the result -- and a NULL texture at least says so. A picture that must be on screen is also a picture with a **height request**: `can_shrink` alone asks for no height at all in a box. - **A cache that is the *only* source of a widget's content must be created by the thing that reads - it.** The event pane's thumbnails come from an `EventThumbCache` hung off the list box with + it.** The events page's thumbnails (the dock's rows have none) come from an `EventThumbCache` hung off the page's list box with `g_object_set_data_full`, and `event_preview_texture()` answers out of that cache or not at all. - Nothing ever created it — so for as long as the pane existed, **every** row came up without a + Nothing ever created it — so for as long as the list existed, **every** row came up without a picture, which is what "event screenshot are unusable and sometime missing" was. A missing cache there is not a slow path, it is a blank one; `event_thumb_cache()` therefore creates one on first - use. Owned by the **list**, because nothing else has the pane's lifetime: textures of a list that + use. Owned by the **list**, because nothing else has the page's lifetime: textures of a list that no longer exists are so much held memory, and the destroy notify is where they are unref'd. - **Hover magnification is a tooltip, not a popover of ours.** A row's thumbnail is 138 px wide and the stored preview is 480: the small one says *which* event this is, and the zoom says what is in diff --git a/docs/claude/invariants-plot-and-layout.md b/docs/claude/invariants-plot-and-layout.md index 2578344..a1e2dbc 100644 --- a/docs/claude/invariants-plot-and-layout.md +++ b/docs/claude/invariants-plot-and-layout.md @@ -40,6 +40,14 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear moved the plot column by about 180 px, which the layout probe read as a different `done_box` and a taller figure. +- **Events never take the plot's width.** The events list is a slide-over page (an overlay child over the plot) and + the dock's Events block lives in the fixed 280 px column, so there is no events divider, revealer or pane beside the + plot to squeeze it; the first latch must not move the plot area (`[events] ... plot_w_stable=1`, which compares the + allocated width before and after). A dock row is a fixed-width ellipsized label (title at 18 characters, `#seq`), the + list is a fixed 112 px scroller capped at `kDockEventsMax` rows plus one "N more" row, and it has no thumbnails: the + dock's width and height do not depend on how many events there are or what they say ("layout must not depend on + content"). The thumbnails are the *page's* (138 px wide). + - **`plot::Plot` cannot remove or retarget a trace.** Hence fixed trace slots created up front and rebinding via `SlotSeries`; a *name* change (legend text) is the one thing that forces a rebuild. Cursors are parked outside the axis range instead of @@ -131,7 +139,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear the Source badge is on screen for the E-1608 and hidden for UDPS, and its text differs by kind). A change that makes it grow with the signal count has taken the scroller out of the measurement. - **The dock's width is fixed too, and it is the readouts' width that holds it there** (`side_column` - `width-request` 260, the split view's 280 px floor and cap). The dock holds only the compact readouts, so a + `width-request` 260, the split view's 280 px floor and cap). The dock holds only the compact readouts and the Events block (a fixed-height list, below), so a section's visibility is never what makes it taller; the scroller absorbs the rest. **The readouts' minimum width is the sum of their cells' character widths, and it must stay under 280** (the Readouts box is 268: 280 less its 6 px margins): a cursor line is the `who` cell (5 characters) plus three value cells (9 each), @@ -139,7 +147,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear tooltip carries the text and the layout never depends on what a value reads. The `who` cell was 8 characters, which made the readouts' minimum width 304 px; that pushed the sidebar to 304 px (`dock_w=304`) and defeated the 280 pin. Widening any cell is the same - trap -- read `[readouts] ... dock_w=` (must be 280) and the `dock cursors:` / `dock measures:` `fits=`. + trap -- read `[readouts] ... dock_w=` (must be 280) and the `dock cursors:` / `dock measures:` `fits=`, and for the Events block `dock events: ... dock_w=` (268). - **Trace slots are identified by signal name, not channel index.** Enabling a signal or a new CONFIG renumbers the channel list, so an index would silently re-point a trace at another signal. diff --git a/docs/claude/invariants-trigger-and-pinning.md b/docs/claude/invariants-trigger-and-pinning.md index 021b414..53dd376 100644 --- a/docs/claude/invariants-trigger-and-pinning.md +++ b/docs/claude/invariants-trigger-and-pinning.md @@ -440,15 +440,24 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear ## A latch is also a file -The pane that lists the cache is **on demand**: the toolbar's event-history button and the pane are -collapsed until it is asked for, because a column of stale events beside a live plot is what a user -who has stopped looking at events does not want. What makes it *available* -- (recording is on) or -(an event is on screen) -- is decided in one place, `sync_events_availability()`, so the button and -the pane cannot disagree; a disabled cache with nothing being examined takes the button away *and* -collapses the pane, because a visible empty column is what "off" must not look like. Trigger mode is -deliberately **not** one of the conditions: a stored event being examined is a file, which has no -trigger, and tying the pane to the trigger would close the list under the click that opened an event. -The recording switch lives in the trigger panel, not in the pane it empties. +The cache is listed twice, from one `EventStore::list()`: the dock's compact **Events block** and the full list on the +`events` slide-over page (the left pane, its divider and the header toggle are gone, so events no longer take +any of the plot's width). What makes it *available* -- recording is on, **or** a replay is on screen, **or** the +cache already holds something (`event_recording || replaying || events_cached`) -- is decided in one place, +`sync_events_availability()`, and it sets the block's sensitivity (`dock_events_set_sensitive()`) and the page's +(`events_box`) from the one `available`, so they cannot disagree. Unavailable means **insensitive, never hidden** +("disable, do not hide"): the block stays on screen greyed, and turning recording off with the page open leaves it up +and greyed rather than stale and live. + +**This deliberately differs from the redesign spec**, which has the block insensitive "outside trigger mode". Trigger +mode is *not* one of the conditions: a stored event being examined is a file, which has no trigger, and a replay or a +group *leaves* trigger mode, so a block that went grey outside trigger mode would grey itself under the very click +that started the replay -- the one-click replay in the dock would disable the dock it was clicked in. Do not "fix" this +to match the spec. The recording switch lives in the trigger panel, not in the block it empties. + +The two views follow the latch on different cadences: the dock is rebuilt on **every** latch (`record_event()`), the +page's list only while the page is open, and `open_events_page()` refreshes it when it was closed; a caller that +opens the page any other way shows a list that is as stale as the last time it was open. ## Opening a file is a detour, so *Go live* is a restore diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index f405ff7..b06ff12 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -491,7 +491,7 @@ Two lines of the self-check output are assertions in disguise: column and says there is one scroller and no stack. These are captured on the tick or at the exit probe: the dump runs after the window is destroyed. A source with extra panels adds *no* dock section: its panels are slide-over pages (`source-`), so the line reads the same on the E-1608 and the probe kind. -- `[readouts] pages cursors=1 measure=1 dock_children=1 dock_w=280 pages_sensitive=1 signals=1` (in the exit +- `[readouts] pages cursors=1 measure=1 dock_children=3 dock_w=280 pages_sensitive=1 signals=1` (in the exit dump; `dock_children` counts the direct children of the dock page) -- the two pages and the dock against the stream. **`cursors=1 measure=1`**: both slide-over pages are registered, always and from the start (not when the stream has signals); `0` is a page that was never added, and then the dock's Edit buttons and the `m` key @@ -500,9 +500,10 @@ Two lines of the self-check output are assertions in disguise: `update_signal_availability()` writes them with the dock), so a run against a port with no sender reads `pages_sensitive=0 signals=0`, a normal run `pages_sensitive=1 signals=1`. `pages_sensitive=1` with `signals=0` is a page that can be opened and read with nothing to read; `pages_sensitive=0` with `signals=1` is a - stream whose pages never came back after the signal list arrived. **`dock_children=1`**: the dock - page holds the readouts box and nothing else (a second child is a section that crept back into the dock; 0 is a - dock that was built empty). + stream whose pages never came back after the signal list arrived. **`dock_children=3`**: the dock + page holds the readouts box, a separator and the Events block (`build_events_dock()`) and nothing else (a fourth + child is a section that crept back into the dock; 1 is a dock without its Events block, 0 a dock that was built + empty). - `help layer: contextual=on hints=12 ids={events plot status cursors measures trigger trigger_advanced events_record trigger_search signals signals_config window} icon=dialog-information-symbolic(ok) hint=12x12; tour=idle steps=6 step=0 auto=1 requested=0` (in the exit dump, beside `dock:`) — the help affordance layer as the last tick found it. **`hints=` must equal the number of ids**, and the ids are the surfaces the contextual help is on: eleven of the window @@ -1601,24 +1602,33 @@ run they report: ## `[events]` ``` -[events] live, recording off: button_visible=0 pane=0 pane_w=0 tick=1 recording=0 -[events] recording on, no trigger: button_visible=1 pane=0 pane_w=0 tick=1 recording=1 -[events] after trigger on: button_visible=1 pane=0 pane_w=0 tick=1 recording=1 +[events] live, recording off: available=0 dock_sensitive=0 page_sensitive=0 page=none +[events] recording on, no trigger: available=1 dock_sensitive=1 page_sensitive=1 page=none +[events] after trigger on: available=1 dock_sensitive=1 page_sensitive=1 page=none +[events] after events page opened: available=1 dock_sensitive=1 page_sensitive=1 page=events +[events] open alone: page=events page_box=347 [events] preview redrawn: signals=3 stored=480x132 row_drawn=1 -[events] dir=/home/u/.cache/udpscope/events/127.0.0.1-21023 n=3 newest: stem=20261013-184140-0003 dur=2.000000 pre=1.600000 post=0.400000 edge=5.660100 signals=3 samples=20001 rate=10000 seq=3 thumb_bytes=190134 recorded=1 pane=1 -[events] activated row0: replaying=1 pane=1 pane_w=240 pane_box=228 tick=0 window=2.000 axis=[-1.600,0.400] marker=0.000 thumbnail=1 thumb_size=138x46 thumb_visible=1 rows_kept=1 selected=2/2 +[events] dir=/home/u/.cache/udpscope/events/127.0.0.1-21023 n=3 newest: stem=20261013-184140-0003 dur=2.000000 pre=1.600000 post=0.400000 edge=5.660100 signals=3 samples=20001 rate=10000 seq=3 thumb_bytes=190134 recorded=1 page=1 plot_w_stable=1 plot_w=756/756 +[events] activated row0: replaying=1 page=events page_box=347 tick=0 window=2.000 axis=[-1.600,0.400] marker=0.000 thumbnail=1 thumb_size=138x46 thumb_visible=1 rows_kept=1 selected=2/2 +[events] dock events: rows=3 pane_rows=3 same_titles=1 sensitive=1 dock_w=268 on_screen=1 marked=0 members=0 max_ok=1 expected_marked=1 at=after pane replay +[events] dock replay: clicked=1 replaying=1 waveform_changed=1 +[events] dock events: rows=3 pane_rows=3 same_titles=1 sensitive=1 dock_w=268 on_screen=1 marked=1 members=0 max_ok=1 expected_marked=1 at=after dock click [events] replayed: channels=3 samples=20001 replaying=1 waveform=1 [events] live again: replaying=0 waveform=0 [events] live restored (after replay): window=0.500000 (want 0.500000) trigger=1 (want 1) channel='square' (want 'square') level=0 (want 0) enabled=1 armed=1 pinned=0 held=0 mode=2 pending=0 gen=10/5 -[events] recording off, trigger off: button_visible=1 pane=1 pane_w=240 tick=1 recording=0 -[events] recording off, trigger on: button_visible=1 pane=1 pane_w=240 tick=1 recording=0 +[events] recording off, trigger off: available=1 dock_sensitive=1 page_sensitive=1 page=events +[events] recording off, trigger on: available=1 dock_sensitive=1 page_sensitive=1 page=events [events] compare buttons: cached=2 selected=2 compare=1 accumulate=1 -[events] compare: kind=1 rows=1 channels=8 samples0=32964 traces0=4 traces1=-1 window=2.000 recording=1 pane=1 +[events] dock events: rows=2 pane_rows=2 same_titles=1 sensitive=1 dock_w=268 on_screen=0 marked=-1 members=0 max_ok=1 expected_marked=-1 at=two events latched +[events] compare: kind=1 rows=1 channels=8 samples0=32964 traces0=4 traces1=-1 window=2.000 recording=1 page=events +[events] dock events: rows=2 pane_rows=2 same_titles=1 sensitive=1 dock_w=268 on_screen=0 marked=-1 members=2 max_ok=1 expected_marked=-1 at=comparison up [events] compare plots: area_b=1 rows_a=1 rows_b=1 list=4 x_a=[-2.000,0.000] x_b=[-2.000,0.000] same_x=1 [events] compare sync: y_a=-3.500 y_b=-3.500 mirrored=1 [events] accumulate: kind=2 rows=1 channels=8 samples0=32964 selected=2 traces0=8 window=2.000 [events] group left: replaying=0 kind=0 waveform=0 [events] live restored (after a group): window=0.500000 (want 0.500000) trigger=1 (want 1) channel='square' (want 'square') level=0 (want 0) enabled=1 armed=1 pinned=0 held=0 mode=2 pending=0 gen=22/16 +[events] dock replay: clicked=1 replaying=1 (second click, live before) +[events] dock events: rows=2 pane_rows=2 same_titles=1 sensitive=1 dock_w=268 on_screen=1 marked=1 members=0 max_ok=1 expected_marked=1 at=second dock click ``` `thumb_size` is the built size of the row's picture: `kEventThumbW` wide, and as tall as the *stored* @@ -1628,8 +1638,18 @@ reads `138x75`) as well as with the CSS border and the display's scale factor. W `0x0`, and a height pinned to the floor for *every* event says the aspect fit was dropped and tall previews are being squashed again. +**The E-1608 never latches an event** (its stream carries no trigger edge to record): on `--source e1608` the probe reads +`n=0`, the `recording on` lines read `1 1 1` (recording is on, so the block is available), and the two +`recording off` lines read `available=0 dock_sensitive=0 page_sensitive=0 page=events` -- the page is open and +greyed, the "disabled, not hidden" path -- while `activated row0:` reads `replaying=0 ... marker=-1000000000000.000 +thumbnail=0 rows_kept=0` and the `dock events:` / `dock replay:` lines read `rows=0`, `clicked=0 replaying=0`. The +`page=` of the first availability lines is whichever `source-*` page the kind opened at start-up, not `none`. +A UDPS run against a **fresh cache** reads the same `n=0` and `rows=0` on the opening `dir=` / `dock` lines (nothing +has latched when they are printed) and fills in from the group leg on; a UDPS run with a populated cache is the one +that proves `same_titles` and the dock click on real rows. + `[events] preview redrawn:` is the repair path, and the probe forces it: step 1 deletes the newest -`.bmp`, drops the thumbnail cache and refreshes the pane, so this line is `EventStore::redraw_preview()` +`.bmp`, drops the thumbnail cache and refreshes the page's list, so this line is `EventStore::redraw_preview()` drawing the picture again from the `.npy`. `stored` is the file it wrote, read back out of the BMP header — `480` wide and `44 * signals` tall — and `row_drawn=1` says the row got a paintable from it. The probe therefore needs a cache a **previous** run populated: it fires ~4.5 s in, before this run's @@ -1668,23 +1688,25 @@ session that then disagrees with it takes the pair apart. | `signals`, `samples` | the event's channels and rows | a table built from the *rows* instead of the snapshot: only what happened to be plotted | | `thumb_bytes` | a real 24-bit BMP, `54 + 480 * (44 * signals) * 3` rounded up per row to a multiple of four -- so it *grows with the signal count* (190134 at three signals, 380214 at six) | a preview that is empty or missing -- the widget snapshot of a `GtkGLArea`, which is NULL -- or a picture back on a fixed total height, which gave a six-signal event 26 px a signal and an eight-signal one 20: a smear, not a waveform | | `recorded` | 1 while the trigger panel's switch is on | the switch not wired, or recording defaulting off (it must be on: the cache is for the event you did not expect) | -| `pane` | 1 once the history button has been clicked | the button's handler not revealing the pane | -| `live` | `button_visible=0 pane=0** | a pane or a button on screen with no trigger mode: a column of nothing beside a live plot | -| `after trigger on` | `button_visible=1 pane=0` | the pane revealed by trigger mode alone (it must be on demand from the toolbar button), or the button missing in trigger mode | -| `recording off, trigger off` and `recording off, trigger on` | `button_visible=1 pane=1 recording=0` in both **once the cache holds anything** (the probe's own run does by this point) | the button going away with the switch when events are already written -- an event on disk outlives the switch that wrote it -- or `pane_w` not being 240 with the pane open. With an *empty* cache the same two lines read `button_visible=0 pane=0`, which is the empty column the item exists to prevent | +| `page` (on the `dir=` line) | 1 once the `events` page is open (the probe opens it with `open_events_page()`) | a page that did not open, or one that opened before its list was refreshed | +| `plot_w_stable`, `plot_w=a/b` | 1, with the plot area's width before and after the first latch equal and both > 0 (756/756 on the documented window) | events squeezing the plot again (the old pane did), or two unrealised `-1` widths read as equal (the probe requires > 0) | +| `available`, `dock_sensitive`, `page_sensitive` (the lines headed `live, recording off`, `recording on, no trigger`, `after trigger on`, `after events page opened`, `recording off, trigger off/on`) | the three equal on every line, because both views follow `sync_events_availability()`: `0 0 0` live with recording off and an empty cache; `1 1 1` once recording is on, **and it stays `1 1 1` in trigger mode and with the page open** (a replay or a group leaves trigger mode, and a block that greyed itself under that click is the trap) | the dock block and the page disagreeing (one of the two not wired to the sync), or the block going `0` under trigger mode. After recording is switched off the lines read `1 1 1` **once the cache holds anything** (the probe's own run does by this point): an event on disk outlives the switch that wrote it. With an *empty* cache they read `0 0 0` -- insensitive, **never hidden** ("disable, do not hide") | +| `page` (on the availability lines) | `none` until the probe opens the page, then `events` | the page not registered (`open_events_page()` returning false), or a page left open by an earlier leg | +| `open alone` | `page=events page_box=` a width near the slide-over's (347 on the documented window) | the page's box measuring 0 (an unrevealed `GtkRevealer` as the content instead of `events_box`), or a page not opened at all | +| `dock events` | `same_titles=1`, `rows` equal `pane_rows` (both capped at 24: `pane_rows` is cut to the dock's `kDockEventsMax`), `sensitive=1`, **`dock_w` below 280** (268: the block fits the fixed dock; more means a row that no longer ellipsizes), `max_ok=1` (dock children <= 24 + the "N more" row). `on_screen` counts filled markers: **1 after a replay, 0 while live or in a group**; `marked` is the index of the first filled row (-1 when none), `expected_marked=1` when the filled row is the replayed record's file (-1 where no replay is named); `members` counts hollow markers: **2 with a comparison up**, 0 otherwise. The `at=` text names the moment | `same_titles=0` is the two lists rebuilt from different data or one of them stale (the dock follows every latch, the page only while open or on Manage -- a latch landing between the page's refresh and the read can show `rows=9 pane_rows=8` once, which is a probe-time race, not a defect, if it does not persist); `on_screen` above 1 is two filled markers (`event_on_screen` not shared with the title); `members=0` in a comparison is the group not marking its members; `max_ok=0` is the cap lost | +| `dock replay` | `clicked=1 replaying=1 waveform_changed=1`; the second line, from live, reads `clicked=1 replaying=1` | the dock row's click not reaching `replay_event()` (`clicked=0` with a non-empty list), or the click rebuilding the dock list under its own handler. With an empty cache (`rows=0`) it reads `clicked=0 replaying=0`, which is not a defect | | `thumbnail`, `thumb_size`, `thumb_visible` | 1, a non-zero size, and 1 | the picture loaded through `gtk_picture_set_filename()`, which fails silently (the paint-able is there, the allocation is there, nothing is drawn), or a `GtkPicture` given a size request and no cap -- its natural size is its content, so the row's text is squeezed off. `thumb_size` is the reading that catches both. It also catches the cache never being *created*: `event_preview_texture()` answers from the cache or not at all, so a reader that does not create one leaves **every** row blank, which is what `thumbnail=0` meant for as long as it read 0 | | `preview redrawn` | `stored=480x<44*signals>` and `row_drawn=1` | a preview that cannot be repaired. The `.npy` is the event and the `.bmp` is only a picture of it, so a write that failed, a truncated file or a cache from an older build must be redrawn rather than shown as a blank row -- `row_drawn=0` is a row the user reported as "sometimes missing" | -| `axis`, `marker`, `pane_box` (in `activated row0`) | `[-pre, +post]` of the event just opened, `marker=0.000`, and a `pane_box` close to `pane_w` | an opened event framed on something other than its own pin; a trigger line left **parked** (a large negative) rather than on the edge, which is a file opened without its marker; or a pane whose *rendered* width is 0 while the divider says 240 -- the position is what was asked for, `pane_box` is what the pane actually got, and a divider at 240 with a zero-width pane is still no pane | +| `axis`, `marker` (in `activated row0`) | `[-pre, +post]` of the event just opened, `marker=0.000` | an opened event framed on something other than its own pin; a trigger line left **parked** (a large negative) rather than on the edge, which is a file opened without its marker. `page=events` and `page_box` (the page's width, as in `open alone`) say the page is still up and has a width: a replay from the page must not close it | | `rows_kept`, `selected` (in `activated row0`) | `rows_kept=1` and `selected=N/N` -- the counts either side of the activation equal | the list **rebuilt** by the activation. It is the row *object* that is compared, not its index: a rebuilt list has a row 0 too, and only identity tells a list that was left alone from one that was torn down under the user's scroll position and their comparison selection. `mark_events_on_screen()` must rewrite the titles in place | | `replaying` (last line) | 0 | a replay with no way back: *Live* must start a fresh client and thread | | `window`, `trigger`, `channel`, `level` (in `live restored`) | each equal to its `(want …)` | the way back out of a file view discarding what the live view was set to. One wrong half names which writer won: a `window` that reads the **session file's** value is the deferred `apply_layout()` beating the restore; `trigger=0` is the toggle the file view switched off and nobody switched back; an empty or shifted `channel` is an index restored instead of a name, after a rebuild renumbered (or a group renamed) the channels; and a `level` that moved on its own is `rebuild_trigger_signals()` re-defaulting it because `trigger_level_touched` was not restored with the configuration | | `pending`, `gen` (in `live restored`) | `pending=0`, and the two generations different | the restore still waiting. It is gated on the stream's first CONFIG, so `pending=1` says the channel list the stored trigger name needs never arrived -- with `channels=0` behind it, a session file that enabled nothing | | `pinned`, `held`, `mode` (in `live restored`) | `pinned=0`, **or** `pinned=1` with `held=1` | a file's pin surviving the way back, which is a live view that cannot be navigated. `apply_time_window()` drops it; the second reading is the legitimate case -- a restored trigger that was *on* can latch again before the probe reads, and a held capture is pinned, correctly | -| `pane_w` | the divider's position: 0 while the pane is closed, its remembered width (240 by default) while it is open | the pane reserving a column while closed (a paned hands its start child its minimum unless the position says otherwise), or a width that is not remembered | | `tick` | 1 in live mode, **0** once a file is on screen | the signal list still offering "acquire this signal" for samples that are already stored | | `window` (in `activated row0`) | the **event's** span -- the probe sets 0.5 s just before the click, so reading the event's span says opening it framed it | a replay that leaves the previous window alone, or one that restates it every frame instead of once | | `compare buttons` | `selected=2` and both buttons sensitive | the buttons reading something other than the selection, or staying insensitive with a valid pair selected | -| `compare` | `kind=1 rows=1 channels=8 samples0=33045 traces0=4`, `pane=1` | `samples0` above the stamps pushed -- a live producer left running, pushing four stream signals into the group's eight channels; `rows=1` because the layout is the user's and must not be re-derived; `traces0=4` because the four slots the layout holds are renamed, not replaced | +| `compare` | `kind=1 rows=1 channels=8 samples0=33045 traces0=4`, `page=events` | `samples0` above the stamps pushed -- a live producer left running, pushing four stream signals into the group's eight channels; `rows=1` because the layout is the user's and must not be re-derived; `traces0=4` because the four slots the layout holds are renamed, not replaced | | `compare plots` | `area_b=1 rows_a=1 rows_b=1 list=4 same_x=1` | no second plot on screen, a second plot with a different row count, two plots not on one x window, or `list` above the signal count -- the panel listing each event's copy of a signal as a signal of its own | | `compare sync` | `mirrored=1`, with `y_a` and `y_b` equal to the range the probe set on the first plot | the mirror not running, or running the wrong way: a y (or x span) change in one plot that the other never sees | | `accumulate` | `kind=2 rows=1 channels=8 samples0=33045 traces0=8 window=2.000` | the second event's layers not landing: `traces0` stays at the four signals the layout holds when the slots were not free, or the row named after a previous group's suffix so nothing binds | @@ -2360,14 +2382,15 @@ is what the compact-row CSS rule is for. ## `events pane:` ``` -[udpscope] events pane: compare=1 accumulate=1 list=1 revealer=1 nested=1 +[udpscope] events pane: compare=1 accumulate=1 list=1 nested=1 ``` -The events pane's frame read from the window: the two group actions, the list, and the revealer the `.ui` -builds, plus `nested` — the list being *inside* that revealer, which is the one thing a lost id cannot show -(a list parented elsewhere reads 1 everywhere else). `[events]` is the other half, and it drives the pane end -to end: recording off, recording on, the trigger, the history button that reveals it, a row click and the -replay, then Compare and Accumulate — so the file's buttons are read by being used. +The `events` page's frame read from the window (the page's content is `events_box` of `events-pane.ui`; there is +no revealer, divider or header toggle any more, so none is read): the two group actions, the list, plus `nested` +— the list being *inside* `events_box`, which is the one thing a lost id cannot show (a list parented elsewhere +reads 1 everywhere else). **All four must read 1.** `[events]` is the other half, and it drives the page end +to end: recording off, recording on, the trigger, the page opened with `open_events_page()`, a row activation +and the replay, then Compare and Accumulate — so the file's buttons are read by being used. ## `[windowpop]` diff --git a/docs/claude/verification.md b/docs/claude/verification.md index d47097b..5309341 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -433,14 +433,14 @@ precedent, not an exception. config that has seen the first-run tour, which would close the page it opens (see the next-but-one bullet). The reading's own entry is in `probe-readings.md`. (Probe name `dock_section_probe`; the variable and the `[side-tab-probe]` tag keep their old names so existing scripts keep working. The measurements used to be a - dock section the probe scrolled out of view, with padding; the dock now holds only readouts, so there is + dock section the probe scrolled out of view, with padding; the dock now holds the readouts and the Events block, so there is no scroll and no padding.) - `UDPSCOPE_PROBE_CURSORS_MS` and `UDPSCOPE_PROBE_MEASURES_MS` also print the dock's copy of what they read: a `dock cursors:` line after every `[cursors-probe]` report and a `dock measures:` line at the measures probe's added / settled / final stops. **`same_text=1` on every one** is the assertion that the dock and the page show one string; `fits=1` that the Readouts box is no wider than the dock. Entries in - `probe-readings.md`. The exit chain also prints one `[readouts]` line (pages registered, the dock's one - child, the dock's width, the pages' sensitivity, whether the stream has signals) on every exit-dump run. + `probe-readings.md`. The exit chain also prints one `[readouts]` line (pages registered, the dock page's three + children, the dock's width, the pages' sensitivity, whether the stream has signals) on every exit-dump run. - `UDPSCOPE_PROBE_DOCK_SESSION_MS=2500` with `UDPSCOPE_EXIT_AFTER_MS=8000` -- the session file's `dock` key, end to end: the dock hidden, saved and reloaded; shown again; and a file from the previous version (`side_tab = "measure"`, no `dock`) loaded without an error. Three `[dock-session]` lines, each `ok=1`; @@ -721,41 +721,50 @@ as distinct from the fault injection below, which exercises ingest: leg of it (`title='Opening ' detail='reading the recording'`, before `waveform loaded:`). A run that names its source with `--server` prints **no** `loading:` line at all: every other probe here is on that path and must stay on it. -- **The event cache round-trips, and only where it is available.** `UDPSCOPE_PROBE_EVENTS_MS` reads - the pane's availability first, in the state the app starts in -- `[events] live: button_visible=0 - pane=0 recording=1`, **no pane and no button without trigger mode** -- then turns trigger mode on - through the real toggle (`[events] after trigger on: button_visible=1 pane=0`: the button appears, - the pane stays collapsed) and clicks the button, waits for a latch, then reads the cache and replays - the newest event through the pane's own row: +- **The event cache round-trips, and only where it is available.** `UDPSCOPE_PROBE_EVENTS_MS` needs a + config that has seen the tour (it opens a slide-over page) and a throwaway `XDG_CACHE_HOME`. It reads the + availability first, in the state the app starts in -- `[events] live, recording off: available=0 + dock_sensitive=0 page_sensitive=0 page=none` (the dock block and the page agree, both insensitive and neither + hidden) -- then switches recording on (`available=1 dock_sensitive=1 page_sensitive=1`; **trigger mode is not + a condition**, and the line after the trigger is turned on reads the same), opens the `events` page with + `open_events_page()` (`page=events`, `[events] open alone: page=events page_box=347`), waits for a latch, then + reads the cache and replays the newest event through the page's own row: `[events] dir=… n=1 newest: dur=2.000000 pre=1.000000 post=1.000000 edge=… signals=4 samples=20001 - rate=10000 seq=1 thumb_bytes=230454 recorded=1 pane=1`, then - `[events] activated row0: replaying=1 thumbnail=1 thumb_size=108x36 thumb_visible=1` (the size is - what says the picture is *on screen*: a paint-able alone is not), `[events] replayed: channels=4 - samples=20001 replaying=1 waveform=1` and `[events] live again: replaying=0 waveform=0`. It ends on - the pane's availability, through the trigger panel's switch: `[events] recording off, trigger off` and - `[events] recording off, trigger on` -- once the cache holds events (the probe's own run has written - some by this point) both read `button_visible=1 pane=1 recording=0`, because a button that shows the - cache must not go away with the switch that wrote it; against an *empty* cache the same two lines read - `button_visible=0 pane=0`, which is the empty column the item exists to prevent. **`samples` must equal the - export's row count for the same window** (`20001` for a 2 s event at 10 kS/s), `thumb_bytes` must be - a real BMP (`54 + 480*160*3`-ish, i.e. hundreds of kB, not 0), `thumbnail` must be 1 (the row loaded - the picture) and the last line must read `replaying=0` -- a replay with no way back is a one-way + rate=10000 seq=1 thumb_bytes=230454 recorded=1 page=1 plot_w_stable=1 plot_w=756/756` (**`plot_w_stable=1`**: the + first latch did not move the plot, which the old pane did), then + `[events] activated row0: replaying=1 page=events … thumbnail=1 thumb_size=138x46 thumb_visible=1` (the size is + what says the picture is *on screen*: a paint-able alone is not), the dock's reading of the same cache -- + `[events] dock events: rows=N pane_rows=N same_titles=1 sensitive=1 dock_w=268 on_screen=1 … at=after pane + replay` -- then **one click on a dock row**, `[events] dock replay: clicked=1 replaying=1 waveform_changed=1`, + and the dock read again (`marked=1`, `expected_marked=1`: exactly one filled marker, on the replayed record), + `[events] replayed: channels=4 samples=20001 replaying=1 waveform=1` and `[events] live again: replaying=0 + waveform=0`. It ends on the availability, through the trigger panel's switch: `[events] recording off, + trigger off` and `[events] recording off, trigger on` -- once the cache holds events (the probe's own run has + written some by this point) both read `available=1 dock_sensitive=1 page_sensitive=1`, because a block that + shows the cache must not go away with the switch that wrote it; against an *empty* cache (and on the E-1608, + which never latches) the same two lines read `0 0 0` with the page still open -- insensitive, never hidden. + **`samples` must equal the export's row count for the same window** (`20001` for a 2 s event at 10 kS/s), + `thumb_bytes` must be a real BMP (`54 + 480*160*3`-ish, i.e. hundreds of kB, not 0), `thumbnail` must be 1 (the + row loaded the picture) and the last line must read `replaying=0` -- a replay with no way back is a one-way door. It then **waits for a second latch** (bounded: twelve retries) and drives the two group layouts through the buttons: `[events] compare buttons: cached=2 selected=2 compare=1 accumulate=1` (the buttons read the selection), `[events] halve`… `[events] halves a_first=20001 a_second=0 b_first=0 b_second=20001` -- **the two halves of the ring, and the reading that says a comparison's gap is a gap: the first event's channel is finite only in the first half and the second's only in the second** -- `[events] compare: kind=1 rows=4 channels=8 samples0=40002 traces0=2 traces1=2 window=4.000 - recording=1 pane=1` (`samples0` must be exactly the stamps pushed, **not more**: a live producer left - running pushes its own four signals into the group's eight channels), `[events] compare plots: + recording=1 page=events` (`samples0` must be exactly the stamps pushed, **not more**: a live producer left + running pushes its own four signals into the group's eight channels), the dock line `members=2 … at=comparison + up` (the hollow markers on the group's members, which only the dock shows), `[events] compare plots: area_b=1 rows_a=1 rows_b=1 x_a=[-2.000,0.000] x_b=[-2.000,0.000] same_x=1` (the second plot exists, is on screen, mirrors the row count, and both are on one x window), then the sync -- the probe sets a y range nobody would arrive at by accident on the *first* plot and reads the second a few frames later: `[events] compare sync: y_a=-3.500 y_b=-3.500 mirrored=1` -- and `[events] accumulate: kind=2 rows=1 channels=8 samples0=33045 selected=2 traces0=8 window=2.000` (`traces0=8`: the four signals the layout holds plus one layer each for the second event, **in the same row**, which is what an accumulation - means). Run it with a throwaway - `XDG_CACHE_HOME` and `UDPSCOPE_EXIT_AFTER_MS` long enough for the second latch (20 s). + means). The exit dump adds `events pane: compare=1 accumulate=1 list=1 nested=1` and the readouts line's + `dock_children=3`. Run it with a throwaway + `XDG_CACHE_HOME` and `UDPSCOPE_EXIT_AFTER_MS` long enough for the second latch (20 s). The dock row cap + (24) is only exercised by a cache that has filled past it: `max_ok=1`. - **A waveform round-trips.** Export from a live run (`UDPSCOPE_EXPORT_MS` with `UDPSCOPE_EXPORT_PATH`, against the mock at 10 kS/s), then open that file with `--open` and an exit dump. The load must print From 86ea0bac38e8c42247297ac9d28fab65a15b6a6c Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 07:28:16 +0100 Subject: [PATCH 093/127] ui: stale tooltip wording The dock button's tooltip, the guided-tour menu row's tooltip and the m / g shortcut labels still described a side panel and tabs. Co-Authored-By: Claude Sonnet 4.6 --- src/shortcuts.cpp | 6 ++++-- src/ui.cpp | 2 +- src/ui/header-bar.ui | 2 +- src/ui/shortcuts.ui | 4 ++-- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/shortcuts.cpp b/src/shortcuts.cpp index e8b44f1..6a951b2 100644 --- a/src/shortcuts.cpp +++ b/src/shortcuts.cpp @@ -14,8 +14,10 @@ constexpr Accel kAccels[] = { {GDK_KEY_a, 0, AppAction::TriggerArm, "a", "Arm or disarm the trigger", "Acquisition"}, {GDK_KEY_t, 0, AppAction::TriggerPanel, "t", "Enable or disable trigger mode", "Acquisition"}, {GDK_KEY_c, 0, AppAction::CursorsPane, "c", "Enable or disable the cursors", "Panes and editors"}, - {GDK_KEY_m, 0, AppAction::ShowMeasure, "m", "Go to Measure", "Panes and editors"}, - {GDK_KEY_g, 0, AppAction::ShowSignals, "g", "Go to Signals", "Panes and editors"}, + {GDK_KEY_m, 0, AppAction::ShowMeasure, "m", "Open or close the Measurements page", + "Panes and editors"}, + {GDK_KEY_g, 0, AppAction::ShowSignals, "g", "Open or close the Signals page", + "Panes and editors"}, {GDK_KEY_l, 0, AppAction::LayoutEditor, "l", "Show or hide the layout editor", "Panes and editors"}, {GDK_KEY_s, 0, AppAction::SidePanel, "s", "Show or hide the dock", diff --git a/src/ui.cpp b/src/ui.cpp index 46cb210..cd3aa0b 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -4893,7 +4893,7 @@ GtkWidget* build_toolbar(App& app) { app.menu_tour_row = menu_button("Guided tour", "Ctrl+T"); gtk_widget_set_tooltip_text(app.menu_tour_row, - "walk over the plot, the side panel, the trigger, the signals and " + "walk over the plot, the badges, the trigger, the dock, the signals and " "the status line, one step at a time"); g_signal_connect(app.menu_tour_row, "clicked", G_CALLBACK(on_tour_clicked), &app); gtk_box_append(GTK_BOX(help_box), app.menu_tour_row); diff --git a/src/ui/header-bar.ui b/src/ui/header-bar.ui index 74248f7..7e86e20 100644 --- a/src/ui/header-bar.ui +++ b/src/ui/header-bar.ui @@ -47,7 +47,7 @@ sidebar-show-symbolic - show the side panel + show the dock 1 diff --git a/src/ui/shortcuts.ui b/src/ui/shortcuts.ui index b6cdcaf..1b41349 100644 --- a/src/ui/shortcuts.ui +++ b/src/ui/shortcuts.ui @@ -73,14 +73,14 @@ 1 m - Go to Measure + Open or close the Measurements page 1 g - Go to Signals + Open or close the Signals page From 328588a8b93a1f02037660eb7d7cb31288c816d7 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 07:28:16 +0100 Subject: [PATCH 094/127] docs: the manual and the user guide describe the badge bar, the dock and the slide-over pages Co-Authored-By: Claude Sonnet 4.6 --- README.md | 16 ++-- docs/USER-GUIDE.md | 67 ++++++++++------ docs/img/main-window.png | Bin 172276 -> 133451 bytes docs/img/trigger-capture.png | Bin 151933 -> 131926 bytes help/C/org.udpscope.app/cursors.page | 9 ++- .../org.udpscope.app/figures/main-window.png | Bin 149558 -> 133451 bytes .../figures/trigger-capture.png | Bin 126023 -> 131926 bytes help/C/org.udpscope.app/math.page | 8 +- help/C/org.udpscope.app/measurements.page | 6 +- help/C/org.udpscope.app/overview.page | 73 +++++++++++------- help/C/org.udpscope.app/pause.page | 2 +- help/C/org.udpscope.app/sessions.page | 2 +- help/C/org.udpscope.app/signals.page | 24 +++--- help/C/org.udpscope.app/status.page | 2 +- help/C/org.udpscope.app/trigger.page | 40 +++++----- tools/make-help-shots.sh | 44 +++++++---- 16 files changed, 180 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index b421921..87a8011 100644 --- a/README.md +++ b/README.md @@ -1277,7 +1277,7 @@ same reason: a frame can arrive between two layout passes, and a hole that empti the spotlight and the card jump. The card goes under the target, over it, or in the column beside it -- whichever has room -- and only takes the bottom corner when the target *is* the whole content, which is what happens to the plot step in the documented headless run (a 640x480 Xvfb screen gives -the window its 756x894 minimum, and at that width the split view overlays the side panel instead of +the window its 756x894 minimum, and at that width the split view overlays the dock instead of putting it beside the plot). The probe prints the two rectangles (`spot=`, `card=`) rather than asserting a placement, so that case reads as the geometry it is. @@ -3048,11 +3048,13 @@ now — they are listed so they are not mistaken for anything else: trigger mode and the block would grey itself under the click that started it. (8) The **dock's event rows carry no thumbnails** (the page's do) and show a **hollow marker on the members of the group on screen**, which the page's rows do not. (9) The dock lists at most 24 events; the page lists them all. - The signal badges, the signal pages and the drag (step 2) are done. Still to come, in the spec's - order: `USER-GUIDE.md` and the manual's figures (4). The events section in the dock and the `events` - page are done, and the left events pane, its divider and the header's Events toggle are deleted. Until - the manual is swept, it (which still describes the side panel's tabs, and the cursors, measurements - and events as panes of it) still describes the previous layout. + The signal badges, the signal pages and the drag (step 2) are done, and so are the events block in the + dock and the `events` page (the left events pane, its divider and the header's Events toggle are + deleted), the Mallard manual, `USER-GUIDE.md` and the two figures that show the window + (`main-window.png`, `trigger-capture.png`, regenerated with `tools/make-help-shots.sh`). The + manual's `math-editor.png` is the editor's own window and was not regenerated. + The main-window figure shows `'sine' is off: 1 trace left the plots` in the status bar and no sine + badge: the headless enable-all probe that drives the scene ends that way, and the figure records it. - **A plot holds at most eight signals** (`RowSet::kSlotsPerRow`), because a `Plot` cannot give up a trace and the slots are therefore created up front. - **One toggle drives both cursor orientations.** The vertical markers and the @@ -3187,7 +3189,7 @@ silence timeout and then resumes, which is the EMI case end to end. - **The tour card can cover its own target, and in the documented headless run it does.** The card is placed under, over or beside the hole whenever one of those has room; when the target covers the whole content -- which is what the plot does at the - window's 756 px minimum, because the split view overlays the side panel rather than + window's 756 px minimum, because the split view overlays the dock rather than putting it beside the plot -- no placement can avoid it, and the card takes the bottom corner. The probe prints both rectangles instead of asserting a placement, so the case reads as a measurement. diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 81451dd..0e80b76 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -6,9 +6,22 @@ what was built and why, and `SPECS.md` is the requirement document. ![the main window](img/main-window.png) -*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.* +*The main window with a source streaming: the header bar above (the window control — `2 s` — first on +the left), the plot, the badge bar under it with one badge per signal, the status line at the foot, and +the dock on the right with the cursor and measurement readouts and the events block.* + +The window is a plot with three things around it: + +- the **badge bar** under the plot: a badge for each signal you have switched on, a `+` to add one + (past six they fold into one `N signals` badge), and, on the right, **Trigger** and — only for a + source with controls of its own — **Source**; +- the **side panel**, which a badge opens over the plot's right edge, one page at a time: the trigger, + a signal, the list of signals to add, the cursors, the measurements, the events, or a source's own + controls. A second click on the badge, `Esc`, **Close** or a click on the plot closes it, and it + never resizes the plot; +- the **dock** on the right, which the dock button in the header bar (`s`) shows and hides: read-only + **Readouts** of the cursors and of the measurements, each with an **Edit** button that opens its + page, and an **Events** block. ## 1. Connecting @@ -30,7 +43,7 @@ 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 `● · · `, and the ⓘ beside it opens the +At the foot of the window, under the badge bar, it reads `● · · `, 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`, @@ -44,14 +57,16 @@ detail: packets, frames, gaps, per-channel sample counts, the buffer's span and ## 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 **`+` badge** (or `g`) opens the **Signals** page, which lists what can be added: the signals the +CONFIG described that are not acquired yet, each with a tick. **Signals start disabled — ticking one +is what starts acquiring it**, and a signal nobody enables costs nothing. Its badge then appears in +the badge bar. Drag the badge onto a plot row to add a trace; drag it again to another row to plot +the same signal twice, or onto another badge to reorder the badges. 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. +Clicking a signal's badge opens its own page: the colour, **Plot on** buttons for the rows, and +**Calibrate** — gain and offset, applied at ingest, so the measurements and the export carry the same +numbers the plot shows. ## 4. The window and navigation @@ -83,8 +98,9 @@ should look like. *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. +The trigger button in the header bar (`t`) turns trigger mode on. The **Trigger** badge opens the +trigger page, which has the signal, the condition, 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. @@ -94,6 +110,10 @@ and Arm/Disarm. Selecting a signal for the trigger enables it. 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. +- **Events.** With *record events* on (trigger page), each latch is stored. The dock's **Events** block + lists them newest first and **one click replays** one; **Manage** opens the full list as a page, + where a click selects, a double click replays, and **Compare**, **Accumulate** and **Live** act on + the selection or go back to the stream. The block is dimmed until something is recorded. ## 6. Pause @@ -104,22 +124,24 @@ does not walk it off its snapshot. ## 7. Cursors -The cursors toolbar toggle switches on two instruments per row: +The cursors toggle in the header bar (`c`) 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 +The dock reports each trace's values at the markers; **Edit** beside **Cursors** opens the page with +the mode and **Reset**, which 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). +A measurement is a statistic of the **visible window** of a signal: average, top, bottom, min, max, +peak to peak, amplitude, pulse duration, period and frequency. `m`, or **Edit** beside +**Measurements** in the dock, opens the page; its `+` adds one, a click on a row (in the page or in +the dock) edits it, and **show on plot** draws its result as a ruler (levels) or markers (times). +The dock shows each measurement's value. `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 @@ -127,7 +149,7 @@ split: they count rising crossings of the mid level, one edge per cycle, and a s 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 +it stops, and once per mode change — so a live view cannot cost you frames. The cost is reported in the exit dump with the app's self-check. ## 9. Math channels @@ -139,13 +161,14 @@ the square on the right. The orange dot marks the node that carries the channel' 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. +as a source. The **Signals** page lists them under a `MATH` heading whose `+` creates one; a math +channel's own page has **Edit graph** and **Delete**. 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 +- **Drag from an output square to 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; diff --git a/docs/img/main-window.png b/docs/img/main-window.png index c8fa7541f9690e42c405560f4723d66509087f48..f626603f352de3266cfc929d47f1fdac5a9ca0f6 100644 GIT binary patch literal 133451 zcmagGbzGIr_CCDnmXz)W>FzES0TJm&LOP_on=K*&(x3v;4bm;r(%mhw>E<0gzwvc^3XK|5&(HB zC#~Tz^C#WYBjNQ~Ps?;_)r<4ceebtA=oKs#ba7D#JghnWF=V_sIY+W`xb99y-6KwE zNIiD%2(%pMSeS#e*kp8msXx=xeU`-cc9idF<=vgqBA@pHy@O2Bu_q;^-O)z#dDB7t zd4~6X{pR5gZ*&ngYS8~2rb_L3jQ{gGh&m!W`u$~Q7505>KE^kXreViJE|E-cv>$eo zWgPk<-i#{;I%u$|k+I*1(m4MfxY8#LehRzeEqj6xua2=P#IE$hkp@enR>2XwYdG*@ zDW13RAB_Ac+yAgxd;V_^f+DExky~HoslY*sG@p1Z+Jze5R?;kfaUYUMt2Y+t$1VzV zT_Cr1{Xf51BBq3*K$w}C9k1qkAy(D8dqnRfj%j}wdd7}EL!FR}t}C(cTx^J1K>goN zN{7G|j#~>N!^b_9DyrcOJS4s+20y_{A8J%`_&@Daw+D<{SI=HTlGs`aqmjfjZP8O< z(CppB$>HN(3oo*NFC4Vr#!VRAwe8RVH;i5=zN|>EHs>j*9Q)!#;s1c8}|29EK-gFNMXKNLw^6^n& z9XngAjJU#JKq6s2^bk9|^ofw8RieiO_tpgeG;ca=%11gfJByJ!f*RemVnLkk%F9h& z<_#e|(?T+cpcY1p+@%WR66IZ5{OtZG==G?qgK2j zzfG!v5BGL;n+&llr^zc|(20Rg<4~$!P$U)>r8<8s9{)M?X@+BCYcYHc;ZHkeCydC< z3c0xH>FHdgv17#lj$N_&+j(V7nIHj(0;g`k1^<)m*)s&T`T61dMy9Q2$?gYV1-S#t zb&J&s$E$Mxok&cNehc0)YksB$zbbpiF!Xj~|Ec3`ixZz8B<=l}sJ};t$%1f)DjX!6 zBH`l3twR-ZrS`e?a^DAxb?J*!w|J#!BWKos&XIJLZR96v$VKmKT zITn%hxSe;8b_hPesqy%cQ{!ZvCn03NHf{IhdxnKKC{O+RA1eqVX0w^d{$VdbjJdVF zjaHwQkwJMKul4=an;pau4@7MqCoxFYxzuZ=L>%P|rCb8;th()Qk}FGXvS%$lv4yQ+ zSpLV?6#tBiiP=gghL~To`vFJhVklYeNQB^`580xx7TQE!3hrIj|oF2UCHRj11PtdHqsDLn-#}aFHL*yC*^dX6+l0 z(9qD%hB7@?-r7zdCH5?8Xi;^gyO*@rF4{*%z&TQzlxsIylw%=*m71bV_|ZMJMdUKN zV{0#CxoEWBib?Sga_g2&$%zAm03D~ri-G>J84X)MiV-PNZwQPSHeiuVs*-!$rDZJU zCYFUB;c#s>6{1l(Y1R?u4+qgKX~ZWo^1=1Habn5Fp4<;5d#3;rOwo9z!XP4i!6stgNoWL9DAup%OnRU%w7-SAVz!sh83co%ln+ zCI_4gr2;7-8j4i6 z^L^2{GXhuT^zLF+)#-4FM!Q&3LnEf^wbm{2$jAuXc$aDUQZ#3CJ5+K%1g!q2Pq|C# zH+BLD4MER4myE7Gd8|hY$^xMyR+xIzZKO3*6Okj{#8c!$SX$}ME-sE2hq!YtL%L$; zbFY?Q9@>=~eq$3EGHz~~+B<6zuP|FLh%yysW1{I8Tyipo)aV(K^$_BUFkiFUaa4kd6$b{DD_@$DD{jlCQ z?N_HzS{j;#ZPCQ<_`4J0h~?TbNrql`X%h(~YfLJQ0gVtekc7}ggRgS7{6T?$*mJ#! z@<>BEcVvpU%lC3!%VR5B5pF8(n}()+*U$K`D(2jZLF3l^U<&1wl`%g)W2mt$=v;+L z20iDveB6AsV8ZgVSJ+aNWoS0uF#_f!IQGcmw5Q8?G9y*3~}U?4!p zrJ#s?xYAfM$@YeW=Jhv@f6d->X*s_E^JB&IP2AAt>a0VHXby2pSFeo}Ca3dx z8#OI0sQ11O2C$k}c{}ejGQ#pQbg9nVm);EEy)mb~T>nWNLa!aF*u|$(0^mI2f z2rLhVB>kAqPPtkezdKLCcOn$O`uYwJ{98h?B|M0LSut1)rs007oLg?U;#;|Z;n!_u zTxY4f&PZw%h&T<2pD%YtelF96-M0NXs@p%~3X|%&XHu00e%(|E#C_OKVNKD|@upE& zx@hg{UhQIQ!7EqFQU@EG9z`nO0Ed=ayoYmuRPF9cYxW>zN@3FIUetE(g39LxhN9hg z!AkJ>F&w1E?wV4s);?H+wafdUDVRdExo4$ID?2X_h03Q47jg*ox0Nz8!+-WHs@%M9 z5==>{nznYpXdc-fjpo$YI&ieD6UDRdQ&WQ-nlIrK6BmuAD^jyG3t_*XmkviWp#~8n z|Cw*-E36DJJqY8C+g+Br3q|mUoeauP2bZh#_CEDk4z-L=NeTS%!z)eXb@*F8wmGbu%G%Ndc{6#@*x^4&|$NrB;26IQZ%k1Z6g}hFP72?>sq8`_JHkQ;) zeIuq4bt4C+8gjn481XcLapl(&qG{+ZCUS<)5%=2WW_EKkT%qPX!dLas!9kTGol3&f zQ>>qZgYCcuWpY{ndU2eY+1Zhq8FWb9E(b_5az2a56RA);;e%c$t+*3Dz59*SDCzE7pF zR)llle-0K9Alemcq@A6e)B*w%ewhXhex?RQ8-`d|c}}~ar6pan`n%C&9p_1FkSZz)tlG9+SJ+#f@-K(;H?l5I7Og;QF`kb^Zh zIvQMULnY;U`Z9)GCn_(~4<0YFbG6#6C$7y5DjxG9RidXYIQ-8u8$UlvLm!xMXLt7_ z^z0}ow5M`NA`}1+Y%ng`l@{!fEHGl8p7kF4^?CDg$cm(s1{qD>?!puZ7I%ywVni$l z7%ox?i3kXE-`zVoIYTE!4e{~u_cR6v1Q#Q=wyb=P2Wl_QW)BY6lWMYlCmFc0P&-3WXC^6ajJ(_C{MnM-i8J$WCky{RRbk4u?A%Ofri#~G*dzO32 zxT6TGTZAQ%75G74D=Q&8=;h?pRLFSYK^F$6SafG6;^6LD#PBQ;?7So>zYZr#RLf-| z0(is7y3I`8;_jyN1?cC`3C!&5ZM;KbZ?X0FBKP<0!Nso(ifU2QnIWoV6m>m(37F!| z#VUusb8lC$MXQohM9=Y%WGrw1(clw^l$#r2hVNMcoaK#6CqE%|eK0<7;lUJ&vRW?3 zIv+oE=Y0K&L>k+D=lSdzPl!ymxZ%=Vy^C?Vw%TjgVY8H$n+htBQ~F3yd7tlBE;0!V zqoIewUtW58>{Yi&0navDb5IVf`{Jmtw9My{Dn2z80h0e+#ER4G{tn^dWb}p5{Y`J( zpNi+;li->f0dg^qpZ0aLE%y)zH7941s{t{R`$SR7#VS?ODNF0Ku}Q(KB3pVi6cA>( zjI%5d7^i4(P<=>iHU=8{HxF8__n&8)hv%vU>`k$yCi};KAH6-7v=Y2)I&80m1T-8N z26uNe78zVa?(WXDgi90XWQ>dyz@ns2V3cy&v<3i(sIe@jG+-?L7riNUq9mx$)K+3$!U87+6&|U{olDndxdUm|bOJK5j30x|OC=Tam-oP7{yX_(57mGjsBqB_E z;w|k)_*>GKDl9mB1i(nH1LH*&q*0E5iyFxwBR_jL_lAas^p6|B-4AYGS5@%|A}sMw z>SKLXkG#J-e-2zJhFhr5<&oqM15xb-lC5v#X_*i?m}PY{wlW`k!g525Px~hDGvtOcIr?PA_bDR zB3)M^$kK8+RV(l=f$5^|CYvg=`kym0;=(C)Q9u?3<`HBLa3C=xW_?nd1Ex%l7PqA7 zjP&NLb`q|v3q`-ug5S{hl>Zvp8YBkxEw`Grbs^0=A3#_Dnd>8{q_7afa*$7! z9*;&)Fx+%ZXsLLhoqH<%2|`w0+XVM3CwgR^haum}9=F~jSyX@Z>ZO4%0Z1O{6hDcC zGMn(+Z>+Dc{Fx@Ks;Z)9U_cTUp7uDK-h43Oy0aOZL;rhsqMco`)>YX1%*;)@&h z3*-+UJ}CVKmV+;u3H(Z@%a)Bi?r*V!S?_=BmbASPoX0tHJ(l8uzy~Pg1{#)bR`uuS zGedu%j7O%5kYFOChRP88%>++$wVWQnZJ~2Ak_PqSQzwg=w7UC%bruy>E`2M&!STq+ z$%)S^gKn@|+HS56om@!G{NDTK!l|g4?g48U{d~g%(88}fcTdlho?UeGqrLk~FyCN2 zp_8ynpFA6b-k-$FydjY-iwFU7wB?qM&q7-&rskqS*NH!4R?)RS~_r=#axw5@G zf*_ZuyI6@nY*=KcAha=66;XC@a8T;;rC5e}yzhOZk%u}KDGg3NH&wRmAnmL84=ILe z3(WBodjneEr+K;9Hav+;=+u;U{$a z={X9h^2?=Qc)2Y4}EyqSfn#lJ#Pw-{#SJtKwA80Xh0CCI`R+$Vahqf zEO4h&odcm>n;vh(il?83$4y~hQ;{m;=UrB|6@)6nIpEqHwC19~dUn%Hj4)e4Tk(-T z&KLzcHp1tv+p&>>NYEaH%%N~_5puNIa54XkBJszNK6~k13sWvy%xcIp%zxX&>68ln zLx_mGhHLy!XZ}7xMtKAI2RuJC6hVVY@_>x~nHf}5R5K`?m5?*RCt1a;ypQAL#;2D| z+yq8~O>4`;i3P8)PtTtncv`indiBU&8~nbeU_BUlcHoM7ChwJ3q?@gX;i9N`FC#8a zA?9gowPziBVpH{~*jtB=K5qi`6wM~&DpS!VffMINCI#|;~)?5^`D~?Ck!*-pH~l0xI@Z&Aj%$ZC;)m8AwX}u*wmWiP+o;` z$8t)+L7N5+KaK}c5b^>-1@5@@n4=!PoSVnzXv8}$$v|H{=rcZ3yZV7+BE_IcUhGeW z*=Yr9C|6Rt04-VZvi0T)9*EV%8s_1ZgTdv+D668F?xLBPue=FD3;yJAO$hVHtiF5c z#H}(hhWXjKL&wd-{0A|=i8b_Zd)VJ%W`4R%z zO|j6Hxa+^8xZha}NJ?Kntp&hc+x-sF#z6eqiAfT>5rY%L$)325?Lq#?1EGj5WA@%r zjSyuWcH`%`|7*4U?r<)#7vriA$FCbhHl;18Z}&RASqoqi4PcDgA`MwyYms-v0dJ}9 z$UYo1t&SDEO7^(pyFTFc@DZc=Xe=gqu||E(Zc)U0n&Sx}hClFMlmGISaiwI|9)fH5 zt6RBqMAo~2CO^HP7|q(!+6Mgny$_oC?6|y1bsv6l;Q{Wo(6rr==tP#<7=}fm9n?6SYg55ywS0 zk}2fn&rOm;qJ+Tzq4K%^5N+2;>bHc9uFM}MO&ZS~GUlBN7I`C1{P2pOdSSC ziEtI#anVzzOx=y1?V%*Fkyh`5Vlcg2bZl(9+jjBj>9>Dx+Y}#^6aLaCbUY zw5O1!wi?|l%h}(AaG_tb&%nhKgM95jtJk#5>7I@=?Bx15ZwUK&Q~PWCGV_+}?#z+l zVXm!((&~DWrd`UopG{tq;+Sn06Lm4FT?Y~?!}&RNMZ4Vtu%th3$ky@PP!Wo*XRn7^_$NAT#aNP zO3;Uf4l*tb@&}}{f~u+h0(?qbWj1{+I+LT)D|ot z8ra*pE#7>&q4$stCJQ;i15!xWYXlKj`VTjR|G@v93aewPnv6r>e+Mgw`YPF&D8eV!6R*+u5TEyXN0B)xq9y;bx6eRksb{%wk~Nd7M@_i2P!TNkUmHG2GRy36%V zgu8Ul9+fQubm$@l;E(}F2tQHMMD_6ah)Tltz64Mig__0P-(MGIkDi8K>elW{7wJ}$ zVqsxjECpl306K+0Sf4y8w;s1D-AWNPE^(Tf}HC6 z6yN*XR$2Q@-y0ay1J;9s(0^b)Iop8&th{nI>(KF7i!G%0O$8O1K%C z)E?&N2Vt0cdb7p?sV{YOBJ(o5kO6v}o}LzZx5d?bf9DMxYG-jRM;_5L9P&17Ybh2E zxtq`T!B^w{5J*hkPS)icd};!z_bDWfo(Y%mUjO6#ap8m3=;_7EkQ=RZhPm&CDuptK zUlnLv^hn;JK>!25-+W=pyWf0m57^9`+k<;4OG~Cg=Op$9l9u~hFm5iJgI$10iv_^L z=l<3W(h9IgFv!c87JQCf+4SommzPK zVFu_udGi0a+EJo33&}Lr!y5i%LsZ!mR(N-_=Wmc8t!l1Z`Oe+-H@-_!k3pqL{ne-= ztcwT%0!UgdB^8yx5vxu>BhrjF0D=&52$=lClhGm5>$Cgiiaw4Gz|JXiCQ=L0=hL;$ zlr{&JpAk^w!7J!Fs>4E@wk~g;1BVaKQ5rFnC-~2V;xgiynT|~728OPD$I`*0^v0RL z_agfcxoQGxI?Hk|92J5_W2H1&3IM%|05LT)ODjp}Gs{-1nr*@U^!0gZurgyujos|w z?fD|$gxDXz=?~QN`Pwi;HfcvWHDsyiZzxSIS@u3jK#Z~q9CrgcE_52z6+V3zaB%=-xE96L?{RhLVoj6+V1Ss&1- z-?`|^q+6nk?(*D5&HNo*MT6S*+qY-725}tMaU_jFmp+4Y^=J2C>T!8P6fLV2Hy=LW zr3pC&jk*~V8*wAksjoV&ju|bV5m8n?S<(M^P;7^6Es6lxaBphy$zJ zjk0L<_jFB3S(S^>=D%-!Xc-E;>#vLJF}oLK`qjbu6H#i#C7tiorQnrtoQ}Jvn8XLX z1nRh`pl^CuvERu4)&n1bM7x)TB_i%qn=KDQSx&(=rZOY`Z^f7Z!@N+#Qbi370y$bV zuPX83ckZ%`3A8laxRH@h)!YjJwFuv2=+NTerYWE@KHg!)mz7_KNSJ`jUd#@GEy`2 zv*5OZV&)xEX!|W4-0N+8&3u`C0;V=$2)P=c1fj>2kBss%2e8`^OJz z)_%oq>_r);N%5`Ai^{B>@x;{N#q%l5TPpt>nw6E6;e~}b;N813wuvCDtgH{@3~*yL z;zJot9)HSDhqtRhainfJY`5HXS72;%ay3!wE(XwP0IJS;ZW>rwiy%Ne?k+dEOyxu} z{O{!dEVe8fx>;M3YL*&?9uJbKYLW$^!v9STYUD(&3?y?;cNUQk;F$aS3BxQ#zJyd)Zds}t{yJS630ux)Aju_;Yop=P8zKoU1HEA^g7;L zaCdfhX4#He={ryRG@f?vLITnneE`=+QKid1m?4hvwE_X+I5Ou+?7tvaRg)$U*r@I} zig24xH-xH^m&7p&9c5mEPlqJn=4gtY+WItflipt8i)tC8PK}#R9 zFavUO;DHMy61j80V*5;ay?HaGeYm>Db*ghbT`|i8;@!t;+rwIZ^A)-j#=kepi@ox< zU0!Vd2SY-IwoKr?Z%&`ze10PWPmGCtcpVq2Nih}f(0m0?SU6zA$ibm)f4(6Jp>{0`4`tqcFa^>v8rKDAl# znRZCpmXiWt&qj!`qmm8}*?Om|&Gsip`Ek-mQ`;_2D;qoMj~eLNYN+vQYd#0Xi#h9m zbT;`5oh1=H>oP=M#P@gsl()9PYMwAM+S@X4Pzrt9JB#k(7896<&DSPLRq7ex2 zE?<_$$$zjDxJQT&36Ucg_CW=tvVx2Zyx(zZ*Tvp}z0~V*IO14^LrK4SAAmcV&YAmFQuqUVoU(#yyqDl4}w*hYe zR#-)c&Su-xL@?|hWc7frP&#&yp3@|Cy;lxbbMUtlnlk?!y_xIeWyR$(VawLAuG!zT zW%|a#LYguYlG}?25gPSDCX6QWuQNxozw2O6es_0?gKp=?NLYzB|JlC3#fQeeaT~kt z;=?7*(<@%wRCdM~(F3uMpFWw*oTLDv>{JJvvG8DIrvv9XeX{diC^IWt=Xi9I>D)!+ z66`|u+W^w&H{=_#UZhtt&7~JLdOx4Z;K&j*pk$R%Q+;@fKipuMIT@XZT^e5h71YCS zZ~ERlG%uPeR42yWKfs;k;5}R|5p*(=_Qx$yfLI)*Sl#bqh7ic18%G{B@0wJqsJR&l z)5S@($(yfFRG@Xx_uU-DkNE=_zCwV&1u&4)M$oh&r$vmvez`rW=RMY>e6=!d*K?yr zRSb*&$3ziR$9Nxy%vzR&iKi$71x(L(F;F8AygOBGgq8aL@U`SgDZ33nCH7nSNql7IW7k6|+CX&)7sgvtm z5)pi}j912neB1m;Bl7Im({x{oaI=chI3?xm2RGQg&GpHfBkt=1?8okM`dkd-R0E1K zWd&L7Mp*mn%|LWcIs`mU1+|iRF&MK431CD`w^j)0+q_`pwp`(pu3o{@0i6Ae*1kd1 z$P~+5hrs^~3(38Y!IceVU-S)bxji)}FZ!O-y#!glc}}hCCaZlw^S98TDwIFiOb>sLG@#(SGjpWM;L!q-Fc*?67 z+CK)0+&fcXZ$9kmGHB#GoP`S%w(%b0i`_+&h4O?`2e9g8 zd}i5!LhMTzeS}HG`@PU{O$<$9*9bW5bsq`FW2EF({H%x}MK+JEu*Xd7gnueO0|c&6 z-35xZL7aQhp}abG(;dSQ@QF!EB?mhWd4(8I$#6B=n6} ze)Xx8Z}{e~GLrqXx6T4wEKTp)pdyf0q%XBNA!S691%f2pfGz(@o2E(8WN zc8GqSBp%!&V~&X2F}TQA2v|i^cI70O$asvExIIv+4lSb_qSvpl%a88&yJJ#Z;!`uJ zKEgSUMxAQkEo3@rPOGTn(f3&n^#oV<@XEjTr+GUU}U8S=LJ1LZCi_cp!_aEBsD14H0;}dOC;bSIMEDT zDKEH4nO7Z!Qa-u*NA#9JFPb`N)jtoKqo`^~E$1;^Wh$Pb>m?dH zj<_7!e?gCm6eRu1=ywxH8uu|t5VBPAWNmCPViXVtMIG=MGyUO!bCh>=b?sz~QhMXG z>a`Lj=T_&482;_ghxovX=a24c{5IPxg(-N+6CPiFKbCr7EyuH$l6ait*vy>c7%@%AQX2?xIJJPVsmc@!(hhX0z`MKoX{90Gm{`u z?VK0cM+%CIb5RG`uj*cnCAKjRMB8g0-+lZ9Rdh4NRfx+7us47%dgnoZ!t_dNz0H4p zRf>8l{r1wM)19U16NQrR6pn2=9JOdE>QmpnwSqwim-7jtx1Lu+7|V0UIQ07B=_+Wc zjm%r9V@)Th2PgGR?$ksFum$>0j{b{)$ayPqd-jViWS!?S-@apK{<5(3wlxu~Ug zZNub0^jg4zRzSa+e7AWvLX*mFz&yR4SaRyDTx#JdGta;`iD<@t1v>{Vr6EeCrs=U> zqx$Jc;i8@P4Z}ER!tXw1BzeAx9b=PPO@g2ep2&}h%9SZSzT)_kt0-ElRV`jFU!T;+ zt!L~EY#8>kYCvl6(xvC0v&BnM@IFh6g-H8x-8ID?6bW$}(e0utOC zkvyeMKtMn%Ap!UO5t2x%o@33c$f*mVwe=0SGGW_fWk#TT9?K{SY_26T>yDF^J$_37 zB?)GJO8}BR8JA;{gJ`X6RMV%9XpBTJl4F~Gxk0_U0BPRKBZY%V0ztt?LyOe*9r9Ir zuG-Vb*%Ywa1NCZ}SfnmZo(@_azaB|W4l)ZrFGu&1pylVt@0I1QM6%SdjQ?!i4cob; zH2cD3F1V3YQG!htRLl$E+epfh#o4PKOctl=d2|qLdL(u*DfE#cK}pu$MqqDs_p{ne z$Ky#MRsBzU-w7(%lj3ds4_0#8(fKlp6qJj&Y|&rV&Cuzu^Vk(zoiBzdXPz-cmpBtg zDPPNcwcWJsp8~7ngtYqh(zm)=Qs?+oKSsGSeo|_ELF-|D31Fc`SK8DE^aLqqlVLH+ z*9lr2yA(U#6!E0tjTbWD9A|pSsoqX3RJPr3jn=tbU&f>^S1&}Rut&uI(jHG45Zm8P zsiTxk7IMsv8@=m>c3&l=jm^5UoohGEIBVYkLSsZyzt#_i%~8$EF+jtf0lX6 zHY`E)%=@Acdr}0RZj#;PbI0_SXLL#9qp$_T>qW+R;xI$s;d9=TvvxNAHsh_(UIyeP z*Omsj9hJvN&c`?m(F$fSu-qu7f=|ZyZk#UB*VZ=LjQ_}Md?erxf3%!9Y1lS~!O__+ zua#X`i2SNhQFjjB`$B6M{qQFj9P#;PC?meAqlUmID=UcI|9K_kH0MI@lpSzz z(g@eJIHefG8mbMVihgp3JjDLOh4 zAD+RsnaG^BhNbmg$N7nM#PH<1x*{SYk)cejAQOt{n$!=+XNoiHj8G5 z=_pS;9;lFqKQ&g-!z;=AIMAO2di>ze*`K8yKnwxKb2vMhNy;~*IfMMAn_+k#25Hq2 z0CMp#cYi%$e>B6Zm0J@*3FRRc+3FoOG`4L6lic+`Bsb@ks!lg!Ph%pUBSQX6SCS13 z3?%cqQ#Qy=}EpM8yqJ0y9E3D{5zcPkRWWla>-qokyej7cHl0)qGn5T0zEdMEUHPaNSh zS*rn-3uyKrtw8u|%ynW4q_&U%pg1I4^f_i{R7nfiEonGQ14Ho?UR_Hs+D@;zE6}%L zWMRVkJ`cMMDdS1#Wp-iGC02=qa4rsQ_OPY!$Lc6*WaPe0_9!L)Ij~Ycn6BQ&aqEwV z7FG~W>Vj$NY8wMo?pnR#oqf?CQq`Esq=}#5J z9v>g~SPDdW*ugPk1=L|U?Ciz9dzR9GmIK;ecnG*_&z21byuZ`Q)=QvzI_L#sKrZe@ z@XS1XR=ir5J&r=uo-=_axdPR+>=^2QLt*51X_V(5=&`)u)Pvp#>uv$cObm#Xon2R3 zFv`fV<5imY=rA{AVsd%fGPC{GW^(FY{C8@>J@a|pIGM|IzlhndH53$EvJ1@ZsQ|ftJ^7CguJhs@)t%HBtsH#L5P$afmn-c{^ z4z1GB(cz;-MiR$!!_)Eb81s=d8A_&AKr%&K(Z7CW@Yw!JfQ8ZA)|K2yaSWhxq-6>2m?ursUoL#(oge`f)j2X6&^@}zlJ3xs(M4Qd+ zst3sT%0PxIcGxNl1gk_NQ&Vea*P9vMlnG&D6$$G?xOF%#hW zGu}D+rrbAsvRih4XxsS8t)Ceg!DicJghJNyDsV6((DjS$`VGSxjk4UEX1XvY+s63Y z0Tg{N-8%^fboI67-HuL^)7N>W_XpD^(}Ku3n>JrgRerk!=W3M(8y-*wmwv5AQZVBz zX};2(^Ejpgvb9H)9ube984HjYZ|#Nlz6mod4Q|%LHdvGlyU9_%S^hNZG&;~1|M6oC zRgGP_e-bf1#vRugYMQVM;l&LoIfEin575a49344pwoLB20@a7dT6{Q7!c!P9F8t8Y zP>+xsG2}l=l3=FK_xr;h{s;;SN`}4ty1rK$592b#g zXY&K53&9(c!!cB%OCMot`6#&8c?CKQ$)49{er7D{{f$2l4^k%;=u6hs;$F(bYGpPD zpA)SXs4nK*soyWiJ7PekO;hO+IR8olWPQT|1c$5po!|Z zK4^+9ZMpk^ScILz_Krla+M40P{c#cF0F5~iO!}OTYY_`O(-X4)fagiUr1CxC2Ba|| zXEQQBlS=DUx!EBw7L(0E1E5+bgJ+GV4ntR!yu}0+!Rti^hA!>q8$#e-9yT9ffjtb) z3>!l&1V%ov$A(_p45jAmdTzX6@6ZBJ9rmY-64^{Hc&lVYVg`_}JVBW1)}w5Xbr2^y zG2PIAvqDy6M`2yBEMQU+9R7+?;=Cf?Mz+S3tsvERH+%((9)>Nq-)^-+l4p+`+$V zud*{)`==aURsL%(PncM5jOvl@#-x$&Th1O_z?x+G-=Rs~-z2;e7NewMw`TYbrG$e3 zB199qes*GY*sFIRq@G(t#?-_;R{@!voD{zGy}3Xe8yhoT>5QZ-Ih%9i=B{}~3kohn zV#j<7{`UxfMa4_Ou@SMcT_6IY6l-EYBBB#!OB0`cJ=69N{PU&bo-f}#PR5odFv#NCll@M6l*y`dHQsx9(mNQ*(y7B_E83rtVlZ%DzMNmj~dK7 zuxbp87v8Yrl;Uwf&#y>DQFirvLfr?YDDdlu59nfB%<63y74u!jq`D@_A1gH)bIha= z3)$9L3-5CObklcpWIvesoI0}cK_jFL_wnOU&XyZeKrbRdfGBXKATOi6kFy0nTMZJ~ z`ORi*d>jE1M=rcm*$7n3520c`X$VOF?K+RvYqJ^}$VA=#lrgbP)gBNvEX(GOP%|+x zErB?Ze|WWf-w8HGnBSkDjH<#zPP`Bx)QpTX+g|nh5=)tps-^sVcfQBEniw&*g|=!G zE^E%q2}4OPXMYGPLnA_LF}p)grRa2eunQI+l1lk@k4$rrEp+77N-CHIQ6bNAd-q)O zPq~Y#K{s9uGGjh>j&dp9#O%f#*59^>@Bi3%eRQoBZ^Tp7Q`uJcl6y=*t8U{~@IDmb z+8=h+q`?fNONxdvtep}u_p;oYOF*U%b~`vO`r0^Mu4BL+JoJkfmSwinJRoPv&7!6P zIp$Qg4bdl%T^agaK90DJdF#*!L#Fh*cqw-D(`>5hEk4-v*Bwh1Uhs-j%EW}G`C?36r8OyEj{L`u6oJJka>3j*OUKV13na%c`$Wd0nq5 znK5YM%ji`+ha1V(s{iD3_E-vrYPsW>C_!iji@UWZ+62dsj8P;k^Q3p2qM4lG0judd z*&nzQ3#XtTk%TIE#BuuU2)DA=FQ!;hL-*BwC5z~rdzD$h#cAmAhHa<`#%}bdlR4)k zO3ivvJ17Gk$&zv0IkF5hi7W3_+(nqK{VOxNDsge#f@9-{EBC+ME!3`agzJtWi3OtE z4woVE=&tBecw^D9>$L<`WAHvRCsica4nmC2m=>OsBLk6o`H}`Ckr;-VCVT<{g#OpF zg~^il>L+Jsrepad55*(3m)wLPvj+MJwohz2fL6CsPT@iFzZ%6L7WaDBYL45fwww!< z=nlgw6}{cv-|q&_?0s4qJlF^bHXXDAH9$CcJk&GvX|&5I{Voy#8}Mti5Z41PcpE?ZS^aGoAAC@`vpm;(kO{I*8l^z#h&+k+r@%sr*MZDnrZVpVoqAg4 zYg24=sz`6&U$h+uYU^juOWNtk1VfsNZy7F(J7Qn?BcU0o*tH7Dmp*SPNWX4B1! zC|fOp$k1cqy6&S@W&Fp)#O1|%KMYsbQ{ZRm*x9l8`I&%@IX=}UI=$XvK>R^;+*jE( z^|5aqh-)*!-V7{IhHh`aiI7}89HZUQZO6-!NK2Xc{{4Fy>{Gaq5y9;t5F8&pR-ld6 z%a^V)aL;ZU_w-UpSxi>Di`yxKOqNQx`V3gT7 zV;i|zm`zbU+v|7LQ~W-tzbd5ZS?HecWP+mGJ?27@I-!*=xN!7-$n0xh&Mz)nnzzF- zqnk5AYM7kyUJC{I$#?)sf+YV>}xz@DH6s+^W(pc|(?x zL_MEcbgx*c#SNLPTZZFTq$0RG!9b|3mh)?vF4x^&e z`Xc^4ANvtG^E(BrNJ{v5pA~CcU(4xRTwcTLXFK>$FVQ+9$gM>kbjvA|KB+9NO#`Ps zQ`Q-Q$wZrB+{(C4Tof4%IANL44*?h2Vwe*}O$gk_%TlIg7H>98%lmB96_h2uStORJ zd&v$EJ}=WtwoJ~wA^n`U`SDWO@0I1t${DeO%KZ4O(8X)4&2s)(Rx)Q~){+lbWfVg) zy?Dw~LK*1q&M*gDc1bq!;l_KWV%kC+7%d6ZV>aL*3AP~^s%=tSx&vit9sMbwL?at$ zVh6b?Iz^osPftrEdWOv9AH|xKB6fV~?Bg?=3t%fKJ@vzxWkqP{xxBWoh@*PXL$#0T z)GH!ku>nQz?HCxxXwysYVz<_CoAlh1Y#B<0_pCZdAD^2OJN*1nDMEEgBdl@!5+sS0 zB8&s$D?za)qazK}#Fxei{2zK(rrCybC#_C&G_#O7ekGIM2(er3hiEfXYV7rQHj@Ja zxCOQksGmFus>qs0{EdjSXU1l1u+2b9B|>AP9u7FmT=%-!=}c}B?O?yUodJtKs1qFm zX?9I-yROQJ*O)6^9hE{&TNH!~)G)K|uN}?DPBrZ1yF708G964$#m`*~1pw#K_Gm_d zOpdE9vtFn6)Xd8vjj}jS$TI8Js-hlkyjKazWpSy;jJo( z(sDjlY7SMZn%j=Wm4pa#eb1os&==o^?1<(=ZO$6Ssb|1CCm9wHGhYP*Altf${nJbQ zbX(7`Ws{$UCN?RAJ~%iYv6@8psYYk}H?CB*=9z@2&E1W>lb7z7e=hyZ*?_ew&>~hD zyQfouNfhaQNslKNdM!?XwhDvUL07_m&;^$)=zG#mMiCgcSlY-9aw!H*egV=Pna-YY z_N2wV=EGHtKTr9U33(x{J4>&7HepVqakbiP)R(AZ=cS6X!Dls^Rn@g2>z=tGrr>`h zX!l#x9>|Hou~-7@ATB{}wIcat_S=M<2t$bZX2IDhUaQ$kMEQGyR+5~+ulm}!)L@4>UwNSxJ5+H2?CmXKL zZ<3faFt5BOc+LJzB|Yp*kZrAV@>%cG{;uf_UccQ}64nG317|nQ088LxCI)1zzHrM; zt91@3cP>4^a2zVyDNeg+KIeE5b<8YzQ$(LtJ!>P}-_9#4N;X5!V&OX2y}>Er{zJl7 z?h$AqtNd(=DBpaw$Eygc&(TxpE*aC}*D_s9{%ujzf{%@b1vaC4+SHURY{R&4zm^-2 z+2=HOc&4xHD>5^qowz!+c^j)06C?Dg5>)C4hzPy+jJD~V4`-w5moi^he#hp0R9}NV zys5KFEtxUn2?8AR6BZ<+)U|5gZiY9jG1WcyLX1NJ?}vJ76z`*0Y#eGhFZ5@7J}06E z_b$2Kyz}AcsX3b%T-fP|Nne;@;^%*y$8X)T>Zz|UX3D!}*ay>~`c`bvi&iFKU|=wu ztsjI+EZHpd_xH>FP(cMmW_3D+NiJvkID&ZfkVBpkdRvAU!HsoL0^D$UTBV(VUmu!= zzuDDGAKj=)=l;_@!&CI`=jt5iC0g{13}q!yPI0F9oAAbyO=5|wUZg##62g9SLj5~p zdzw^wMu{@?{~t?V85Kv@bUQ!-!QC~1AVCNB;O_43?ye!YyL%oWA-KCUxVr{-cmJCA z-nID0tnQiab52$5+ItrU6lLTKi*w78tlNlRii8=6HxZw=YT~yIh?;>WSb|zi%jipl zQ{;rhAw^6MCqlX+tgJjLAe8`njc8!uqzDu$73_9?rfyM@NTm`&WC3)@!H49b4(I;@ z{T5^8g_0-`wy5+1{vPYpy43;+MCYtvgP) z)6hgHFoY-*yG+=*d>IlY`Mk!x2=9Q#J&@~gr`s#9Uc7x>%_*}J z3b9I{BD=czyS{-}(O_)9b>HIHJG)fU{0Y0pD>XS|V{#OZ`OPQW<&_~CLKbx0@GYpO zx8pIuR$h4FrqmRFC6nki(-((7Y3%Ob`+F`7G5N?#8ICe`DDHS^x6t z_deaBR;J?4f7vf}^1#FJTlZE~#U7)L6Qn@nwne!=j9u%OhwM2$uW56wEfweIlYo`d z=Wi`D9qCIiT1!hYbIXxL>yQps|+~`F*{5Uj`8ttUCx>7HyXJ$=Hp+aK8FZd8M4KCPdlf%&zY)u z+nVp)+FUM(*gP{t-||_guhEIu-lP!NG{IXUUS;x zTIqF8>Fj$p3e7ixNK{_BBcgA0IXU*9@49!XJiu=B8+M$L{*U<$e|z-iWTEDYD)-6* zwm@UFW9>Tdh2aEqDv;cF~!kf5CC<^Y#V$S1jY9W3KzEOP=Se6J$V#+I8aoOp1YlX~;}K7M@UGD+RN* zaLz%^lO!P_(RrQj3~0^K327nmJ)tK%j7_hwSn5$hvqnEAjk-)z83E*gFI3-eG4wxa zzsRPJ4Ah#w4}l-kIE^XUpSKtnUVHB3#6-~LK{bO&0GyH-2?t2{gG4coZgtj{5&%vG zwXmds&-v^WTpUL3{>_t5yUzsQbP@K^XFEml>zsHA-Y!aTtpg&Ed3OuCRFAZouVRSa&5@ z`{TO2z*#4V1Du+%Ig6f~E>BshkpV*D>dS#~xscXnRx-*&fgr6t1S8=;9LqA%KmDPR z%jG}Vf5Mkw9(H90*;5?d57Z9`$zk+#ir&sjm~R1SE+$0kMwG#r@8J>pcv9J zSUMbuuNWQaKiut>BW7w@S1N6|QgrTk(xSwOc+4U*>Eoq7N5*uis_O+E*)`$|<)aCq z2Uh~74xdssEJ1$}<nFHwZ!2$N(l)pncEp#wRaZ#lUyDN@sx^^e6X?xgq7Y(|U zwK)*4KgU++m=%rg4aeKYdqySVmXupC;g1jQO8#;|m3O98cQxZ_NUHDR_eZXc{^Ow; zrN2cS)O5B`CWC_vii7k8QW@MW2R+>xPs6; zS4At+3=jKlcX34DH&^&AsZU)<-)$e(xnaFgLZ_F>Sh!xb|4#?=`0k*0z{P}NWCN!@ zQpTl=%T39+XRf0U1A8WR42jepLH!w2}-Cu}LFc_5tv+*Ev1pc*N=aTAbp`~vp@ z^_OAAaEHBKWO2&cse55;8J*vp*N2lsxrXtNquIx}JE7LTw>=V&#qDlt+(B;V{2nfX zm!|V`E1dn8{w>tIhTE4xwBi-$2V*f!o7S|Jm!eMB|0MISYMtf8Ox!FL- zp5asyqJkbj0SJwh3i1(GC+ylq8$&d?0A{i2q4BYK-S9C=6A+R^Zb@z6n_iz5Ax!Bq zY3$DI44RFf@bNV*xV%Gfn1KpRkom9guNTi04FYVm)Z!ETZjoR7ga(i?k{YV!%?w>u1xiya@fEaf+EU`>zMKaBx!Ku|7ofk+ zoA6(ElVc&niiE4xJ+?Xignz|)xGb&ZFA+fMzoMZlB;jQ}3+CP2MnVXrzT0eD##={M zF=pu;wE{)Vv+*!xZg@yqAJe)G?bg3+M&rqTb3QYaod~fUoC3YieimhY#c!)bN}5r) zNfjfdue&i4!>qW7i#mETort>*v&$)pw?CjbcB{g_mH?*|cuho#pSy*-YpaA#FA>AB z5HX*rWTjfNMQ^X-Z5zQzksK`2%J^Y<4#+7=OwJ!qiEH3XO*Sg7fNnzG=-U%UYOy0j z#j8PeSSX@d7K&@@o)kOM!DXeb0`EfA^hwR%Mv64U;6AHhq3Mq`eGeug8N6%Op5LD< zk*BC>yi~r42nL*J(>h+?E2fQ7*4Eem%WilJcH9~h23YA{+&q(7?gV_RtN+4(E~q^a z0}S{R)8DhJoXaanG#-GYa2b!s5qeWuX`)E!-~9zZSiJ@atHruJ`SaI#oE2!#CDg|} zPM{X|w_}D1N)`{J&jxjmHki_d(C-2yAQ=V{{19?1iqAs8l8Ea^B^nQp5oVJxvZO{# z371@)u$}=u(;fiu4A#^H-`69SzDh9zCG)6`KM5kWwio`_af@}Pg(A%aDiJ>~K-|LR zHNFrY9{h}x_T*8PCg#?(mX*RTOERS;SWtV7?A_VDb}Iqb!f9HlG5UA30hKSe)Lzqc^Ve{RSfz?>^qotILqJ3YD1;V@w1&()@`nJL zjRybmHqB42`3ygEPuEC>u0TX7%hEXDcn}OVWA>E^4C8oV7D&RJefCm zg8|BpMV0qF!!_RZJ14Ob@8S@JRCINture%|bNY9l=vRPv|7il<;ERpaoPdd@3cga} z7tFVq=mah_Y(@6S0*STAi~8%}&$?rad_D0p!I7f+)R2h;*JG0?Y2!|VpV5;H7e6sj zbzL#d1Gjo4vqh1CNNTU~sXy;Te(Zj6aN^K)@3{$@PYW`O{%<%@Oo-&f zO(L+!+w{Y>I!It)S|=z6ip<)^(3_461G3*oe<+BLVAg1+j8YrjIw)`EgW*9`M>=j2 zB0%=(>58Ji_Xp(5Sf1)YJ3|rw*}EHOcO<{OY$tL2kk+m=RHfP1>LpWI0_71&oPP?2 zZVQ;3n9!FV=QDGOEC{{~2U(6HLsa>(YjA%IW51F*7ej^^US+Qa9gyr@_x*n;uInrH%rKmpEPZ9e#Qzb-~ z*)lP@Fl5@VQofWyyCur6KjuZ0k)#;4Pi?tS1A5xJ^Hf&oG}8OWMgzdfkU^gX$CCcy zu4qeP_Ns%FaqXX><$Ajm<}xKefE`jiL8wz(SKJgOozDJi7|6Xo18-;W@*WE8=R)-7 zf_B=<(_~s?2}M0=7*%(AVEsRSm|<#s1Un(?jz`h#%*&dVR85p zAgK1oK`{-8vI*C8b`0~^-0%J{NsWqLJ5O~?`<*I-wWErDh0VD#xxYzYdb}1Rh9_GT z)?PCLOYb2o_3ZM5$d5^TZ$x&QLo1DeUdH}&)!7699koPY)x;HB@e{{XktF>it?DB~ z-b3)7{M-eQY6ZW9<#3UcT-r?sTt!$gxj&8k8=?~s?n}MX%3E~twPj+xT<{Kk(B{>s zd4{B1rp7OUFFJXKPwz70Aj=-($yGVl3lwylP9%3!k?p%YW`U>Ui3!n@aw`S9Gxwb# zKPQ9za9duZOxl;?jxP8Um`mdxQ4u@)pSU$4ccqN%h}RMmKN7355E+%&Pe#n>jA67< zeV@=ToJWA#hCG3SK>-MJ!0XT)^%gq+!3>*&__G!ZF!l_i?s1zjt)T`5RKp)=v{#~G z2W#%Nj_G(&xf1;em&mRuJB^628pVoY9E;?W>)?Pu~n!#JpKjD!CBg7mzY-aMXcDP)-{s zUOIy<%w|E8=J6v-36OKYq@b5mLGdC%5;I|~gq+0Xqi?q8s1=fv3DCw4&U(qByz|XW+hZcXLgu{Yhe+YxO6NV4+;wwpQ$J)K@GSjbTv=IpT^MIA^mc82n7;Ji zARBlpEGWv;^JLp-t~DZRAR>uMJ39*?vf3k3$wRnxYDjVr<+*@Z^{Er4PrayL0rz1k zCnsQsEz1>U?rMAHu@nk)w%+s!fAB(Zep$6B{TBto@2>{JA>N+82u{AN&gpcD;uzS&CXbcNsbx|H66k(e9s9$k z9559=ap*nnsU6DfhV+XYu6AKSPVC5CH9){+Y$xah&qN}%$AH;sX{8-G&6TT$$&ENvxAz0=Maubp`kP_!-W+Jw*LB7%f50&a8~x;-=W$ZK_0+@kaS-i< zRnUVq!7$g%%u(*Q%zhqqABApvA<)0Vs+oM~N6r!c!L+urcNw;4F3w%$ZP}g)Q7I>5 z|7)yjN%&0gym;uL^Al>r=?a`c@+(GspK+R!{5w6l%~m&%%?}^I(kEIB>Sy^XM#eP;}_Dz>RYtxu>T1d zvOs=XDI~aOJ`+SVHli9tI_}w$|B#K+lQ;a<-4#-r!ZWxCFlOAmQFD-3>{r{FD8`Na zuP9mker^VjS}Qqu9b@dvJ9K|#^asGchtx>V$*Gcae3*n`y2h+SB%-VSGDM*S%lhhF zdVaQf04!N>o762CV1&;^0_W@L+`L)qJ@)!?e?ax~J&`d}TXYu|{Du`|&J2S-L@k2` znpn0D7&H-4Qu?CuN6re3y;;ByYG_CdU^XZMB6V?RQ1M0x0zerikVU1#ap#GcwGx$& z#erpX1Jqrg05#O@t(%LxwZX}-kX@BV-s~Z8Jv@hTTB7pqF*EQAu#GJfiw&pE*A4O` z%bAU(V70#6i%IFwxm0TUn~!CL-+h0ern`xbj1;brKvFFpa{FM=GFR+G!60w0dOGua zT0zyl`{;@)n&!4N%(G?wjB2OgspMmh)ly;{Ty%ZR%@vEQ;`I`5BR~Q8{TioBDYA4sauiNCsfo0pGd@DXNq z-1FvDOD}_Dn}`%@GSPfJUCr1n;#ny%K@PjA(*vw?T%I@;E!7{;Vg>H<_4d7G^^R;< zhJsH=0cs6gma6j?NQzjn@5mKLgUk@M+mOmoitGGH&EPe^m>RMjv#kZkBIJ+21;unA zUPP;>+0OPhbo6VF7|>?x98dXZa*wjdI~Fpo6>ESl1_EJG%}C6h3Tg?_WngE^HHUV% z?M56Q+oT=YZsiGCT{Oq1vKB3zY4o*vJ9;)l+|~ff?}Qnx^y1OO%aRbAN9xTGTxvmU zKlP<<`sutLu1=t({@T zlz$FWQ2dr078@%rBSWX9tsS3`AQO!N0-QPv(QT-wCno~|lU0BO_OWypI$i?Fa{aYX zZ#^<20~77j2POj9cOnJ{$GjzsXz_GmVNX#FKoQF;DM16)_TizNAX%lS^03Jyz(&Ah z8t1h^4j9=S!kl&<3Sf8J8KBx0Y$)wt41+cpD;{0YpSD2!0@N@TJ;z>8+8*N2?BY7p zW2E7=KK!?Q4Mdn{zI~ec()2%Xuiml~o1e5wfPGnM8{3TPkWqMZYp#Cn_OmSqsEy`A zMA$WdYi^j&bC@bY&8**nR@Ew)M{DDmp>4MjpW>xL6Ti*NWkk!rwJchbUQ!Z9-F+m- zW~9^pNt^3~ugll`>J}y`v}6MIQsFzV?;G=&{mJZm3xQ=@crv6uTbta+IHkY^7GNOk zGIQ|7HzyxlfJB={!e*3FOA;*?=s>9+QmaAIiP^oLc;& zfexUuisTn-G2MSflKLW7ewdyD=Q`ZAGzdp&V*MB08(&AS!?4+m&;5Fn&T@*Cy#VWJkTWNDEw1DX-BP0 zTprBI&uWZOqu6IAH?j-4_o;vffC1JLpRw>ia@b^b#68a^NAjMl2H%KsLnb}(<<@iO zy)h)(&;?k2D#1^{`#9aH3Otp^^~BY7zg!cL)CyFX`(_|-Fr5Yy3OZ9^^kWqkDh766O({;F zWcjAYA1tJpwLn!K{I)j0LCl|BW+(1=7^n5vno>n;a3QypL08HCMqNPHxO*16`FD!_ z&EIL+I4oWY+)eaRFA+h~IKpAW`i`uj6>GWPu5SG-ahXK8iW3T&gjC;+2x3h7GAx^G zRBkxr*>FqMaCFOeg+t*@@aS68cJ@!9?TWICk@oe2qqKXj+(A>8zS=WD$Gwlix$QJ&dJFMotR$O z*Ow0o76#zLvPGfRE|CBNBS3pyTbm{7^%!P&bqwe>0AF229i6C!1-$*6wvzJl5FI-n zLuGya*qIqtfXUNs!@dP*3KV{;NXQ(M7VlhYZ13y{mZ=ryb>bud`WI0%GfKcT5-@0Y z*dB<7x>@PD{N!*H`*i$v#=(%FfzT$66j)ST4ps0J1N!qwAMD@mWVAkHTrhnx)Gb+W z3?;oQFWl2MogJfF4jhEalr8S8K;CxiA2VMZ6?T!&ISM%N#H$RMze93K#SRU>siH%p#v z!c8xD>Y{S}q&@CTz&NkVf{E-dFXm?U<~S;e-{*1PJp5?7f3tid9-Hn*DU;e1Jsq=E zs_^ZCI#OL#CNQsQT2E{!46jvXa%+x5 zw9U)3j6|xhxxm<=ZuYMjPT!4Ns&%^>nstS5kpVLqm7y*+lIEjj7{)RkbE!YevSbma zk?ARL&lE1=np|)ndlACb6z>rep7s&EFoNfCt|4{HUhB|U=0)9c451nfwS@k}rt$HX>iqgO3tOC{1J}_V z3?e=2Da+r@UDB>7f4#RKq*_;Wi=KuC_l9lfqeeuAYE@cUrFN1`GiV@+MdP$%jtK|P z*Cs7v@Srf+zOTv{$J{d0%JU1R&938?zhtR{#ER=6)8$;o+Cg-gZ6N4#P~^u-4cj-s z`ANnM*rmXq20f0(Q~Q=VrORrS<>6UW#)XCeZ84D`` zvI#RjYX-@_i+P2&XVEEo5ktuKFIi(pYcLC_VF_aBLEw4ll3 zj`s9ljyaAuMkB@zJ!<#C>XW7v%)zqvC*3I&GXy$$e#n3q4DHF8HQ?rCH*aQcZn*n5 zeGI|bj%0IdGd?+c=b~+Ich{4yr_N{oNN3owjh}}H*n+41>P7rE!|44(@jDw$1%;-! z*$86r{#FMk8cc8VB*5aiGt}X|GirF^uhfMPXI-j4|7$7cWP~x{@d33KXTgM^UZ%kf z3B#oU{siGsU!AW1P=}3miF3bBsP?p6+(i$;uJzRj*oF91Qn>#@th9AHr!Ybn)WMQW z?oub!7O4ln)o=ANsRR~Rs_Kt_DOc1EyI4vhjb-wU*{KLt$nvx6Z;(wZSNv>0bMA$Y zql>-JkaJ1GqmkiKR3h_E;?uqX?nh!l4|+5*5-X_&+SC(w&i4VG;)azcXpO4c}yLhWkf*Jl4h~#wwmG#y4h&XiAs$1gs&OVm8(3+ zZPEROfuDioV}VB$DaRO1VRMpPoxg{J-h4+OyGWDBF5aZ5|$KBG+kR~l_D7T`=W0p*X zZZ?!}-Pb+Dg62qL$m+?kO;hDMWfxa9=BL!hF52*vNn(?#*gdUY$`Qgd=8ew&Sa#0Pf)Di}ftXM4pQ#o%TkM}-5XmZ6TYT!5f73w) zA|*T2@^o}Ahq@Rxdg78U+hPX45&FB@?$SA-R92zxgY#5jf?)-=;7`Ay6lLQ`|4gZ?^OMGjVb?_P*@c|$ba-Z z3+P=kX^)WXwQPP(w6WdCc#1oS4WMV=y5)z_u(A1T8)eBE#g)sxYvx(g z+mx=ZTP5~=Uo}a!RF6IFmZr+9yh~E%YDwKCw^eJ>Eqc3>#Zw4drg%`_x-H*aR#zJd z*mWk1XP)@pG#_Ozm)?d31$-sW%xxv%(DTLy&enH;2}{>k@WI%4)dUWtWS1GzdxJ6NbI3Rkpx0p|5#S>Ai7c@rs>o^UyTQFX90Dl z+v@Pm5nMuI-IcR|nAC4=0wEq>#5l0U z<3@*h_RxXk0L;u7zLibk>{4~H!CS0&-v0jgTl7paSOmz%W$}7Lvh(tH zn;{3q+7|+4T^`D{JJLKe(pT?wj25B!+10adMz;mo1ue~RLMbDMyf)Y=HCiKo%leL3 zfLE0jg-~vL!s%#V_P6%US@)QYoqZA4>DJX>m=9>C=6|0&HgPc=val#|Z_c6VVOv9W z&OSo7l)o_?vb3?e)*$Uii~+AiOUO9VichSB1FP6#wF%YM9H7Y^984BW=M#mTHP{B# z(hW)$O*3$zf*hPLRLHo|0lasBB+7eljClX>uzCOW$bMuuRi`<&wWcfa7Ma{b+n`D~ zCVZKFUZaFdA;;&OLAj@wdRvm=gNiehiW|qD@xJqC%`sW; zWQw{-9-iq?9lqAqgPr#V9qUv`Mr5r+@jtuwUqm0UM1q`3_C`>9GR+!8E7orCN^Wdy<@_j|RZs#jCn zyp^oKkN84A&R{J|vOVGWY!Y*Ny3oG5yiA`tNFskFu$5F*Ro#T+xYfck5~=vc5M}VK7;h}CYv^U@q7eX~TEY3a@uexH zqYVYI;jd7HY7C6`T}!g}UWK{Ax;zgQ$b2m8fQ4*c7w2w> zJ^l63A84o)ye!}gPzwSfX65{VhC-(bb~IwlZ?&)mlkXeMl6+Q(ie+!_yiI@0RK_@X zPkH&7shdL7J95tKS}p?GNU7?2Qhj2!ux3^~En=+4RW9Urcm*+Z%vqnWvTc7XssAzI z`n@ld&m*sIn&VkWB;zp$#e7zEY)_k*apz+vVEL8Lvlcei4xXI$c2C9(TB0Q9skgr? z$z@`Kr}G&_lp%E{$ZcuPQ}a$0o}Xy<|%Hx_dP6GI>8DKUo(nDA};A^P=OeUVcuOJz4tt4#H| zj*n6CmhtueHCbRQ?@a$$z3sU8m$CA(c4Z{7jx*gdHSZZXqP>y-NA3Zr=xiPL1i9&F zfb>OhVg;Tg);v|ze!R~?bcvd1v0=TqT?C0zI0oCwl|xEe8tip#5k?J0uDa7Xees$ocq2 z?VHNV$^g=*ke(jN)b#XmxFA5L?leeB-iA@DZCxd$4Oz5ge7n-SuBEZ5G6DUCcBni$~ZlSJaMnW8|n)m zWE!-yYv%j{fRDKG%uV@;1>V*W5S3fIwfH@akLnH6v~8KX@DOqRSzV7vsUeWjd1#(? zwtZ{D?>M#=9M+6h&MMj?+?KF?Uffb7Y|@0j_B^9ssU9jS;lCmGYWC_Jm&Ipt>cnGA z=*RLNHAx>&UC!m1Sx~Ym8OIXNKRoyRH8YJquHEkUp_{f^q0WgVWBndIJW*=$@rF@u z$ho!%8*YB=&V!Z5R_~*o#|BHW`#5zwfIv-_D>M$)9(i-rSxmMo|J-%ky9Y1(a~@uE zU}6rc7U7$&&HU<}^rg4+HGV=BpL`sok@#;6GLA@cKd}Z$!}9}8_^AaIK4~em8wF*` zG-S;#MCT%{nPgmW8#^Rf8rvHzmIK_*3g%u9;$VU|a9l4U(83FgOcDQ8@$pfnL9HCJ zh(!EH%}#7}#{T=l45p-^kcRx zQ}u{JzqQiih|}Rx%v$vwGtl1`ObP|3+19)HYpTP1RYyDGNGvYqG6W3W(IR{OOq=V9 z@F|13WQ+FxOH+7Su%Afchg#AT?r>jkMac7jPid|1ku6F`?paf&kGrkQuW(%2x?kd8 zs7~&i%Z3z>`ijAnt^HFh!hevQTkOXT!d(plPzJcyyHRV!_Kl=*{@daD z7d_J@69)|ks%;SD&S(xYO0tI{p8l%ar{ePZM26G``{`;*10uhJ0FYAn`$eGT@n_O+ z9r-RgJ9{H{0Rlgr}wTGvciET3CXc;vxLGBq`&0Az;Q*}4kfk^#%Ch;jzk^*f*I z>+4RyEC-kWry_{QDdF#A1ZBKA51ghMVWwzEJoIST68r%eQ$EJTBV5i z=7C)HJ&podcA@y5U8DET9jXI{cps)CNlb)Sk9HJ~d?N{*i)ut0ef2RsCeh|B??sop zUb1KJq8r3nuY~}cTx^r{rp?3Vk`2wz7fTYp-B<`LMg}48+;EzYMa|1j7h!(IsY1$$ zK?O%5+7B(g>)0=xTk$<8`8Y{Oaj@T)@XLl@Y+)^8R&2aj>EU|@68n`L;8lO-hXVS{ zwWK^j_s0c!OYsm#w{?KwMV&(7sT;s$mg~Y7(Vb!zxK>8YH&`oVb)fH)i9F!BBL!qD zTxz)2EM5tg{YTVEoPjn-WSMGz8{RsqDJHGOG$9%epsbjqWz6ai6KlC3t;qPX7a8fRhik=lTJsPn0tKnJSike&13zG&kK))d3l)I!FS;(pIWxU= zAqPO2Ik3|gkej_SAM>RmB<`KKhNl}gpHhCUMQY~g=cfjb;}587il~a3NnMRi9`7oA zPirM1c-XCjwH<$3Z8k}noeN6g+O73bT%s;rqQG(O8y4GXPxmc)uCeoWJU&vr8sY}*99x=Q+xld-!SPz?;zR1Kq;-d zu;cjFfai%-RMoE0>35FK5)}T2aK`jo_O;Y{s6lwi&vjQoc+YN&(_{t=a8h-h;P=k# zf#af}U(xWxIeWUBDEaAfbG~A08KoWoV@Kgv(0GiGUUD#NzJ|9g;}E zE48++uFHnKuw(#-&dY(AJdp}`=e?H52J2W8nvcE1Zi=0@!4Wq z9coQu1i<;-SKN{8a$8*-*&F_xEb<_mu-Rx)IedNi=B3b?jm@Q=?#9Qy2QS>bau62Y z(C3gB8EU{s1Ji_CBeC%TxK#{IAGI}#Ub^%5s1x?ZqeXo?o;vipISJ8Au}G1FFUsi8C3zlzw8R!kRrww6|t(36h)$yf0jrhQkq z&$+rqT&nee9F;&hDq{_$B7Lbz3WTBfl{r8b;H5+Vr@pjVP15L`D8AY=&q+MsO#o)8~gXAyY+f!AaA6R>UjjSFAHNP=dJr-~GIsl|=ksDj{Ys*30&e_Hf8|Ygy24fSm?=If<|X2-$4|dC`3D^UG1J{rBz*bk7gPHNSH+XPFPK z<~Jzyw$Z9un;R=c*IJE~xM&8G#{uKHQ%x!wloK#f8e*K~_F*QXgF(;WL2_O!&BHN5~*k5CO`S$OX z(!E}M@yBR=5tkO}x&6EDAlB|uhu&@Bn}VhK*bmA1z>tQ!je`ew;n_c(E0Fa%(;I}m zXs9=FS6D(Dq>>RuEVUqwaNEmLZ)Zk1^?4^feSiFyA19$e7Q*K>8m7I#sN&VQzIaBZ&0U{>zk%1@w$IC7(%l>sz3 z{%BiWlFeZ+Z5ijul&HYa=*f&xAB$=N$Nkre))?Y=Zic~rn2~q|-gx3==7^wRopLgq zZ}>h0(h0lKuy1)7p>K@$ju~J%fbAWYAkodd`|(e1mF1Q|e+ZIu()eA*Xslqp=;S(@$+;1asB-D%*Drpfn^sawRgpuA=+$+K{}h?s(_ z!1IvC!uVDm4%euXrxRqu(_X3&U!T8MK*r8t{VMYQq{@nA|bX;s;zJ!kogJAQY;f+_F%T7hsCx{nqd(qH0ZN`K}hn zEv$Ku+;p2iS3a-(@6EC-y{;-mG{pP>*Dz<*7 zDp-qJi`KDN8Fn}Ibl3oJ;^pVdsrIg@=`I_u`>EnK+c37vTBzhf50hpfBP`B)9xV%c znE}>^NC}cPjKR^<`sZcp8}6!NT|cO1S3beij(78j3TH?@L5XoNzq=6(c7lVYWY8*--ZCKO(>uHF3=PcLHRgRO4=6FC>i!lF98~! z4re?kRR)J`{$8q!TC@BT?+;Z{MIVXtFD4ABY*u}gE=Jo4HV<}zYl`irx{B}eWDMy^ zibQj-nER7<^}bW8{FjXQwr~E^;nNdtu1|F2wnF$WpNV$hx0|4Ew&6dr>y^Dml?571 z-Bc44^KIDMeCnMzW@PyGy|YoVYs~na2KQ?`ZlHI1co))mfb^H|RK)C>bs{cZuC&IhKt~&GNilUY=gGAywd6rUbLxg|&XG$v9|fnW#8*i{Z;$uX&r7<(SMeaNGbGae$hPa$-f_?)f-S8@ zv(MwoOvTXKWuDZ=XeWCx`2i`T!(sTFhr4dx{C+L;TF*=5;h~6AKC5YILl` zVyx=Rm(J8Uts&*!q?KkRv^Vp=>{8W{F5l{6_NB%PntV8_sCjSxP10hsm!}shZ6de<6fn<`z$?&*Y!e!wbcxx*ih^ANs+YMd`;^Rzq$dUOiO(o2jA9N zZ5)I8NN9Ca+l>@e_P*-6`MrWEY}*sfibx-buD+iebH8lHejQQp%q9m@bL;Z9^2QDB zuN=i0n8nH4q>maa1tHvX9gh8pdjW+q?m0F=deiIQUp!Yd39!@RIhIZcAf4+#;Bg;-Ja)2tn$u)MEg-Q5Q?QqqYR;X(ex zSb^#=xXrplE%W}CtdoL)n@{VcdL6j$7+qqw*@)Guwr$1q?PJgWq&J7d(-6iHJ{UW+)H(6}R5!^lyQG+*$~0tul)v6c3;5f$ za^i@&eB&ETTf>&=$h}OkauBBgmqrb+^Oc!YI z0FU*(l-)BWWcf%R~dj?%0e`ipL=#lB4RteWi`PF(gH#hqj{6XCPN7xOzkr?C(CpH0*n5jlE zQiPEz1uq?#$Bl=A+(uTX{}}?5gD0Q%?k*S{u<#|MZ5Qo%ZV|H zBj!kdZ<;L+s2Kyz<7$61C2H{yw~3C=@7<4h@4v!k7R~|ETQx|%u;}MHvNBeIufqob_3Sm8e$c{Vf_0IPv=?XO^5^vON*bPCY1KtKa8mQ_zVI+( zVjk@UVeS5hoB=)uoWr|~szjdKV{%M7yxwB*j7NKDolnK3U-0KlEyM;ufoFgJ0hG5r zh+P}KQrbn&d_maoM`RYY2NnuuNHbyuFKYIjol2S8*v!F|MUgO)fczk+vHxbFgQEuv z7eDUBvk}l*AW|QBPNf;SxVq-stcl2YIq-{VI-jRT&I!&gZK&O4ti?dpb@iI{3};f= z{ffclw%Y`YB?FHG8w5>KtecxX+P?{`)eEbve%gSJQ8KB$xAP7_-mu{ycf7<{cPp5EnF%N==h}xz^qB z7llFoWle8jL_UJq_Z(j<)K;hcVnCcC6!*g`-F2KE)U_4d(G66JBJ(A%GOIj{L2tu# zlX5u%9q}}Ziz}x;USa!MC*#cWO1oL-e1KufXoqHo3BC0+PluJ!+?m-WRC+*~w`W5~ z@TF})rHo4t&oX?PG1~gEUVKVhM7eQuTEf*hr-p0Wif;# zBdQ=wdEXZ^kfQW3lI3!~NYW6zVB}0Or4Z#Kw0esKBI8Pniu~rs%YHo=&JKa^!SU6E z>Fd9q&5oj+5MhcO$q>6}cdkQ79EJYmSyt09dm46W_g@+WW-=^VoXe=qa(LR9wC0ZW z%7aR7vJK5haja*=fWGjIocrEsiYK8AaH{hUq{_;7^txk0z@-8aYX~&t4D}=VS~AbqdTiMCQ#w6i%VlGY4`5pDI{=w#DboT~Yeik{bXH~U}HfMTGX zZx#H0EEKx(?!Iw#vt_4gkiP5Z?KjFeXX3K-&c2iMzm@IH*U5$_Q0i6Y__s{we)og5 zitG&@_(rb;II=kE$2U66Y1*<{YWe#u%VDpLa-C)iiHE_OUzC5rw9KPxPHhf+5%Bri_f%XfL94NhrJ8kJ_7&HA; zh-`x!wAivwT7lGMqJ|vC1F>jFF=B9+(#&zW{U9)=fj%Q+()v^JM(7?6#Qhs&Ej2v* zm4ByemH;ix^~nIZT>IaH!fchjjv>}`Ykv*ud-pcxAJQq_d6ZLJKADjOLAYoE48SP} z9B7W`4hM0ww3SjR8vUS?uvtsiWKv}vUy0*nIqZO8l$!40Z;NolZeQ9l7z8hZTho?xLH;7dSBnD0KRl))bIK_B;gepX|@xL^Aw#08?SMmrPwe<{i`OL(X zz4|6CcP{~o7kmMU$2_;k{twDZXT`!Iekm4U`eREhJ|>&1o@TonX)~)1e}cY!Hg151 z@MS%tOOZ5mhYmEPVgDgr)JHCC#HZr37e)v|8-Rw#>QwGW4)jy3NLc0iM(hmf5Rb%7`8r>(R1`J z>e0B6YHo9P;%%sH3E;grH6|cpyOF4dS3-4<2nbt&x!Ct^b|dC5G|p52azj8R)Kko#|!t)Fjmtg?V8d8ULRrJEa|; zX{}FoT3`*@uK(3kKuJXMoqk7Nvl=_cO2v~e2>q~n4iFYmzX)eCrI+~X6Pnhx4{w<9 z99&vd-1IfVP%2i!#0Mymri9EmYo7hD61VTMSDOb}kQAz3FK(5vrgJ z>zz9BZ!D<2X>n}NcBUYeA3xm_9(m=D?Ucs%s%!ly^&{J&v<+$ktxn(B`r(nNJHXmOdP?pg0JdJ3j< zuTo@xE#@Oyxg02JzTfktolkSh*Rn{mrj=-8*S8<3S`V6YIGFERVCJ;m@BJP?o4+fW$p`R!wCyUG%tZ?77vxY!Whw4BU#s@eO`__*-zK$ zK*KwZp5-g5L@*CO%J;9{|#hj8+4-aZ$%n( zbUz2&Z$AHdD=I{1PLeZ!sLaA?&^KK1s8Q8SrsdSJVKoK0Z}|huJ;$(5lHKi9Ad{=ww*Z$1S3h4CVH?F)xRThbV@9SPO-|+i4cM>hBCTn#r}VH(h9;=I2(}l6e7gB+|GU; zLM`efXs7n>XS>Sk!S72Rl$M%#+zCb{`qaMq7o9gG&DN z6PLUVXPCd=$7mM$UkzbaK9X&cDpyvj_?$zf9YvnTwtXfQ;}wCBp_AdmnY2WB!D1bcp9NrGB6{CG8+rTqv%p74 zHBCR2->eQtRE>rJ08}~FOjNx7>bFDvEK91Ygm2#p6=%{hksAaE$;~>YTO8~PMENL) zt5T*&`(7i(48HF?w`J}-EN&_^eXwfjd_1wW@bVapOV^!c}`4B%r1um zI2pzyGFpCUHnZ`*mn^ScwaAi|{v)%?br;_E3n~E%-(Qu3Yn(aDdM)nE zp^1rW4I+awe8(`H*g1_l4qE1!)L-LK-XYgLF+=0y5ECVDiDW}EU#r_N99lHYBNs&1 z4)(aL?yb#lzY9@Aj&+>;Q@13iBo|r2QkmRzx~h!>&Rfv4l&yGb+RHJ7D;H_0RPrvpg8&Z4lGCM8Ewqj+EvkBQ;V1*;fohy;? zNW<5w8Fezv4hBBqa6s`@BkJ+LGL9ff@bD)Aj9E38L@n=R0UhkD&^$^AkX9Pk!eD); ztH_qiF4zAASKS=06meg$NC6+GZhD(iEiE>q@Dop0Ycuh4Fs-(tH6$(Qnbr|gQF4rx z&7_8weujHBz`bkx@oK8ct+`o1y;cbMc4fD3#vXr}ktG%IyN!-Z4s)x=Dd{-)mtcG3 zfz>GRDn%!rID4x~1(iqWAaXnHPkaT8fV^blReUFpPr__;XsrPfsc$BS1G%w-p>_%v z0+Vk&s%81WhJeL)0XGm2!GIZ{QAkf!LJpDp`BO{w<&uDiz-ICP1YYH}Htp#L9c&H4 zEiTrai5@kjBVBZm`Xz-@HIPB9fQ+jTgev-m@e9hz$Nwqvj9 zj0W8+Z^3CxS-bDeI|B44y-+eQcm!utX z@u&b=XNNqFBSzf+Q?jjM$Cm&Q4rwTKhtNi@x!sGFrzuRFW9Fm6Vk!YMY0+C zzKnyU)fq83pFYm<E=vp7cG`4=n<}|`RIVzOIy%fA7YN z+9(U+_yABR8qEHW5j&5ZBx3l?>K99rK2V)98=5PfL!ox~8w?VtxY@Q7U_gu1t^8$= zu`Ms_bDus+#@NFrD!dt@0aR28T`L{|1mI*=kz;k-Gh%b?SL(`+?H=0z-5?o*Ecd;v zCX&QgJiN5@<6dl4*>y`v)*bztl}MPkrz?MY@ z_~(r}U+vah-n}nFlWC$ANx>!FxkAcOkM;fd8J`@-b~s9_5JEvs4AjVuy4a@p*!J=?3ZeFt-u zXX+mdwyRFsdDI3(SQE*LP6w}X08M*lnv$`z=JaEyRl_K8H>%#nOb^%;fy1gqKIhBm z_VP)ssyUl*2k(oG75BmWvp+T#j-C}!E;jupk}jRKC^FP62>4($3qxFaq?k+kQEN5hBuo-O=NJKoR zYlE+CuBL!>L7;Bd=r<&s-iAEkVaj0W7p|;oOI!e}5dxaxmus1gO5a?;0T!18;0Xrl zC6J1ncFM9r?37i+h@Sy}X+ww#EMjkZR~s^!MqIfS7jpA0HF?oxYstP)`78(y>uk*`u}bU825%owy@`X9YsHv zJUE7;6TS)8$Qz7VahUtl`pHS4-+IPtYg5%2V}uQ^Fxg)hQ1lG=rVEuf+Pn`|Hla@JFCN2qfDbfUNRGm_x$@pAluufuH7-N4F(B9cKDSuP66 z?@SW_JT>?ZsX*^p?M%o~C2j8?W3f`Ev9htl(y=5YBtHWwPDyDQLlXVPE$izTb@I#A zI{(D4v@ARq)C-oD*ISm(`;;=2zxeV)Xr^+z$Ce%P=#jamkwW}p*?`l z8~oRO@clKkVkh0_*Ox;-;V!|PN`x#u17w~M{-`o8e)0WN(}Eq z&m@%YTDWE~J8mK;FLFDvHaoIkAZ~A>M>AXVb`W1gvxuIZo~2r&bG{DhJ?QuGi5(?~ z#E7=Vsl9SrNXdMkxi(33hN}t@qx&?~Dw5lNI)s>J{40tka%`f;lFWav?|guD1A0f> zaMwWg+hUU8;re9u{vX2KV~8Yk--0cO@}aTlg_j`RhFDm1qV*h-6t3m2W8-_Mr@tG6 z+%mz1UpT=)^6iUArS6goYM<-KZo?Iz1QY6?sDUHj9fN9KLuH(y{Bu!M( zY-LdS>@L2Xm90qkh~`j9B?`rhq<9&OJZp$e-!sftx=)wTuetPHUZiTj<&Im_;&5D7 zGs~(Ta#%`4H)f8g^=4rsND%VP zIx-8acB@;;MyAk@qZ{7Dk9rI(j)uy0^_dk}Eli+$3F!H%iLYUkHROchQ;z205ehp9 z+=zsvmzs=DB)*+1liR6%;xWg9_M*>loPcC`Q(R0UWs_#cjzW|*&SU_l#OIF!jXfB> zs%z>(mVeWu?%P$n7fw!|aN3xbmBthLd3YZ`S5Ci-I-0oAM11|zX1qN{V=rbLBVCsO zGTLF(G>TotN)9vGz;H3hrsYN}e{J)UYKL9skQsTkuMSC)s}$>&1z*DVIOlgOmlwuM zJ#04k$mh@5L^llmeAC}QkP7eez1E*=b{v&+FH7}$$zo;@&%>Pvyh4gJJ9@}VHuinW z=5E_WX2(mIMNfn6Fi^aF>rC$tjx%XxHP2q3r*WIZa*2%Fz6YW7L~6uzRAdi#k6tF9 zVnOdxlM08G)AyO!#5yk=@=qX2YU^k+!-k&P8)^thvKR5QBYw)_BX`;msS?%ZsuMqO z|ICrGQ&=oZyfU3mAAk5fCa!z#}~I9KTROWO1%w%(X@29lx5^A z4$I8^OD9APO_Rk3EEoL6b1)_&#VxxqMVF=oJ@g**+7^fqFB3nmWa2$WsXCT6HZ_k0 z{|)Go$qQGE%SqR@+YHa3L@HgdSm(=ecW~98=B22i{OLh$;#ZO_vU*?-KHB=N?(fk#U`8~U# zWPH+rTb!684|J^YIM~bU3~BlLGwH^unW;S$O$#VRGNy=RCXn9{j!#fWLiE>+lh)#Z z^du%M!N&%yt$R4FQ7%cB!d*{6v)#Qm-Qkg+g1zC29HzqrwX z!YUvTxv{aqG?&~l1InJG+>YCl^x5b+AjZZm?34Gw1J`{*!pkBXfGfNk_7%_*y%-)@ z%L{;auJU`xaNAG)ggsZT6%Gm+LpY%J^|MlQ`+N8DhaRfqfj(U&>9(~d?J-W%sMxCz zA1)r1_~pkWpO8s}J~U!)oDp+HV!q zGIgq&%DSVl&fh-6u-?FL*F2!1YPAdV#qo+FjCu}_-*Kzpaj}7(0*#N@>sJ4aGLoK* zq}0?W^oGztqQrtyFcMcoC!x2k2f5m&<`VMe4Ynu-W2BWuBZKQiZ{PUjim7S}RA+c= zVV{^oqxHz)U#aNo5pbzDjsCXB6{gBaj;8$UxP@$g)42b zdEd#Y_b`{HPaQ2H$-ZW?2D?Ui&uvE9yZfXQz{ekL#R?CIW|1-Gtd@~h#yU{O4u86p zb#0gw0>3A^YwX4Nk#(;zhT+1(=y9^ZQ22h{Ak!&%K*}KloEWj6L9oyPOHB^w7ev8A zxr3s}Z-jW9wms9>%{v1TX;nUNQJ2$zyOzNQQwG=&$t``L>d?%Hhh_@VS6vF90q~h7 z4Zl5DA_T_e#+_%g3o+meouUruW!^=W_Thk=Bd2w5q3;GISe@_IfNC@Bs~rhuLU%{# zr7*0?7pvR{#X((em2N2d;m1H+>6us9WYQ2Had}OZE`dzp`#OkAXJ=3gq9Q9Qo%25> zaau=Dn`$#6`<>^Zp^#ktvTFd4V>e_)XntyD*9zL6{o0U`cj>QAG|H7{<+kO(X88I! zX;G?1bl{IXYqs!t?>+%u^{pt>tGAK;>VWYw4~OE_vs@bLc|%B|yc1kndlJ&07}7pB z>OOO7%B^d!@MJlfS);F=pC+`w7!hq$Ik6?$c7#s_$9Z2iKOLA!xtEjsX=n*z{!|o7 zW@xNHLH#L|f(*H11ja$f$4tjheMwBx6&lvhHVsUF>I*GefUq~_e+dOsn8?<6nZYJ) z=CFTO!l}3gW$-U*-_SSQ=eM`+7jMFzvvp)9TDv>llKXE(|K*z%hO8M}{lv>Ds+kpV zrORu52OSGTShG$gHyaO}Q&*MkCPGwL`|s zY|O>{c!b5#QjwJ_Psxf4%*4)9Z)D%Kt{}i+YiSQVnLS92hDT1f<#%<6(-YyzW)t4= zDDBn}$ppGyIC+u1e1^IB%~3pX@B!1oC3a*M!O$hnb5Iz~=eG!zIK8h4l-gIn%!yNl zKGUwsM?I`i=smtR4(~*L-~jGgs1=>>RN+~Iua`s)fT!qI4+2xqNu`|}%mw??@}y6s z;QLj~tyn(A6`LS#|8$;&`$wS2>$|+CCrl(Ezx2;Qz~aDvS|gN3B_wRZb;BL&-_it3 z$u8JIZagN3g28{fq^ZrxGe8xrV6mEU=lWFNJ5sHSX|UjeRD(GV`iMDfI*E!#+Oap5 zL%Y%WV+TbqUIx|uze0OzH

AJDeotWM;!kk@tq33(W-3y66k>u);9tsETWn5=YPZ z1vtc{A zcLJ%GNsn^={WTPJ`gVVeR)gWk4KJ#pN!!&?isF4&{k)mvv+cK^NGUk9QdtyiC6ro$ z&kGcECMOaBmTJw(8Tz%&2kvu>1aqPJQd^<;g`v-V{JHl z(du&!D#bXY$oZZ;$c(La{c9b&s)s>#OD-7*l13+WduM7*r$toyNi)26cWE5xV!CkJ zLOPv}<)<Mu8BW)(7YSdsOOg@eqMW=fTth2^5-TRNn2C3 zqY1GyGbE1IhLUajh+;$gg+Apj+5=St>!rFP@L5UvO7sw?QUohT%?6U_7T+wL;ok>o z$wn*M;2*x^kG-RVH(k7b7c7`niHC(m7xYD&f0`7vWYY6fQNoK{tHF#pHyG}uicguW zrwqO4$3%PX-?kDR+`@N7c*fYkfT#=1BDFzce4qZ#ez6qNT)?L9-uf+3{$?16;2l*S z2^kr{D3bi`12PEIv&DRWzCO5|_XQgfP=ZDe2)N?!@7-dSr9}7e^xW~96zYnKqgrKV z);DwO_vFHaxnsa2Ma*1+35>K2Z7>-1E-a& zPFo?&?JoX89=tq1Zx5`_&qwT3hbaM%b##P|h5*ANUiZspZh&vB!|srhSe{b%0}x;P zc8965x*BZkSl=-)r8=7)vp$;PJ|291MZaMRQ}VL22LjTGJ;ui`Ya2&&K!C+Mb>QBX zp*z6=qd4*kg}S`GR>&bVEp4z4V#5CW32`qTCuV}JRXg+oM^p*s-|aIZbFoYpnfQ#8 z4QFyFt8$z(9*?~1!2QMAK0L}kKYBoeb9o|{=G0#hJ5ZA(vtN1lTdu*C^_*@8s5bZ8 zlGA+coOayAm*r@#l&cUhctdjH;?FhCNrZDTvDUrL0vX5>WPeI&CDekT83Z@Qt8rsR zOgW_2*&O#4Cr`n8gdxLfDeEV5F|zdmK?9`kuo6}GJ-;69SHrlNoRHp~y9r>QsleMx zvpR^*!0_e;L)ry*Z^h;6L+*a^u9xFJcA0xXFZS?u$a8HsTK7jEVO-P(GS)UZ2iDaa zf5x_3(2!%PVaLH3a^mrK@Hpz6;--X>O-QX@uX02MHYTc5?)>8{l&ikinD@%9U+IB` zu#d2IAvOX2_Gr3++0}2T6Xh;O8ms^ymM=y9kZP$T_0%BTxa9dkI6~1)EV6YtQv@8(Ib9d%^Y&9-?AJX3w~Mua zXe#1SA4#1Trr5DexoEPpm3jow<+52Y3kOHga;+Jgfa|p}UPbf1XVX?t*5BjO%I<&u z%nIS5t>xtez^TFy7=I#H|2xA2h_ofZoU?qMaXQ}4N*!k9J_0U$9{#-!&!8Ud@0x;d z3mjm8nTOu67b9^wn`H|QYI`|veLPa&+;Z*oggsgOfc3mT#l-J5isFxKJ#T?Q=D+p) z_wNDE>doE`?~iU|pDIGZr{RdE{nU$`(y&9TQ+uu|gI}evSb=y!ynz&#cbR2W7Be(vBYNazRaaqf^Yj6=j<#g>y3Srv+8L!)L8(Yx;pT z956U%0(?_mc&DNt{J}^`@^M0V>Q9t7kTU__E4s8IRT!wuDPJT%`A!9NEMx{|(TQ_i z3nRN)+ybE;gfO}@a({?MJ==R;HMssbnaQ>_1XsOBNvHj(T?$4UmOSJniH$jEZG*Z} zepVPtU{DV>`pYjJ8agn#YxyyXjOFL_;!m-xP(XpCt>Q@uoc6-m0;L_=4G|o_bTm41 zQyyfAe>@Kra?}|_gU9~zdKMTz)Nk)!OR?;){#@+0XG9%Q{Rij z$8AiWS<2BkzPgq$-|u0!K<-swGW(rynl9V3KXROoliZ|;%##U><3HYMjMU=pRQ>XDT?A_=me~XOfL0q>&;%h%bzI5-i znJtv5jFr{cvAO8oGC=pwiQe_`kTybn_nK$O#lp1S4A9x??l|<`8hlV}NFO8Iqk~K6 zwPt{(CaQBr6Hw5kWYub~Iy?WWXN9cWX>oIOmtaKSWhp0)f7tN00E2xqu1oivLj6^e zNIdexT1@wQ|-24Q3{2PFrtxC({dX5i`Qtf_Ii5e;fSgCJBrP*!xDF|=}GHAU+fdD6B zK|qhPs~7o$uyOO<2{@PjZhlZoqhz^lOS!cR@V!48HC=Rg=1iMUEl_53uNt#%P4HfH z#Zt&X4uVi99s#ShrjrU@RQO+j8}LWih3`k};})`S$dvL)naAN#!>ntWVusr9DyRfzt#x;xoJz${UrZI{{H~V`30J7D*0Tak~~heVzfBV0J&Qi%nCz?=a+v)qlOIfz#yf1 z(XV@aP&PRPVV0iqLe$&>ba4$j`KA4Non|;leB!J8_=@|Kd7a`}{Z6-2g*0tx+?ZNo zYImK%d_vQ~?>k8B=$wErbh1$LYBuC1f2~Q!+8q4^8`f$(2pTq@K}K>zqs)t=gUkzp z*H1h|n#&<#xoykdp|{z4BOuuz5Q$Rrql8s4U=()MyOPEAC;?NCjyd!2huJ+ki9zsD zq2a@Ul+dRUu94QoE+_`!N?gnD+Z46NOpRa?}qw50jAi zF-`6kj+RQS9V9G{vKF)!SsM`!W%r`%>@Fp zK-SaUEv%{6Y&H6%;zhu=^^8NKJWo~XqF94T5%9(Z(yc{3UjoJ? zyoWU>tO*{gl-(=uht|dI*PF?J$pdtJ2pV-=cPL=}YsbgOXP1>>(5aPmnI!2*_qA4Y zzyRLfLgZ+|-x5&2+k6IEr}n^6clR-Ej&ZW67fd0OsQ=`+jb`oUhMkar2@LWkbCko+ z&(G7ich-5;?=^P5`67ZxcfRhy^_+i~-#Tk-MDN&RXw-kXw|P8w?F`#8@Yytddp@y0 zt6v0x)XFq|GH7LfwXm?j1-lNS!fPQEG7Uj1(MUC_Fjb*xySxz_6%rgv%pY2Glw!jQ zu$#O$hMK~8>Ch{N@&Vd7oXgx`G&J(?SY+Bg%0<7V2{3=m9m+QiV9KRp#?nb9d9rPw zJ6y-s#Or!6(lS}(JM&<`{yvVzSjRk(7;fbdMrKFz1QmWi1RYgeyutTBz23a`L|x>7 zD4ub@AwzQ6pukCjb-CFY(r<-=W4CSB?nIs`T!NQ5LTKT}N& zGgL~iQaC>w3agaH9 zN+dE+DT~Yhl_olpy3WX2EK4f^ zA$_B#!{il8rOGFsL(6!Mp&PK!?pQl~*=%EMk=a&7qTZvdKvir&ApIJ(E7kW$ZcEZ8*JR7U|gCei$+E^g-suBVu}k z^y2zt6fZ%@^jq+;FeT2|gtao1@Fem+h4QroX<}*3wK9qY(Xo&tw{3kG!%N6g1KOl< znHW>QO0{SHtvw!ym8>OX-?I3zMcYsass3kHDb&~$HTviPRx*Rtm=FQ4DW`PKS`iZm z{l_``89(h`!w@EgXglpLb?*nTJzMtD%T0SjH6tE~rjUL@hDj(Q;iFz0k?mPaWhuXX z@Ki_Upqiz11lHE`F6+_9=Ra<;yx}ecxn;rHh_xeI>W@>`H#XQD$gJ44cAD*x;JSy))x&k6Qws_O3jw9t zSBKELbqW!p6xp~s={>-z(8ZlQRajz6HFkSrcgc?+Z>Vaa@Nw|Z9- zyml(SJ};}AC@j%Ymp>dGYjCm_Pww;{U8T)H{wYVj>ZHW+JpvCgpV~OD94TVTBHF#n za|@V%oO~wpBevuC&Puu$l$I`f-9>rOe7jBg7%-d=`>oF-%gg$x3#Ofl%TrbA>@3Dw zbFR9xJ-o1VGDuvMYmdYiMa@R+@^b2XT{(s+ec zE2>&lz5?sh_ygg!+wahIw%bDoUfL*@ChCJPzfq)j3UF8IjKt~n6W0qmSHCy1USj0- zlMReEOI4n1f|FMp;%DIaTn)-7@6>K*8s?vHMHPlIX{z~kyuct1^u(~;I{7Keg5oFu zG*$O~xYF3H+8*q)b(bA$V8$J<>%206zh^t|i8y!GVSxz>h83^LDij}(BrOTpLa54q zzN4*{;KG8$W1v>qUj7o+z(tZ^zU3E$ ztgu5H8*g0@S^pZmNH8&4I;d=|M!fiepQ(CHO_Smmq`3zQDcj;P@#%T_O}7Cwbo{Nz z3IzvGO~YT>Xwr}?!UBa-2npVmro;MZzcH%97wE4dka)WEPK^K2YQ)ANgkt1>aa=0n z7~p#h5T-9JOPXVP>MY+)LdMYQc(s1?(q(67&u&U~f5%1q^|-RgI50@$8=hwSOtj`W zD!%4%qHwjJwi+E11JQ+1 z4+>$w*R*!4q_hHv;JyTiL0<3eB+Sswj`QC(gVt>ak$oSH)*J?Pnyv=YE?+hU>4yc= zSql&|GBW-lc=_Y!}>|bIU~epLHe|x zsi>%63JOf985l$zq~KWD;*d8~rp?47cQQkqBsU&!zFbieTwdvVikNtYg0T5fsrYOt zHQT>RX3I(knBTX>USDcWD&^CkUI8uL#|6Fff|-vvxcdsv{hv(-WxhMM)F)2NHLZ97 z5-sTPh<)jf6~umHiKKRd=%~ew^VY<#*#&ea)|Q4K>h?i0sCO_}itO3q^G%w@+plHF zYwQJ?=)}^hvIgoRV4G2mn+TpHV3C0;I*GY~XwRThvpMzx zU1ezSr12OnR_cb#BpQy5SgOad7D@t%p?JVPp5Y#sD=OMr?gI4K+=?sZ+C8@=RFT-z!hIhg#Dq zoK=)9RlD1s6;9YnnRkyFCjO;9$x~n7m;yjM6X1G+{K6PpV4V&o`U{dOLcwgI zl6hSj;Su1pd``+6|N6ckP-0+UD{5$j{Hx;tFysNKEw5TP`GH}@@6e5&_?#P$==9o6 z(IbT~n;&nO3zeNnxN?;62nf{{vy|H2Uz7^&XR|hbf3Z?C0&;TzrLpUGKgbAfb~+dU zMJtbG@FvlMea3|V$!6(>0e@5`-WW^y1jKO4YAg)+e-Go%{=(7?`ByID;+g`b;nwP= zt^}%ARhEhx2NZDKUtKs@B6-i7AV!mzB-GSCYcnSOcg-7K&&V+C6c=ThzB)8(1D^`% z_wV1Rh|mr%XRZdLiN0I+s~2eAx;_F9rUcxM7$47zoh$+ZJ}aA2Zzh`Q(s`(9KKBS? z*2xflLaLkV@06Kt+mrU(Dp_CpF=f@Rt3Y~hw{MT5uC~zZp-FXrO2W2?co>g&EpJkU zRe+)_x%}eS-bMMJOH1QTzv=)r(l~iH0Vxg+8*kLMx2)|E-;H4J6JH3-$?{`@)yyse zHm0PqNC27s!Dc3jR5tt`^f^I+-JG=!{q57{`7;XEfkLo!K|H$zn{6kyFP<+#FE1~u z%S%iEJ@lorAHDor^r*9?(i3)pl5xY_7vHx}^2@Pyx|58vY6p~wF)Ca|Nay^7r)O4Q zO>NLwU$3l6C*~o1EcV9O^qLSVH|ucja3MCn`|t~tr9`YUHKG2L@s&Rn{jMCNTN(UC z5`-zi$>(2&_-QS~^)~bU+@@(aW|jMZAw@sALk*-z!Xi}O?uJVCxQlSO{14Ysd>KDc zs$>@;LGhCvWsFZRRwSJ0snO%uks5_Ld^#aECChq2866!IT-f|MO7vlZ&ozzikSkz` zQpf%BvZ_@6ClS(7a(HO>8L=6O9EBR_bM<#Pz`4VzSd@uLm=zhLC>1D@qQL84t(WGg zhJ%=$z*iz_O~Df9?qJbt!JHMla<6k_P2rfi7tNUTkRFqHPM3E~ydo>FI-}bebQ@6A ztdJqb!2M#|zz2LfF6~@*Bo%lgZQUTxJs^%Nbp58Q@M3fVnBAH^Uo(F!ESn#lg}gig zonXHgHNDYRUxb4>mVrMO^fGRr@LQ-^y-+2`1Nc3k9G%-QnI^hk=IQK=(%V1U&RsxV zLYOEK^>It9i@%S)m4!hF3ES5%IbnV5K_xSRB8 zW&$C_DNYFRwdWXcmb%rd8V7-mlgIEY+m%;9RuFP4P5O%8ib*?U9&00n)ea0P)A}oB z6u}g`ye^yTHU0rBq6iNIs z1F9SNm^Odcb>0vMr+ieJD<78N16f@&J(uIC+9T5vXgr%uULiu94C#9d*Q7mTt8Mr( z*K)babd7P%Ud?D~%vD4^$nW`?2gu9N_v*s1mA@S3ed*KlX9D%W*(bU)FOa0 zljC^P8QLI1JmAMkNB`3s6ckj`ULu^Q=YEQreA#?I*4B0yli_m-xY6UVg0235udD2# zy0|#>&N@hcIF>@A#yIrQX`$?i=ikrW1@Mk*KNbnXCokLezR!?S0?{w4y9KD4cK7$U zlRDcF9yVS&56{oVU0pdDn~guQry|xhWbS$cHCaa3KeXWO4dQWi^BozO#7O3>JgV!M zO`?Rb?|%IS*j{vJ5qV-`m2<%moo6e;N zDAms}JN(KkOIe~z|g!+k*OiZVgYcH`J&uI+tq#*<}pdyl3W6u?V7}U(!B{?C3VdA5dEmca5$o2jINon=S1+5^Xi}o$)NKdix8dwf_7PkS zJ``aNUK+VzcD&+F)GOqYZN5*1ekSqSveXmCy1o{wNXA6JH!xSHvU9K3_HS`2WBPuQ$}-1Jj+TE1%t)Y zb;$Sf0U$jbdKuIw_9_bsf_}Zpn*4h;NYtCi@X3GZ@B6j_=0#isEM5omi5 zn@&=qq1<}#XL=l0x^0Jc{sq8P$cP9wg_jp^K+N~mADsG|<>{&fJHf|c?*}n%1Zl8% zOlAl@O=ZGvx&?Gb`j}xpyPyY_-BIuyeHF!C{iF4g7?h_M*pFFEGb28=?YZX_-L{%% zQ;Y+=ZGVgk@m*z)ElEtRNLrJ8qGFd3_d9d7h{KENw04C~L)7`&urt4~gx%`V$fgm8 zrXilCuG*sUc;>(VkdfGc+{_Ra42=TYKyJSvV+|F%rDmL+W?|Z?i(15dRlTNXI>*Ea ziobyX+9Q=f7BiL`3W9}V?Ysw?!ChJE$Rc?YVuN~uBAcO%%bTNv+k*>R@k>E2R3zH9JNK;14m?K6P`FWTu#^s9Lo z%!@Wmltc0#HI0dh$z9@n1ZQY$Nc;u;g~gC?8)`UyfYc5BP-@gCQ6rR#sL4!&55l~b zOeQWA=ppJ|KYU#L{^8(3-Cr5qQ*$!;Z5zlEhYmMC4>S!&zQ?uu`+Uu&4?fFcd*sBZ3flx0de2{oMYx)3urae@L4Z*@-k*wg zFR(4AixbQ*^D z-VK#GIaUb1(QrOe6>M^^`^KS-VkdCZNUcf!V{z`G0Z@KnVRM@=dn(*ZY)s5gAa2Gd zq9w)u_%S;@$DTxcm%)-pC@1HH^IrD7T-?Uy)4we4hWqhRj}|T3o-45B=Bdj$V1Ew{l%79=8Z7uT@+lh_rbGJAO)e7_jU z#B{~%avbLoaKeHrL(15d3um5UYZwyE<_;VgPQ4(J-JbUi2VkZh;l4~6H%v_Pj8--? zzjmrAokcUegz*MDBit}osEi#)-}FDJ1z0sCsx)#Fd9l%KQKw^mP# zU;I?z7WqaE<`QO5bdPak8`x3c6}>h2`8lM#HC4L7>5Jf=g=N&GYvh*by9~22c%7n* z!6kwS@q=`ybcb{!-Cfes(kb2DE#2L%bc1wvD;&DJyLs2|?|T1}i-Tu( zXLja^`~FN+fi5WpB1F2sUstRC1zTA4zD2_e4T_+k`~CtDkVL*>Ca|kY^Wb(pH&jFZ7{KS^eP`{Pe}?5f{qD45VVit!SyZGysII^ z<8>FmAg9sILFL}Xe2I808Ht;MuOt(I>@wUX|EC4e5)*$ei4PEI_nk9a@%s~=o9h)Di9ApJo#**9KH*`U1 ziN!(rEMZSbk6ydrIToioQLK-t5Pd-`SSbd;Cx9PBQe{>KHB7`=f#1Ct-hLiN{BAF5 zDP<#qZpJZLx@P#p0UzNjZ&@x-VP-zmomGoFmt;W91juX_8w~p<%v`)T78Br}r?6X;h>7+Y_#lK)2D?HLjbF^X-!sS*XUh&K52_ zaNPlI>0jV>&|)}zd~%lG5SVg0u#!cPj|lcfRCOg^^?Ghp%FJVW-|tv>`}B4`%o8eG z;m*;E#UNnnw`vqIg2PG++J@t|gi~zki!3W8l?S=ZM@RhX?LOp_ifZ$OFr>+Ol z=vYawPI(|5XKdHQ#%Eo>x9`9OXPxV#{2Ns{_} z)>6Vv6#*w3vz>w!ZN&+Cp0HijQnH2Z0ClnP{Dt$n$}={+a(;hL zSU(MkwxkDnSJosgp-K#xTIhCwjLE3^0RYvm;#$9(#yixmYQ$>;Fh;}h-#0>nmhN*1 z1(3p?mD-Bnf22(qcfooI47>a>gB+gQBvOS99khN>5c@o@O*C4nAZDlzffe=~_q!i1 zNOr88Ib(~>s3VlopAM5M8Q8xR^~LV^=T4b<1ZUN!PmwJl5uJYIUxTEQT(Q2Ci|6Mf z@uJ36Ds`*s^|~)mu<})^|HR#gv9y!Y;bqyriwwAI-gu0J|8ZC!Up{7##h4)~u+2Yh z7OI@rP{{Jj->_?*?rRpKc@|YetDpJ3m8PCht~;^Kstm#SqEHAgFvb@^GoB+!q*5*a z=IZ;7z^WV=qCRGJKt&*Gj(|3M#zQ#U_~g9woTQ2I&jQwV8Cm$H*B5(vo8r56 zI=P`52i@loT`|YAkiEE-DXjqE6|n3P;_E%Y~n?d!lD+!>mM(_w;$owl6cSeM(qIfF!*3 zv?(lpAEl4)plB^}Y@h1A^4Dx%E~e-OCeDc~bwh1hQCSPpM^DDuzpVP%g!)yY(25@L zE`oEfa%^`9-Gkgijl=mbRKRMP_SH}ga@wNaaCjo`hJo=v4Ld@?OI^pb*SXEmcI+EE zqZA6F?<6NL(((bCOT8IML$~!&@4+xBKQUKae&59fsdb^)h5O2d$DknrktCq@-Y|Xr z-MEWM4tX&Tx@C8=qi!eW+Gf{_CCK5#JPE?i;&+An8!z&m!G>C}PpeBew)2g6Q8G*k zU9Ee?mso{I!&=i{GRT7u{(qBY{6hQ|!I;?_c zM(tjkS-gN;FpTBi)yUZdZAKPSv;*6H)fkrry*ZGq8rF^Znb+Hvnw1OWNTjF9Xm%vP zU+YH1;-Wgm!#x``8S}Sgsv-L!>WM1}&w}R(@3RK+->lmGa6ytCPnW04d2irq!?j6r z%?VGbQTp&RDS(I2$QhtOuK75ChttPuQ1CY_i`j8}?M_hBb7^Mj3VysJds_V%9pQP# zacKGBMCh3npFn^3*w9;}VOIFCugepqdl$cOFWVc{&WmKl80EV>AgV(`xmA7V4E>sQ zuHeUfwaTYJoWB?OhWA8BE*6$jn9Jtu)II-Y0`HNysA^_Cavt@GKf!71wgvBe^49C- zewgT%MNq7Y=n+pPOgj5*XHVmDO^#XhyyT?0cf})6oBheV=ALdLE1q|JEfYAYt*xp&*i-f|)a@?2XlbEL0BMHUw+O>1+vA%yyeb$G&|sCK7IJ-;Ufr??xdb{-bbcdw%gg^qeR z`(SbJLUreqQ8N2V&3E{K$+2N`9;9AqxTphCGQ-)}CeZKkV(uVxOPQXE(m?Hq&CiE$ z=eL!QHtDu(E;#KsFN-nhKEGD#y1#|B&1YL{0g8g`Pi=GeAABZ<8_C!IaPmT#SkNxd zoDr2wqCY)3w}!D892#e(7gc3^`R1@)cufDu&W33<6U4P~`ps_e6!NJHd>34$x@6h! zP567C=)`^Z>(TS@ZBaF<&pMFqEjCy{yL487*LKEsbEz{R6BFTo%egGZ@4u#tatpYOmTPFr`z$sKmr#~Fep}OHU4SVB~)-K z+n1i#o5JBa>@B_Gr*3es@6FpQ=85O&=PY|2+@_zv1BG+biA&GlTVdY8hv9uzK0r}5 zKLB3cDJ#3y6WVg-6CZOTyrP>Q4#afq21JHSgpNWp`$4)SSMqsXsi~aR^G?RblrDo~ z#)*fy6g~!yfO{yc$}KvTLz&lSD+HCr;3$8RcNTSz*NV1_xk-O`A259!obg5^4xwxY zFZWvVja)WnKOj-BU?XWh(Gblc^KE;@$C_?eZjs3JS`0bS-Qv6n^;67zo<%ON0s%DO3Ay(a3wU z`Wtpr8dtWnF4)pkjR&a%bq#2qtI;}!&n`-%b$OrDwsa8p=;1lbe6+$#yXd?5yIXt84A15P(cNb!K>d;~jmE=k_K4CEYy@;aX^_di zXc1UFWaHNm&JxH3KbeCx+aB3K1~*(w>+4L9d{%5%`GDy|sQR~nWkM)H*WDMb;)s6R z)b$e~Wo!e%$9| zbv5fXTJUAYPC3hN!@H_rJ=DmUAE!VQo<*Ztt_?};a0BaLL@F62N*>gj(#r(xA#0F{D&afPAVB#bK@&7>C?Zlz+agxDA2go|>@B}#zxbqq|VENn8L?cDGjlG9>NL!FGe^5<$+hi3`qSm?OtiCTJOACo-)q+*ac$( zAnMEdFx;kn?~zC`23j=qYPPu77!Mw1@IJ}h=~d;J-RRkiB01u`ykuVN1hdrfrIh0! zZ345ORdb%k{KsF-G-uG8js!dR%tP+0UPAV18B^7MoY{1Qe9W!a)9+@(*G?~Z*me!= zsVeSQHTMmMVFT`9qv$R0m&!!w`f08o#P}E8wl@rtp@0<%<4l$2uBvFhA{$mp1SIx5 zoFQ78`%;gIK>ZsnKF23|(H>%bA(3*UKaynznNU0j^3@S>bkoIv<79a5sT9*vJH}DGiFbk$ps<^c5!5io!3%_q^r-^iP`G;>Y}B>QPk_IKU}fYx=NbH z7}zmZ-NPmA_Cqgx{i&<_w#S&J!wXrZVAfT;Cv`3wG7{AKgmBaz$0kGx4^L7ffAOH9D;p`7?7;4||B24|S<&IRR)U>I))_K-_bY?P*X>hfnV_JxJy8&uM_%?@e zP-qLx7J1J2ptiyE{%_W^9GutOI)Hq+AlOS3Fn@Axu7COGnoD_Cf1CyRhY9k8`@LPi z$K_9Uk4CB+UL-;Lc|E~9yE+g1X?BN5Gh;Hu<3A)uM-}sOMXbyx23X(RxuNlJ;N!H9 zS)RN|{Buo2%obLmfjI2%@e&T{3Zoy&OkLW%8f}iHk+1 zY;k>HcU$K2S=tDyZxasX&58Ph@8xA;5sx#=TEP|B=u;L}=z+NScS$3*E5PnyujeS& zI0vZ5oEstwbHXuqt?(g$SCMzKP7%#A4sXIcubB44=;Tkk)@NLT%!gNmMA+>oq@Ic! z<_yX@jp4t@*3UgW?{kh9+direJNhKU#LGcC&7_2z=1hNmP~hbd#?ml zR(FK**}mi1zCTK?-2d{bu{$Szcr86w&q0a<#AYZse>o%SKw~`qhYWF{T2M*_{N^y@ zK6^!j@^#LD|204v-(`9>hHU=#YEZ#`2GAe*rd~>3PvPb`^-644^#7k;R_*>PjwE8fHB=3U` z@#F`N2#9J*yv9W0LlGqH}A#sm|d99@F?GcE>)nn zF*fE^g5-}la7-eVLMbL-xJsO~FEm%0z>Ud`J=9H69O5nvLEsruVFG#5 zb8x#}iDwBa{@sPZ(WDGvO|}UFdTzwEfijgj-p>c`o3U99rp$2{^g%O-DnS0^(e|N1s5ZCF9SgFjMe`R# zAT;+{s$z6>lewgX$ntFTe&1fo6codsy^&<5A<$>Dx+otL#n_@Bkp46_pBB<@9QFn4 zAUX~FuLOW=tXY!6Y=RWsSNvbqR%=&I;sXW}3=127R6qd4;wfh^WePslWO*cPzGwEd znFZkE_^Li4N@u!o@B0zfq;k!ZqV6+P%@!e%F*m;Tg)~x<6+M}M#(s5|23mz-kdF6; zb}}7~Rgh}Uw9NW5T5ojb>orp(Ql(Fx^=xf0XY*VHx#Tn>zEg1r( zB*7fQN!nwVG8rTth^G*lCia!Xzxt<`I(}T%NBT8j|JNo#`|@1*D%QTfd*@j;`voId zMgoQy>=$=#BwW^2xFRsP?^UEd+!^Am;|)A32snMhngzKSBAlavbS1mZIZ8+NVi}eU z<{S=!?v%9&oGvL};%ap;{Nis$VVY$zXcrCoBZ?u(D$6SA6V&j=Wq`wuvjl@+Nb~Pk7gA`psHOOb&no1z|HPJm(UhHWuH>fP9)P?8as#GA6&j=NzC~hJj068wG z{JhoZ%=H2!_r=!p{3AV7fsUFWzsH1Rlu0P%1W~Rpuwl7BQg>lwleCg9?$he_8}2+O zc7~Nwzip9zEfN&RtM}GPV1bwZ@sg+}4J;XSAsFjkyRM6fklb~LUrzjdsgbJ6PC82? znrun^G|o&nl*2-rJK~bA1Nik1dZDcy15%8pa6U+D0Cwf8QZQb8`Dk2}I{x$~N%F2L zl}>(KN<$G97=DX@NA32a~)piC9AWI^pKC-meW1@1bhbmp% zm{H2omPV`Nt8m5|#eB_^XCBs0i2_2>uyW{+-0ppmxCFJ-FN93y=_U{*d<7@>MpNuw zOydZODlya=)0E;V@ns55p9QG1eSyTNI5jsc&JByvtv&j6>b$>2jBr@yQ@tvjy_Io! z!iwZ>k*+##WOI@x1~;~`0400cfrt$oscFwR8%N6gX7|%Rs|j!N>}$gJy=#QTu*4f_ zhI-Ybj0jfZ*?S_dcfqDts+IdKReR4B3wjYNIe0Y+1cEk=j`nn4hVgP6j|MI z(-O!XD-m)Sr`6Qdd`+^+^z@`=%?6|)Ci1&=ZV@M6zoTSivixo!hW#sqItPFF-ep2=)2JzSzEc{3}r6++%W!PFqa zXVV0Y4at49kX|~>=^_@+MOr44xY7!i9mxt((XNbhr2!)?kIWx}se-fju1bqP40@Y4 zaE1uHw}58<4Xx#4-KYj8C`)~?Wpj%Tq9ihj7!y#F7Lc+nOjg=rF|sCRjC^ION6NQA zYg-@;^v8M*WlH|CSPWx%vgYHs3pgP9;I?GY7{B`C2!Qh0>t2mti8Of|i6xMChsSA$ z?w;SG$r2@I>xqGtcg79hg~y!;muX0krp2TlN6vDq`k)!Y!vj}|7dmKEobUvYa8R}& zkVSR6A)(B%Fz4lQV9S^(CebHqyW*{6Ua=7Fkxr`3QUrke-W+f>#H!F==sNAb;jLV^!@`iU{_Fa{iP%N{kbg`{yL@qc%+aU=UWdIUc$ovs=~{r81Vp>)0T zi0S8xh9$!fNENVC?^z)(%^c*b>x^}sINPK%`_Hn=X$Y$;EubuW5rIdv-Iujrri6%q z6KR;ED1_mN@!R{rDtY~$NlPiP{X!-wIm1f7Qh$cOr$Y8vh#{HrZ^ebQ*-i?blLlk4 zbH=F`ny?bn=UhPjJlUCR^mM?z_T|(qA7f*VO2wOZYO)ZiJ7bl?F;8~rFcq;jJ`su& z7@+o4L+{j&K=k=XOl`&v(?_cFXY2?DAa|KIE8Wrhr(LUFAAKYAuuTy72ANMfieG}k zyNcbT`s+Npe$J>9ufr!=sTeXnV*GCZL9<{Ep!)!Ggms-s0Gco_>l04CJtb|of4hx2 zY>Ia}hA!Jh;rHMI^r*}eTnujE$o8ugW!sueS;=o~4;2e&AXI=?a}P6(=^(4HejdRc z&w8Mc?(|3L8?+*(*j?h4Ht1|J zsjTZ0OLpZ+3_GnLUPR{XMby%viATKTfq=l54}*jB?% z#I6lIXIpzV0MT~ZWTG(8=y*uc-|aF}1nsf+{e3UGiQ zq@~>!2}LuZ4o|mtfXPGK^gGj_xU@VVa2}0dTNF*HPOrr+O$MB>BY=jd z4OgBY{b7o1p4RA_DdNyS`L_u$+D9!WcrtZVWZRyrlEDY*k4AB$aT+yk6%vq$I&?w+ z15?LsEnX!6XDm)_A#`A#fC~}>TY}zdBU-`h>%>w8lz(t!ojaxDICp&>G3F14J2{hf z267RtLEmVcVpqcS-*??MM&OlsCsN_3xWXtSV!jgezzPg`EQIn~75kfrT2ZRO_bxBg zuOcR^Oj!L=VZ4zSZBnq}&~EXvt~VKZ1Juc#|EeW3_|gHi8%3R0SkI3(qhG7-jM42GK|U;SU4GPu$1*7 z{FN=SYYg?I%k`vd`qd?~fMakG7F zyXG#}$|NJHl3oKO8Pli;6;rg>W4AF$ppdp33P9r;7p# zd>39mwB`?mQ-(jSiI*XmFX;QTJ#rD#GadwJvEAtzjSE1yKDIJux_9izrV?eqBdeD? z-aKmP{lL7odP&?kc~Vgv4yN-GJn)a42T7F`<#wblUUx6#BE^&TDMZ49DdOilOrBHA zkL6gQVEG05eGS-y>z1i0oh<(lIa5wZ(*J$Qy^o54qGHX{K!w63O`7+iarOXe>3ucs zFiENn!%=%G=IdH+Z!F~}nt~hQlb&NPJ+eZmBXui4@=FwXnD(ZyyUFl@av}}{HSLX> zFziqIb9A@C!{l7`DMW*fq2TCAg_|WNU$fe*NFbXTLJJwyTY`{~0qvOpH9ee#a+L5DD zJVA~@1?G1hLoE@z{EO_)56|~0`J`NrzOsO_C7uWoMLJJ{=K2T2GDVM2XP&g9Oo4QF zBElzeru@{D%RsscQYxhq9705RerZNT?2LVnU#aNDWyE>g10A$pb^sPD)Ky2+r}Drb zLEj^bFacRbQim5*+^%zQtA$KM%TRLHN_dfjEFVv$0MN-K@F+(blV|NL>YUx`Le|D& z+I8c8mw5BqycZEo0=iQ_^OY!4#^XSSb8qWsxhQ#^DhvarX(0eJ1G(biyHUe!pY$O= zh|t-Kq>=*!cAa5&v@{hk^;4})l^*$BgHBco2}D2>2hpE2N@#CcBtMN_6&-N!87MRe zl>yb~6{x46Vij{RRgyJr=I4)qq!L(=EqrXMNgv{V-oaDCO*CRV-ef`P%J_va0jndG z=UTRcmL!EHtAH0AQ>NJL`N*xyM)}bT;9T0MH31rzs5i~>!)x~PW&9;$pRT(D59Vx(;4!8_7+(NOd2yh zid5|6@Z@8sL()e#?$tyTK0$EMDjG2(lkGd1Q*&oGrZ~pSs8f>PBt|S>m;R;ql42m1 z$r;uoNxf0e2z2y_h$M`CAiksZS^!!J;qrQx00Te`kcr_=A&aVNpd3%beYB4F7AHLd zO41HY`aztcQa3M66!;3It9R&=+#;>gA3S#V4@_2PWalv!0!suD&` zG)p8-FEhIa;&bk}-^H1D;2-sXYCgHYlI)~d8}5<~OP1s_y}G)))**wp&ESW@5DP%! z#oA3<$R;)027haUg+Fyek3FD7LQwH4matw4==8GI62T)X);)mQKYm^-BYX-3O3PO) zidl%%?3V^C z7?#e=LAjfBF!98L_ zgeC&$GV&*@bw^{$8vz^jE#38BxpZRyFc&MeHnRCMi}xvH)VW+Ra`Z1K_tIamlMZfK zzGzgEE0eAT9S;03l@F&Ll_-MonHp6IBnbj>?GedhSaqJq(MbxhybKP<8DI4$`D}T> zFeeWC6F=kowHX)iutsqU68T@wBVjj61D1^N*#EgRN+m-vjOM>}>@#y;YiJl8?u{wI z74%0iD!QHEFl=rg%h5seCj%JC&tbYJ3nPIbUDts2GJ`30eK0Q0u%sR#()mg7`mtEN zMntOL6+MD8&waa7qEHggF#V!%`m~_@r|nFxhp2$1k0Fl^d4Xsflz(#S#$Kube_e)~ zwfJXQmWER_#|CiJlsvW-)iW3fC{R!{6Udu0t6K$S>P7oVXZ@;pY=M$ zS4s>OE&oHXu;V@dYIS&hkEh#88(p+S0aZr?TZi6#|0f1u`Ze?gGvi&s;<+j#@U^Zu zMc9sg{{E#`VDj#+@#=|qBxUS6La(}1nrjEavQ)~|&1q@DBN9i37xM8Y{yafjo@>|x zK-6_#9f6hT;B#fq5m=Y_!XJ@LT=n~{B`Qlmu0)iTkQ(q+x~qlH3Pss}Q6y$&*82bA7kOHOE)Z$xB7^37O74+#Wa-xp#?qpE zEuEDdHi`6ML4>WQR)j%2e#T2}g!aXsxe=fsTh74iDlvhY<#LMsctibe)3a0==*?R( z^m&~{7*e+29{{z46iZ%U+q5n^+7wzznaJ*w(oeSa0|5{~Dfk(k=lzAES|QQ#esbUB zKE((!$jF~kKbK#{_GO6+^Q@0TstAL5<)P8;DrM0gn)F|;oke$FuwM;(0a#!I{DlPb zZlQc0=pt4~3jv{jAakenUH}~t_WL$pl0uhWdrLuy3)d-BfNuAu3QY}Rx8HZ1uKRjjC^EjBh*c*( zHC1W?lW0XR_m6)8zgSjfzCMcnW4{kvsHg+4x)&G^H%oN!Fa=f6^dZrCLkERy;?U&O z*Zvq=OE7NnE#W&KSOBdUZSh~VFQa5U7+{6{L=H{D=5+ikI*!8@@=a!<8FyG=X_C8} zS1i)puO_PbN%%W_6_$=V&>Vs(b$4*@JLZ0RqlWvT=5~?LFO62~oAh6KDFHjZb6MlE zIQLYD{7C$z+>M&~*`h8CJG-sZeMBDjx@2PmnYrLzv-AKW!-Czm6;Dxg7hNm|xWRCH@Z;81TTz{N5kjWrmth{tpw5iO z>kI9CXgj^vg`~;PGNa^Qi)wad;U`lyrczddvrz2|VM!xkUQXm&fyfd!5c1#5A)BKe znKIO;3%?bT<-CxC?(R98D?4Z&X#8#v8$uB0NY1W^Zy_anyW%-sl^Y6e8qHnv=Ai{d z>N5;q|E0e_nMrh-Rg*pDwg|Bp>=@I&5tPoITvN_`&HP#=C@L? z4eXc_ejKkdUG&08Gc-2?vllwNf#3&$Hk8My0DKA@t6MD5c}fG!PA5&rjjo#BY+U#5XB@Ax?4-I;YMHe5yrKJFArnC$Yph*_=0k@Lk6N87g( zS?GB#C_%ccMe`u>5JFmLXuwmCTDC#rs8cP);>zb?5*fC54v)N33I@<77qt7*eg8Yz zS8<^Bau~?RA~=Y;cF4BXrC7!r)QNhyohnx$Dhnu-5aRZ(I{M=KU$B?qBN?(&<~qZC zBeYoRqqzBiZ!E$52LFgq9)-DRAmG)Zy1D+Z3?Va?D${v|=Wixq#HB(Bsae2wzeQEs zWxBPx-tJG9NQJ_ZS32udn=%sw!;~UB3!j{L+6}8MJ7ItU+1xKIUEX1FP!XmZmFZj# z2>RUhld7fCHABSd^m#<-{OJLzr#dQA6@htuFC?GU zxG2}5=^Q$x0{I4pyJ_~*gk9yZv=&jhj!=2=y5EsHMRlL=781X9I& z;|Q7<(1w-I0Mvs`XsQFR(vLos0)eDT8j5bSPTk40_%w5cze}am-vyoeC&0{6IiD%i zO0-yhyNXKfrIO*IAawp#?A^t9UR|WBiPzF{-yR@(*k4ub7BtyyH1~GZ9=%VT0!Yum z4c3(2<3d{O)e!+W!+(|s%4NfGG-(>sx#%&N;mWBx( zpwC+hu8Yw%^XJ`@aKya{!p|f{gO$Ezdd^hAWfkHtpCx+g(@AM9YkSSzt*S->!AMw+Op{dz$qYbd}EDP*Ybhz!KVG(>nS& z8lRls<6iwuz0Zbh6H+I{#Ltfp@HX=lLU93O;8MOjK@sn}6UGU{pcnK8>^-Z!9ydV)kIh=Imx@T*c>@6#8`Be9)m+hFgN zYhgip@T>fpM6&3J7b~emKht*r`4|PTZqt9|G^~AdMjvhRYPtvgp>_czttaP1VK&Y1 zOwXDtZ;#{wWh(Vq7)FyQXCXl!PwMV3GkVi${_azAxbn)i{NbnMeG}jRCL5QF^s6Fm z651Wm1Yqa`1@U(aFtp$Fm{sI}r-OPLf7-9=4WFr*6qHeg-!{f|dp~kc74$OVPa%PY zf{n@Q01COZkZyzI_`KU?Zfffm@8I?LKPcr^(>iS&5G^OCj`wo6Hd@sG^wP{ zdzWp(D9wdNFE?A3KodSDklqdQvubxW+r}p9sn~gDr~H5ew}6+?B{xR_C||S~j`d}| z&Es@TB#cQUB`sUKE}q7;yrV+B;1UxDER5w6onAj0)PvDb!dTgiBv@Q6cW*s`e?`x& zN>c{Lqtmb0at+->v#=TMz6e$?M9$182lrv7ml+!?91#sqEn2%Cy#pWNCq|W&lEORZ zU^5EI{rmS5z_6Gv7Xdhpi`JY+&p|z;l9Cm0P+LM?bV{LD5TY@MU_3@ScKI*qsG+^NX1R=#CSOvOnZqd)t zu(LgY_Ah?4i>ZaS=6JUzQa_ zfZerm)BCsu9~?wRc!{KXJ?F-UY^Z?ZD}apTc2X2=0;}96*no z-czjbQdPyQ<46~dMEqCvjhtwl(Uh6^{K~WA8BKH^T2hOYD)c6oRTr&v9kr=9sEP0c zDQjwEYD|iA=tb#z#Y_^|is%4kS9GbRIwr3|({)Ukv;4^APOcco1j%_L#SUU#UCwjI z!4Bfhc;3Go0>17l>G5JZ3%SY#HR%v|kmancnp2xVvraE|uuhBn{N=j#wvub>W@!JO zVucT*nYn>I>Y^zf@4B3pssDthK0t#yM?WzhRmzX+ht`u5FoF-x?r86cnUS>z?iDYS zL4lDtB0Oikt$-_aU(X{#H5Ysi4H^VD;x1j{UH0AiKJYQNSPV#55O6488>_o35zSRf zcQKyv=w8bo!Taz7rTLTtEBF1dhKP6{oBkZP#q!(#cRQyrZWE*C#_@j1CAs2xa= z-_Q0F^tNRtmxSysO=}X%{w1|tp+(r#h;fG*lX$V#NO%2^38n zc+}gex1Iz+-%YRoqTUksnHPqBIMNlMlWTBz8fj$C$jGqQTLe62pYj-mq1OlS9*1Kq z$3(`&C7sqP@W0zdHQx?_uYdmhPRc0+$a>sMQ%Q}dqfZdbHqug4V^})pDzxJcd|6FP1#!HjFdqFE#&KqU zUNIsrVi%;vyeZTL9tn;H=8*k_6Xb3h^{ zG_8=!Nl2qWWmG+Bzn%(P-Y5GzCV-x1{zp-2FpnT(OLWpnwr~NmfnjVgRB{I=W|Myk ztvbhEAWU~5W*pS+OA;kXacgTsB|NuK0?F9#wRh;q>NRQY?R|i@&GSf1WxD_IHHaon zRSLXO@@cPiI!!gSc6*gZkCOyf0Pmhq!nyT?sVfhB2WgeOYs-{VT|j zNu0Zv>HE*m-mLi1EcnskCsy11r5r&DM<-MZ^vy-MHTzd*%ulVtLg#h-dYX+|NWW(| ze!(wZ@`Cc*cXfj|L?oqPygv~HzJcEgWw;^Rjx~Su87cK0{!W;Na3nNS1~Ey&BNG?n{6l4Wn0`IvwGZDweO5*VyIzU01N_@Yoy$F49)E+gixjZlFrJs96aNj=nc~gEk}Zg=lb4ES#J$joQ(`&d9;V zW$f)uNJP{R{)Z`+PgjA-oCs}RUsLFqyE$wG?D{hJ0UXu0KobiWD{W;k zVs<>cp02>3t}Yb(S?U{}-M~i$JuES1?P~mXJqrd-Y8f|}Sgzf!Tx^Uq&he7kC>YEc za-UqRFsnH?$X1{>t{TakK6LKIch2*7k$LYA)LSXfji^l_9*kyr#yiR5+i_&eGIr$G z!owd0lbOE1`Ebw9DL_B68+9qN^>|8E3cDh02slEu$umw`^P{?k5lJYKW6WVb>DDhq zu3ClL<(#VPtT>JKY1z;0l~$l#o|`g=hWTMZD~obH8E=;(nTn>C7iTpR2V7eCx)5w) z&D9k>@vO-K!Sv6(xXrkD-VUpM1%_d!zCe5;ieLU#s}Kche~B2yn%KJ+A$MLFB5FZ6 zBJecf`zsIo6An{kw?oJG`Gi_g`yhh@a-Q6HbmW*u!bmUX;@a@VbL|qJ16u{EKn2)UQwJt^~#B~3ZRof z`Ro?M3VR|VwcQ%Z`(0UK}zrvv&0kAm<|Y}+1kU?$eDW3 z1IA7)C@AZ?uptTph0l1^t?ONof6^fhe^RO=G+hhBk;kFnm>Ux=<7-e=jxDZ5Dn^cf zE`IFMC;V4eyF~xp3&AHliEF-Y(}^ zG8CQ+p53oYb;!nE?^l!G#kd-EET+2vSFFtTk+E=4ta<<1^{gK_&xdqOGZ+YATL>Z= zPfe4vVxoCiuw4gFJ-Wss{u&PjywCySb~ILO zw_A@lm(d>V360OQg*WqZXLVd#7g$R}@~cgho_n@A2-tt6KM4WN+Id~}bt@#>4t!`E z6blz9+$q6xPvE}AMv%ns8Vxn8MVi?CF)LnOc81Xz%4Q51PN2Rmy8_QAirVgUM|KdDrs*uTwqiHGK_76VLoRcZ;e4fwcm+F7(zI7}hJVa@tT|QB z?<~d@5^-Y9oP=Ur2qOs;O9nv_3~!n`MeSyNlo5_)+J?bn_@n+p_S z(GN0O(vW!w`&$|=k8Ss89(S)`58mK)P;tzm^RkHaX+?7ocV6N)Ks@UzFF#TRS3+!- zn}?DSRNrK}kO6-kTTVUcZ=}fjE#^O{#1Ck{UH!1QhF;aT#`CI~*WKF)A>G}Ey!v0p zzkjNmiQ(C_sNYDM6@6^E@eni=3TfNG0`}IRKSBtRqp&fvW4>Q&JO{Ywy?G5U%Z zzr!0wIbRX+jF0Usv&+zAFd@n-5Yah)TlBQx#7vF-SnDTLe=fi+a27E(jXY7qcQOy2 zu{)D0SAY*%A2?Li3_`&IW%(+|7@}`DvzVL+4m$%tp)Qi_AxK3#4XCkl&7DbfST~-YtAfpco)K8VJ3V!~_{8UpDwM)st=abvb ziEj~m+=e($6R*-=^u8Za5uy~9nBkWwWFs^$NBg}$^8gzRnr!ih-)`rF5;)(2M@JD_ zR&CpFX2*byl7=ywI* ze9IjgMtuaCRL~IR$07ue`tN{d#DVkP24Hf1Q8mMWeJgW6YRCG^_7r{YN1$r|Gk7)V z=zqr`^voHHbsyto5qN^@=IhiXy1q2LQ-GZxMybz?xv$Uu>h-u}up(i7#LD3${|Fu= zhtW(F@cUPVmvKc`=vYSSTA-Ez@YhzOH(%*k70IcVMSBNO(nbZOC((KKfR}yWYvub^vE&8Fgi7wz3z9w&4_Y_sP55{&evL1prL*<7+ zKZ+g&-}&#eJ8BiQ2J7w7>_&a?Uv7F0av&bZ-Ftgc1#pAUdfq>@HCXwiQX;V@%liQH z<$w_)2rjC$`vdKo1h&7klisf2+8l;k_2)|I%@MB0!f5YIsTD#~c{|cL{95=pJ0+Yx0CNmprzFq^E3ZmsYFtyHi;4v1C>whOVF< z7w627@9JS@=`@j@TdnL!*bB2c$FO-O4WXn0h#s3)dq7J_{=m5!x$_R<2(O$nf2$tP zD1oG&ME5z+_ftX<*XGqr(NmLOPJt3AIa%CMR6zm7nU@Gi&lyrwr;l&TDk}2cs!K{> ze0)Al?qgwNlT-X<7NDjkkdO$e-KUSs%tXwc3Io`}0EZa(t)YblE@vi@hsWc=Iko>8 zFeilzXY!hxaR62yTv#6*OwjcA#%T*S`;Bf9fM%Qzyp^AsSj#Iaini<`cd?RWsmSBu zLPJ9VgjxQK$-k1o_X;Yif%)gX-CgDLB;ecZPNI&FjzpA|(L=+eSU5P4f_gxSmPUF% zf0n4MYpJO8>a1C@;7$W%Y0~tOi?~%{h_A^pv-^ZPc8A6YZdN^`a#h0?u(9$kVihW3Z@oWm%?h&_*17 zJPuNnDyAfLYMSU1Vwiji8lBQ9eFV-ri!Pel&B7H|7BQR+2%384WTqXSq07rv-jt{*FCb%C0{`TPc1Dr&R@3lJK6F-FOX!)^r4#+9=f!Z zITm^PKbEdCEUK<+4yA#(;UdbzvgmH#O+u31){9$$xrM6dh-SOuw6pWqPtMk3-uAGl^1zvL z0ux72I#FW_{JsJ!TrJtU&UZIxO`-C{q~Mhk0Tf*X(4N-@#T~yupdkIUgFo|ZEBV0dcnmJ|6A;;Xk@8;*!}$PB&>8RrKvUUs zhgd+ZK50G$yrx$>JAB|57I@4VfVyijrxO^C%5ihL8s+`$fP#P+WYn%nju|j_nAi3Y zCy$<;)d7m2_CJ3XIR$~_cJU*L!6N)Q?cN}ze3_IAr5baJ3z7z-lVvdA)`~T3!lvVS zPIa_+?M2A^O0-$=tKFpn?}61x0jL&Gck6X z5;v$V->n*z!|-t5(Au? zq!WgQU}ff*+F_uJG=9Dg(o9}xr0Tw*wzuGwrC=;vRrevmKya3dhuYZ<`l@jP z;&TvfT(P=#Is@8M!{A+6AF1emS8*$9yuA>Q_Va@ckJP~Wy%3O{jfI1=Me`_=E*BIY zj+*ASqgbNTiu~t$`Cq`?(Fc0D+qsVK-(%3p)ec2VbDCvU{{CI5LmkNJro`?6A_vYQ zY)ZsMMd8rN1Yki5e^kyUJV^`UUWUiU#)9Mux{6islvdjfdY}NF8GB8dkRZXJUX|?L z+k;o$-MGt`L}t;{$Kq6s1-#B-SD~XkS2s8LFi|zRFda2W_&fiZ82@P8JPt?~iKUa3 z#{J5A;RY+qRClszwDCuATQN^m%aEe8W@G>TxVI^zdDr%TSuPbW zP2+5~4X_DJ*oRaksKH{8A5dr=cRFrrlC7J-RoQrMM3{Uz6l4*0aAi6*Fc3=D_GBRQ z7Kb`_%HC1AFfI9wTW%z;I`2ms6kdZm*hcX)M3Hl!#4nui5HDnt-uq)K_qa(j=Zb>8 zdOMRY{G6uvQxz~($3XCWUet6reKrIaP(7a_3#X?1tlCcy+sT;pp<#r+&zn+YQUmxk z0G}bwU?)H6ZJ2RIUQ@R$ppWL3hn9dhB)ADX$DcX*pgMRwpDEs+n#BT!pn%I#PI)a& zb|rF%dgq&(DTghT^BuDCnQX0+q`(l8UB|6<-F~yIyn+tg{T+$648cs65rESg| zUN<&R`uKIQAgpc(tBT1o(69~C--DBelqGvV9_UPTQFZNi6Iq_iIe~)vAysOTt?YCO za7Wam#i2l_V0=@}R?BB<9k|AaH(ZwBDfjBmUVT`QeUwaC{&Yl#n!=FgK^olNA3GR1 zid%-lLZ=>}_=r~Z#@)>LDT#HIfu25~Q?KLU^i;z81%yt@2fS4yo0|4RGF7Chc1F{~ zNO+z98nI?qSEDesUa{Wa-)BvkVWCToFD@bj;Yj=*H;N0cq)~W)`!qeU8~tl}64YZL ztp8yhFFq+Lr?nN1M!s$xh*{b@+FI@E=@IWA8|#ZC;hk6&^hr#_<-1+JtRiqt{k5~P z)){37Y(#+yxZ>r(D*(uPCRT?A273GZk${A!-xIXxBs>Ay%_~6wCUJ8UIrXalso~m_ zvxI^aPq8AbtNjWP774(58~2stf^mi8>Ep^iY?S12$ZOw^2wu%dEs$>NCLpbLJn3Kb zz>AZeZiljqxMe!JA)%!!K3_>2`O$?k8T5MYQwR!<46B@CP;b8QTDjj~-eV)t|LiCq zcypqtKQ#bSMtZZw<4x*)EADxWQ9pnjNW`caS_v<<_V4;$fLmhA*A=7vI?d>z1Ylo} z(gL(D1hTa&KxdOij+TVEpjhr#=NN+np&lRLz}Nh& z`lCe3M-aAgquju!dvTijw=8*#q3s2*_dM4j?^N-6@ zaf^f>Q&ut^bqoJ)t;0LRZ1{=f%-Y_ky?p*X$5!3;RazRr)0bN!vU}kQ+(fxLMc2;lVw>ZcL_}<`rL;Mvj zoS(-B5-bQ%5eyjI+}yXeWE6IyPn8v-qjDrQnR(1b9UR!Is;je==@oQzN$yvGfdBbW;IdCdO*9(5>xlVJ;2~^9bC?I+%r#5GcA= zsyBP2cC6!%0n`jJl6oMh&lNGfP;uJ;$6qSK$*)uKv9%}7E>_(!e%B$e*FNC4Fu*Oe zrEo$gOSeiH^F26=vPX#AN-lk#Xz;O}%KQF-iHVG~KGoKhXo%97K98PgFI4bUVn_Wt zO%6YG<+ig_voPy?DynWSw_fGg7O@Z1{a1?C)xy5tjgnCa)*ns`8o| z5-59_yc7YkyT0_j>|BeJ!h7DqL}}#d|Dx`fCe##2Z&Lss2Q%qVV1ft^rAp#w-qbj{ zWYS<66VX%UudM2N0NL^9I@c|^yLo)ol}l)7}RvU2FD#yZLzMa z1{yIn-1E8?9c?S|c+g;J-H|csX5;{-pBq`?#CJPYuOiToEH6g;i6d&wTUv5@VZ;x` zpU)YNpl(pxp254&7U>r7V&B`9{_{_{cS>7eG(kZ<1;88F*~h@o_|4>Z&X3~;lBy7R z2|2XTduFXR^(>h&?xoN4WZ_i3@rQVB5@wW(Gk!SlWTezFn!85#ccYzk7UYi5qAd7| z&c~mME=L8)G*{gzbabg;?OPnwR8ZB3r`z$8=0bErWoOlCxSr`s1&Im-_Jjkuz`8ys zOJPt_#h-szCEadN!&64=EQfR@QaJv^oZG}^^G23iP|$Pg)N0| z>1!f`{$q2tJ~dz@*vns#n~RU9#oXD}W->D8*6t5QuV%KGWjI&`0BU&9a0(mENww!` z?u3!S$gi2{_OP%p1uakX>(fOIlhyIiPEn119V;>`(q&?qM?C{VnsQ+1H;5UeCpcMqJx^Ih}TVci>6akv4* zO^q~r?;KHKezWlLGn1#ji%LCekx9Q&OjkAnzfGVyIkre}y!?8qsg^gGFzpiab*thC ztNt1JTTq0B*K7Z}Rg(M`m515A1>;#R?8gq4?xk(hs5C)yyv*0OVHi`dz@uRnL1E## z&VCW&gBK~x3kLGfR;21mwH7cfIlGkSio2Q;{$AD2?h1X?1Cz!-fu{)8FeJ zS~4HMVz#8$e{ao+Bs64Wlf4?H*~tJU z#k!ge*C5`cge!s4&qYMy8ppgEBgHQT1$5WHcg2eb?GIBMhp^nLaB$uO@pJw@9RSmsNdA$l zAXsB}NBARDeh5HxUfvB|fT{ls4eL|BUc{-rd{}(L_L4h~(X>+F*8=ppK!EVGCZ*N6 zfyfwWx^SjSD1^w_y0^%lNqH6%aCyf>4}A0RQOXW^&(gApcKzvN60GT>Z2yEYD>0G) zVwlmA(!$@CHL~wnf0JryhSq~i>E^6*aR$r^ah-^_<2&uI``6>6$rjWJg@Ypa%K$)z z#<9r;GsYGlAhXtB)yPj~ZfEz@z|&>8UX->*OG-ym-%=dWVgPK7?Y<`TfltoZNk8n)zYlHou3!z?TZC9?t__D(gjHvAw`^cwQ z;G{n3)C71N48F6LBLAahcB!^Gswyyv*A$!}1SisxUD?4JeEl0wJ@!1{?(f+um`0265k0E$q*5 zX-h$;K$sWzR7u8Zf8?4wwe%cyt9ct;cVf$v+)*U_Z^IGef%QgA-R_It{BEyDS1 z{IW%OjvY||rIA9kD^{pP%=*{-QEbSU1bQw;Au=MOoI#E4Hw*)B?k!;vyoKK{lNz)w z$kqYAN_-7YC~cQXsT07p-x2#`chsqkPeA-Dz824bFW&xgXcy<5!^j(#`6u)KNllg@ zm1$p-TMtE3gJl|$s}Jdb=H)BAZ7zr&NGdu#agCb7dS@?^XJmhMoq}JIBI1xNLrsmh zCI-9en7$K7Ch+7ST_8_#C_XnZ%~i>irW6DO;PMuAHWHHy(Qk6Ex=b+P!m_f4@9{t$ zZHID4ByPojs$;Oxjjhx3Z%&ytX&W8ZOscWd34>w+`0L#Zi?$@ zIUKl2-yFF^{a=Kub%U2};oz=;*w)6F7&55xP8`89~Y`TXC- zluiLg^h!6^TJPUAuX+p8Jq)<4Tmz?^`R$L+K zf1KzaF7sF{nt-I{Tqh9)YGQ=(4WP%x3Z`4WEGsJ;YiU9dSd|Z^R))C#TeJc;uXp%j zID>1=gkyOdmh?637Mb27%1PRUosUGsyQ0inJO!mfU7U)XDP!?UNHuB|B>&(>9Gi) zckyN;vdP*SQE-QuNhHeB#;#OS0(Zn@lj`q~%eL6D|7aG z`c%;$Qlo1UK&lUgg*kG%r<$vG9$Jlq;p-tnCO^D2wV+4@J`+w>z1v}6Ck^X?UfmTB zjxtmll}ifhxNjTMqfu<%(aKjdv6t3^n{17yQm6R<7y94iA=6&&AwG()k-tVclMC_k zNx=)^VS{~0zrsGM`w6!+)qqe=?|63Pq=x0f;OXpzyjgfXn+Uj-KL1a$1elo1;73be z8%zBDSf>g=&)AaEuf7}I3w+}35wH!wuIdNJ09+`LtyTeu#F=R{A_TQ<5fMBPjWU%K z)noHYlA^m4{4x+ez$COEy9HH}0tQIt4Di(!6YFF@qu?E*O-(wFG^7h)K6bHUEeVH$ zH{|vvDO4aRjlG*#Kc)Z%by8JZLEexFC&6V*Nbl0mXD7uvRyYg{KKXq}KW#q_&>vW^{ZU>6| zr2l6T;|v{y^8q%m?_G#eN+VpR2e9?7YcA%yw>LsK+6}lrT_T595$j~zf2&Ia2wZPl zbpfwElKeUTOCe$f%Ih0P)*(sOW9k-Ym=i72U?1SyBLOwp-!|Rs7}GW6{sdez*yHeN zmYs=JS<4Kd!JLzB2@n=8}t+ zCce?D=!_S0`UUIb%E}B7#Q+as{~3Y%MqlJ3FSDy>5zzSMt^BwooHVcz*i#*rRkz`C zmmpo`EAZ9Sv9XH_}m&eep{yMOQtbQT5_~>E#Bb%~z^F z6io_xtRyq`QiR7$0QJu+*^h1{3;Ph}2k>{I1$hTS)Ao?2v{p}WfljUnUM*X=0%2H| z%TK*InD(V6c+z&4NvmD}Ou;eQwLL)t(ONTq%thbaW2)1?97L+SW>g0591dQ^s`V^g6Ob5p9#~+$w#6R(Xkn0a71F!3x`L%YZa=z^>kzI7_xl zd=QI{f7@g%h2N|xZ!Rp3>Pus8&TVwf3=uQuN$3M=%8dDpgijV8=2?hTI_Yy3A|Q|N z9p6_D1+Y*bO@Nu@B{GK9Oq|S9D}6ygz5BQCpBlm+i}0lao>{5^IlN)YdG)C0qf&06 z)H1T8S$0cZb@Sm4(*h-`;)9KNm3L_;sMr5f*R)Up@z1f7DyDk#A8bW4gxL*G-63Se z6mi;td`A?zU7jjZ#;bGL(k>z~NHoeuf62e_Y}Zuq5;+aC`77 ze!*Fcsym03Ruoch8|x>iR?wQnl!RDvkx%i&zL?8 ziZ3zyK{3WpUu@1yo&2h;8z75u{SEf3)qeA>$fI%7MWAxpc%ey6rJ>0 zxHWcAuHsROR-kwx8xV-vcx=&y^S|b@;szt+z@jRJ(cYb0=XMwl-wBLewOxc0i!UVy z>Y0H9*u|pzO(^T!lOy0c!@g!JT~f$5R*(|^m?GPWbkc}VdJrYmD`>ZX&$YlZ&Nv)O zKb@1*f+?d?S2U^iCW^_;HvSL0PUPu_f|&LvHN0&M+iTGDFs;8v&d=t)K)?W@2($IX+QcSwMZ^{QxK=Of~ZzZ$yZuL{u12 zbk(6sL8+E+hBzJW(ZJibMp#nkw-s8w>*+4KKmBiYgS67Qqs0!c02Hghm{jRo)re%T zL(|qQS7wMN2IE=%P8r^}H8OGMe1uC?Ceh<29kst`s(E#KZvE(aV3Grp2S9quMoHb+0zmu;CJJ*3lwMpB z=3w!Ba`D?Ueun59pc#>Fx5MQNTEcIg*o)=*BXQ%_hu0l%M_&#s>Llb449N?{kQD*KCn8hS(5<|^+5D%RX~rl z-~GyhEsJ|PYovLxPfU;DR&UGFTECR~7nW?c}dWO}rK86|H2zr6WM1M5#!dJQZ_n8udcu(cq*K z@sdlY_Qk$A=Jm+fe&b;uY(gEz!jaGb6|Ne(*7^6kH z^qm6&+!Ki8th@B$Cegas1EDSJW2#`a%2u?XTFA3grBC`DF=&hQ~1C52CP4cR}uAEj<#wYk#x;T$Sfc zf8RVI7vK23Of+Y`@yQ&RcfPvq3&kZ&o_4YGCW6{tEGsYC z@WLfmB7IKh>3HdxEE+h9>rY&S=Hc_OAjzZCIuATQwEwJ-K%2p=ZXX)&)G;zOW9gkV zo8wh2{0OSqfVtl%r$tKIRa$TyU_DYv&ILQ=SA11e4N;RINm_1bfIY)7yc0}M{yo(5 z1yP~Nm0rT{*)?6j;=>|_ljv!r&^i3X?kkG-4Ne)^coGKxeJ;|T#{Nc;&qt(mn~!Jt zjvB);tCd(Yw8AfSIkd9AZLvH|wOUou38}Ui)dN+co`+P%pBd=M=-)UlP8kY0hhQ^$ zfEoiCta%|Pj%qHv(Zym`ED!F0aoN`}B66zl_BDC_x5GJM%cz#_n7kB!U=CPG#$lBc za4!yOey>Uxp+-c&R0ebaJ?z_S?Fz`{-O*IEn+MvvH1+Br=HF$}r-pL=A-Y?@I#X7tjHRJ6Mb?McQ*9l#9Ds zhr@V*c1A^%AViPv@6`~f( z-CHq(`iI7(T)l9f>O!#ftjr(vEJ6op=t+5k6Uv7XL9XtW@#!~@Zd{>bC5GKXKwZI) zFc)e++|;=Jl-YpsF#Cc~)Kn*DOa!Zwxi-;kdoegNeft(>FE6a%x8paoK!HA1y|5nG z`qboGnIO2Usik$>j@8vn2?oHqu!fS-9rD2#4$(6A|2gYaBY`n$ysgof$tE*n%iR#RVYUESk%gkx?Bzg9{qoDbN+e~)bDZ7c--J<=oI-eh1SzV!f%$SwKMlY zQTdhOY_?^jNFD!vfP{SZR$m_AlABBf@}?qnPk-9Tah$r$1J$PdXZ|Lu1re+AHj>z1 zU@NY$WF%x|__?fH-47+>u)~zZKDV z)bbHe6c_QLsy6(&=?!@!rZ%ES(e3YyuFAg90?d0Z4-GJ&jB|6+fXQ;U<3DwX7C#DM z`_3q|(8({8-gc`sJv?r(CuaF?CgluX^HlGbVO0cj8cKizGs?CO>?3TC1KFapH<*gHh8A;{_PMHqs5@$~J2-Lj zckGlB;?$~Zg*0~v6lxX#2}9TISaRqoQ4;9cu6Hp>_VZtbb~K>$gqQv09*5s$>>g(e z!uL2E9Xhnz`oz{mU`oSp6N zD4LzF@Sf331H%v79%;8#@RCi`j_yDDzQvbW!X6EuYn?5Xy4wD;XDH+{*n2~pF2U~i z=lMyAnBa7M+U$d>fui6MEO{j$saudw{R)f}@ejEZqRX(4B^8tCXq>Sq3d*T6_NDD-omKgfG{iwOv(H3CIQ>b>oF(5(-hHy>anB zELTQcl{$S*%!`x0@|+oH_^4_ZLrEJ>JZy-OFcvLx<&QjzxAQP>XgzKQ&>VaQO{5D_ z0kb=bC@bG41(;+}{E&}ce4s#g8m|>w6B0S2vpZ5lu#e%L!!^gy&`?ZfCI$#;XV?Ek zYIk>+BBu)bfAZj9?)1nzw!J^xSjKs8I=ca3Qy;_Wn}(l93~Amc_J9Wa^+s2^Z6ge4 z{_7qfb_0|*4S>+;Rn7#WezywnOF|=LBr5(VThbw>AcCS^fa51JRs3V=sJH z{r-vb2S-XWm#ONqLqB#YKqj7+`{U~_3kLMj-K$ZK2CBwrfR2S4RL^E2q?_(b=CzwT z%{I$_LEno~%#tI3Xxt-C%U}QYY`)?I4F&jFrG*vP`thW`(sL$7=89J`D7EZ~lP9w& z&VC1myga&Lib1=YxK5)CY!9wB3^>}6}O*+ z05wx_4eekD2E>t}tMz~;QO*sCK*vk3M}3tBy=Q(vwkW(UW>eQEu^djP(! zD=y}Vd>`Y9pouZCtK54PWF%vJh_U+o`!vj!GQ8y89>GW0%lX+48m=f*cKb1pvmMrH zs=(od;wB?1xm1nM#%tLD7$M*g{V6)j1=t@`?V3cE@!*e@)&)E&B@)#eRWM*$}q+&kX$<)Cg-w3~2p zgSy`hMObJkLk&(m2(}%&BI2R~E$~>L_x&U9c>dkI-C^I!DQQ6J;ix?H|J4|N&Y?zuhC-b7vfB{PB@2a+*+SOi9OnKSiYVFk|{rSdrk zfn7kB6tyIJWg0z}eVeEO1SJJV!1?s2I3ZP9f*;f!wb=9Z4V;s*g>3>~j56V=XA z)Rx1qoneDVzJhE*DkbT{T~|+?LZ(063r=6pTr{c82aAz*X5_fyk*TsqM4f#RJ|$&_ z9M_k_f%qH;L$htzqX!xZZ8s|XW(yy2Sg zeyOK+Y`SE_!84VepM#f4e;%k^?Y(dyvV}N&j+WYU`qoM+8^LC~v0|e@&TCnqu*6)c zQ+xS!y;Q4vd>`1_!W)XCU#GQ~r}$-RJy;<>Hm|9mDEt%KXU8hytpuKVi9@FDvfAxC z;A5#r<9j5+Gmj*IxrJWt`(1{VvE*GY`6}Y7Xfky|j(bs6qjo(}S+n;A$42E3@Sjx4 zy>&j@Q{BJcswOlY*{Ip2bYN2_ed!{53cdyB4dEJe#`XZJpjFrY)|`pMbFl{jp>gQ#P%&@82Kp3WNQ=RK7zwqt`(EHVi((eb+V< zI$xmb@GJl3Vd#~P1%@xB{gmE6IZ{5-x%|r>nYPiPBJe^#w4cE@nk*_x8D>Uy9^wYx znUhUB&)fYB(@Q~HHY6M)kgC7r)tcGRGa<`F(eWpmtgy(3iZ2v+7VVCzx1htdZH}wT z0PP4UZ`{`k^#+Xga;OG3!xE#r6;@Y(jFB$gWKq;`n1^Y?jL$J$Uq6{b#%wdIJ#zY`3=^+R8Eg?3N z|FEbXU&aH6bZ<9@nf;ch zANajp!}?>xFm~Is7hnWDn*~R9^bA6~++`IIuDCp)@i?M!CqSPBKBy9qKA@O83XOJqkXVBw{SiZSWVyJ~$ZpdjGpt*# ztNUyRIsG(&E!M-WW>)Q|%AK^ZEFu*BZm!UcU)aQVKLmB!$WegpSI&|>PC~sP#)BXu zf;}tf6kmepBln_}@bE*ji1qQRIODoD@!+&C+#KiA&b zqvFat^Do^}f44(#6wK8|O^Md@meX1a{HCjwCy%w;#{2LH4}I_RR3CTZMk;gi=&+bd zyOO)r&+kq-Ak=}2%Cy?^KG~vgP*w)wtQs8D3D%-q%0_m)rcK_tU)=v}M+6PN6S;Z;{NYd#z+J9lfE17MFURJ`yO4!G_XPAS%?SraMqts z;1>mr_phvgt8#{&wdf}Jz#Dm0!*+uJ3b;2oDtAkm*J{F}6@S!$&!R(yB{0U%KfJPs z8=pnP&nL%DQM34vj^3E2&{svha(7QgP#0=t3MhC;=S~MW%_VV1IQj18<=3wo2yWt@ zi3_Ge-tp6HW>h~s-Lj}Qvz9T0o`SbhE_XkB8$OnlkG=W%k*4e_Re!Ag2GW~BO61ae z-N}~XPVkU|({ysW*!T_zn}$U@21{y0vI)Fwh5S?7DM2K8_Imoey6FU3rFObsw}DGU z`iKR{ky0k2ll*UPoVB7SWQ|du?#GtwcENZs1X%Hm8KLL?6njTx;+=3$6{`r3D@Yv} z;rFBZo-d_Ttv>&73#Js_sf)}BUh#$(vQ=2nWKXp-9wK-e&U?*AHtM4p8>=j`0Ae7{ z49T;5+0@F%qmYj1iYf0-IwGqmsIT6RIk)>;@|=0l1{75L9mVT{^5{iP+ULWkRFD-+ zU|%l_aTmfN{vcAeFA$&LVCl?>PR@(BLW4B zs6xDE0p~TQ7f(_Q*Ja>}61SKWt`Qk7eut~LuCn*JNejEKu6R5m#DVj%K|NflSUVU; zY&g&MXa2{I(mb1}GB%SBOPKdZ_)W4@%QynhvIKqNpJ?|Z08%ZRt?q7ajv6GjPUH+J~@EVJ;p1j zGEJ^Qn$~bjgoOCd)n&*-EOG9(43ObfVk*!xpWTzTT(23n2ne?~W=@?{LX-r&vsRa* z{G{QU+?&9eY(V)Omk;3S(}>LOn!qcNG^r+d4}vZ6a^2cR?=t{ZQECpCgM#>VqqPG! zG_s^)C=N0kG(EtYQD;z^-IoiR>BnfE*2i#rAwhUfkMIOnPt>N8WVshBL4iZ&&Do7@ zW;qT89>?QZCD+XkVLk+zy_!}$PI1ijaav=%xpXrSkEX%RHK^w150XcDGHr+?#A){vZ;75TL{l z$wcs^dbASb-_ANOYBED*ITUejH_=n)b(O8{$3=pzJjyK%b#LD+t055Ceqy3Fz zQ_5Mku!DDn{E#Iz=~J>I{%1w1G^(f09Q?&{zw$~{@DrzyS+3(R#sUP6WbBWQeP7`; zqyX`h2f%qn0PQ|Om5BGv5J4ruD&;AN%j4aC@>6THyDP7?pH!3WFes zVl|FuWW{jz$@vopuAafL2f7+FT*r7Kg)LX2zHNNjRJZA(z=4pC&)y$+V^S}E!+7;# z2Nn=p_T_;z+!~?(Dw(u<>lcL&>R5*}zkbkxla!0>Ct?V6%~t^+`Aq{CtyVtu%}Z=I z7ee)?a8z&1aTw-eyUrBZ4qlmP$K_IyIy21vNecWr`-7_qwC}>Ai#<^zsM@)Qj;etO z0P9F#VJf0noo&OhY0kQ9+2CVy`(gEm@CaVJ>nWMm=c{pY`hJwciEpFl$tM}}9%0Xp z!S5W&op{*ygo6f%%crW_BCVG&9btZF5<2$WLHyrWL@9yjpw>-`bUsVybA@Dm?hI1t z69r2M@^weY7Ck>}Wu*jz=plIb(H@vS$eZj{?-_X`N~A%!LUCWA6qed01-^wZw-R_K zkafM~yn{7i#3LRcnRWd~{m(hM@5%gW9u(+~gjXF0E)EA*>#O3?;wP2<%VU+O4S+D5 z^blsxcWBe_;41B>ZTfHRL^JysLwuOU`-+RqzC!G=Y_%PCYwh=1$B^+iesPPVfFD}8 zZ(;}+5r>2m{*bcY3aL;(lf3w8n^7j4AwF%I60HXHvP&V{FNNUAK0Hply&BHMfA}MQ zZr!~W1yY!hiA;3;<5*(8M*pI@L>N3)Wid5qX;h}TeniZ9nrxC>->t=HuE5mw0_MtL z@U8!6IL3|NB0>dBYUkX5N*GlrLLLLHcftL>QTSfwYIoiSZsK4oM-?ZiDl2v&iv53Z ze5!a#2JGLYRBoheTaOL@KW5Uc44l)-K6qCWjav09s#cU{&MlRdEDNX(acJC;>Xt|d zA!}{dKW&fH*wAN3yuHA&Spfc5ftzwI386<*_v z{1VIKmmT#m&b#*obw0YEQL1X&et)BO#xzSUlkE0VOx;Hcx9wwjY@9gnOn9=iVT@&> zVx7@UyrM@5@WjZapB%F3pW&<@HBs$3)=ZLzbTSg8Q@=&dWyT7P0QQ#JY3Z+(0GX8A z=flGr+L?PhqEj~egF#;wM6H5e<{xBV)e5#NdNgXWBz8W{H6Ln!*ke!evpkl936JY` zAElXryTF$eZTSo?PWT~Dq0Qn+iB{zxX&_I6y z%j#xJQi`+t7@}C9NudoA`ga4=-vI4FeBTtXvgeUCAx5#KCyEq>~COaJS(a$>tw3!Mk`)`Qs#l` z{QVa^H}FsleV1>G1+7)ja4}cE#CIpS?Nb4q{WwNPjNVn)Yq9QX>P2a4Lc`R*jGp5T zTG1a)+P`6s6fQ2!4H89%NNth{rd(N$3?QWibgSC<7%q>1<&DTqzd>C|zBO&$rl;%U z@Y(u+CBeqD+Ny|RqPjhw<*u~>kqa_U5a59$1+xd5(8~ZaD{geQ_JW5Ib#F1*&C$f` zVhiOE2d`R=)&Vs0V@QC=s1BunijlDQ1y~U#qxO`=sR=sK?DGX_VH@7(;_8Bu26lET zk1q{AL4@utCBODx_}FAb zb@tFWl>bvYtFfeUIH~%lwzd`!QpU_eIw5b7gBw15Z&A22OxHi%?vsw}C%{{uaS6w30aWhaG`SsYT>qh!x z4Z5J2kJx;JfAqwsA%0TtMhpaHqq61;zs(IrUTr#aPX(`in#$m5_qxL`;ayQ+`pxIm zf*NQs%A?=^();aOPQ=VM1>uit)&uQ0u8@D|xCPk&H|!7(*1Ndw*=oFYrJ~c^IG|3dbziSsbs0{%H>WPd-=n^IK`lm-MJOE z=pQ6EtL`uU(nZZQeWf9ZS;UtT2Y1V`nNjI&#JZ1=l{06pewIFr`#gm)nPa)kXH_?s zsVwh5Hk)Mfiq6w?JzeKry8fGOsCD;4i?<%uMMdt0IZaa=EhkYi!Si!#m!NSyw^q{` zX(wJ{WR9h&?YYB%6(*WilGB!%X6oMc^OJyJ2%5BLKY<)7 z;*tT|&B!f30cic{VQyvHn5x5E!U7qB^F47W1nZde*2+`(#p>opw#H2U--j`pX5fFz ztS>m|LLRWZcAvm&H*Uw?TpnYupnBj`I3~_y-7DRyezA=>Y@Kw;U+%C@Z1ajf=OF4N z?y%jBE2MKj=I#5Ms(a#M$2T7yM1kE~B%ms7ogiOd5S>h4)(ML|wyJ6FNqG}rHchQf^RBN~GDw;Z7rfm(%WvB1eEZDg*`fkj z^~USF$^`|uZ${S$iW9d&=$j7S&@rkzGu2#TU+?eL+#cfdYC(9v7SyIlUj5WtO?%p(DX7t#5UFqHnp~KAM=f*2Rd{TLP+ib@ zAEN=8T2S2J&P4EobvkCL;Js5A3xV+d!f;7_f{NSrN;ynV>_c5o%>unveW_h`BirUg zR!9w%k}@YxleyA1`zRem+7}yAt-uvs<@J}@^_S&f8msyG!Sc3!OFBl;^nd?IZ!LJc7A;_6!VfZ*0D%C~K36vMN;i>Jc;-mUE~J={~JSlpwMhb7ea& zin9tAw{pJ4Ya@`{{t_v-U}{>}RJpR*W0y8kMthj;|1+xP;nz{hTOFnf57SWS#kxbq zuEVt#+tKk&%gTbt`yJJKf3sA}hd@X(R1c1AgpKvO(dsm-*16#9`zK?5g%abjlQ@31 z^$K)}KxFTgU;Ac(k%Cbq6eLJ)E@SAK7lcOyJ}2QX1Dm0Ri+RVHs)d!;Lm2JWsjH}V z;{yO1Ou`veUsmS2hC9Q_5xywX7p^B{-2+$OV1KiKWZE2b(Kx*>CfRS&d7bbB7mJ=?NwjWlXs1hQ@~^6ZA% zf}PtP(~lw6^LlvvZAU>6kJOs$&8Md(Y_icXj)D3m)+b|jt!a`AO9`exR=BREtgr%t z4_*(piJ0qCpRd~5m|D=-zaJfg1rRBwGFJQ{t6^;yPQCf{_jeZpm)~7BJ6ZqLn+^T2 z^R)II-kETc+8ipiR3ST2{hm5=SSxu@Qe=J`OQvgjaT@l6%+c199hDvNKuthRDb{z6 zw5`N6w_flTofb@Q&e!%G<5uRzx38O6vgEMA^u0G(^u+OV~);+SYs{8SHP1Ydu~Mb-75sChYMl#mLWxnP9%?xyTfTVXkE zeSF@i><@otvz?D#3%QfTtit8`P-RnNsZ;mvXL;SS>)9si-t5cg$FQtr#{D6@@{Bg+ z>+AUmm&CRL0+-FE9Bmiy8~Ukcm!t>(((*Rra#<(Q%hb1?eE~Q;6nco189F1|s09 zMC|EyGvD-2Q{}|+Msr89n1NR@bLoaOaVc8D9VMsz>bL?QbtRH5$Lwan*42G9zh3hQ zg4MXnNpBkPzzS!ZtNR1>n)n?u8soUBLPuAQIOVCVkb7pz&OY3|f{8L(9se_Kd!G5Y z=>{o!+pr$;j@{y%dj87AghxnTYG03WaP8hD8`PwCD69iO6o!1+R++DVZi&8iMD7AlmvQfL<^v<<^F?p9 zxw!?wxn6KRqbbGl9lm87`cabw$ew$Z$lus*ZOxWBGYZ<-W&qii7sR;hNN;a;e)v08 z(h^VNlyd-AEC|1eg%UUX){E%w!g-~^bY{@MI`m^#)$B8S8L!55CE?AvaUt1w>%U+7 zr|n#6!a=@dC6bH$D`dL?Ix8lTw=49&JiCp`;m+Ge$5Sv_dXK9u;VPNJjr1UXPNb)1 zLb!s(<}8yiG`dG&KfeEL1b!+gTwQrY=qlkm^ zYJmgg=!4F(vOVFr=c^?W-{anUY94q9@ZGhZa+UfXxbIoGH29K?qt|CocTJVDfJfC* z942e3b5u#v(ca^oS$-byz{mQAQ|=1kRKfW7zRDNo4e#jx@7+{}`HlYXdnP+OZV=mB1Ch zdx3VdhopNs@jxX?UN#lvF56gAp|CWS$MlOnMH?L?+6`I*<*eU~Wb24Q4;S8d0`2as ziSvUjq~T$)`=@|+0}{2ozfdb1_>n5PzNX<&;`oH$c|zCvng^eG-hn#kV{^-lAzMba z79U3b=_R-1Qnk&!fV>T5AD?`?QYPkly(iQjrJ{f-=|sI&v7R4Vp^I1ofsp0_`!l?! zZB-QxgtpWsrs_$F3x+}8hm|F_VY0^4R`GI3z^;t<81oA6hgpl;i5Qdq%(0(nb)9X& zjlkpRrMnh+Rf)NR5ayZwjYH#rZG0PJ6}(rO(ei(V< z8hLY@l-$|EOOaeYlyoSi+s;iCfffxS$HWH_uYF2a{}sG2T^fQKXMp${Z(4!L3;}qw$#T*|xYBCEjjC1~fq|;g)AVTDn#V+M3&L zy1{JE$+x<*a#IU+#l;yF3f79nMHpj51^Ya5v4n{VS8z0)iLnLu;Gk zXWDy1!!GU&2b1v7Fzuw~ddXJ1oSxdmv6mfwgZIWu@bOpkwcMSdT2G;I8121p27_*yLKO^7N2~^ zmU~cD;CW)~m#$c^mp5HY-6o6-36M`3b+*4b45835s$aA9mmX}boSQc;ZW}-Sk%ICo z0=}{|HXR@?aHm_t$X%Faxz5|7SHDXD+RW~bABDG6^>roe%a@*Is7S3M0gr2Si{*%Z zm$X&K0=aRDAi;rOqijsTd@ZL=v16&$fW$D)^E^HjM{m`9nr{24^`bzdyoqVljbS|V zg-dU4fAzHBw%yIO*fKVh>EeyI{al2cCH27W%Ki@Q=ygd+3BoB_i9vzSlDI$i^>IFT z=kjgAu%^Xv78U2n6buAf7%r@vIutkXA{Ul+Uc#+17zxFinIa3#*`K#0a>mXdS9gw- zo@|3kOkg2IwBPPJk5zX@;X!WdLaaQJEyr_xlletWBDx#mGN=xl|21yggV9>s;P4w<`JN|Cp6m{+>4 zA-ApjU1>7Shc;e2;G`*zuqfLr zmR-qb4PmJGN*yn2e8Rb#M6dVobLG4F;f}k)ZYi#bagiwbZ|uE&q4RtLv#I!dxBBF( zzck;r+%^qN|3Jp*+{t{iLNUpqQIFLKE2SuWJNj@GZVfUF_twU}6Vnyf3Qcxr*=VcF zmk|yG@V3hCrz6EHPAf&Qu>;t+MddW_7lgp1ok?2=bI#$?k_k~2l9|~SU73*ZB4Kx< zEan0k=rOlDxw4{mGrl>LC~c&dE6FpU7MGbwB6Nlbd6|gsf&G+NiDT8d^;s1?9ZMeM zPF(#|wLt~Kjl8w^c_i=oWxk4vS8jAA+Jv*%5hYlzfaLlUT)ctgItkTn44r#W?QT_w zy0v55I9Kla04=|>`^8u7{9MpZ7z>OQ00#->-op4n{_846=qcKpF~QtDEFmuaBjhgBypd>cQ@gnM z&UquJJyYZsu=I?NTrZV`Tz2aU>o_+?LUv@|D9gBJPZW=gK;v?|ss+eE!Htr#@+`7O zS6IpxmZMjN9EJ}U8V7pBX};x)&L1+%IRw=uD0F2(fntBZa!zbXVW9#iB`ah>qT0Ul zDvUh)B5lXkw_4{(<}&Y!vsSsG`ld?iMJ!KE1eH9OPmESrxC>5q#ZD{{A~hv+;DE>& z_bu1QXTd`8y!@Z049b5S#_7=@p7f+t5a_QDeSP{szzyV~Z7G}0DU4zDwHBLXOHSVW z3(Vv(cty^U+5ACHN@3~}TLul!mSs)5q`wU3v*4m9$D|sK8ANtX}AF^ zE2-BDM{03UkM$?+T+y-AiNLAo9GcChutoS^-GxUzUdmfs z6`Ch<*nVE>nweo*6&qPekU*3?@BwA+<{SeWna+h9E%iv%3?pg=2Pdm@K=%w~W##nv zTAE3#ydkrtWm{iWRaKVMUdbn~n=kmW@NwL%hi)vO$Usvvvgp#8d&)cmI~vs>WC3=m zq>!n$9Zg^%C*lhBAF@d{MO(d~=~MB2E=cqJHG@ee;V%00MVg1f+|k7w_LmV8)AoXJ zd&_>FkeKW@rwaV|kR+FP99M_)>qL{2Xc)N}9*!B{Y0{3=7Smq8ewTyY$fTtwS$(_b ztVKyGP#`|+mf0H8GYWREz@pHswxp;v zSMLb+gH^|)ptmcz&|N(RM++JQYA zc~xSHSn+kfkKIj>qlvmJ&e9ZFDyL46{i>ZAwAJg zQ2gR(7A+Vv-$*G=_jQ5A(+J`aK#qgMgEO6T5eC?R-TUns!Mvm5O zGmkkh1^aC0309#PY3Lu8m0jdat)lH+^^5)Z=n*>Q8xd_aH7_&pM>2`peM&q{N z4y5)yQFM{WYT6XFOmSEpp6y+)?cGgAd^EX}YpV7n-cP#ogRzTE4Hmq%IMqRLZjf!b zeDP+45di>^qiklRr6u5hC9wAONLhPAwy z(b3aO?Iek#X+dI3i_F5++C-R1Dx<%sRA!kA+rB4FmI;Q+@CzTRT+pjZF7)yh24UgD z_nRrHuJlG2Au_f zUsYu7guc^WF%&GGd2pZ0VNuOA`C%r~Q9zE|q3(19hq>PX(m0ND(9tDty8m0$x(Q-| zWJ@48AYwUV69|N&XRk1m6$*7zu&{5kb(}f}nYn*avNBH?%_}p%*l`Bo#G*!52{W2G0 zy7QIxz`5B;$ap9>mzOMG{P}ncrJ0+yKN+g%E{r3uGbYDYFEL?;ZekS)B`^>q%{GJ@ zHlj2s9blGKs45Qq_Ba?Xb}n83dt?q>$=o$Sy#p*CgaZYo?~NNbE}gf2W*a3F$!$DQ{m{S&Y@e1On^LcSQja@iCKDm# z8bKo+MpaQIHd2C+myyZ7af9ih9AzfxdQv)2=7z2?v9K6mYa&Y&bY>`YcMk?jflI$p z`qr&mCYy5|IYu)fAt7l+MfaO1-J|sM^_A7ibdr;AnHU=@gJigkO`~Z~(l6}c((ejC zS$>_K?jIe^8Xo2|Ff@D+U}9=YEveu5?vbLRV$B3%X{qM`t!!dqqHyotOYqU)7$KD_ zQeA7R*4Dr9@eOks_fYr4c$y6hT1<*eMoXC*KD#*1%jxSU5|X7oGBQd{NJt0@3CVc* z@}--5YQ9YkXnK4JY)Q|`VpZL=sN5bC zK~{4!Y*3IPAD=VXj?Nv|IcQagFQ-@PrYGD`OnO{a6}Ffe|M4y&OUwBIahE5YmsUT8 zLiWK%Y0RlWHSkyXRk|gVL)+O924VIFIC6Ph>vm>C0+(ej?^EX`*(!yi{{E|}&vN15 z@_hdj<&auY@;2>S*H(+rK?j=*k7`ZpRZ zX>{WRBACc?{ws50_@CdLpd(v8Z!jo6Y2r?wTIh1h=8XHqSAPP_f8g4MSHc(~u$Y=_cb)|mI? zfbHeX7rroq9a5MH68I8ibjfvBP*x%oqroMl>khmSV_#`QZgCAFm&YnpEiFgl^_%9M z$0+2YOP&2Wuxr*S%$$qO#+U75%|}ZgZEolp_~t!e^U9jYXl3|rZm3Hc;g)?B=UNrppwoq=l?<~9=LadM12^MZ1sId2B`kThlX!J$ zd0k+9D2^*R64mj31?Au_Z2QV5n=2>fF>reVPb;`BM$-^Ajw5jh7RjFW%i1NWJ9h*I z5BT!anzs$&Qn$CX@s=hb)(S3l*HQ3_!zvy3-KC-&cth>L#mnJ6mOZt)^VV`O%WFIq zU|4#H`)7rs-JNq&$NG2E7EUNDeX}1WS%$6*eiWNMKLiYvcfpOaR8TBkj!sfqx-T`> zjV7`qC7i>k^(lLLcD77~{pGeryQR|UE18LjVk|ZLJ^%qeEmEJg<5N~q$p{Egh;~|P zVUO&{$j(+46Y~=KHi^A5Q8U4nX|l5_xYw$xl2%o9$Yo(`TR=wW{qW(#?M{9~^HX+u zHnvCo{rwCqEQ;pl*?xQ`ScxiYrUQ|Yk;;GDEfk-thqTukfh4U%97@e>Q!H+d03T)yBi@0cx=ahOC*1;jQQ1^Q(7ns&?c3h@Soa)o-wK(iJCej&cJa5% z7eoyV4AN8PJnWYT9h)c0K}UVN)v|d8cJ_z%&fW|R$Y%%pjyV!M(h)(ao1Ofs>+9%0>eO4`Z!Pw=pjE<#ciha?|2?Ipt%;ct^fNS1Wb8JS8 zWT163Sizf_t}7^$wFtLpQ!6cm>nyTuJhMmuzHoZuUY!{`7cUH+( zD)EQWjCXdABVqE-of|dMs(HjCf$$yFWUnYJ)jpgW?^SkR%(Jz^as%g@~(Z#O-M|t>EK(;qGPx$?Nj2tEBE%K2XD_Awn_(;f;yoxZt;=AwU_a`TFha`a@>_fW7EFFiuZ?5R&cG#n@ zjn^btrQ_elyf#QeC*Sn?1DWvtwwrc|bEX`}o{OVa?eZtJrM&d)MAv&97l@k5ZYquH zuMt*wlDtFOlWn{z5f`hrs|!xbUw*(<%;_89v{S7kh#rldZ`(mGuYJSm{aj_w)z!Qv z8>>%2&@-RW;j=EARf9JiMXx&{nFwU+iQcQ;rl3D_Ms zE=dYvy8~1O*k=A?rq+&*MV`kWqNB3`tpr2=tTT!?0`L|an|u_S{OxR8xRPN3NGGZl z_s~XXsD;GuU~&14*K>vTHY5|M^$<= zVY|(@)waycT!OhzH_boW|3(%&ZgkKHiKE;WN^Kd5lLGlokPtbWi|w~MAo%4K(HM8a z*$?&I4ZX2N$P}P;+S~W-2>=@5gkJYGpyJ2JTY6yu=#m_^aecV?ovY;#NWh*n5*V6z^*otU^a@>JIsv zyD_?CsVKttuQiBu-TNGYrMzpMw#&<3cnh59lKbV@Qa&mo6;nBXB9hjLCFj-ay$1&D z;lP7v9d4WFeE`@O5GCM@p3Qe+Y%+rNNkZnXh@M_ocHnfAKMb^t=Q8RvlL=$J%gf6P zCWyS-9t1ED6(wn-mC~ecmpuj;zLGjUDd}-X2Qxn5r`(<%1#ku>4GmgA4?s-xv9s+c zvD8T}FXsym49qOE_3ztS>>cIqUJqHp#>+4$5;qyIbmaT`=4Mb#%mBi{3Gcuu7VFNM zyOe^RU0>~luYx`DjxS|w_VTi|kH5d7xOn-En>W3*C2%42quaScUw5zw-N&w6_7UG$ zPWPnzBwmmAZT#(!?nKU%d8BCn1H5JLuUf(cC$&JJ$uKuLwNU(ePnyNO65&cdv&QY! z0vjoH2`6V$w?QCCvk#h0SuJU!p32Gx_4Aq7(BW9x?=A~#CT5d!UAH~U=}$ruje5$d zCHJoQ9k`kL*}X*h%CWrz&3?ln^%u5Gn^5u1sbYgRLs!ts9(_=-Gi2%L7)UJ{>s~Dx z!0Bic12^hT+2#yI%>>F78u!wekCn^h)*QSBeVOmWvfos{;TwcH9v(ad074S13OKAL zGa=zdw#JSh0|Nu-z)LHcub%DW<1+&}Sp!}@eDFYNzVBgHZr#b$y`(5Dbq>Dyq-RxM;zn3cQa8cGbSi~bBVMypXh*s}Haa<0ao zL$UJ@=wRQeZdcz%x>R(Rv(&91$3>!?(Y*3wBhgUKs93RWQjhqka zSWhIZWR$7};$ko86YDzuS~8G^0cc3(=H`1_yt6nX!J9cb3TkTU`T6;KIz(a880viC!UaIq;sOHn`UeJ{ zwTH3g)PlRzox6E>`krdWc({Bp*79cu^V*&-S=;TG6HO?coc)HT7Sy?Wi~@1kDW?Xs zdLl)jf?F#7Af9f0o2nle7Fa--0T$Fo>t8+MAvkumxIFEFyR25m*5{`+?s->@k+_k& zR|&(BobVlx&Dbt7y;6=lb3)C(JHptVGF7k&fex;6*jfJaae5PNL%6f;bs(6jk^xBA zli7BdQ#(IOA-4vT(<&~Hcw{}UuBDXJ^CH;?@rEE>zscdyx#qUu>Qj2xy0{wEr96UN zsP&5V0viiA15MgC$s%!M=3AdFR5;@c*cb~_AAPazb`qJvE zik+QZjmFDc#=t0ByShRFl&YUn($f>I!76Wf>#uBNq*{X&eA8b*0S5=?Do0A{x~P&; z5C(;DadNg!S<%tcKf3bvK5eW^Kt!ZQ-|%oobMu2+G&E!zJDyXl;nERo`md?WEP`cZ zWxL;za$Gupe#&WF+sO$`M`P*1;o#uoj%eqk;gJ!dYE?bZ$2Fz1)7s~kMc#3lm?&EB z`l-FcTl73U5^8GUIV1M8(qVDd(~aqgiS^sn52d9Sc1EiKs83E#&H(h|(W6H|y^ks=}N{{o0kvyp8zPQe1bfg1{cX`hN ze~)W!mEo+ZAa68qGLXefWLzlD2LCidfBM<*jzrBjQ#36)<$dNf5aYRE^^+9OEq#mO zFY2EF1I#xx{I*q5rR9u={$lyD)IyDnj*Dyiy}_!XYIuiXauS&n${s)|fM`##?E}U| zfBpK@dwfF8E2N~*<^qTejbcr0RajV<+o8Lq#8frzfnT5Pi1kG6MzwndfZ{+TdS5>! zZ*O0`j3iW6g_XKlPTJy)na@vsV*X_1N_D1aWX{ErJ6brx0o?s$C7es43;*3njLYB( zRVQE&R%i9Y&NS={ZqZtFH};Ubq0U`vrMxX(H?m@UP|eH}miuYL}G~upXpx>NVLvt30)!lY+JCP zJ|{#OBZN=1cpOk2(HwNZk#)Vlp^9Aqp|Xc69N%2r@4m50dBamRl%%J z9ZooJ>R$~z={f3M%^h}C21ZvsP!H1}bJiJo0AEfQ2aRM42?kKZ=8kk-W2@%eTvu}h z#d~da(9rg4gYBlJP~@zdSN!mOGLQ9lAS8l5?WW*9aS?!y0LPU|%3=7M)+SJR1b@#Jrt@dCw`N;$HPYh|V$99(Y_?D)Ghgtb}^9kkF?$1B&WSO zAv-EwHQS_v&Eb+qruD-5?%OY%>J{a9VrfD`PYOxROXhtGPvgFDA6r~(YwBjFdKHhj z=&q?K%T7z@Bby2FxX#)R(bdxn$f-DxP~ZM)>a;$~v%O`@vNXEc8QOof^3kA(gZ0@W zP`wwQX8w}=)BbM!{E<#eP(Ge3qnQRWe~CE*p=k7NWv+n_i;vzfT4goO)|6W-5qv{! z)V`Iz#^2keY(9XcQJ)8^vYr;Nv4B6YP^HXnGIk%WzH^mhr7bH%*U+idA9gBvx=ELd z@*&>3m5^*GpIP~c_hw8g3m@9GeKVQjV4;x!LPAlG59t)zctBod^mI`8-sPFlPI0Y;b3o#w}rTg4D_U(EtVVd$r`v0;msZ`3)?<>ex><6W#Tz0<$RL0@+51f=d&1073J zLZ!5v@b=4rY%S1~8r>SJ5?7$=a({7=)a_6MR4BcgdHKcL#9i}C`$0XIkd}yF%Gu@V z+}k8jRDCFxwe*OS^q(O`-Cxg(pj>Z;tvoPJTNL@eX`q2nh)eUQDN@R8{T= zR1^8l3;?|r!vY-hYx_gNCX>wEs{<=-JR#$TgfC5+`sA0Xu4QO>xK=$t*x$h~Bl6HNc+l(Sl6$&cF~MHr-_iAyc$Mv5QW3j10Ac`&c0sd@Y<$Ctkht5 zq`g`MDn!ZLbj#4;goyifF6_KGU0}Vk)(95!TRE_sic_;!O7izXND zJ2`J(8~!##dzb&06CSB=<=>HU-1DqQjqj@1c}84R6t9fY?J7S#_px5b-L$ZC4iCl( z@%VB{0o#L~z9oVW8lgA+RkJMvi3lAs3#p%a-=NpsEkT)>t8M?P*kU~Ca32-Bv|u&y zECIy7>*$rxhb4^88+HcH-=f>IG%vTS#}$4IkKoqN3BWb(e9vL+lS0X+BV*3&mBVQC z^-?lYO%73x?tpC*j#9`u>9{NFM~^@!3g>twdmKVzriuxT^*OeCd1B-hkSA{$$dTAp z*ecj4pYP6&qUQ*jNeK~4x3!mk363eO3tiPP)hTWx9$$)@t3TL4@>h+JUA&gNPb$G+ zV>TBDH+kr*+!;jbbhdUj2sZ)S@=k2ZkD2rGRuaKPFjprq1t-gHtpR=AEp#s)O%g2gO^jW zQ~Hce0>8YYdRbA2LojGMI(2i(O2=KNg>SxhLbEzd*`Ce^LCJ*cKlO!N>laU=Z*-BZfeCXEyIhvg$KBLY(=O#nX9#9k?82! zoK!)B`p}xdD0UUzi{)syeUdDS>KR@zqz&TYMCCFEwcVPD^#EMg`=72m~ z=g@B?7DTAa?P2kjZm7e){{Hk}{{s!Yj7&T&*nkY>t}Dt3re^!q&3s~GF|LF~6J9@& z6UHf1bly5=`=p+;w}Q8iWUtb!R6oS2i>XEhm{t{D5*W_Z?b!!p1gQ}v^0XbBUMzm{sV#&m!mgPu~;mY7fxX%uP z6j_dYuy18lKKB{z|O@@WmWy z5W5$#7(d{Y>1`{4_~=sVj#w!f-`br(*}U7fUnXhgd-2FfC!fc)SHejMjXZB&Qd3f9 zNsgBZWzF5JBz;bPE^TE+Yv;bI9L2`&?e|dR_HaEV{7mEap*#%UwqAfh;FkO9M$z<6g)GWn%cJhC z)!(}P3F2{p&w-s+oFd*Ow0X(-QvHUvYRsexH3R}7v$q?2McqQr8{HXmxM}aET^x?NBO_06u>HS*triQBg_Y-fL&DNy1v5 z1~go_{`QtAK$8IhfGMO54?BfR(*XPkLxAhKPH-ZEKKC%|HbcOrFmLLe9Dezcoq zZcedPE0}&^_6WKA3|RZ1=D28>zs`*kcLV(q@B#ou=x+19miC_fSpO^>DKY1z<$sxI zG+{Uemt7_$9WR`wS7^A@`XB_&fx zM8Y`2rCXaHsA_3d0WlK@-&`}K9{7+GkWY9*R8QA&E;nIXFXdK`0D*5&}7KOGwCXwHh)CVEt8oZW%7FA|OTV>`t&jPEj%WHr8Il z4FdOVZ6p?5CX&NwZr69)*R9Txl1iS3;4=sb$to-Ni|z4@o>-Q!V?K*k?=5;K9cT=O zyM+@<(+h0UTP$Vq1U@95`TCRg_inMI9>De>=M2Nh&p_hM+ryr7@s#ZCquL=SAyl7E zbU(m@K&XP22R|y3lat@w)o|WfRdjX5zV98c#v~Tm+XSR3Fi2abGBEb<=Kxb0uVUox zkbDcAYnl_hO^obaR8myzvaTsAk})@5@!pqpa45m>rlFxls4H-H%*@$2ehGx$V%!>m zKv0G|l$V!lx)&h!D$2`UhJlQ32+|@zUw8y44QvDOZ?N@-V7Q|&J-uvV;yra_cFQ&j zDDOZNLgQW;86F0@TXICiRUkE8AR$q?ckeU!nHnBg0RNrbwC9dMLLh{ptE(0m@qwP6 zK7VAz#M~TYGkj=dBIqPR(nitPI34J;{o~^~fUW~QXvJP@cD6!UQ86(sjc#bgUM8AP zd41h_xXdf0=#m*zP=IM<`E$M z-rwCF4=Thk(uiS!8n9omsO|uLhl@)Y$QLaR2l6GYt*uJHBrum% zR0IX@1nxZM6$brl7T4buvA1H4N9Qo;Sc*BvN`8m4<5ED3=6oa0I(JhT9X5BQ!I1w&IZrr5?(h%!Ic zG%&zP2>ags`ykWbYJpI4cCNrA_rt;__tZ>GGJrHjND47~_Do({`W;+lEG&$qsHCLL zqJ$BIDt`NAc?%0aKE7pOcOZkNBqo+94LI$HYlmS0$RlcvOj}go3T3vN6oIi7t#D8U z^jJAPo!-KT;}$I~X2gK$0AWr+ODmd2E+z{YGPu(=JuPih5qWoaw~3jVlDay>O+k-q zDf!FvvXZUOHy+(*QBMm9xX__fI+(}uZB7HBEbio!Q?+yk>E;1Y<`)DuQ5r`Ph2Rf8 z0B@%3Nng0M7oVc(l0eveZnf3~8iD2%ztQ`CYwGnjxa+&UO2Xq!lSO5bB!r4nVIQ{M0-JaNZ zVAuvN9ks`)F*%Ou#;#zXvPE{#RE*^S0WBjXMWw=ig>Po!siC1sd)T+TC5#|R66>;& ze&);>5E+>zF&vH)b;m%h758JW>%@WwH543J`>Y z#w=hUqif%6Pfblt$L}0vzrtXMOI0T-BNII`%dO!B?g&pUxpHk>wI3(c`YbEtWumSS zBLP@jwdcvnvq5H*my9sHLr_o{TP_5)sYgp-jrF& zjf$!gEPgUVumUma2RpmM`Sa%^?pP<7ihRt>G;NkS?_niMtSg*gsuWMR51ooYhJkoL(&s6cDd5Vk+?0uzXLrrMY=OsN}i~e28@gwQ*09B)BU6!Cf8& z-~}-yJj%+!3W|z&D@x$vgM)&S1_o3>;;byhA1EE*7?^~6b+vXNjf{egO#n4LBcq~9 zW`Dmc2}BFT5%4%gikTI*#JGukP>8gJaXy z!R_@WA3h*SeEE`1sAg_?Sq$$K2Yb($(0ms#ij5h8x)WP>FP=MzPsE(k*>|-sT>bmP z!#5$=c|CHHI?4|C-sKG& ze+BvU>1d+CJN$Q8zy7h#0wVu1Uoc_-Tz(Vtr|;i~czBYUKv)5C0TcADMh+N(jh z##Ws@KDrTG>sd(|sa}7}z4SRUULCL&eZV~l_qYMHJyQn7vxyY@I1pb-)Peyn-&Yp$ z`q3-U$&3x~06(O?;O;m5yAwP zc3!JZv}VAL`R369*=3@C939)En-y~E=%XYg*d9l(JUrPzW$>S4x1-6@FU9&4GG`ck ziP#hoa|U_)=zDcB4bNCGSxSF2;t>}Y&qs&vmI^tC#OJ}z1E?6>FqThVi+rm_2A2ON zmrJXy`S@2V8vTH75M53I>~lxQV`asK9326I%@J%MXZ=v2G0>mEsaq5*m}dud4X<5m zc_y54*?Sc2R+LrqcCfQ_2TqueZJmD=cVxzjMoZG!=^SZXxZkYmod8;p4B%50qCt)A zl(~htz~zG1n1u>XtYxL7bgtM$X5V#mbPPyB5+E{{UbKZ_{TLY1e}7>9)QSyHHxWmN zf0&98sxD7!E~-QXZA>vC&_fSk7nf&2#VdCH!BJ5!8`Vfnc^ci!l7M_EjQ2evRHV-l zfHh7;14kgrn%)Wswj1F3a)IS zK4F6)yBnR(1;s5&>5Lm#SpTbd>tcvAsrFYv>-!AqJ_VZv|MS+@eext$z1Z7yjv`!W z$^e+k2tF_Mdcg<}2njuSI(oeS{OgK~|5xVvr?VnXta}n)s|~=8-<_o#f=xmXp8mU@ zMgQ2C_#A=9uyGb8HE+Y?U-z{`gSdhHS)*=UA!mX&rP7#*`uV)SqLRNG5cu!&d22tZ_`}^BUl7C)R(9Y+x-nQcTI9rc zXGlee zdMO&oo0dvo!o+>>bo2-)#Uc5B<}B1wcd8fK-{HfspMnC?vXC9uG^RF*YL_D_k|fkO z#M;L3-D^B>O)Gd3Vq`drj8qeKO|wm7$<4AzBJ2yFWuipdfVs86LM9r#dWFUBdD3Le zb97E*JpbI^&Mk=Tx=FtcCS7VTZ_ z=__DPx&f=51AEVfG4H-Vs6GSqN3^&@`W=mdKZ!nYbwvZt3=&aGBeI~2C3&+sVS~E& zS0U;jF)U@s`^fannd~c~i3TCK02(q?RM|c_e0?0@#Xo>sI$wAAcJdqrAoZXcDm5)F z?K+zOII57`2Lje`MM;1tWX#Mm(){z*1B>SyxyS>7ph@fae+0&=>>G01P z0`W-c(MJgYIDLNk#hU!uYagS(bFGvXG?IY&T3cHK@(~K;564{#P!J>rt`@*&a1<@h zGg$bIK;g^Glv-I?!4!OBs@o+c!^sF!zkdA+pe${{5K~~w=VJ^d8s1`NFF4W$0AqXD z0mmVbPv|e`ZcTf%Wc4W>3StKx$vA-gcy{BEV2Q!lkK+h%u$VYv)ul*0*?cW0aJ%O% z{RNLF)-ar~-@XE*r6W_opb)ove+LhKLx+vOLBv7E(ekbl=~L&?%pc5aNv z`q~NBYJ;shtW@w=exs25o{QM|Xz&TqoKEal&e7PrZlD?zv!{ZCQB`g67pxY^iP%>t zYx+xYvWz1KId)5IYX1U;fq!T244K!&R*x9@_xGYu$29U#2o+gaYkumTTlCW{5|^u< z#RGoI={}`bUp*OVS*1$4NMDZ3U>rnYsj!;ftsxTl6`jbNF5#Q_Qczkv&P__9mQ4wkYY)B0D0YDXbSF?8bh7#T93*op9Bl`2y;W;0VMm^B+MAXdi6K> zr2k`mva&{aQ0YE@#`Z^VdGQ>?QxrUXe^9mm4i^4{39q}Y9eCe`_G$+xPy?@X# zfdX>OHXtOv09!Bk!gP9zS4M&(q7sG*{K&R;&KNSUp(Z-pTM3$-u*)z+C2oldY-31uMLQNt+8F{J&9lswS} zW+Cu99z}x}YG>0V&JB?J94WWIai6D7oY~hP?>d!21RLljN#%qx7Ve(~byf+xXNkQ> z&+<-vzE!@uM`?-}KV$yfA-eO9?JMAI_qMf4j5mpOO5G5@*MM*~Uf>)DZm=R`0Q?jk z^1e2}NKY>r&{wv>HiW3|+AjFXeS93c3hs+dD`_&ZR-JLXwEa#OWPfJbe`_N65Y*Nb z$_j1c0}8zu>%R}G2_9- zF}_bcIr-<<#8YqKR|rJd@S7|9XOQ_%U9LYk!HHKt&LR>9N+phdA%6SbR24^bN6bJ?Xk*rPzFk4Z>Be?^=WDf?qdk60V$lQz0vwDb6YUqk zYcW?x1L#d?Tql#!ZeA$1#|KOTgqe{EfABQ5Je+Wb8*d}zm3)>&++Wxu%4!lI3eUP9 z#Nc;7_^$!UUwi?KP?{zGuDqH9RSEAUQ*xglYah(NA!Wc3I)!|?_@_>`9CA~4L0uf! z@iD*XBkjAo3Id}@+?O9X^c2U>q3l0qF@9eC1V2;IUqj`e1aM)lKy;w zU;G!;uk012n3DdG7nY=t4f-nN-4Ead^b)42ZwVOHdx9bx&NLnx=2o&H~=i$9t*`TX`P9NWe;!Rh;xh1}0G2GicFhFURjhb;oK~p10=Z@P>T0 zEnT(F_b9p#{_8+7UwUaTmgNN64GVG;M2A0P3qbHXdFEL4f_Sk0j?DcNcKP3Esr>Jb zvhBzFnfvdWfW|wfjuSZw^=begec5)G$fs~M{71=0#s{p zCl+-E1jWYY)(Cu~f)cKwZvBo}&Y(BO5Uz3{xiDFJDFI5@KdoS>Vo{^fCi(~Xgsm_y z5~Cx!UV6#r`=_Wvy}!=C6{*&Nd|G&p3(Cy3zu=036%GLZ42P8HM+#4M2fBe@1L3jR zE_N5L)_WR3PMo?F^Fyb33F7~aKUsBb*ylvzSbvC0e_VA0%Ndu?V4RD$UbdL^k97@B zvn+O`N#R1C0~&j@n10qK|5`sdb?I0A;K@%*iUhKC^Y4Pl@4WL7R)JvuA72vh6eyR>!e+zO%FsfO!E3UmHpa|r z>DW+1-Zt+lGJse~Jx-BSe@yxVvjS2ZmHcQ21f<6Q0n`L~Ayz$y)E|W$U>8*K&vB1u z?mvY!#Rwn~9fq^>a=rAaQ8}ZhFAi25&HpLqD~XlwK?;`nF=(&5Kj#n>$p46?dM)eV z`JwCblcNv4my-2)**<2+fQ(T`#R<6xN?`P5BAaM*brU~GJn$V?G5H|djU2CXtk-UM;7kBW$6Q%&is7# zhK8mB)uT^(c!`?e>K^pDL^~qY_F5h1%MSP#XA}*%{ry7k{vEFZd)l`)*_eh5F0WI8$l6_6nq|INjrsD`|hN^Kypo0dS#tRyM<%N&cQ*&wmY zX7u4Ml1g{9{*X8emqD38FztKl^=E;;y0In&nZ;=n>mHw(iiw# zkY`2Wm?}{i0^u(5s!dK0aRfQ3mc##qdVYQHd|m&-1-lkimR{r3=wy%quG;F^+?z)4 zkZ(BuL(yEv&CR!RicgKDD(nW@Q~QnZ?E8lUVP?N8p1%91(Y(eZVQ&wqZWoqQe;`(BOzsov^{lJ)iNaqNH8vRQ)|H6msfs{o~ zjs+LbP~UJ*>o};^0S}SE0+JP+mE5epvn&F+XcXg5MjDAV2=*-`@JxJ}W2rv1H<9vp zrv7UX`ZN+ z1;1C#?afw#!g@zI-RNPS%S_vRXLvGbjq`&xViXx^zCV2t>n%TW| z$zLk|G3HY^HjZ>|)u(1qIFNUKAT96+fn!c}oXrs`puPk@p9b~T`!(tRL&T$yfeVoV zfb1bM^+SjUiV4PE{$s|D0|GcWc=|r*f8^lZpK{dW^DyEg-2mUEo#T21aGH)E=^BU! z?ax{V)zRGP>i(Cj#J`zb0mHNXXqaS-p<25OyQ2pkt6<;B=?TTbijL#U@gPi|3YuVuF z6EB&?xUj7G$o8fPga;UNbGu26vfoE_#vQo+9Wed~08F+{jjXJ<7!=CY8p-MSWr8uR z=o7-7pM#Ny3QjPC(Xl-ff~l;8DK}CKm~jE%h$hA;V`N&YYC*JxVY8K@5>Nl zr!v?IH`fu(0y=^qk;QT5^-Bo}?=}8~ss#oSfw;;_0Z9ea>m6litpTP4`uhWu>N$FafxiE3WdyZjChOgHy%^rxBfU0U6_OZT8Z{Y(eOVHnf@0__5Txy>BoZQdvK)kk0_hm zX#)~P`2pg=z_f+qMC`6Drm$!j^ykune=$9(FWGXA7VI}H98e1Ydk?ll@hFozvD|7- zW^@7+bkx1^vqz;)HL40>+~h}27Aji!qhQU!RE;j`C-K(+sxPuc*8J1bJ~Oi+zwdIs zdFL#IguDx~l{kKOSOioWVPcqzp(NeFO^^xtxd`*92m};iy57N*dwSOWgi9fPb2=DU;gVVr2kF4;h*Kze^e5+=mVxPaJ&Iq%;TQsU1Hn8zM8Q2ZFwL4Uphv3ODI8wYE6cA1N_oKMB+$qMl*r#P8p z9o0YD_xX29n(}IHpKX_5_EHAJZ$-gVh7j%{GYp^v(2Z-sJ@W4g{C*Yee@?X+{%Kf8 zCt$)lQUEhu*)Tk`rQ2U>X5`qqkIjm4XJ=<61BQ7jb2HA~=Fq-)PA4pSCFxkG zd~zM^^|3sh?O1li929Ka#Q9TU?@uM)S27phJ8t^*ev;x=;x)H~F5yEZo`VIxGfTBmytdGtDtCENOY+|;+J0}%Mk~7 zK^)iR3L3) zntEzX<-Gt-FdT89AY!14-yXj86Rj>AB(S6tmHq?4 z@;&E?N3O2q4OqH&o^W8VU7Oesu@4Ph+{lf|t-ICKJY}`uSK)>-xZZB4#&iXAR%DM1 zCs~wSeR&#;ys&X=pD`F;#yYa8%^Gm$(w(+}!L(jE?F6Hk{n$CmBKi3!TZ!WaI)OWPr6cZ0y6;cx za0=0?%BG6VU4N<`0CuQA=hTeau3o;C*)V)a<;>c9@F zc=bX;ZtK8c9NgV1ID|z|yrV;=9L`g7v$y_zL`aIxsJ-_U%94B0_7yjnyyyN0Tkjdw z1UG!4fE*`!!GE(mQ_x`OlTq;l92BYQMw>E}H9*n46FX@flbi-PYa8m?Hp3!}I z!?5ISE>9hrLOP~Qp zYw#*3u;n4$tE|S^!|&12g-tCfIo2s~exGK2~xaNs?xY9m$15-|yU_ zUF(%33~qE{?it1>kSgnrgG=iHeVBSE3VjN~}gz6YGPRUqp=gZVCI5zc_ON$@E$2^Ow-jK?1V& zp%QL=RZ_xjRSLi)xcHq*jxzxZ3we(VVZ~n-M)eDU)HyUL3vS#zymRA5erwJh^v@-4 zkZMerjWz9V4oLy^eHHV5&Y$}cN2IXi>9$&FT)BOAvt?Lpzo6HyZV!FQa6FsRT7jQY zaHzCtgh#JZHn%jZiLQM{Tf2&|cbhf{Zpjk{YZwRTTmJlE^CR^lb2Gex+}t?e;x@ug zH8=?zX*fIOMT9A0<(7%hUUZ2*FQ{wR1dOLKuj%q+38Yazz|!)RYh;a#7(IyV?|z94 z2zc)3wZjMsZ3AcbTmj&XyuwejAmj~57PFpVl6fJ$ZE5Xs%U*QmZWzdZF0=&3JXzbsc46qf6Bc6J(8Iyu(ShywI` z`Cc%fj%K!BB(1FZn%&NqcPuQFSo!KM}2Qo0((Mu*7WHX;5~qhB(?{ z2I;hcvyY`f!h5l+t5et{nBe_|bQ+sdJcm|-s?Q|D5|22Lo(|Y2yh^~tH8=5H#OfHER>~4LiyW8fu@$Gd=!e%u8JVp z=M97iKJ5T;aqX zfXedNypZHBRkyCj%6ZuaUh__37F5|Y?ER5*VN~JCe&LPaJCOSsc zitA)2&lO3sDMecC+6V?LQLoNM;KG|$g)a!arm_`A-8Z+Xm&?vZ7vRz@oICDp%KdJ4~CHN=uJdgy2VXkGiL^Ja0%Kcu2xTrgaGjD`CuJD0UIpE zNM!~*zM!nCEO7XGr{;{JeODsuty_0z4J%+fPSSgSC<|58ULlH2c$ki~J>m>P7b~o$2sgJc zMOE7p^70CV&=CZeQd`&G7^U7NQM10h`4lZ+Hvqoj{^gp0gxtNpGOvw|FCifi-u$AX zUdquy4xmU#`FfSx`F5Rl94phL9L}>YE!gOJZhJv+jrQB)5f4;^E!(uQ(^TLu%Nz;1 zpm9q(?y!j+;t5?W>%+Wy9w1-H%ZsbBP^?2x%8@3iwtd!1ebA^>V^DR`}R3C za*QM+Fydlm`&OWb3V;~-ZNOY}Y$o@kjI0&tWuU~7k`@rahAr9O_mQR?3P!M%4rfsF z*FNXsvQ<`B8t9>>U4%j@3e0}}f{g+a5{9xhlk6)aC`CZrzG6Uu$Q~Bs42?*GMrJa7 z<)Dc3STFn+m z4+CHI^+tKF`?TcDw*W>iuNgc7*bmh)02i@TWu4-B`M#pO{9=@r1~y!=Yrs0iFORfV z;7)c@R?aJ;^n{f|H3ID`?9H&01@>V&`u)pMI7!K4S6r<%=|@L_YxQD_Y=1fh7P>Wk z)r8W>Y>$|g0ZQxTC902>IVYZEH?k-~D+!df)umNvPt5o48T3wvpEubx1~lM@&#<3& zS1P{<@gk~YcqNq-yOOd?rp%=`{!kix?0lh>T33P^Nor1*lGSU-9UQ^wrGahn(NSfP zN5f~8vWc8P3Bczq-|RsUK64!9^VIDj_E&YV3%}pnIqn|{N^cRh-n=BN!Xcg#)d%n@ z83$0pzfVf412ofOxc5ksVOfrK^Bk?2LvI)3h!Aw_q#7W01N((El{Y`o+v~6_C&}RE zv4bgV6yH=u97yWrq6%FPmFD5}ix zb@-5F^~fZ#eZxal?n5nffYR4tPzo7G*Fl6`_leVCkbX^S2(iIJ*m6k-X!FG;+GH&t zkMvU}P$-FmdHkgG8bG9=x0h4^zpT$=g{BBQhK7~F@pP5`JPH^WJ!s|q#4i84t1(*> z@AulAeN})HiJ*-5+u1q|Kx?7VMf*pTKH%r!NNXwZC8gP!S|muf11A42oB-cRg4nIu z#452Tp{LrdCp;n0_Qi!b7O`)dn6N9^T6lcdQ}-p0?NPV=O%?v7C@ppDte)>P(1gSN zGF3%oCGY_#I@_UFc*T4sSqPMQ*&}v9shoCqJ6JJ7p|w^R8G{&Nzxu+LSC?Kv^(s}f z{JjZFZUg%S@Q}Il!PS7=1WYDCLs`g4zrEB*QzvC6y)i_Iii=DAE@Q?`^f_3m)YdXe z|8zc|UA>K}(AGD?{8#~S<|I~kZ+NVF)8_g@69;DkEk-81I85|Biq2YEGl+y?4h^~N6U zgvVXO^To!;=MlfEg5<1aD389JTv~iWf=63!Hjgx`mI)poFK&?qoMwA~s;Q>b!pdxV z%+=!Jk?vjSClSOogtYmPgy^utKBnG!@e;ORxOAX*G%X^6-#HkNOL}Kx%~HLoZ!eJa z%zE~rgViz%{a7p^E<2?c(3&BC3WgEs|c(m7y*-x7HW!_!tJy=6$ z`@wbqEI(cN0U{pQ$~l?kIsnJTC_;kmWzwD_5C2i}#+6!E4%U!kj_Easdd=fSr(uGj zZr#0=xrw(tHkLe&&d^rTaeF|afR_H3D?1s!dExlR)u6D}6?2O(wyEW#rGm6zHpiWA{&5?C z?L;}k%I+tn-@o6{mLO%IA+sS1nh$8U$E0Me`iEU`k;nvho6@A2Y2bhZ^Dw0%Xe%A6 zp6&6cK-_0)gTe(YkKi!b_i0p*3Vzz2f$YtiVM`h!wcRJ)MMOlBI-SWW>N2xJO1;3C zTuKY(*R)7i!$==Q;cz3-k&;i@ZzPp`%y-#Ss-EsbL3)~Ew~fYZD+%Ba2R0+sdjKFg zGpb*Eg^xdy&5umbTOtZ}^^Q2#W$KywJO!2t0NJQMM(@r#LYG3##``GG237?^)G*zF zw5Dl-T~aF6iB)m~=d9N|61@ZXDEqg*1cwX&zg_E@AG)_s@Fp#-#$#zQ^4C(278B@k z&cV3zUft2&&t&+LEBc@dcn|$KsYU9N(CtgjrhA>vK)ZK!8#xya(j)7*gPYQ7YPfFp z1sge|w4kF9Xr1M;G16tYaSI@nI2*2~B2D*&ll->DBYd11Yl$W8mc{y{25L92QSaaE#H_8Zx+guwu7JF4eXppzTuHWi!|E2FtkS&itgl+)_v|VE zma(=rv;@dQhBV)`UIJ|V9#@WsD?5qh^#O#vjSWg+zXgRHX6n5szcN~@qpnyvChnEL zwu+yni~OF!Q?xr*ubKiVbEit6Ly(ClyN`Sl~S9DjoBkhO7*0UE78f018l!*EbZ^^N@_?Q$znJD z+`Pt;Kd@Boa%kQ+Z&>Y6#nXpsZznDbY6SWv0Vq9rggYk1I5sAnB zULa72{q{2J1EquQ)qy-63_yXuI`$nW10EcUz_$WQ$lA>aP;@Nj<<;FgS^#DOr={8B zJbrU+Gtrmb4Ma+v$1t|GD6>5#AJ~=Wu4og&5Wh4cw25=} zz*+4lez+SiAZ)g>;^?y-$8iiun~SkR?;Z*o`i@?Zpfm)tk)&b?eHt?1f8X|weQ^UE zto&bIvD}`qJOFNm7A$PoIMfHM9SV78{Ga_z~#?b9H2gn|?mjHLKMn8YY-o&EQGp!MBaN^Q?*|Gfgx}(e3;NIjwy|H?veV!GQDfPJ#B6=@ODFY=8VgYPzLm zNZGl%L!l89{J;Qv+n>r@vm1?~qx*O4OY`y@Jm>a3~x3nFSb6VoC9q5p@SWd$P1 zc6RXEDRT=QBkY#U4|-z_J%BWKSQ$54T>3i1!^*04gI)bq)2dsgpy$Ke!2$vTY=5Ko zfF*hy)DRb+9stDk%@ToH9T_i%7wSry=5lExMwy|ezg_@S4Maj|OxjCIONE_Acj0E- zLc$|ui(42Vw2y5pF0CwXb_qj3rNdQ2wpb3Xk^y%N@SRkEl}~0Gcm$#?g4P|c>( z?n#KUaxjR{9QUmNZ4K=YDjex#;WdVaMwTLRx9D%A_m=ejl$Hh1Fq^p!17&4(T@%No zFTv2f;^KPJ*06i1wv|;(d_sJgJN63W!dBp05D)^_J2`+Qwt_3~6YiDFOaUnfRTSRB#+Cz!$IV|q zV*z(f`&Eq!2pk!3ipc zdBqc3H#G7EB@`lNMoY6PG)9KQ#Z-rF!c_VQ0;BXf79~;rw48s_-QI7`*R{#Ch9yEE z{pa2RJm`Na$^Z9N>0I?0F@VEUKJFbDofzfY%I~SSs&_DAKTw`Ov=glnSFrGagY!Pm z)}tDs?8iIb1sQmzE)mEIUc+xMHXI2UzaP>|)qJ?!x4_SEVBP8Rf`mQdbS^LyrR zensX~cQG`jI@xcGB&cO~5ykM%tm-TktMzS8z+@VTisIzsz>=u(u{a z#$K;D9(s$%tzPQE_>PhMS4`H_@f^MTsb^GcH25Fr!9L}YNX*F->pWo~aZq~aLtdzv zOhJ8J?J~1pTHbVQ{z?^Xj@Z9bd-b{h6)N279(oCh>Omq3j^Biu-_d6L_t)pISDt!T z0fQswWTkI@lN~zD4!ZMSyv&E0ZzrDg5%N!o>|*oxG-zBU|1*JCPlH}&DDLf6-vj3l zn?VF5N)vpEl5_t)@*UNZD3!i@XV{1R7Bczk(|>BzJiQ$zqdA|| zx%_Wk3DvzP#;4wOhDk&8MSgp(F%MBcp%NAq&Cke)k^>)q{d{|E9GRELeenbpODotP z5;Ax(B7*T+YJjr|_|J?>sf0XjRzlu$er71&GGagLU>9SnDg|S0r9&l@msiPMHsSpu zU?j{UVG?OBU>Wgw>g%_@!MrZUXg8sX_M4WPe|B#guxBV`O<=cS<0wNA6zZ4WIn-@o zto)t?vJkFjnZi~e(mSJDOo;h?0^|5Q}*-YLu zX1Ef|U^d&0DS6O4t~PBTf4r9mS;L*(mkZEkc$Y?+3)kO$dwX|PR;cL@=WIpF-&#ll z9fhd(fU?}8T)PoeLY`fVXndRjfxv$~4R2RBcRo#+Vb31R8_C(Bt88V-w%_wniVNqG z;GY(IVAT9FXVOGG#}Ds0EXx#L<_m6Z25=FS)4=PUW5?MDW2BPkF)(Y)D>otp;(O#&^7 z=NuE@#B{>&W3cw!ma%?1v}V4kY_;0 zj^pnE#3o=DE`6ZxqQBKQvr~q>B(aNa9~{W5T#_9awg@Bb!jkQ}K}BQ>6HG2a)L9qY zJB5mwXXzG*8Cf(FJ02bmAXmF<{SG{~Iz<;(DzTug6-MXf%O5<*!|Dp>@zm^OAH&O+ zFB?{SeD0Fkx}?+#4DzhHBa(BxkZnZmX0$tTcUmI{^bKlZLcN$zES0k=-TOIM0W%~2 z@JFB-)Et+*tP{-DrF*k+KwU1(%ht(*Aa0$GscF&iM7lO%jUmhEN8W%ms$*!Fx4Y}b z2wT1ndc1HIQAdp#B6d&|&>`9CfXI}WlhX|@C-XNq-2hkfxK6QqqlH;?>B?)_!^z9X zzFQI$bc$mD2nlMCDBarHnoRS-@9e%$tao)YWgp8A<)z@)R&^a53INL}83B(TL?ZM+ z$EoV&5)~3??~?-v-Fl|pfuS<{oJzex%s@?3;^bV z(&x_>9{rk%#~o`UGNO4Xg!sEvpj?5xhe3n`OKla8dY}Z@LO$#DLC3vsz^RM6cjCNK zOnA}n#E62K1bEJ=@hZb@-jR>xpEOr49G~f(c8j-{htomZ zo#?*BehtLFAeuBd0!&6AKKiO5YYSioJsqbf%ZjW1WRH*ZmKbGc^F}q@oODI}%6|$t zp2zMpsC(44XbFu5a940-fC-OI1#9pEq_+UsnGax5kapg>m)_o0pyv)~-bT;?vUWbj z>_PrO-uAX9VEY#p=;o`7&%On8X5g;KYJnDrAU`YtTovmA<9^KB8V%Yu<|a5*Z~Pn_ zto06a_8vFQ{XSdD7QBN~P*7NE2B|u8oksn5eBovKvA!5yBjSWVB7)8DXpfydaSoeb zUakX11DL-)(;=eePI$?pr^RE2qC!HuL?+;MQJc7T(j0dJnwfbE?i81rXw7;b#2Oda zBgu&LC8`S7>3MLsG5g{Q71acXzsdBD$rv8*PCEpYNIsbQX40@dz8VTxx;dajaFU;A zA=_f7XfUxfg?!wT5;(f@^7$ZGbjfkJL>@F-i)A~0Ny7MHD46?cI(=U-C{h~_axpdN z=wWcvRCN}5IuuoAjbUo~vkR~6#g!Av>;|Yo*1$j>;BWiwPPP~pPdP9`N6m#raQkF9 z0IYXdMJ{{7>MVJAJ5Qf}(n_}-#pUjsc)ivyvl&gwo2v5(?`8U>5i`2cXc(K0)>>MP z%B|{}UIQ@6JV2HPI8~q3s%%n+pvF>=Y>he)jPH**^&S$W*t$cV2#@{6Y}*xFC9#zi zf$d5{eU$lbQAzkJsE$P<{IZk1=ai%mT`)c%(stZ&iwxgsWu0Fr$C?g;gIxr&A(jd& z$8?>XicEcMqk;Cd^qZ#FYqKg{3_~3Dy&0b@PYS6`+D*dSEo_ocV=K=BPOZdZ#_ays zPHnypNDu+ev>kw;9}~YpcfUS3g%9*+%Gaw)H9Oj*jmWT0_SOaJ+z6bEk1>2E_mvDw ztsLtRr3eZf@7CVwGZ{Ef%r_&yiXe`1A&a1kh^;ee!K^2^cgnbOfc}?b2dd}e4P)g<7K3IB2korlscXy(% z^j@R>K+<{o^ZtGPaKbI*#oN#M^{!o`zxe9;7vg>C52w#xy?3?u{Dnod157sH;jmD| z)rj+$HR`uJ9UXD*R$W~(u(q&q3rou;z?~RI!!(lCQcL`9-@YvXgoucfF(zI(-vt7( z94?tHiT`<3?mWvM-i$**xO^<1iNG03mU>b{tsg2*$o4)Eh*pcYAMRdH?`eH^f;?Pq zFR2pqaBft5Yt1^CmY?PEJFp5~V3MW9yf@!w%BwgyIvSKO>4`YvUgo>E zc6M5l_j}90Hl6mTIcoH$32m|wDsjU505r4LYL&HWdZ>iwa%)GN^ZHx@QV_Cq^G8|G z4EX0GyME3p&VZ{FeG=hBj`q}UFZE5Qaiz9j;40Q#V(`>HXbHw`tCfZIgX-$}kaM+# zv+RdU&TMaQ&&u-i|IJdn`%f1q#hRhx!XTT0u>8!Xm1_gt! zkeJN{3+u6Xy(66Un(q#tCQ;1wB$a>tlc!Hla?oHj$kd8wm*$&4P z1G)R5Adh^=lCBW)Bv-o^wPT`hU;t7^o+~J@gI4H?vIwSkKrx@pe5I5i@}5Y*#)8i4 z?(46`{Pu}OebQ0k;ctBI8CAIn*HLPV8iW~?p1eaunl_sGk%U1yxuA&#ptMRIEN@W} z7kd0J+(?d!F*(l?a5e2j6rve9bRlgS6%rYFW4a|)eU|w9)Xo+jD*Jt|PenyVknBPx z_TA{Q5%O3bdJoKo?|!l)C@3?PQKb{Bh{%<9KpFPAwq-eKgo1PVf2tLEEwoQ`n>-oOO@06aIaDh10KlMKwe`My# z8+$q3Ss5I-y#D;kWoLs${eLEXvcC-HJY1RusNKVa*_4BJ*|6$g#z{2zfq_GqnWpA7 zsyA;c`>NcnOXoGr%u)>JbGa?YuN4$_(oU8Z7lR?A{?7MB)n# z*#x=5OE!62ws;1suao=h*PTM$_Ft|vn;>uHTVi5|rtC|mng(96A zk6%AQTsnKIB}_4XCEMZtFyQI+9bXQKs+5AOArA@0;M=@xVq%~A`?Udx9p35AmC%yr zJT9Y}xOdFA8w%Y1)a$`Z#)d zd42aet)P%R5h~g6h%O|HfX8AhyLP(mk)2P{dtSbJ)i6=-uS#PK_IFEXXSu=Yp|+2~ zr!LV6ou;R!A1orP8M=cVF84_Rh>5dNd@o{SV}p{C7(r!5mbE+QWg=I!GWhP<8Y!uI z$Pp?rKb-HXmzfze2wN@mWsB~NNuGU2O>5q>=z*2q|I^rbigP98drC?e$Th2~s>;a9 zibwMYTopx~y+CCrQP%P;`RQt}B?&i{XT))--oGerwLh0L*>Bt1!NK7>5VE16p>Hjt zyKJ%55c`6{!bR{3!2NldRxSQjkP>7zVHqN&0K-ZICq7iK!~=L)J-7O9Bz7?y|jP<%d19RE(F2) zWk&K2k|i~Nq4!hAlr=aC8gL@58$xuK(-p&}ux5Je_2=BZCf*->4h@|H_kKX&g5h*a zj6VZX>g%1yiQ{miMqtR*pQ0I6G&GcuJ=(p&9sOmt9pSY3B>z|o1=#rDQ9)D7%c@pZ z?8w@EiDyAq-t=X4T1hW~sX9-MO7c6{-kJ<@_g*em6oZLWzd9Fku!@TP^eO#tr;ftP z!9hz;Zy)n;FB$mJR+uAE|8q2XP#N-}ukCQj18~;NH>MsOOL?%_yB9sYyuEdn-T?I? znh#b=qIUgKE~YksMKIrVB%diob9$-HQckY=u|+e_L;dv!Dd4{7rG)k9r)!Pi^}Vyx zV0$Ro*>Q)5bL?)9Jn&fR|NN%$kuuz*;x~oPrOPm7)MV|8}fqFG~nC z+_2bQrmU}2s=Va9DYd`AnkeO+*UR8}tK4qjOPEZ2Y;YV5y-a5KOvoI3C#MooH)pJ3wW`?aGoUW%|M-dNIqq7 z0FXNrft;ZiIjoO0gIiXgweE~Jm_^2wR|NsX;Dc_y<#e-}@8L#vmH>RK6PsYNz6N|) zxm@l0;6$Bgp^J@jaCzkUToa%jZ&f|Q&~RvlhRg4SJ+ zpsOuqjw3OTEmq%F0%AU!ccP-WYQ{iNB`sbxZW!FE&bT=excOPD)x8H=Dgz)h(K?pHJ z(!_gBlR;dE?iy%;*TYDfB9+^Nwh168%4htk>Gh+Dl@*;?1_UkNpOcyRzw;Q%2e2t; z-=ABx1`Qsvgh20tn>TOvW~to+n)1&dz(mXeYZ^IR75 zK_^d4+`D-(a4|L?+}CSgl(~E9EJ&~W+uok8lOGd3S7mdBsBr2+Q|`>dW;dshmqFE= z?2;nVHi)j{1i+U3)57sXn|otx>jC@RNr1%#Y72Z^PP^asA~p4e^Xn~t|6VB6Re3ve z;pW9NEG&4cU%q<+pFh*8K}sxI)W>|?*}=A(ZDFpP?#)zrYdwGU&YjnEgLsEKmlpV} z%saTAq!vFJXuBI&I17n&92Op`^^+{!(*U7$CDocwAt9IH@LDSxfm`j>6+177WrXQ2 zpEz;UdH*-}ze}qC%ptJrPuvp{nu*7x4xml-^fBpn>Mk^nZ$#gYWz_h5%r2I z8|&-vx*M$6+#bjskQ1Aj`0(V(lgf1z=JOnu%jtSBDqINS2k5!o&~~vLYF}_}yXDR1 zX%E*6H=Xe6+q7nHT00A&SqxcfxmzM2stt^p)MlY_+aG?uMV>pJZf*hx6aKw?gxUF( ze6c@e=KGsY{Xx&4z2b`6HSj)}Ov0HK>gpMtwJXOt)P{a>hiVtdQd0*jA8f9#=eVke z0uw@Hd@@J*ft|D7@A9YZ71eLLfW*(bh2zd!GR^Ij*6x!D{Kp2DO#ssnjY$}g4$}2C zL1KZKsRfLohyn>Mz!g zG(%Pu6 z{d_-Pke)aZ&y=cv8X9Bxk2D4f5^*(Jxe2t*?MM`7j?0>R-|L-e-R0XS)G$+f(0uip zYZQqf`V$VKz01qX-ycuk^4VTyIl~ojHRO-b=^^(fjU2S0`{M#cIwSCsM6^u{GjIR1 zlLD?zG3PierH_spCnhDWZ*La~;4@WXU#O`y;&5~bglxYZiPy|i;I#gE*Ytj|(|4Y% ztOUN9v*#R7aBmcxmU1pSP5GY(6;x<33lkP$;Gw!UNS@l)H83hqj7DwhxpEj>V9-KA zX_5L^+aKFkKw9{7<@@x!+3%;neYy<%{z_lA^56gU`=0Yr1L(;|e%xJKb0Q1%iSwP& z6Dj}wFnB`93xmD{@X*OBYkDwSSjbhiu$NLh4v}x_{_}?8*9ZyBmS4}QF`TY2=UTPb zGap83BzZak$pii|Rbig{aQFWo5zF%%IV2q6c72koNy$|UC#DA+>aS|ns2)rPbti>1 ze~S$6t!ILuDr|g6e>S@w2|Z2H92!x&@W00#&~fse{J92AHj}hnbGJ?-uABN}kaICm zyrv__^~lcf_-BKGaeXR5_RdmL-0q^2JmRm(30={mdIXNhlf~~79$d8l9n;iD_+9?I zIcW>Iw42|qrak4#JpVuQ#}e?%TZ!uBzms|VflmqFZO{MQl5{@tEjc-~G}1jYU|;3$ z%@th8>gXPBtw!yhE_wfl-LRap;qiy2J_;yb0|u(}BFV=?LfQv?EFU%`Mzqgd1R{+) z`eM0ZpmJtj6gw;H4SRb+?`o%3IFtcRt z+`EV^h`dhwpVjV}DKq2+k~(W*ywmL=OF)it`HA0&VX~}SvwHNz*Kz!H%3UofO1b|t z3IU)bK>Fp^=H>*MdK;mL$Vh)vWb8$-XR5hRy%T3fD2#mw6rFho@~M=s{&$|AM*V)@sbdRZ+C)a%qs=WhRZObca(j*);(u{2H1;-qGE z;)#je{kEMsp|84c`xAC?DsQNeOao`{r;jv2LFGal;*Ac z`AW8!tja~X^F1^1&(3P?7%~&#r^KjeQ;Z(Wwde=7h8RQY;v#~(? zEX*F!-8lAPRQ!LBn``5M^+wpxkQj&~s6PLr`RWzlsfoqKr!QWJe!Rl?Athz)GRFU^ zoH30S)lac0{UlM{D%7M1#ex{iZ^6l}0AxniGs)g;bg0OjT;aB6jlF2|sMO7E&sX&S zJ*x4SfhPoFn2jGV)ANvUKqmo>m0WI*6?B__ecH4{B?8`dr+6ZNPK0N#59#0%z#q@3Cs3w~tbAeyn>OtK2IQ^g0Xv&eI>5Vf6 ze+%NIzm4_}NjU4JY}$RY5DfrNZU}6&cdCZfn8(d!H5HTU!otJ7@|3oseONw#J`wX_ zVQcvw{0dHc%m`mMStA)lzz{2eZYEyo3$C*Z$x*!8IOzcH4E`t_YC`1F!!vU53ty<6 zhN@ba3@|bLOqO6U42Hr95e=j4`9T5GM#D$5S~kI3NPAfXSt9roDWx?i59~bKa>FtB zfmwl3<%wwJPLTH4c(|8RV%4b(k_-NV{}r(6u+iogw#PcX9xQ;qb&_RyUJ5Ze@ni(1 z5FxdBV#-(Y>OA&+##2@ygsO8)v2-t3vubCu9d&Cn1K9Zl_BlH@$ z^-a-{zTa9oaFyVwfGY)iLZ_A(Pyfczd~_3^d&DbAtRGv!JyRg!?tA)eJw(-d$0W8F zXJD_s{w!S*+L;pK9D@xo)l+@d?^Grap+2wu5cw>Be0n{e#%?srJbs@E#ln_C&B4!_ zt3Kjz=;1(mpJ8Q~(&QbN(ZrO=DO&h<)5Cm3PPeWvp@#T8B7G+TUFm()c!-`mHA=32 zv$D`NhiP_S4%@>*&`cw{;&pYRcTAM76-p4FrfQdXkLfoc{7|oOWxp&6QUtFDzVd$7 zMPB&m?GV3nPuH`{>-3lcvDQpq5Ju%=q7>OLZ`v&1I~AJ7&sAMS&Ulc>#R^+h9Nrkk<3D{8Yfh67&YTFi z+8I$@OK(_q;dc&J>pEn=T2S*k{DmD5*~RaR%jz3?vj!Ir)DH9Nw|?{vy-qE*1-mrL zXY%|COjdaZW{SG5&B?+#$;N6`&?Nd+zi>O;#g|%16XiXDCo>$a_+2_{Qf|8?$TxtB zHFnq5@wQ}FNYx%Vyr!EV)M=nhd~ayR+!>f)Cd#xgR`NI0%=lYpz1i5?^)1<#-y;=v zzZhigOx+i`4tvWuJ^y@Sw3?NwXzb}SdE*w21V3#LtMoBRn5?VqG@%kb5+=+H*6tlL3j*(2+@RHtdYlT%FXo-Kz% zPLFva(y@vyaEfenQIrGo6 zs!Mxb-2h_X^4HAEOBd@a`>C%b4(&hiUc4|=1^VCCR;o%*gcc+P*JgZ6ra27$Q1G_G zcjG)c#k6%%wvJVJn{#`$)`NLV!jo?-i#(gzbbVOBc!5qRZZK(o&@0(`hujhP%)ZB-dc%QUVg1{DkN!yH)vAy> ztnU*y;?sz9nh3X}tK8)uBN`Z}M0|zfbr}yH&172YkDxZNZBQ4|mHX@4#nGpDYEAAQ zq_{jRuuJ9_HYam1?CwFR20NHupd^{o`rdtZ*gL$2!wV<(Q!6UTpu%J{cz^c@ek;fj zeC|Y!Se|ho67%UTrf8KpAgl-Gvw2c24)KGGH2uI% z;b)yboYhcB&8f}S+Z;ZXh^zVz79i2EBE7wv)^&Y7`w>TU=T?i>x1@$5`ABgTlq-E~ z=#z3`?#7YrmA2K9_n3zY*}PMjCXC?gO;Itg-Gtsh4ErHDCMtJC9EFvm_v3e5l5h;v zJk7Nf+R)_piKE-)!?4Fey3>qO-8Tsl< ze?9BbbjE7_Tz4vFdKbd1W7{+m&|0|r@oSC_OkdB(QX+xzH^9YSrcFz*{WoS=d}WZ>zF-J8f9c z{9k^TjiuASbCbreqbaoD8UBHpyj17l-lsxBA{T@10;9(UL}~6^Itya?`h~Z5*LQZT zch^}Cj}9b&=I)sJtD(YjktIIbC*-MS&cKk23x6oTl>b56nX=KJUvAs${V151WBQnA zoO^-ANlwNj$OSvSrHaUIVYDVmqBZk)ZN?8Yi8yoJnt|Z7noCpykzLWLJk8Iab zo;g}-fK7S-fka|CNqR7c>*(LL+SQjUJvToAB4e+($ zuxy&@sgsFD6%TB>*{B$#PM)U|`mBD6qu0CpEtSMJEaJ!0$e%w;(la>2fSz=7!;xO0zg#~YBI{R~c$)imrSUJvC z<@&k_yOs)% z4E~lMyir8*yjg+ye;%v5v^G&MR=)bXOCIy{V1!n6UaPo9;&P(?zCnieH6fep+eKs1 z+audu8v|LxLP((}Q@4aZ1x%0ZlF_Kgbj0#D4G6XyywPPMR`mRWH;oMbqub1m__Dq} z*`Knv9u2G#s?4^N<4%pOO5&I=qOgc?m+RC#l6{crTSD)^B)rV)$JQX?{WqUkB^gz4 za0rJA!eZkyKabW>@kokBaYG7MC#zo?=ua)&)t`ld@8G&aBdmcOoLK4$BnI3l$5DD@ zWu{x>X%=MWAP*fGipwC)`R)i$xOAK#3@&tL=a%xV!EGnbDDJ)r+>~zwX1OK19baxo zLuhO0jd5blenS|=J^cMW;dYSXlO68LT>Av;H@1z#Zlt@gU1&s_=M60nKCIf`92C8m;>aR)GmcRl89C~3-zQP%Q!}FSjATCKoTpPzMLqZf9 zBOZ)@_n}1V#Z)*Lfk>zN(!i;gi%W9N1ZS;Z*35R!&Z9Ek%j_Y@;pjwS?@7K2Xnpl6FlhvTP&%1qR z^@xSR_(%kZzWQ}N-S={?v()GZ=VCfD)P}^U5)I3d5O`@s*S7?^_61SVxDV$GB}t9- zeRWH}F-@HJMfqP76B0Hq(osFhKGN7|C*HeX30G=u)w7wNYcCE-nPwE~j^aR9d}1_H z`vD$U<9{xGd#78mrDsUYL@y3@di#gtkXT71CLvTW3zA)X$!ArvZ#0i2lUB5iDMXaH$NLXfb%HB|+pI1g2 z;i_LIrg(R*XgHbunbcVm*d)nzoc#POyO7OyPj%aO=elHF^7Hm9&>ruJzOqH(K@W8* zS`G5&Xf!HU&(MUh<<>EtI948DIz5)(U;2~#L12{wWl8B!PH}R0uHvli7ElVu(mNG< zJGJEJeHk~Nq-q`lHDQ_}VofaY5FNS?vU4HJ2M7j3G85N4&E(yoNI@8) zeheR`%jknWV3H}GRRK=JHHm#4?b|WsUBPV)C2dWQF6~Fycm0Lh&6|fh?|%qKIzF@vRzvEzSg|9EV?%S? z2aAEn3lL!Ou>c)>RtEK}9w&vIsHXxmQZM~Oa?!QruD~oU+^6ak@L4lj6wQU`R2TE6 zn7Z-6=y7ox2;FV%wQf_f-nOuvw(*2}7Rh8Kca%Fq$#ROWz$Ph^_hw7N1qSO`AELsh zewS3f>lN2ADx=QF)`ZEO-&m;(z9Uh#w2kw!RDLL5#rzK3Ojp@_b2?T1Yqu9MePgK{ zou#L7EGSPD$<%*Ma1ZE1#kiOgVwVkt?ym9qSHar&v%KTD1rnZh5+6YzwVD*Bor!Ua zvcA7j~3)NlBS$`Do;>%-H>vb9?$~;Pb=Hk$Eg%+uv6@*>tLLTm&Dy#B)81MEz9=zeT z?et3&CTvQ|!K6l&q{|M>>tFjGRgarqI_YOE?pM2YkvhYgt2g%y^(BAgo0Eo0+)CYC z3O(=(kfuAtYHry3f%zj_J5Q%tFUTrp7_G8#X<3O_o3hxwcA?b1xM*j6ebK)A_Us_n z?zz{5(Y&@>_3nITIgc04xcF`%PKYtro=ho%>W#p`f%(Jl!NzzXT^~Pu>H^bdBG>CX z2nXs)=|}lvq~Yl!gR%?sLHQakog8*TC0hg~;Q!9nBG&4A2dlt}Q>z%&eAtilGMB`q z8IK-5`w87?Y?O`4=tEU8Y>ApC9bmnrbq!A#q&~tUfL4Ckex?^WX0{EngBn4W{R5+U zl{8^5K8=P#*Y6oZS2fg?FM-{{_S}i}C-&54>cfDX@Sq?`SJw@20|GJ-S0t+cnCzJ@ zPfqfk0TCxpIyS1D#qKK>u8*r^dGoQ`G0=}nYUf>pU0imiw>Q>KeT!wnY+~ia(FDm4 zK~iS(epOzO2G*Y(jBRT7%wByDHncEVdlPy&-_h;iZZ&FRS*`r&hXwF>TzU~sMx1)h5Mnr+&fDT zc`D0>rmcd*z}&Ws4P-a}e_fq`@Ut1l7v)@ zC2Pc39%RopNRxf6VN&*8!efsy7|VO~*89i%-~6uY%(mBq}p{MV#{l`w3PE^ zCS&xAY}bj3-h@r5* zDfxjA7+06V3%1DT&*_<&2SAaRD+gqW)+s^q0aC@1o}n^mE%Z2&t%!0-a5#p+drlTc{5M zyO~HszKcA>Qk{D|_sDf;X~>^fyuWOec-*^;peDIny`(CrvR+_k*+j;UJi@O+hk9y9 z6zvwCqIoGX=w&wRqjkPMb)XVS@=F3!P)AM0J5t9>1n48_2#}J3^lNrzfphO(rYSNc`jsHBa zWhwWAozNB0t$zpYKyV@-eKISUEMnoLTWf6n9)GoglQBKzy*%y zzvp+@h_ZDdB-@5zoGs%mQ#B%x)wBonY`0tA97FleegT=Se5mu|d&QU7{* zC?TPpYeMr0R@p#$KY$&(t%X{cowF`Hu!*FvE67od0{nCAy$vI?pasX6uubbfw@9R^K_{KMbD&{^IP*5;cy*n!QF4kf9r zfTQhH66$tMsKq^sOemm4yu9APx9~{%Q>c8P>_Gk<;{Jvi47zT?+xJNmD8QkO@~G8@ zCa3ORHT%zNB&McgdLVmf45d2GvN9cjp#m8n5>20kV5Ej)Ihhs<7XD?_9Nix?f@i0Q z$D~z07ueyl-5%O_SJ8KU*lRu&SRbmHv6lKts_oBpTyTOh;&rkQEf?3o#2k*zdQ5l= z=Qup!3Y0nYNnY7_ylV%{nCBggQ@Spt-a9MLyc*QE`Lj@nGGHQ}xeW!g3QdJgww)$A z+ilTdrsI-&Ng3Ke_KM@M56@s!5esf#29h7O6m zehzg^hfhvcD)~~m$yiGCW|@(mNT1euqCM+Du)c#-m5Xyr$;_~>3tsGm+uuzc;Bwqn zg6~QSq@V?9Ge1n$AVcGDKn8hzS-1`Ys{Wz$@#Tpte`5(SvimNpO6u}-nUvb18E}z@ z%QS0eKCL)xw&fxneE)ce{`RobLC!}9;L_DxVDEhCLBsm<3k<%?S^g54$D?R%)v^ae z-2lc9Z7>H2;Q2K1U-d_94AhVZ8NiI$o+Zu!d&qV_W@fC?`!88dmvDumR_DQm`KdPv z6=iTdwdP$kISBJ!&X3Yi30+&Tf7DLSH)GYdVBry-y@oKinQk{(^0{ICH)S zT{T$lIM%ob)1N6mrpmGj77~l4zFWt&=_X%?$O5tc{f|t^D6)Zr<$Nk%wFGO9Pqy5o zt*@|rsr-Hy!ZdIEIyFJg>QMRWka$Y4GmRVO!#|TT6b$kOn6QIXz9G0HhMT?J2zr?9wJ9zzWM%LGC znDXf+1#^?~OF6Ag9hcvL$1~MZ!e;~>6&-v9JF_c&M5OA9?X6;3oki0Z8~(ze)4*@gt zb5hXQvCX`s_n!HY&)VC2>Cec6J%m|b77DRls6Qmj_#q?--Z1Am z;{;24sy=2ts+S};Q@1Z{H>Sp})+*au{f$J}Mf&#ciE)+`2dJ6otMxQAd2ekse&CJ4 zsSW2>Gc7vYU-<5V&W^)5&rYZd03XmOIXY0(FeGKMYwo^!aL1d_Bx#{t!GC7C->#)#-t4!(m8p`W zYdj`sG_pbZ&s*6DWM0(Ryfm=jeO$jyyGI6dx0mq<2$UJG00vp@h*4B(7luMbX}M$~ zo*W;}FPV&&w_~xDzDt7Qo|Dgv?^?u?$mds$B zKE`xhU&ybDpvMC$sVpbAa>oT1=B{_2>QCWY($QGajcO`Ssu>85mqDeqS6Z6lhs z%dUjCcu!$SFH@5v@-NZQ&_D$Q9QpxkOiYAi|L;5L&^}PJF!uj73`S$;gt)n_5GsEv z8S3Se5MABfq;h(O&cjmRYV-J3{>}5;N{a88Ahc9e@%mt{|M#C}Q;#j-f3tWJ;P2ld z|0#opn=PJ}+`+T(DhVAVVF?a|~;MKXP|U=+Ke o>b@$j!bkCY1kYM~YM)+I$@EqcSfWZhI~l32tgTe8aR1qV0PC%ATmS$7 literal 172276 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%0Dhq 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%0AJYox$#yT#LN!zr0_PsP6D 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 literal 151933 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 z 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 PzUQFr5gUT_sayXCygNO} diff --git a/help/C/org.udpscope.app/cursors.page b/help/C/org.udpscope.app/cursors.page index 6d51f0b..b02eb20 100644 --- a/help/C/org.udpscope.app/cursors.page +++ b/help/C/org.udpscope.app/cursors.page @@ -22,12 +22,13 @@ How to reach it -

Press the cursors button in the toolbar, or c.

+

Press the cursors button in the header bar, or c. It switches the cursors on + and off.

-

The readings are in the side panel (a read-only copy) and in the cursors panel, which - Edit beside Cursors opens over the plot and which owns a - Reset button. Drag a marker to move it.

+

The readings are in the dock (a read-only copy) and on the Cursors page, which + Edit beside Cursors opens over the plot and which owns the mode + and a Reset button. Drag a marker to move it.

The mode dropdown offers four modes: vertical (the shared time pair), diff --git a/help/C/org.udpscope.app/figures/main-window.png b/help/C/org.udpscope.app/figures/main-window.png index 449791c8df19f3237cf74551e9f842a3141d2f28..f626603f352de3266cfc929d47f1fdac5a9ca0f6 100644 GIT binary patch literal 133451 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 literal 149558 zcmZ_01z1#F*9JVaw20Cn(%m5`Egd2PA}L6xbaxHik`mG&B@IJ&NsM%N_mJ}+p6~m8 z-uL^j|2kVa4s*`gXYaM{wbs4XGE7xj78`>E0|WwL%e|FS2Z2z4Q>2$qP=OzwDWkU_ zkS|D1N?gNrcE82N^Eq@14o{tF{ax-ZnJt0;#1Dkf)01cc(dzkA-yIfT-%ac0e=iWU z?slh?ep9_hA01y$b7I_U&;P4Ein-Fhj+0O63G$P`H-&WzQsCCrQQk#hgk&wtu% zS3DQ6SBf*)Fdv5rm(TB-aL0~fdNKL?KfeDIIab27Nf?%Bz70lD4SiKXD8@6VdT`n0 z884Vj>J(tZ!}P*+EL)OZ0Vm|YFOz?T<%@!H4FYA2hEyvN%ug<694i$sP~6on`L`uK zU*Rt{8Z{0IuS@gL{Fnhgt5h~b}pcZv6cS6<_xAs)b>q|R#j}ZZSr#cDUh`&HM<

Kb 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

lETCl{|!~i6J*t)| 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

k^ut% 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 literal 126023 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