From 0d741c401b8c1fe1ff0553089f412c582a44efa3 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Thu, 1 Oct 2026 17:54:17 +0200 Subject: [PATCH] Fixed triggering mode change when acquiring --- CLAUDE.md | 101 ++++++++++++++-- README.md | 73 ++++++++++-- src/acquisition.cpp | 31 +++++ src/export.cpp | 10 +- src/model.cpp | 3 + src/model.hpp | 55 +++++++-- src/plot_host.cpp | 34 +++--- src/plot_host.hpp | 11 +- src/ui.cpp | 284 +++++++++++++++++++++++++++++++++++--------- todo.md | 8 ++ 10 files changed, 504 insertions(+), 106 deletions(-) create mode 100644 todo.md diff --git a/CLAUDE.md b/CLAUDE.md index cf4bb7a..a7f14f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,13 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ then dump the whole pipeline state to stdout (frame/sample counts, per-plot painted frames, trace bindings, panel sizes, status line in both states). - `UDPSCOPE_ENABLE_ALL_MS`, `UDPSCOPE_ARM_TRIGGER_MS`, `UDPSCOPE_PROBE_DISARM_MS`, - `UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. + `UDPSCOPE_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_FIELDS_MS` — change the trigger panel's edge and Normal/Single + dropdowns. As much a crash test as a state check (see the GTK signal-signature invariant + below), so time it inside a capture: `[fields-probe] ... capturing=1` followed by no + SIGSEGV *is* the assertion. - `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are measured on a still picture. - `UDPSCOPE_EXPORT_MS` (+ optional `UDPSCOPE_EXPORT_PATH`, default @@ -75,11 +81,20 @@ Two lines of the self-check output are assertions in disguise: the documented probes have shown one ~320 px step — the side panel's width — that reverts; it is intermittent and unexplained, so read `changes` before calling a range the twitch bug. -- `[trig-probe] ... trigger=on ... display=...` (stderr, `UDPSCOPE_TRIG_TRACE_MS`) — - with `trigger=on`, `display` must read `event(frozen)` or `nothing`, **never** - `live`, except for the single interval that contains the toggle itself — recognise - it by `latches=0` *and* the next probe reading `nothing` with the same frozen - `axis=`. And `snap=[a..b]` must equal `axis=[a..b]`, with `b` positive by roughly +- `[trig-probe] ... trigger=on ... display=... epoch=...` (stderr, + `UDPSCOPE_TRIG_TRACE_MS`) — with `trigger=on`, `display` must read `event(frozen)`, + `live(capturing)` or `nothing`, **never plain `live`**, except for the single interval + that contains the toggle itself — recognise it by `latches=0` *and* the next probe + reading `nothing` or `live(capturing)` with a frozen `axis=`. Plain `live` with the + trigger on is the old "the plot keeps scrolling in trigger mode" defect; the named + `live(capturing)` is the feature. In that state `epoch=` must equal minus the capture's + edge and `axis=` must stay at `[-pre..+post]` across consecutive probes while + `data=`'s upper bound climbs towards `+post` — an axis that moves instead means the + capture is being treated as a live view (`clamp_to_data()` reaching a pinned window). + `epoch=` must be back to `0.0000` in every other state. Note the probe reads + `pending=` fresher than `display=` (which is last tick's decision), so a single + `pending=0 display=live(capturing)` line is one frame of sampling skew, not a defect. + And `snap=[a..b]` must equal `axis=[a..b]`, with `b` positive by roughly the post-trigger share: that is the "post window is empty" defect's observable. **`drawn=[a..b]` must equal `data=[a..b]`** in both modes: `data` is `x_at()` (what the crosshair reads) and `drawn` is `x0() + gather()` (what the renderer maps), so a @@ -109,6 +124,15 @@ Two lines of the self-check output are assertions in disguise: is the same class of bug, not rounding: `publish_event()` clamps the slice to every value ring's `produced()`, so publishing before each channel of the latching sample has been pushed silently drops it. +- `[disarm-probe] was_capturing=1 display=.. seq=A->B events=C->D snap=[a..b] n=N + axis=[..]` (stderr, `UDPSCOPE_PROBE_DISARM_MS`) — with `was_capturing=1`, **`display` + must read `event(frozen)`** and `snap`/`axis` must be `[-pre..e]` where `e` is how far + the post window had filled, with `n` that span times the stream's real rate plus one. + `display=nothing` (or a `snap` from the *previous* event) is the "disarming mid-capture + makes the event completely disappear" defect. **`seq` must advance by exactly one while + `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. - `[export-probe] mode=.. axis=[a..b] rows=N cols=M time=[c..d] stream=[..]` (stderr, `UDPSCOPE_EXPORT_MS`) — **`time=` must equal `axis=`** and **`rows=` must be the span times the stream's real rate, plus one** (2 s at 10 kS/s is 20001). The export @@ -116,7 +140,13 @@ Two lines of the self-check output are assertions in disguise: cut from the LOD pyramid, and a `time=` narrower than `axis=` is a window searched on the wrong clock (the live branch subtracts `display_epoch()` before searching the ring). In `mode=event`, `stream=` must be the edge ± the window; in `mode=live` it - equals `time=`. The two `bytes=` lines are the writers: the `.npy` is + equals `time=`. + The one exception to both: an export taken **mid-capture** reports `mode=live` with + `time=[-pre..e]` where `e` is how far the post window has filled, and `stream=` offset + from it by the edge (so `stream - time` reads the edge exactly) — the export is what is + on screen, and during a capture that is a partly filled window. Pin the probe outside a + capture when checking the `rows=` count. + The two `bytes=` lines are the writers: the `.npy` is `64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)`, so a size that is not that is a mispacked record. Read it back with `pd.DataFrame(np.load(path))` after changing either writer. @@ -146,7 +176,9 @@ once per frame by the UI (`tick()` in `src/ui.cpp`) around `RowSet::bind()`/`acq Samples themselves cross via `plot::data::RingBuffer` (single producer, single consumer) and never through the lock. Cross-thread scalars are atomics on `ScopeModel` (`trigger_enabled_`, `trigger_armed_`, `trigger_latches_`, `trigger_time_`, -`event_`, `generation_`). +`event_`, `generation_`). Two of them are a pair: the producer writes `capture_edge_` +before `trigger_pending_` and those two carry release/acquire, so a consumer that sees a +capture open sees which edge it belongs to. ### Data path @@ -157,10 +189,37 @@ that is what guarantees a full pre-trigger history on the *first* latch after ar 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. -In trigger mode `RowSet::bind()` binds each trace to the snapshot and to nothing else -(the last event, or nothing before the first). **Never the live ring**, not even while -the post window is filling — SPECS:37 says the view must not refresh until the next -event, and `trigger_pending()` is informational only, for the status line's wording. +What a trace binds to is `ScopeModel::display_mode()`, one of four states, and +`RowSet::bind()` takes it as an argument rather than recomputing it: + +- `Live` — no trigger: the live ring on the stream clock. +- `Capturing` — an edge has been accepted (so the pre window was full) and the post + window is still filling: the live ring, on the coming event's own clock + (`display_epoch() == -edge`), inside an axis already pinned to `[-pre, +post]`. The + post side fills in view and the marker sits at x = 0 throughout. +- `Held` — the frozen `EventSnapshot` of the last event. +- `Waiting` — armed, nothing captured yet: blank. + +**Disarm during a capture publishes what the capture got to**, then disarms: the producer +cuts the window at the newest sample and that partial snapshot becomes the held event, so +the picture on screen at the moment of the Disarm stays on screen. `engine.disarm()` clears +`pending_`, which drops the display out of `Capturing`, and with nothing published the view +fell back to the *previous* event — or to blank on a first capture. No event fired, so +`trigger_latches()` is deliberately not bumped; `ScopeModel::event_seq()` counts published +snapshots instead, and the frame tick keys its event framing on *that*. Keep the two +distinct: the latch count is what the probes assert "how many events fired" with. + +`Capturing` is a **deliberate departure from SPECS:37** ("data visualisation should not +be refreshed till next event"), asked for explicitly; that reading is what the old "never +the live ring mid-capture" rule enforced. What survives of the rule is that `Held` binds +the snapshot *only* — a held event is never quietly replaced by live data — and that the +*axis* does not move during a capture: it is the event's window from the edge onwards, +never a sliding live view. + +`display_mode()` must be read **once per frame** and passed around (the tick does this, +and hands it to `bind()`). The producer can open or close a capture between any two +reads, and two reads in one frame put the bind, the epoch, the pin and the status line on +different clocks. Layers to keep straight: @@ -265,6 +324,15 @@ far the largest file. capture may legitimately be shorter than asked for — the ring need not reach back a full pre-trigger share on the first latch after arming, so `snap=[-0.339..1.000]` for a ±1 s request is correct there rather than a defect. +- **A capture in progress is the one place the axis is pinned from the *request*.** There + is no snapshot to measure yet, so `DisplayMode::Capturing` frames + `[-(span - post_span_s(span)), +post_span_s(span)]` — computed with the producer's own + `TriggerConfig::post_span_s()`, not a second expression that rounds differently, because + the marker at x = 0 has to land where the coming cut will put it. The latch then re-pins + from the snapshot, and the two agreeing is why the picture does not jump at the latch. +- **`clamp_to_data()` must be skipped while `capturing` as well as while `event_held`.** + A capture is live data on a pinned axis — the exact combination the clamp was written + for and against: it would follow the live edge and drag the frozen window off the event. - **`publish_event()` cuts one absolute index range and uses it for the time ring and every value ring.** That is what makes `stamps` and each `values[c]` equal-length and index-aligned by construction instead of by two truncations that can disagree — @@ -288,6 +356,15 @@ far the largest file. tick as the channel dump; the status line only formats what is already there. The badge's markup is likewise rewritten on a change of phase (twice a second), never on all sixty frames. +- **A `notify::` handler takes three parameters, a `value-changed` handler two.** + `GObject::notify` passes `(GObject*, GParamSpec*, gpointer)` while + `GtkSpinButton::value-changed` passes `(GtkSpinButton*, gpointer)`, so one callback cannot + serve both: on the `notify::` connection the `GParamSpec*` lands in the `user_data` slot + and the handler dereferences it as its `App*`. `G_CALLBACK` is a cast, so nothing warns. + That is why the trigger panel's spin buttons and its dropdowns have separate thin entry + points (`on_trigger_field_changed`, `on_trigger_drop_changed`) onto one + `publish_trigger_fields()`: changing the edge or Normal/Single dropdown used to segfault + outright. `UDPSCOPE_PROBE_FIELDS_MS` is the guard. - **The header bar's buttons are icon-only**, so headless probes must reach them through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via `find_labelled_button()`. The Arm/Disarm button in the trigger panel still has text. diff --git a/README.md b/README.md index 4200f46..061d1e2 100644 --- a/README.md +++ b/README.md @@ -95,12 +95,45 @@ of the live ring into an immutable `EventSnapshot` and publishes it atomically. Pre-trigger history is what the ring already holds, so the percentage is a share of the event window and not of the buffer. -In trigger mode a trace is bound to the snapshot and to nothing else: the last -event latched, or nothing at all before the first one. Never the live ring, not -even while the post window is filling — a picture that changes only on an event is -the whole point of trigger mode (SPECS: "data visualisation should not be refreshed -till next event"), and binding the live ring mid-capture made the plot scroll for -most of every event cycle. +**A capture fills in view** (`DisplayMode::Capturing`). The moment an edge is accepted — +which, by the gate below, means the pre-trigger history is already complete — the axis is +pinned to the whole event window `[-pre, +post]` with the edge at x = 0, and the trace +follows the live ring inside it until the snapshot lands. So the pre side is there +immediately, the post side sweeps in from x = 0 to the right, and at the latch the +snapshot replaces the live binding on an axis that does not move: the only visible change +is that the picture stops. + +This is a **deliberate deviation from the spec**, requested explicitly. SPECS:37 says the +visualisation "should not be refreshed till next event", and an earlier iteration of this +app read that strictly — a trace was bound to the snapshot and to nothing else, never the +live ring, not even while the post window was filling. What that reading cost was the whole +of every capture: for as long as the post side took to fill, the plot showed the *previous* +event with no sign that anything was happening. The literal rule is gone; what it was +protecting is not. A **held** event is still bound to the snapshot alone +and is never quietly overwritten by live data, and the axis is still the event's own +window throughout — a capture is live data on a frozen frame, not a live view. + +The four display states are `Live` (no trigger), `Capturing`, `Held` (the frozen +snapshot) and `Waiting` (armed, nothing captured yet: blank). `ScopeModel::display_mode()` +answers which, and the frame tick reads it once and hands it to the bind, the display +epoch, the axis pin and the status line — the producer can open or close a capture between +any two reads, and the four disagreeing about which clock they are on is how a trace ends +up drawn a window away from its axis. + +**Disarm during a capture keeps what the capture got to.** It cuts the window at the +newest sample, publishes that as the held event and freezes it — the axis tightens to the +part that filled (`[-pre, +0.3 s]` for a 1 s post window disarmed 300 ms in) with the edge +still at x = 0. It used to lose the capture outright: clearing the engine's pending flag +dropped the display out of `Capturing` with nothing published behind it, so the view fell +back to the *previous* event, or to blank if this was the first. No event fired, so the +latch counter does not move; a separate `ScopeModel::event_seq()` counts published +snapshots, and that is what the frame tick keys its framing on. + +Mid-capture the live ring is read on the *event's* clock: `display_epoch()` is set to +minus the edge, so `x = 0` is the edge for the live channels exactly as it is in a +snapshot's own stamps. That re-basing machinery already existed (it was written for the +frozen view and then never needed, since snapshots carry edge-relative stamps); this is +what finally uses it. **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 @@ -222,14 +255,15 @@ line always names the state it is in: |---|---| | `[live]` | trigger mode off: the view follows the stream continuously | | `[trigger: armed, waiting for an event]` | watching for an edge; the view shows the previous state | +| `[trigger: CAPTURING (post-trigger window filling)]` | an edge was accepted; the post side is filling in view | | `[trigger: EVENT HELD …]` | an event was latched; the view is frozen on it | | `[trigger: disarmed (view frozen)]` | armed and waiting are not the same thing — nothing will arrive until it is armed again | -While an event is on screen a **red vertical line marks the trigger instant** on -every row, at the edge's own stream time — the latch may complete a post-window -later, but the line marks the edge, so the pre/post split can be read straight off -the plot. It is not one of the user's cursors: it has its own colour, ignores the -cursor show/hide toggle, and is parked (invisible) when no event is held. +While an event is on screen — being captured or held — a **red vertical line marks the +trigger instant** on every row, at x = 0. The latch completes a post-window later, but +the line marks the edge, so the pre/post split can be read straight off the plot. It is +not one of the user's cursors: it has its own colour, ignores the cursor show/hide +toggle, and is parked (invisible) when there is no event. **The status line.** It starts with an activity badge and then the mode, the connection, the measured rate, the packet/frame/gap counters, what plot 1 holds and @@ -253,7 +287,10 @@ the buffer's size and span. Three of those are worth calling out: then minutes, then hours. A frozen view looks the same whether the last event was a second or an hour ago, so without it there is no way to tell a working trigger from a dead one. Measured on the wall clock, not the stream's: it is a question about how - long *you* have been waiting. Before the first latch it reads `no event yet`. + long *you* have been waiting. The clock starts at the **edge**, not at the latch — that + is the instant the user saw, and it is also when the capture appeared on screen — so a + long capture reads as an event that is already ageing rather than one that has not + happened. Before the first event it reads `no event yet`. Switching the header bar's **trigger** button off returns to `[live]` and resumes drawing the stream; the trigger panel carries the same action as a @@ -403,6 +440,12 @@ answered: a live view keeps scrolling while the file chooser is up. The live cut through `ScopeModel::live_window_locked()`, the same index range `publish_event()` uses, so an export and a capture of the same window hold the same samples. +The export follows the display, not the trigger switch: it reads the snapshot only while +one is **held**, and the live ring otherwise — including mid-capture, where the rows are +on the live ring too. So exporting during a capture writes the partly filled window that +is on screen (`time` from `-pre` up to wherever the post side has reached), not the +previous event. + ## Review findings not (yet) implemented These are known deviations, found by a spec/code review, that are deliberate for @@ -429,6 +472,12 @@ now — they are listed so they are not mistaken for anything else: - **Pre-trigger % is a share of the *event* window**, not of the retained history — see the trigger section above for why (the spec's sentence predates the event window being a separate control). +- **A capture is drawn as it fills**, against SPECS:37's "should not be refreshed till + next event", which the app used to honour literally. Requested explicitly: a capture + whose post window takes any appreciable time showed the previous event and no sign of + 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. - **A plot holds at most four 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/src/acquisition.cpp b/src/acquisition.cpp index dbea192..828ba4f 100644 --- a/src/acquisition.cpp +++ b/src/acquisition.cpp @@ -349,6 +349,32 @@ void Acquisition::on_data(const udps_frame_t* frame) { model_.trigger_armed().store(true, std::memory_order_relaxed); } } else if (engine.armed()) { + // Disarm cuts a capture in progress where it got to and keeps it, instead of + // discarding it. `engine.disarm()` clears `pending_`, which drops the display out of + // `Capturing`; with nothing published the view fell back to the *previous* event, or + // to blank if this was the first -- the capture the user was watching simply + // vanished. Publishing first means the same snapshot path freezes it, so what is on + // screen at the moment of the Disarm stays on screen. + // + // The full post window is requested and `live_window_locked()` clips it to what the + // rings hold, so the snapshot ends at the newest sample and reports its own shorter + // `post_s` -- the same way a first latch whose pre window was short reports its own + // `pre_s`. No event fired, so `trigger_latches()` is deliberately *not* bumped; the + // UI notices the new picture through `event_seq()`. + if (engine.pending()) { + const double edge = engine.last_trigger_time(); + model_.publish_event(event_span - post_want, post_want, edge); + model_.trigger_time().store(edge, std::memory_order_relaxed); + model_.trigger_time_valid().store(true, std::memory_order_relaxed); + auto snap = model_.event(); + std::fprintf(stderr, + "[udpscope] trigger: disarmed mid-capture, edge at %.3f s kept as " + "[-%.4f..%.4f] n=%llu (post window asked %.4f s, filled %.4f s)\n", + edge, snap ? snap->pre_s : 0.0, snap ? snap->post_s : 0.0, + static_cast(snap ? snap->stamps.size() : 0), post_want, + engine.post_elapsed()); + std::fflush(stderr); + } engine.disarm(); model_.trigger_armed().store(false, std::memory_order_relaxed); } @@ -437,6 +463,11 @@ void Acquisition::on_data(const udps_frame_t* frame) { last_stamp_ = last_stamp_used; 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 + // capture that opens and latches inside this one frame never reports pending, and that + // is right: the display goes straight to the frozen event. + 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); diff --git a/src/export.cpp b/src/export.cpp index 80db2b4..734be44 100644 --- a/src/export.cpp +++ b/src/export.cpp @@ -98,11 +98,13 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) { : std::string("ch") + std::to_string(c); }; - // What the rows bind to is what gets exported, which is the same branch `RowSet::bind()` - // takes: the frozen event in trigger mode, the live ring otherwise. Reading the ring - // while an event is on screen would export data the user cannot see. + // What the rows bind to is what gets exported, so the split is the same `DisplayMode` + // `RowSet::bind()` switches on: the frozen event only while one is held, the live ring + // otherwise -- including mid-capture, where the rows follow the live ring too. Reading + // the ring while an event is on screen would export data the user cannot see, and + // reading the stale snapshot mid-capture would export the *previous* event. std::shared_ptr snap = model.event(); - if (model.trigger_enabled() && snap != nullptr) { + if (model.display_mode() == DisplayMode::Held && snap != nullptr) { // The snapshot is immutable and already index-aligned, so the visible window is two // binary searches over its stamps and the same index range for every channel. const std::vector& st = snap->stamps; diff --git a/src/model.cpp b/src/model.cpp index e4fd8b0..f37f325 100644 --- a/src/model.cpp +++ b/src/model.cpp @@ -350,6 +350,9 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) { 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()); 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. + event_seq_.fetch_add(1, std::memory_order_release); } void BufferStore::set_epoch(f64 epoch) noexcept { diff --git a/src/model.hpp b/src/model.hpp index d05806a..b8c21e8 100644 --- a/src/model.hpp +++ b/src/model.hpp @@ -127,6 +127,21 @@ struct StreamStats { std::atomic have_config{false}; }; +/// What the plot is showing, which is one decision made once per frame: the bind, the +/// export and the status wording all have to agree, and the producer can latch between +/// any two of them. +/// +/// `Capturing` is the live-updating capture: an edge has been accepted (so the pre +/// window was full) and the post window is still filling, and the plot follows the live +/// ring on an edge-relative axis until the snapshot lands. It is a deliberate departure +/// from SPECS:37 ("not refreshed till next event") -- see README. +enum class DisplayMode { + Live, ///< no trigger: the live ring, stream clock + Capturing, ///< an edge was accepted: the live ring, edge-relative clock + Held, ///< the frozen snapshot of the last event + Waiting, ///< armed, nothing captured yet: blank +}; + /// The application's data model: the stream description, the two sample buffers, /// the trigger configuration and the counters. Structural changes (a CONFIG /// update, an enable/disable, a calibration) rebuild the buffers, so all mutating @@ -191,6 +206,11 @@ class ScopeModel { std::shared_ptr event() const noexcept { return event_.load(std::memory_order_acquire); } + /// How many snapshots `publish_event()` has produced. Not interchangeable with + /// `trigger_latches()`: a Disarm during a capture publishes what the capture got to + /// *without* an event having fired, so this counts "there is a new picture to frame" + /// 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); } /// Publishes the live buffer's new samples to its `ChannelBuffer`s. void acquire_all(); @@ -211,16 +231,35 @@ class ScopeModel { } void set_trigger_enabled(bool on); - /// Whether the engine is mid-capture (an edge was seen and the post-trigger - /// window is still filling). Purely informational: it drives the status line's - /// wording. The plot deliberately does *not* react to it -- in trigger mode the - /// picture changes only on a latch (SPECS: "data visualisation should not be - /// refreshed till next event"). + /// Whether the engine is mid-capture (an edge was seen -- so the pre window was + /// full -- and the post-trigger window is still filling). This is what puts the + /// display in `DisplayMode::Capturing`: the plot follows the live ring, on an axis + /// pinned to the coming event's window, until the snapshot replaces it. + /// + /// Release/acquire, not relaxed: `capture_edge()` is written before this is set and + /// has to be visible to a consumer that sees the flag. bool trigger_pending() const noexcept { - return trigger_pending_.load(std::memory_order_relaxed); + return trigger_pending_.load(std::memory_order_acquire); } void set_trigger_pending(bool on) noexcept { - trigger_pending_.store(on, std::memory_order_relaxed); + trigger_pending_.store(on, std::memory_order_release); + } + + /// The stream time of the edge of the capture `trigger_pending()` refers to. Written + /// by the producer before it raises the flag, so a consumer that sees the flag sees + /// this too; stale once the flag clears. + f64 capture_edge() const noexcept { return capture_edge_.load(std::memory_order_relaxed); } + void set_capture_edge(f64 e) noexcept { + capture_edge_.store(e, std::memory_order_relaxed); + } + + /// What the plot should bind to this frame. Read it *once* per frame and pass the + /// answer around: calling it twice can straddle a latch, and then the bind and the + /// axis disagree about which clock they are on. + DisplayMode display_mode() const noexcept { + if (!trigger_enabled()) return DisplayMode::Live; + if (trigger_pending()) return DisplayMode::Capturing; + return event() != nullptr ? DisplayMode::Held : DisplayMode::Waiting; } /// The x origin the plot uses: 0 for "seconds since the stream started", or minus @@ -283,7 +322,9 @@ class ScopeModel { f64 epoch_ = 0.0; std::atomic event_span_{1.0}; std::atomic trigger_pending_{false}; + std::atomic capture_edge_{0.0}; std::atomic> event_; ///< the event on screen + std::atomic event_seq_{0}; std::atomic display_epoch_{0.0}; std::atomic trigger_armed_{false}; std::atomic trigger_enabled_{false}; diff --git a/src/plot_host.cpp b/src/plot_host.cpp index e45d70e..2bd44cc 100644 --- a/src/plot_host.cpp +++ b/src/plot_host.cpp @@ -143,7 +143,7 @@ int RowSet::channel_of(int row_index, int slot) const { return row(row_index).channel[static_cast(slot)]; } -void RowSet::bind(ScopeModel& model) { +void RowSet::bind(ScopeModel& model, DisplayMode mode) { const std::vector& signals = model.signals(); const std::vector& channels = model.channels(); for (int i = 0; i < kMaxRows; ++i) { @@ -181,20 +181,26 @@ void RowSet::bind(ScopeModel& model) { r.slots[si].bind(nullptr); continue; } - // In trigger mode the trace shows the captured event and nothing else: the - // last one latched, or nothing at all before the first. Never the live ring, - // not even while the post window is filling -- a frozen picture that only - // changes on an event is the whole point of trigger mode, and binding the - // live ring mid-capture made the plot scroll for most of every event cycle. + // The source is the frame's one `DisplayMode`, decided by the caller: `Held` is + // the frozen snapshot, `Waiting` is blank (armed, nothing captured yet), and both + // `Live` and `Capturing` are the live ring -- what differs between those two is + // the *clock*, which the display epoch carries, not the binding. const std::size_t ci = static_cast(channel); - if (model.trigger_enabled()) { - if (auto snap = model.event()) { - r.slots[si].bind_event(std::move(snap), ci); - } else { - r.slots[si].bind(nullptr); // armed, waiting for the first event: blank - } - } else { - r.slots[si].bind(model.live().shared_channel(ci)); + switch (mode) { + case DisplayMode::Held: + if (auto snap = model.event()) { + r.slots[si].bind_event(std::move(snap), ci); + } else { + r.slots[si].bind(nullptr); + } + break; + case DisplayMode::Waiting: + r.slots[si].bind(nullptr); + break; + case DisplayMode::Live: + case DisplayMode::Capturing: + r.slots[si].bind(model.live().shared_channel(ci)); + break; } } } diff --git a/src/plot_host.hpp b/src/plot_host.hpp index 5ed3bc0..8971ef3 100644 --- a/src/plot_host.hpp +++ b/src/plot_host.hpp @@ -117,10 +117,13 @@ class RowSet { /// The channel index a row/slot holds, or -1. int channel_of(int row, int slot) const; - /// Rebinds every slot to what it should show this frame: the model's live buffer, - /// or -- in trigger mode -- the captured event. Called every frame, because the - /// channel list is renumbered by any structural change. - void bind(ScopeModel& model); + /// 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. + /// + /// `mode` is passed in rather than read from the model, so that one frame's bind, axis + /// and status line all describe the same display: the producer can open or close a + /// capture between any two reads of `ScopeModel::display_mode()`. + void bind(ScopeModel& model, DisplayMode mode); /// Publishes the live buffer's new samples. void acquire(ScopeModel& model) { model.acquire_all(); } diff --git a/src/ui.cpp b/src/ui.cpp index 68e7314..d6a8b44 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -99,12 +99,26 @@ struct App { /// Trigger-mode state for the status line: how many latches were on screen last /// frame, and whether an event is held (which is what "frozen" means). std::uint64_t seen_latches = 0; + /// Which snapshot was framed last. Separate from `seen_latches` because a Disarm during + /// a capture publishes a snapshot without an event having fired. + std::uint64_t seen_event_seq = 0; bool event_held = false; - /// Monotonic time of the latch `seen_latches` counts, so the status line can age it. + /// Whether a capture is open on screen: the edge has been accepted and the post window + /// is filling in view, on the coming event's own clock. It is what keeps + /// `clamp_to_data()` off the pinned axis and tells the status line apart from "armed". + bool capturing = false; + /// The edge of the capture `capturing` refers to, so a second edge accepted before the + /// first capture closed re-frames instead of being mistaken for the same one. + double capture_edge = 0.0; + /// Monotonic time of the last event, and the edge that started the clock. The clock + /// starts when the *edge* is accepted, not when the capture closes -- that is the + /// instant the user saw -- and the edge is kept so the latch that ends a live-updated + /// capture does not restart it. /// Wall time, not stream time: "how long since the last event" is a question about the /// user's wait, and a stream whose clock runs at any other speed would answer it wrong. - /// 0 until the first latch of the session. + /// 0 until the first event of the session. gint64 last_latch_us = 0; + double timed_edge = 0.0; double window_choices_limit = -1.0; ///< the cap the dropdown was last built with /// The rate each channel actually *stored* over the last second, and the declared rate /// beside it. The app does no decimation, so the stored rate is the datasource's own @@ -133,6 +147,10 @@ struct App { int dbg_reset_visible = -1; std::vector last_stored; std::uint64_t dbg_latches_at_disarm = 0; + std::uint64_t dbg_seq_at_disarm = 0; + bool dbg_capturing_at_disarm = false; + int dbg_disarm_polls = 0; + int dbg_disarm_in_capture = 0; int dbg_swaps = 0; int dbg_axes_w_min = 1 << 20; int dbg_axes_w_max = -(1 << 20); @@ -448,6 +466,7 @@ void on_arm_clicked(GtkButton* button, gpointer user) { } if (armed) { app->event_held = false; + app->capturing = false; app->model->trigger_time_valid().store(false, std::memory_order_relaxed); } // Arming is a statement that this signal should be watched, so its acquisition @@ -457,8 +476,7 @@ void on_arm_clicked(GtkButton* button, gpointer user) { gtk_button_set_label(GTK_BUTTON(app->arm_button), armed ? "Disarm" : "Arm"); } -void on_trigger_field_changed(GtkWidget*, gpointer user) { - App* app = static_cast(user); +void publish_trigger_fields(App* app) { if (app->updating_panels) return; // Read the widgets first (they may be touched again by a re-entrant handler if a // setter ran while the lock was held), then publish the values under the lock the @@ -481,6 +499,18 @@ void on_trigger_field_changed(GtkWidget*, gpointer user) { } } +void on_trigger_field_changed(GtkWidget*, gpointer user) { + publish_trigger_fields(static_cast(user)); +} + +/// The same publish, for the two dropdowns. It needs its own entry point because +/// `notify::` passes a `GParamSpec*` ahead of the user data while +/// `GtkSpinButton::value-changed` does not: a two-parameter callback on a `notify::` +/// signal reads the pspec as its `App*` and crashes on the first field it touches. +void on_trigger_drop_changed(GObject*, GParamSpec*, gpointer user) { + publish_trigger_fields(static_cast(user)); +} + void on_trigger_signal_changed(GObject* obj, GParamSpec*, gpointer user) { App* app = static_cast(user); if (app->updating_panels) return; @@ -897,6 +927,7 @@ void on_trigger_toggled(GtkToggleButton* button, gpointer user) { signal = tc.channel; } app->event_held = false; + app->capturing = false; if (!on) { app->model->trigger_time_valid().store(false, std::memory_order_relaxed); app->model->set_display_epoch(0.0); // back to seconds since the stream started @@ -1317,12 +1348,30 @@ gboolean tick(gpointer user) { } if (model.generation() != app->signal_generation) rebuild_signal_panels(*app); + // What the display shows, decided once for the whole frame: the producer can open or + // close a capture between any two reads of the model, and the bind, the epoch, the pin + // and the status line all have to describe the same picture. + const DisplayMode display = model.display_mode(); + const double cap_edge = model.capture_edge(); + const bool new_capture = + display == DisplayMode::Capturing && (!app->capturing || cap_edge != app->capture_edge); + app->capturing = display == DisplayMode::Capturing; + if (new_capture) app->capture_edge = cap_edge; + { // 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()); - app->rows->bind(model); + // Mid-capture the live ring is drawn on the coming event's clock, so its edge sits at + // x = 0 exactly as a snapshot's does and the pinned [-pre, +post] axis means the same + // thing in both. Every other mode reads stream seconds. The epoch is a consumer-side + // offset on the live channels, so it has to be in place before the bind that hands + // them to the traces -- and it is written here, under the lock, because a rebuild can + // replace the store. + const double want_epoch = display == DisplayMode::Capturing ? -cap_edge : 0.0; + if (model.display_epoch() != want_epoch) model.set_display_epoch(want_epoch); + app->rows->bind(model, display); app->rows->acquire(model); } @@ -1331,11 +1380,12 @@ gboolean tick(gpointer user) { // the group and pushes it to the others, so the "which row acted" search this replaced // (and the per-row pan/zoom propagation behind it) has no subject any more. - // While a captured event is on screen the window is pinned to [edge-pre, - // edge+post]; clamp_to_data would fight that pin whenever the bound series - // still reports the acquisition buffer's raw coordinates, dragging the view - // along the ring. Clamping is a live-view concern only. - if (!app->event_held) app->rows->clamp_to_data(); + // While an event is on screen -- held, or still being captured -- the window is pinned + // to [-pre, +post]; clamp_to_data would fight that pin whenever the bound series still + // reports the acquisition buffer's raw coordinates, dragging the view along the ring. + // Clamping is a live-view concern only, and a capture is live data on a pinned axis, + // which is exactly the case it must not touch. + if (!app->event_held && !app->capturing) app->rows->clamp_to_data(); // Curator: the trigger's arm state can change under us (single mode disarms // itself), so the button and the status line read the published atomics. @@ -1345,48 +1395,88 @@ gboolean tick(gpointer user) { } const bool armed = model.trigger_armed().load(std::memory_order_relaxed); gtk_button_set_label(GTK_BUTTON(app->arm_button), armed ? "Disarm" : "Arm"); + if (new_capture) { + // The edge has been accepted, which means the pre window was already full, so the + // whole event window can be framed now and the post side filled in view. + // + // The split is the *requested* one -- the same `post_span_s()` the producer will cut + // with -- because there is no snapshot to read it off yet, and the marker has to sit + // where that cut will put it rather than where a different rounding would. + double span = 0.0; + double post = 0.0; + { + std::lock_guard lock(model.mutex()); + span = std::max(1.0e-3, model.event_span()); + post = model.trigger().post_span_s(span); + } + app->event_held = false; + app->rows->pin_event_window(span - post, post); + app->rows->set_trigger_marker(0.0, true); + select_window_choice(*app, span); + // The user saw the event at its edge, so that is when the counter starts. + app->timed_edge = cap_edge; + app->last_latch_us = g_get_monotonic_time(); + } + const std::uint64_t latches = model.trigger_latches().load(std::memory_order_relaxed); if (latches != app->seen_latches) { app->seen_latches = latches; - app->event_held = true; - app->last_latch_us = g_get_monotonic_time(); - // The window is pinned to exactly what the snapshot captured, read off the - // snapshot itself. Not off `event_span`/`pretrigger_pct`: those say what was - // *asked for*, in stream seconds, while how much the capture actually covers - // depends on the cadence the trigger channel was running at. Pinning to the - // request collapsed the waveform into a sliver at x = 0 and left the rest of - // the axis -- the whole post side included -- blank. - // - // The snapshot is edge-relative (the edge at x = 0), so the axis needs no epoch - // shift: snapshot, pin and marker all speak the same coordinates. Rebind first - // so the slots hold the snapshot before the pin is applied. - { - std::lock_guard lock(model.mutex()); - app->rows->bind(model); + const double edge = model.trigger_time().load(std::memory_order_relaxed); + // A capture the UI watched fill already started this clock at its edge; only one that + // opened and closed inside a single frame -- never reported pending, so never + // displayed as a capture -- starts it here. + if (edge != app->timed_edge) { + app->timed_edge = edge; + app->last_latch_us = g_get_monotonic_time(); } - double pre = 0.0; - double post = 0.0; - if (auto snap = model.event()) { - pre = snap->pre_s; - post = snap->post_s; - } - if (!(pre + post > 0.0)) { - // Nothing was captured (no samples in the ring yet): fall back to the - // requested window so the axis is at least readable. - const double span = std::max(1.0e-3, model.event_span()); - pre = std::min(0.99, model.trigger().pretrigger_pct / 100.0) * span; - post = span - pre; - } - app->rows->pin_event_window(pre, post); - app->rows->set_trigger_marker(0.0, true); // the edge sits at x = 0 - select_window_choice(*app, pre + post); } - // The trigger instant is marked on every row while its event is the thing on - // screen, so the pre/post split around it can be read off the plot. - if (trigger_on && model.trigger_time_valid().load(std::memory_order_relaxed)) { - // x is seconds from the event: the display epoch is minus the edge time, so the - // edge sits at exactly x = 0 wherever the user has zoomed. - } else { + + // Framing the held event is keyed on *which snapshot was published*, not on the latch + // count: a Disarm during a capture publishes what the capture got to without an event + // having fired, and that partial has to be framed and held exactly like one that ran + // to term. The latch count stays the answer to "how many events fired", which is what + // the probes assert on. + const std::uint64_t seq = model.event_seq(); + if (seq != app->seen_event_seq) { + app->seen_event_seq = seq; + // A publication and the *next* edge can land in the same frame. The display follows + // the newer of the two, so re-framing on the snapshot is only right when the snapshot + // is what is bound; otherwise the capture above has already framed its own window. + if (display == DisplayMode::Held) { + app->event_held = true; + // The window is pinned to exactly what the snapshot captured, read off the + // snapshot itself. Not off `event_span`/`pretrigger_pct`: those say what was + // *asked for*, in stream seconds, while how much the capture actually covers + // depends on the cadence the trigger channel was running at. Pinning to the + // request collapsed the waveform into a sliver at x = 0 and left the rest of + // the axis -- the whole post side included -- blank. + // + // The snapshot is edge-relative (the edge at x = 0) and the epoch is back to 0, so + // the bind at the top of this frame already put the slots and the axis on the same + // coordinates the pin is about to state. + double pre = 0.0; + double post = 0.0; + if (auto snap = model.event()) { + pre = snap->pre_s; + post = snap->post_s; + } + if (!(pre + post > 0.0)) { + // Nothing was captured (no samples in the ring yet): fall back to the + // requested window so the axis is at least readable. + const double span = std::max(1.0e-3, model.event_span()); + post = model.trigger().post_span_s(span); + pre = span - post; + } + app->rows->pin_event_window(pre, post); + app->rows->set_trigger_marker(0.0, true); // the edge sits at x = 0 + select_window_choice(*app, pre + post); + } + } + // The trigger instant is marked on every row while its event is the thing on screen -- + // being captured or held. x is seconds from the event in both: the display epoch is + // minus the edge while the live ring is on a pinned axis, and the snapshot's own stamps + // are edge-relative, so the edge sits at exactly x = 0 wherever the user has zoomed. + if (!app->capturing && !(trigger_on && model.trigger_time_valid().load(std::memory_order_relaxed))) { app->rows->set_trigger_marker(0.0, false); } @@ -1568,7 +1658,9 @@ gboolean tick(gpointer user) { StreamStats& st = model.stats(); const char* mode = "live"; if (trigger_on) { - if (app->event_held) { + if (app->capturing) { + mode = "trigger: CAPTURING (post-trigger window filling)"; + } else if (app->event_held) { mode = "trigger: EVENT HELD (Arm to re-arm, or 'Go live' for streaming)"; } else if (armed && have_trigger_value) { static char waiting[192]; @@ -1724,9 +1816,9 @@ GtkWidget* build_trigger_panel(App& app) { g_signal_connect(w, "value-changed", G_CALLBACK(on_trigger_field_changed), &app); } g_signal_connect(app.trigger_edge_drop, "notify::selected", - G_CALLBACK(on_trigger_field_changed), &app); + G_CALLBACK(on_trigger_drop_changed), &app); g_signal_connect(app.trigger_mode_drop, "notify::selected", - G_CALLBACK(on_trigger_field_changed), &app); + G_CALLBACK(on_trigger_drop_changed), &app); return box; } @@ -2068,8 +2160,11 @@ gboolean trig_trace_probe(gpointer user) { for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { const std::size_t si = static_cast(s); if (r.channel[si] < 0) continue; + // A live binding in trigger mode is only legitimate as a capture in progress, so + // the two are named apart: `live` with `trigger=on` is the defect, `live(capturing)` + // is the feature. if (r.slots[si].bound_event() != nullptr) display = "event(frozen)"; - else if (r.slots[si].bound() != nullptr) display = "live"; + else if (r.slots[si].bound() != nullptr) display = a->capturing ? "live(capturing)" : "live"; shown = &r.slots[si]; found = true; break; @@ -2160,10 +2255,11 @@ gboolean trig_trace_probe(gpointer user) { bound_hi, dx_nominal, dx_real, dx_real > 0.0 ? dx_nominal / dx_real : 0.0, got_lo, got_hi, want_lo, want_hi); std::fprintf(stderr, - "[trig-probe] trigger=%s pending=%d display=%s latches=%llu armed=%d " + "[trig-probe] trigger=%s pending=%d display=%s epoch=%.4f latches=%llu armed=%d " "live_pub=%llu snap=[%.4f..%.4f] n=%zu axis=[%.4f..%.4f] " "data=[%.4f..%.4f] drawn=[%.4f..%.4f]\n", m.trigger_enabled() ? "on" : "off", int(m.trigger_pending()), display, + m.display_epoch(), static_cast(m.trigger_latches().load()), int(m.trigger_armed().load()), static_cast(m.live().values(0).produced()), snap_lo, snap_hi, @@ -2185,6 +2281,28 @@ gboolean arm_probe(gpointer user) { return G_SOURCE_REMOVE; } +/// Headless self-check: changes the trigger panel's two dropdowns, which is what a user +/// does to switch Normal/Single or the edge -- mid-capture, if the probe is timed into +/// one. Reaching the dropdowns directly is the point: their `notify::selected` handler +/// has a different signature from the spin buttons' `value-changed`, and connecting the +/// wrong one crashed on the first field read with no way to tell from the state dump. +/// A line here with no SIGSEGV after it is the assertion. +gboolean fields_probe(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + GtkDropDown* mode = GTK_DROP_DOWN(a->trigger_mode_drop); + GtkDropDown* edge = GTK_DROP_DOWN(a->trigger_edge_drop); + gtk_drop_down_set_selected(mode, gtk_drop_down_get_selected(mode) == 0 ? 1 : 0); + gtk_drop_down_set_selected(edge, (gtk_drop_down_get_selected(edge) + 1) % 3); + std::lock_guard lock(a->model->mutex()); + std::fprintf(stderr, "[fields-probe] mode=%u edge=%u published mode=%d edge=%d capturing=%d\n", + gtk_drop_down_get_selected(mode), gtk_drop_down_get_selected(edge), + int(a->model->trigger().mode), int(a->model->trigger().edge), + int(a->model->trigger_pending())); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} + /// Headless self-check: acquire every signal the CONFIG described, the way the /// signal panel's switches would, and put each resulting channel on a plot, the way /// a drag from the panel onto a row would. Without the second half nothing is bound @@ -2214,6 +2332,8 @@ gboolean enable_all_probe(gpointer user) { return G_SOURCE_REMOVE; } +gboolean disarm_probe_step3(gpointer user); + gboolean disarm_probe_step2(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; @@ -2226,15 +2346,70 @@ gboolean disarm_probe_step2(gpointer user) { a->dbg_latches_at_disarm = a->model->trigger_latches().load(); std::fprintf(stderr, "[disarm-probe] %llu events so far, armed now=%d\n", (unsigned long long)a->dbg_latches_at_disarm, int(a->model->trigger().armed)); + a->dbg_capturing_at_disarm = a->model->trigger_pending(); + a->dbg_seq_at_disarm = a->model->event_seq(); + g_timeout_add(600, disarm_probe_step3, a); return G_SOURCE_REMOVE; } +/// Headless self-check, the half that reads the *outcome* of the Disarm. A disarm that +/// landed mid-capture (`was_capturing=1`) must leave an event on screen with the +/// capture's own span: `display` reading `nothing` there is the "the event completely +/// disappeared" defect, and `seq` must have advanced by one while `events` has not. +gboolean disarm_probe_step3(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + const DisplayMode d = a->model->display_mode(); + auto snap = a->model->event(); + RowSet::Row& r = a->rows->row(0); + const plot::Range axis = r.plot != nullptr ? r.plot->x_range(r.axis_x) : plot::Range{0.0, 0.0}; + std::fprintf(stderr, + "[disarm-probe] was_capturing=%d display=%s seq=%llu->%llu events=%llu->%llu " + "snap=[%.4f..%.4f] n=%llu axis=[%.4f..%.4f]\n", + int(a->dbg_capturing_at_disarm), + d == DisplayMode::Held ? "event(frozen)" + : d == DisplayMode::Live ? "live" + : d == DisplayMode::Capturing ? "live(capturing)" + : "nothing", + (unsigned long long)a->dbg_seq_at_disarm, + (unsigned long long)a->model->event_seq(), + (unsigned long long)a->dbg_latches_at_disarm, + (unsigned long long)a->model->trigger_latches().load(), + snap ? -snap->pre_s : 0.0, snap ? snap->post_s : 0.0, + (unsigned long long)(snap ? snap->stamps.size() : 0), double(axis.min), + double(axis.max)); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} + +/// Waits for a capture to actually be open before clicking Disarm. The interesting case +/// is the Disarm that lands *mid-capture*, and a fixed delay catches it only by luck -- +/// the capture opens as soon as the ring holds a pre window and closes one post window +/// later. Polling for `trigger_pending()` makes the probe deterministic. +gboolean disarm_probe_wait(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + ++a->dbg_disarm_polls; + if (a->model->trigger_pending()) ++a->dbg_disarm_in_capture; + // 300 ms into the capture: post-trigger data worth keeping, well short of the latch. + // The 10 s cap is for a stream that never triggers -- the probe still has to report. + if (a->dbg_disarm_in_capture >= 6 || a->dbg_disarm_polls > 200) { + if (a->dbg_disarm_in_capture == 0) { + std::fprintf(stderr, "[disarm-probe] no capture opened in 10 s; disarming anyway\n"); + std::fflush(stderr); + } + disarm_probe_step2(a); + return G_SOURCE_REMOVE; + } + return G_SOURCE_CONTINUE; +} + gboolean disarm_probe_step1(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; if (a->trigger_toggle != nullptr) gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE); - g_timeout_add(2000, disarm_probe_step2, a); + g_timeout_add(50, disarm_probe_wait, a); return G_SOURCE_REMOVE; } @@ -2350,6 +2525,9 @@ int run_ui(int argc, char** argv, ScopeModel& model, Acquisition& acq, RowSet& r if (const char* env = g_getenv("UDPSCOPE_ARM_TRIGGER_MS")) { g_timeout_add(static_cast(std::atoi(env)), arm_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_FIELDS_MS")) { + g_timeout_add(static_cast(std::atoi(env)), fields_probe, &app); + } if (const char* env = g_getenv("UDPSCOPE_TRIG_TRACE_MS")) { g_timeout_add(static_cast(std::atoi(env)), trig_trace_probe, &app); } diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..18af9a3 --- /dev/null +++ b/todo.md @@ -0,0 +1,8 @@ +1. better time window management: + +- instead of a drop down simple combo should be an advanced sub menu with text input + unit selector (us,ms,s,min) + two button to increase / decrease (factor 10 each time) +- when `timewindow x sample rate x sample size` is greater than `buffer size` apply downsampling of input signals with low pass filter to match the correct freq: e.g. downsample at 100kSps -> low pass filter at 50kHz + +2. improve layout management: instead of a fix number of figure, in the menu add a layout editor option, user can add and remove rows ([x] close to the top left of the row to remove, [+] in the top center and bottom center to add new one [above] and [below]) +3. improve plot font rendering and line rendering +4. improve plot performances with large data (e.g. at 10MSamples the plot start to be laggy)