8 Commits
Author SHA1 Message Date
Martino Ferrari b02419dee3 feat: a signal switched off switches its dependent math channels off too
A derived channel cannot be computed from a signal nobody acquires: `Source` resolves to a
*channel*, and a disabled signal has none -- so a channel that reads one silently computed
nothing, which is what "all math signals depending on it do not compute anymore" looked like
from outside.

Rather than leave it enabled and blank, the channels that read the signal follow it off:
`disable_dependent_math_channels()` walks the graphs to a **fixpoint**, because one math
channel may name another, and the switch goes through the same path the pane's own toggle
uses -- `MathChannel::enabled` then `apply_math_channels()`, the single writer -- so the
pane's switches follow. Their traces leave the plots with the signal's, and the notice says
both halves.

The channels that do *not* read it are untouched, which is the assertion's other half:

    [enable-all-probe] switched 'sine' off: traces_left=0 (want 0)
        math_off=[math x2,math3] math_on=[math lp]
        notice=''sine' is off: 1 trace left the plots; math off: math x2 and 1 more'

`math x2` and `math3` both source `sine`; `math lp` sources `noise` and stays on. The probe's
switch step now runs late enough that a math probe armed behind it has installed its graphs,
which is why the earlier reading was empty -- `app.math` had nothing in it yet, not
"no dependants".

Re-enabling the signal does not switch them back on, for the same reason it does not restore
the traces: that is the user's decision, and the rule is written down in
invariants-math-channels.md.
2026-10-12 22:25:15 +02:00
Martino Ferrari 5eb20f3cdb perf: editing a math graph recomputes that graph, not every channel
`set_math_channels()` rebuilt the store for any change, and a rebuild replaces the buffer:
every channel in the window started again, which is the "modifying one expression invalidates
all the others" report.

The plan is a *partition over the channels*, so it only moves when the set moves. When the
set is unchanged -- same channels, names and enables -- nothing is rebuilt now: the store is
untouched, every other channel keeps its samples and its runtimes, and the surgical path
prepares and recomputes only the affected graphs. The affected set is the edited channels plus
whatever *names* them, because one channel's output is another's input, and the recompute
computes the whole list in order (the inputs have to be there) while writing only those in
the mask -- which is why the step-loop is now `math_apply_locked(sample, write_mask, timed)`.

Adding, removing or renaming a channel still moves the plan, and still rebuilds.

    [math-probe] graph edit: stored_slots 800 -> 900 (want the second at least the first, and non-zero)

The count grows because the stream keeps filling while the edit runs; the point is that it
never falls to 0, and that every channel afterwards still reads `N samples vs M time slots`
with N == M.
2026-10-12 22:19:17 +02:00
Martino Ferrari 8b3240cacb feat: switching a signal off takes its traces off every plot
The switch in the signal list means "do not acquire this", and until now a trace of it
stayed on its plot drawing nothing: a slot that holds one of the four a plot has, reads
`--`, and blocks the drop that would fill it with something live.

`on_signal_switch()` now clears every slot that holds that signal, by the **signal name**
the slot remembers -- never the channel index, which is what the enable change renumbers --
and says what it did: `'sine' is off: 1 trace left the plots`. The follow-ups (a relayout,
the panels' chips, the layout save) happen once rather than per slot.

Re-enabling deliberately does not put the traces back: the arrangement is the user's, and
guessing where they were is worse than leaving them off. Say the word if you want that
direction too.

[enable-all-probe] reads it end to end -- it enables every signal, assigns the channels
across the rows, then switches one off *through its own acquire tick*:

    [enable-all-probe] switched 'sine' off: traces_left=0 (want 0) notice=''sine' is off: 1 trace left the plots'

which is also why the notice shares the status line's warning styling from the previous
commit: the trace leaving the plots is something the user should see happen.
2026-10-12 22:12:24 +02:00
Martino Ferrari aaf544be1e ui: a refused drop says so, and looks like a message rather than the status
The refusal was never silent -- `on_drop_channel()` has always set a notice -- but the
status bar renders `notice + "   |   " + glance` in one label, so "plot 1 already holds
four signals" arrived looking like part of the routine text a reader skims, which is the
"no clear user feedback" report. The probe's own reading showed it plainly: the label held
`plotting 'sine' on plot 1   |   live · 4.44 kS/s · 2 s window`.

Three changes:

- While a notice is fresh the status label carries the stock `.warning` class, so a message
  looks like a message and the class goes when the notice does. Every notice benefits, not
  just this one.
- The refusal says what to *do*: "plot 1 holds four signals already: drop it on another
  plot, or add a row with the layout editor (L)".
- The drop's inline notice now goes through `set_notice()`, the one setter, instead of
  writing `app.notice` and its deadline by hand.

[drop-probe] reads the whole path, with the row filled first so the drop under test is the
refusal:

    full row refused the drop: 0 (want 0)
    refused feedback: notice='plot 1 holds four signals already: ...' warning=1
    the status line, composed: warning=1 status='plot 1 holds four signals already: ... | live · 9.97 kS/s · 2 s window'

The label is read in the third line and not the second on purpose: the status line composes
the notice into itself on its own cadence, so a beat after the refusal it may still hold the
previous text -- which the first attempt at this reading mistook for the fix not working.

The row limit is not silent in the same way: the layout editor's `+` is insensitive at
`kMaxRows`, which is a control that says "no" by being unusable rather than a message.
2026-10-12 20:11:36 +02:00
Martino Ferrari d34b97158e ui: a press anywhere in the window closes the time-window popover
The window control is a GtkMenuButton whose popover autohides -- which works for as long as
GTK holds the popover's grab, and the grab is what can go missing: a window manager moving
it, the nested popover the unit dropdown opens taking it, and every headless run, where
there is no manager to hand one out at all. After that a click anywhere else in the window
leaves the bubble up, which is the "sometimes it is awkward to close" report.

`on_window_popover_press()` states the rule outright instead of relying on the grab: any
press that reaches the window's own surface closes it. That needs no hit test beyond the
button -- a popover is its own surface, so a click *inside* it never arrives here -- and the
button is left alone, because its own click is the toggle that closes it. The press gesture
is on the window in the capture phase, so a widget under the press (the plot's own gestures
among them) cannot hide it.

[windowpop] reads both halves, with autohide turned off for the probe -- the state a user
meets when the grab goes missing:

    [windowpop] opened=1 visible=1 click_gestures=2
    [windowpop] after a press elsewhere: active=0 (want 0)
    [windowpop] after a press on the button: active=1 (want 1)

`click_gestures` is read as well as the handler, so "the rule works" and "the rule is
connected" are two facts rather than one.
2026-10-12 20:02:58 +02:00
Martino Ferrari 47d49e75ff perf: a file view re-reads the file only when a structural change really dropped it
Three tightenings, all measured with the reloads now reported on stderr.

`set_math_channels()` treats an identical list as no change at all. It rebuilt
unconditionally, and a rebuild replaces the buffer and drops its history -- which a file
view then has to re-read from disk. The comparison is the graphs', and it leaves the nodes'
`x`/`y` out deliberately: those are the canvas's own, no op reads them, and a graph that
merely *moved* should not cost the buffer.

`reload_file_view()` fires only when the store is actually empty. A change that really
happened leaves it so in a file view (the rebuild replaces it, and there is no producer to
refill it); one that found nothing to do leaves it full, and reading a 200 MB file again for
that is the waste. `ScopeModel::stored_slots()` is the one-line accessor it needed.

Every reload now says so, because a second read of a file is worth accounting for:

    [udpscope] reloaded <path> after a structural change: 402 samples back

Measured, `--open` a file:

    session with no graphs            0 reloads
    the math probe (3 real changes)   3 reloads, and all the readings stay equal
                                      (`math x2 402 samples vs 402 time slots`)

I had offered this as "one extra read at `--open` startup", and that was wrong: with no
session there is no `apply_math_channels()` call at all, so there was never an extra read
to remove. What the tightenings buy is that a no-op application is now free, and that a
reload cannot happen unless the buffer was really lost. Each *genuine* change still costs
one re-read -- a rebuild drops the history by design, and preserving it across a rebuild is
a model change rather than this one.
2026-10-12 19:55:31 +02:00
Martino Ferrari b090cc2a21 fix: math in a file view -- the buffer was emptied, and the derived ring filled twice
Two defects, both reached by adding a math channel to an open waveform or a replayed
event, and both measured before and after.

**A file view has no producer, so a structural change left it empty.** `set_math_channels()`
rebuilds the channels *and* the buffer, and a rebuild drops the history: right for a stream,
whose producer refills it from the next packet, and wrong for a file, which has nothing to
refill it with. The `--open` dump read `0 samples vs 0 time slots` for *every* channel,
the file's own `sine`/`square`/`ramp`/`noise` included, though the load had reported 402 --
that is what "adding a math node breaks the plotter" was. `reload_file_view()` re-reads the
file after such a change, which is also what computes the derived channels over it: the
load's own pushes run the math once the set is in place, so `--open` with a session that
defines graphs works for the first time.

**The file path pushed into the derived rings, and so did the engine.** `push_loaded_slot()`
did not honour the rule `ChannelInfo::math` states for the packet path ("the ingest must skip
it"), so each loaded slot put a value in a derived ring and `push_math_sample()` then put the
computed one: two samples per slot, a trace at double rate -- the derived channels read
`804 samples vs 402 time slots`. Skipping them there is the whole fix.

Readings now, all equal, which is the assertion this file's own invariant makes:

    --open the file:  math x2  402 samples vs 402 time slots, y=[-1.9021, 0.0063]  (2x the sine)
                      math lp  402 vs 402, mean=-0.4900 (a real filter output, not zero)
    live:             math x2  25100 vs 25100
    replayed event:   math x2  4000 vs 4000

I first tried filtering the file's *derived columns* out by name, since an export writes
every plotted channel. It removed the double count and zeroed `math lp`, because the column
it dropped was the source a session graph reads -- so it is reverted: those columns are
legitimate data, and the fix belongs in the push, not the loader.
2026-10-12 19:49:39 +02:00
Martino Ferrari c3d588f03d fix: the math editor's body was a child of the window, not of the box above it
Moving the editor's frame into math-editor.ui left `math_body` and `math_actions` as
<child> elements of the *window* rather than of `math_outer`, because those edits were
anchored on the file's tail and closed the outer box two elements early. A GtkWindow
takes exactly one default <child>, so nothing failed loudly: the body -- with the canvas
and the parameter form inside it -- was left parentless, visible but never mapped, 0x0,
drawing nothing. Two symptoms, one cause: the canvas is blank, and the window is sized
around the head row and the buttons alone.

Measured against the pre-move revision in a worktree, same probe, same environment:
before this fix `canvas=0x0 mapped=0 window=756x940`; after it `canvas=420x836 mapped=1
window=938x940`, which is the old revision's own reading exactly.

It survived because that slice's probe read the editor's *values* (nodes, gain, output,
name, unit) and nothing about its structure. Every other panel moved that day has a
structural reading -- `controls_inside=13/13`, `nested=1`, `labels=2 list=1` -- and none
of them was affected. `[math-probe] editor frame:` is that reading now: the canvas's
allocation, its mapped state, and whether the canvas and the name entry are inside the
dialog at all, delayed 500 ms because an allocation only exists after a layout. In the
broken build it read `0x0 mapped=0 in_dialog=0`.

check_ui passes both files: the XML was valid and every property type-checked.
2026-10-12 19:35:39 +02:00
13 changed files with 687 additions and 11 deletions

No files matched your search

+30
View File
@@ -335,3 +335,33 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
reads the *reverse* of the file: `header-bar.ui` declares menu, sidebar, math, measures, cursors and the reads the *reverse* of the file: `header-bar.ui` declares menu, sidebar, math, measures, cursors and the
screen shows cursors, measures, math, sidebar, menu. The three comments this replaced each had it wrong screen shows cursors, measures, math, sidebar, menu. The three comments this replaced each had it wrong
once, which is why the order is the file's now and a byte-for-byte diff of the probe is the check. once, which is why the order is the file's now and a byte-for-byte diff of the probe is the check.
- **A widget can end up in the wrong container, and only a *structural* read notices.** `math_body` was
written as a `<child>` of the *window* instead of the box above it when the math editor's frame moved into a
`.ui`. A `GtkWindow` takes exactly one default `<child>`, so nothing failed: the body -- with the canvas and
the parameter form inside it -- was left parentless, `visible` but never `mapped`, size `0x0`, drawing
nothing. The symptom was "the editor opens and the canvas is blank", with the window sized around its
remaining children. `check_ui` passes such a file; `xml-stripblanks` passes it; so does every probe that
reads *values* off the widgets inside. What catches it is a reading that walks *up* from the widget
(`in_dialog=1`) or asks whether it was ever given an allocation (`mapped=1`, `canvas=420x836`). When a frame
moves into a file, the probe that proves it has to read the frame's *structure*, not only the numbers it
holds.
- **A popover's autohide is only as good as its grab.** `GtkPopover` dismisses itself when it loses the
pointer grab -- and the grab is the thing that can be missing: a window manager moving it, a nested popover
(a `GtkDropDown`'s menu, which the time-window row has) taking it, and *every* headless run, where there is
no manager to hand one out and the probes have to turn autohide off before a popover will stay up to be
measured. The symptom is "sometimes it is awkward to close": a click anywhere else leaves the bubble up.
`on_window_popover_press()` states the rule outright instead -- any press that reaches the window's own
surface closes the window popover. That needs no hit test beyond the button, because a popover *is* its own
surface and a click inside it never arrives; the button is left to its own click, whose toggle is what
closes it. `[windowpop]` reads both halves, with autohide off so it is testing the rule and not the grab.
- **A notice shares the status line with the glance, so it has to *look* like a message.** The status bar
renders `app.notice + " | " + glance` in one label, which is exactly where a reader skimming the
routine text ("live · 9.97 kS/s · 2 s window") will not see it: a refused drop that said "plot 1 holds four
signals already" was read as no feedback at all. While a notice is fresh the label carries the stock
`.warning` class, so a message looks like a message and the class goes when the notice does. Two corollaries
worth knowing: the line composes on its own cadence, so a notice can appear a beat after it was set (do not
read the label in the same tick a handler ran), and a refusal should say what to *do* about it -- "drop it on
another plot, or add a row with the layout editor (L)" rather than the bare statement of the limit.
+49 -1
View File
@@ -36,12 +36,40 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
`visible_samples()` reading row 0's axis after the window was destroyed, and `visible_samples()` reading row 0's axis after the window was destroyed, and
`UDPSCOPE_EXIT_AFTER_MS` arms the trigger first, so the dump can land in `Waiting`. The live `UDPSCOPE_EXIT_AFTER_MS` arms the trigger first, so the dump can land in `Waiting`. The live
`ch N '<name>' ... y=[..]` lines are the race-free reading of the same thing. `ch N '<name>' ... y=[..]` lines are the race-free reading of the same thing.
- **A file view has no producer, so a structural change would leave it empty.** `set_math_channels()`
rebuilds the channels *and* the buffer, and a rebuild drops the history: right for a stream, whose producer
refills it from the next packet, and wrong for a file, which has nothing to refill it with. Adding a math
channel to an open waveform therefore emptied *every* channel -- `samples=0` on the sources too, which a user
reads as "the plot is broken" -- and the same emptiness follows any structural change in a file view.
`reload_file_view()` (in `ui.cpp`) re-reads the file after such a change, which is also what computes the
derived channels over it: the load's own pushes run the math once the set is in place. It fires only when the
buffer was *really* dropped -- `stored_slots() > 0` means the change found nothing to do -- and it says so
on stderr (`reloaded <path> after a structural change`), because a second read of a file is worth
accounting for. `set_math_channels()` in turn treats an identical list as no change at all, so a no-op
application (the session apply, a re-open) neither rebuilds nor drops nor re-reads; the comparison leaves the
nodes' `x`/`y` out on purpose, since those are the canvas's own and no op reads them -- a graph that only
*moved* is not a change, and rebuilding for it would drop the buffer to store two numbers. It is why `--open`
works at all with a session that defines math channels, since `main()` loads the file *before* the UI (and
so before the session's graphs) exists.
- **Every path that fills a store must skip a derived channel, not just the packet path.**
`ChannelInfo::math` states the rule for the ingest; `push_loaded_slot()` broke it, so loading a file pushed
its own value into each derived ring and `push_math_sample()` then pushed the computed one: two samples per
slot, i.e. a trace at double rate. The reading that catches it is the one already in this file --
`math '<name>': N samples vs M time slots` must be **equal** -- and a file view now has to be read as well
as a live one (`--open <the file>` with `UDPSCOPE_PROBE_MATH_MS`). An export writes every *plotted* channel,
so a file carries the derived ones as ordinary columns: those are real channels and are filled normally,
which is why the fix is in the push and not in the loader.
`math cost: S slots, N ns/slot (T timed), X ms total est` is the per-slot cost; see the `math cost: S slots, N ns/slot (T timed), X ms total est` is the per-slot cost; see the
math-cost invariant below for why it is sampled, why it is an upper bound, and why a figure math-cost invariant below for why it is sampled, why it is an upper bound, and why a figure
anywhere in 78--101 ns/slot is one reading of the same thing. anywhere in 78--101 ns/slot is one reading of the same thing.
The probe also asserts the new editor mechanics. `dialog build:` reads the identity row back as well -- The probe also asserts the new editor mechanics. `dialog build:` reads the identity row back as well --
`name='math3' unit=''` -- which is the pair the `.ui` holds the widgets for and the code fills from the `name='math3' unit=''` -- which is the pair the `.ui` holds the widgets for and the code fills from the
channel being edited, so a row that stopped being the one the code writes shows there. Each line carries channel being edited, so a row that stopped being the one the code writes shows there.
`editor frame:` is the same idea one level down: it reads the canvas's allocation, whether that canvas is
*mapped*, and whether the canvas and the name entry are inside the dialog at all -- delayed by 500 ms, because
an allocation only exists after a layout. A canvas that is visible but parentless never maps and stays `0x0`,
and reading values off the widgets *inside* the editor cannot see that. It is the reading that catches
`math_body` written as a child of the window instead of the box above it. Each line carries
`(want ...)` and the reading `(want ...)` and the reading
taken from a real run is the reference: taken from a real run is the reference:
* `[math-probe] hit: inside node=0 which=-1, past-edge node=0 which=-1 (want 0 -1 0 -1)` — the * `[math-probe] hit: inside node=0 which=-1, past-edge node=0 which=-1 (want 0 -1 0 -1)` — the
@@ -252,3 +280,23 @@ filter's history remains the last *real* samples it saw, and the trace resumes a
with at most the transient a re-start costs. The exit dump's `math '<name>': finite=N/M` line is with at most the transient a re-start costs. The exit dump's `math '<name>': finite=N/M` line is
the assertion: `finite` must be about `M` minus the hole share, and `finite=0` beside non-zero the assertion: `finite` must be about `M` minus the hole share, and `finite=0` beside non-zero
`hole_slots` is this defect returned. `hole_slots` is this defect returned.
- **A graph *edit* does not move the plan; adding or removing a channel does.** The plan is a partition over
the channels, so `set_math_channels()` takes a surgical path whenever the *set* is unchanged -- same channels,
names and enables: the store is never touched, every other channel keeps its samples **and** its runtimes, and
only the edited graphs are prepared again. `math_apply_locked()` computes the whole list on a recompute
(the graphs are walked in order, so a channel's inputs must be there) but writes only the channels in the
affected mask; the affected set is the edited channels plus whatever *names* them, since one channel's output
is another's input. Before this, every edit rebuilt the store and every channel in the window started again,
which is the "modifying one expression invalidates all the others" report.
`[math-probe] graph edit: stored_slots N -> M` reads it: M must be at least N -- the store keeps filling
while the edit runs -- and never 0.
- **A math channel whose source is switched off is switched off with it.** `Source` resolves to a *channel*, so
a derived channel cannot be computed from a signal nobody acquires: left enabled it would silently compute
nothing, which is what "all math signals depending on it do not compute anymore" looked like from outside.
`on_signal_switch()` disables the channels that read that signal -- **transitively**, since one math channel
may name another -- through the same path the pane's own switch uses (`MathChannel::enabled` and
`apply_math_channels()`), and takes their traces off the plots along with the signal's. The channels that do
not read it are untouched: `[enable-all-probe]`'s `math_on=[…]` is the half that reads this. Re-enabling the
signal does **not** switch them back on, for the same reason re-enabling does not restore the traces.
@@ -173,3 +173,12 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
because `relayout_areas()` is the one place that builds a figure and re-points the area at it. because `relayout_areas()` is the one place that builds a figure and re-points the area at it.
`UDPSCOPE_PROBE_COLOR_MS` reads the result back out of `plot::Plot::trace()` and out of the `UDPSCOPE_PROBE_COLOR_MS` reads the result back out of `plot::Plot::trace()` and out of the
chip's own markup, since asking the resolver twice would assert nothing. chip's own markup, since asking the resolver twice would assert nothing.
- **Switching a signal off takes its traces off every plot.** The switch means "do not acquire this", and a
trace left behind draws nothing: it holds one of the four slots a plot has, reads `--`, and blocks the drop
that would fill it with something live. `on_signal_switch()` clears every slot by the **signal name** the
slot remembers -- never the channel index, which is exactly what the enable change renumbers -- and says what
it did in a notice, because a trace that vanishes silently is its own kind of confusing. The follow-ups (a
relayout, the panels' chips, the layout save) are done once by the caller rather than per slot. Re-enabling
does **not** put the traces back: the arrangement is the user's, and guessing where they were is worse than
leaving them off.
+61
View File
@@ -1214,3 +1214,64 @@ builds, plus `nested` — the list being *inside* that revealer, which is the on
(a list parented elsewhere reads 1 everywhere else). `[events]` is the other half, and it drives the pane end (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 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. replay, then Compare and Accumulate — so the file's buttons are read by being used.
## `[windowpop]`
```
[windowpop] opened=1 visible=1 click_gestures=2
[windowpop] after a press elsewhere: active=0 (want 0)
[windowpop] after a press on the button: active=1 (want 1)
```
`UDPSCOPE_PROBE_WINDOWPOP_MS`: the time-window popover and how it closes. Autohide is turned *off* for the
probe, exactly as the menu probe does it -- a popover dismisses itself when GTK holds its *grab*, and a bare
`xvfb-run` has no window manager to hand one out -- so what is measured is the state a user meets when the
grab goes missing, where the press has to close the bubble anyway. `click_gestures` reads that the controller
is *connected* as well as that its handler works; the last press is on the button itself, which must be left
to its own toggle (`want 1` rather than `0`).
## `[drop-probe]` and what a refused drop says
```
[drop-probe] full row refused the drop: 0 (want 0)
[drop-probe] refused feedback: notice='plot 1 holds four signals already: drop it on another plot, or add a row with the layout editor (L)' warning=1
[drop-probe] the status line, composed: warning=1 status='plot 1 holds four signals already: ... | live · 9.97 kS/s · 2 s window'
```
`UDPSCOPE_PROBE_DROP_MS`. The first line is the refusal itself (a full row takes no more signals); the
second is that it *said so* -- the message and the stock `.warning` class the status line carries while a
notice is fresh; the third is the composed line the user reads, which is why the label is only checked here
and not in the second: the status line folds the notice into itself on its own cadence, so a beat after the
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
[enable-all-probe] switched 'sine' off: traces_left=0 (want 0) notice=''sine' is off: 1 trace left the plots'
```
`UDPSCOPE_ENABLE_ALL_MS` enables every signal and assigns the acquired channels across the rows, then
switches one off **through its own acquire tick in the signal list** -- the tick is a box inside the row, which
is the shape `signal_tick_visible()` walks -- and reads the plots back. `traces_left=0` is the rule: a disabled
signal leaves every plot rather than staying as a slot that draws nothing, and the notice is what says so.
The switch step runs late, so that a math probe armed *behind* this one has installed its graphs by then; the
two halves after it are the other rule. `math_off` is the derived channels that read this signal -- they cannot
be computed from a signal nobody acquires, so they go off with it -- and `math_on` is the ones that do not,
which must be untouched:
```
[enable-all-probe] switched 'sine' off: traces_left=0 (want 0) math_off=[math x2,math3] math_on=[math lp] notice=''sine' is off: 1 trace left the plots; math off: math x2 and 1 more'
```
## `[math-probe] graph edit:`
```
[math-probe] graph edit: stored_slots 800 -> 900 (want the second at least the first, and non-zero)
```
`UDPSCOPE_PROBE_MATH_MS` applies an edit to one graph through the editor. The plan is a partition over the
channels and an edit does not move it, so the store survives: the count *grows* (the stream keeps filling
while the edit runs) and never falls to 0, which is what a rebuild would do. Every channel then still reads
`N samples vs M time slots` with `N == M`, so no channel was left behind either.
+16
View File
@@ -58,6 +58,17 @@ struct MathNode {
double hi = 1.0; double hi = 1.0;
double x = 0.0; ///< the editor's box position, logical pixels. double x = 0.0; ///< the editor's box position, logical pixels.
double y = 0.0; double y = 0.0;
/// Whether two nodes are the same node for the *engine's* purposes.
///
/// `x`/`y` are the canvas's own and no op reads them, so a graph that only moved is not a change:
/// rebuilding for it replaces the buffer and drops its history -- and in a file view that means
/// re-reading the file -- to store two numbers nothing but the editor draws with.
bool operator==(const MathNode& other) const {
return op == other.op && source == other.source && in_a == other.in_a && in_b == other.in_b &&
gain == other.gain && offset == other.offset && fc == other.fc && q == other.q &&
lo == other.lo && hi == other.hi;
}
}; };
/// A derived channel: a graph of nodes and which node is the answer. /// A derived channel: a graph of nodes and which node is the answer.
@@ -67,6 +78,11 @@ struct MathChannel {
bool enabled = false; bool enabled = false;
std::vector<MathNode> nodes; std::vector<MathNode> nodes;
int output = -1; ///< the node whose value is the channel's. int output = -1; ///< the node whose value is the channel's.
bool operator==(const MathChannel& other) const {
return name == other.name && unit == other.unit && enabled == other.enabled &&
output == other.output && nodes == other.nodes;
}
}; };
/// The per-channel runtime: the filter memory and the per-node scratch. Kept apart from the /// The per-channel runtime: the filter memory and the per-node scratch. Kept apart from the
+129 -4
View File
@@ -257,6 +257,65 @@ void ScopeModel::append_math_signals_locked() {
void ScopeModel::set_math_channels(std::vector<MathChannel> channels) { void ScopeModel::set_math_channels(std::vector<MathChannel> channels) {
std::lock_guard<std::mutex> lock(mutex_); std::lock_guard<std::mutex> lock(mutex_);
// An identical list is not a change. The rebuild below replaces the buffer and drops its history --
// which a file view then has to re-read from disk -- and `apply_math_channels()` runs this on every
// session start, where a session with no graphs hands over an empty list against a model that has
// none either. Comparing the graphs is what makes that call free.
if (channels == math_) return;
// An edit of an existing graph -- the same channels, names and enables -- does not move the *plan*, which
// is a partition over the channels. Nothing has to be rebuilt, and every other channel keeps its history:
// only the edited graphs, and the channels that read them, are recomputed.
bool same_set = channels.size() == math_.size();
for (std::size_t i = 0; same_set && i < channels.size(); ++i) {
same_set = channels[i].name == math_[i].name && channels[i].enabled == math_[i].enabled;
}
if (same_set) {
const auto math_index_of = [this](const std::string& name) -> int {
for (std::size_t k = 0; k < math_.size(); ++k) {
if (math_[k].name == name) return static_cast<int>(k);
}
return -1;
};
std::vector<bool> affected(math_.size(), false);
std::vector<bool> edited(math_.size(), false);
for (std::size_t i = 0; i < channels.size(); ++i) {
if (!(channels[i] == math_[i])) {
affected[i] = true;
edited[i] = true;
}
}
// A channel's output is another's *input*, so whatever names a changed channel is recomputed too -- and
// nothing else is touched at all.
for (bool moved = true; moved;) {
moved = false;
for (std::size_t i = 0; i < channels.size(); ++i) {
if (!affected[i]) continue;
for (const MathNode& node : channels[i].nodes) {
if (node.op != MathOp::Source) continue;
const int k = math_index_of(node.source);
if (k >= 0 && !affected[static_cast<std::size_t>(k)]) {
affected[static_cast<std::size_t>(k)] = true;
moved = true;
}
}
}
}
math_ = std::move(channels);
const auto index_of = [this](const std::string& name) -> int {
for (std::size_t c = 0; c < channels_.size(); ++c) {
if (channels_[c].name == name) return static_cast<int>(c);
}
return -1;
};
// Every affected channel is prepared again: a runtime carried over from the old graph is history for a
// filter nobody asked for, and one whose *inputs* changed is history for inputs it no longer has.
for (std::size_t i = 0; i < math_.size(); ++i) {
if (affected[i]) math_prepare(math_[i], math_rt_[i], part_.slot_dt(), index_of);
}
recompute_math_locked(affected);
generation_.fetch_add(1, std::memory_order_release);
return;
}
math_ = std::move(channels); math_ = std::move(channels);
// Lift the previous synthetic entries out of the signal list; `append_math_signals_locked()` // Lift the previous synthetic entries out of the signal list; `append_math_signals_locked()`
// puts the current ones back, so an edit is the same kind of structural rebuild as a CONFIG. // puts the current ones back, so an edit is the same kind of structural rebuild as a CONFIG.
@@ -301,6 +360,10 @@ void ScopeModel::push_loaded_slot(const std::vector<f32>& values, f64 t, f64 abs
const std::size_t channels = channels_.size(); const std::size_t channels = channels_.size();
std::vector<f32> slot(channels, 0.0f); std::vector<f32> slot(channels, 0.0f);
for (std::size_t i = 0; i < channels; ++i) { for (std::size_t i = 0; i < channels; ++i) {
// A derived channel has no column of its own: `push_math_sample()` below fills it, and pushing the
// file's value here as well would put *two* samples in its ring per slot -- a trace at double rate.
// The same rule the packet path states in `ChannelInfo::math`: the ingest must skip it.
if (channels_[i].math) continue;
const f32 v = i < values.size() ? values[i] : 0.0f; const f32 v = i < values.size() ? values[i] : 0.0f;
store.values(i).push(v); store.values(i).push(v);
slot[i] = v; slot[i] = v;
@@ -310,7 +373,18 @@ void ScopeModel::push_loaded_slot(const std::vector<f32>& values, f64 t, f64 abs
push_math_sample(slot); push_math_sample(slot);
} }
std::uint64_t ScopeModel::stored_slots() {
std::lock_guard<std::mutex> lock(mutex_);
return store_ ? store_->time().size() : 0;
}
void ScopeModel::push_math_sample(const std::vector<f32>& sample) { void ScopeModel::push_math_sample(const std::vector<f32>& sample) {
// The producer's entry: every derived channel, and timed (see the cost sampling below).
math_apply_locked(sample, nullptr, true);
}
void ScopeModel::math_apply_locked(const std::vector<f32>& sample, const std::vector<bool>* write_mask,
bool timed) {
// The common case is no derived channels at all, and this runs once per *stored slot* -- so the // The common case is no derived channels at all, and this runs once per *stored slot* -- so the
// early-out is what keeps a stream with no math from paying for the feature. // early-out is what keeps a stream with no math from paying for the feature.
if (math_.empty()) return; if (math_.empty()) return;
@@ -319,9 +393,14 @@ void ScopeModel::push_math_sample(const std::vector<f32>& sample) {
// slot is itself able to drop packets, and timing one slot at a time charges it a whole clock // slot is itself able to drop packets, and timing one slot at a time charges it a whole clock
// read. A run amortises both: one pair of reads per 64 measured slots, one pair per 65536 slots // read. A run amortises both: one pair of reads per 64 measured slots, one pair per 65536 slots
// overall, and the figure converges over the millions this counter sees. // overall, and the figure converges over the millions this counter sees.
const std::uint64_t slot = math_slot_count_++; // The sampling counts *producer* slots: a recompute must not inflate `math_slots_`, which the dump
math_slots_.store(math_slot_count_, std::memory_order_relaxed); // reports as the run's own cadence.
const bool timed = slot % kMathCostSample == 0; std::uint64_t slot = 0;
if (timed) {
slot = math_slot_count_++;
math_slots_.store(math_slot_count_, std::memory_order_relaxed);
timed = slot % kMathCostSample == 0;
}
if (timed) math_cost_t0_ = now_ns(); if (timed) math_cost_t0_ = now_ns();
// Values for this slot, extended as the derived ones are computed: a graph may name another math // Values for this slot, extended as the derived ones are computed: a graph may name another math
// channel, and nodes are evaluated in list order, so by the time one is asked for it is there. // channel, and nodes are evaluated in list order, so by the time one is asked for it is there.
@@ -334,7 +413,11 @@ void ScopeModel::push_math_sample(const std::vector<f32>& sample) {
if (channel < 0 || m >= math_rt_.size()) continue; if (channel < 0 || m >= math_rt_.size()) continue;
const double out = const double out =
math_step(math_[m], math_rt_[m], math_values_.data(), math_values_.size()); math_step(math_[m], math_rt_[m], math_values_.data(), math_values_.size());
live().values(static_cast<std::size_t>(channel)).push(static_cast<f32>(out)); // A recompute computes every channel -- the graphs are a list walked in order, so a channel's inputs
// have to be there -- but only the affected ones take the sample.
if (write_mask == nullptr || (*write_mask)[m]) {
live().values(static_cast<std::size_t>(channel)).push(static_cast<f32>(out));
}
if (static_cast<std::size_t>(channel) < math_values_.size()) { if (static_cast<std::size_t>(channel) < math_values_.size()) {
math_values_[static_cast<std::size_t>(channel)] = static_cast<f32>(out); math_values_[static_cast<std::size_t>(channel)] = static_cast<f32>(out);
} }
@@ -347,6 +430,48 @@ void ScopeModel::push_math_sample(const std::vector<f32>& sample) {
} }
} }
void ScopeModel::recompute_math_locked(const std::vector<bool>& affected) {
if (!store_ || math_.empty()) return;
BufferStore& store = *store_;
const std::size_t slots = static_cast<std::size_t>(store.time().size());
if (slots == 0) return;
std::vector<bool> derived(channels_.size(), false);
for (std::size_t m = 0; m < math_.size(); ++m) {
const int channel = m < math_channel_.size() ? math_channel_[m] : -1;
if (channel < 0) continue;
derived[static_cast<std::size_t>(channel)] = true;
// Only the affected channels start again. The rest keep their samples *and* their runtimes: their graphs
// are untouched, so the state they hold is the answer to the inputs they have always had.
if (affected[m]) store.values(static_cast<std::size_t>(channel)).clear();
}
// One bulk copy per channel per chunk, through the ring's own reader. A derived channel is fed a zero:
// `math_apply_locked()` overwrites its value as it walks the list in order, and reading its half-rebuilt
// ring here would read out of range.
constexpr std::size_t kChunk = 1u << 14;
const std::size_t base = static_cast<std::size_t>(store.time().base());
std::vector<std::vector<f32>> inputs(channels_.size());
for (std::size_t c = 0; c < channels_.size(); ++c) {
if (!derived[c]) inputs[c].resize(kChunk);
}
std::vector<f32> slot(channels_.size(), 0.0f);
for (std::size_t start = 0; start < slots; start += kChunk) {
const std::size_t n = std::min(kChunk, slots - start);
for (std::size_t c = 0; c < channels_.size(); ++c) {
if (derived[c]) continue;
store.values(c).copy_from(base + start, n, inputs[c].data());
}
for (std::size_t i = 0; i < n; ++i) {
for (std::size_t c = 0; c < channels_.size(); ++c) {
slot[c] = derived[c] ? 0.0f : inputs[c][i];
}
math_apply_locked(slot, &affected, /*timed=*/false);
}
}
// The pyramid is the consumer's and the tick publishes it; the samples are written here.
}
void ScopeModel::rebuild_channels() { void ScopeModel::rebuild_channels() {
channels_.clear(); channels_.clear();
// Which channel each math channel ended up with, by name: a disabled one has none, and the // Which channel each math channel ended up with, by name: a disabled one has none, and the
+13
View File
@@ -281,6 +281,19 @@ class ScopeModel {
/// evaluated from those, in order, so a math channel may use another one. /// evaluated from those, in order, so a math channel may use another one.
void push_math_sample(const std::vector<f32>& sample); void push_math_sample(const std::vector<f32>& sample);
/// Applies the graphs to one stored slot. `write_mask`, when given, names the derived channels whose
/// rings may be written -- `nullptr` writes all of them, which is what the producer does; a recompute
/// passes the channels a graph change actually affects, so the others keep the samples they have.
void math_apply_locked(const std::vector<f32>& sample, const std::vector<bool>* write_mask, bool timed);
/// Recomputes the derived channels in `affected` from the samples the store already holds: their rings are
/// rebuilt from scratch, and the channels a chart change does *not* touch are left exactly as they were.
void recompute_math_locked(const std::vector<bool>& affected);
/// How many slots the store currently holds. A file view reads it to tell a structural change that
/// dropped the buffer (0) from one that could not have (see `reload_file_view()` in ui.cpp).
std::uint64_t stored_slots();
/// Pushes one stored slot of a **file** load -- the values, the shared clock and the absolute /// Pushes one stored slot of a **file** load -- the values, the shared clock and the absolute
/// clock -- and then the derived channels, in the producer's own order and under the model's own /// clock -- and then the derived channels, in the producer's own order and under the model's own
/// lock. The loader cannot simply hold the lock and push into `live()` itself: every setter it /// lock. The loader cannot simply hold the lock and push into `live()` itself: every setter it
+133
View File
@@ -2534,6 +2534,63 @@ void remove_trace(App& app, int row, int slot) {
app.rows->assign(row, slot, std::string(), std::string(), 0); app.rows->assign(row, slot, std::string(), std::string(), 0);
} }
/// Disables every math channel that (transitively) reads `name`, and answers with their names.
///
/// A derived channel cannot be computed from a signal nobody acquires: `Source` resolves to a *channel*, and a
/// disabled signal has none -- so a channel left enabled would silently compute nothing. The rule is that the
/// channels which depend on a signal follow it off, which is also what the pane's own switch would do by hand.
/// The dependency is transitive: one math channel may name another, so a channel reading a disabled one goes
/// too, and the loop runs to a fixpoint for that.
std::vector<std::string> disable_dependent_math_channels(App& app, const std::string& name) {
std::vector<std::string> off;
if (name.empty()) return off;
const auto math_index_of = [&app](const std::string& n) -> int {
for (std::size_t k = 0; k < app.math.size(); ++k) {
if (app.math[k].name == n) return static_cast<int>(k);
}
return -1;
};
for (bool moved = true; moved;) {
moved = false;
for (MathChannel& channel : app.math) {
if (!channel.enabled) continue;
for (const MathNode& node : channel.nodes) {
if (node.op != MathOp::Source) continue;
const int k = math_index_of(node.source);
const bool reads_it = node.source == name;
const bool reads_a_disabled_one = k >= 0 && !app.math[static_cast<std::size_t>(k)].enabled;
if (!reads_it && !reads_a_disabled_one) continue;
channel.enabled = false;
off.push_back(channel.name);
moved = true;
break;
}
}
}
return off;
}
/// Takes a signal off every plot, and says how many traces went.
///
/// Switching a signal off means "do not acquire this", so a trace of it left on a plot draws nothing: a slot
/// that reads `--`, takes the place a live signal could have had, and blocks the drop that would fill it. The
/// slot is what is cleared, and the caller does the follow-ups once (a relayout, the panels, the layout save)
/// rather than per slot.
int remove_signal_from_plots(App& app, const std::string& name) {
if (name.empty()) return 0;
int removed = 0;
for (int row = 0; row < app.rows->row_count(); ++row) {
for (int slot = 0; slot < RowSet::kSlotsPerRow; ++slot) {
// The slot remembers the *signal* (and its element), never the channel index -- the channel list is
// renumbered on every enable change, which is what this is reacting to.
if (app.rows->row(row).signal_name[static_cast<std::size_t>(slot)] != name) continue;
remove_trace(app, row, slot);
++removed;
}
}
return removed;
}
void on_remove_clicked(GtkButton* button, gpointer user) { void on_remove_clicked(GtkButton* button, gpointer user) {
(void)user; (void)user;
auto* target = static_cast<RemoveTarget*>(g_object_get_data(G_OBJECT(button), "udpscope-remove")); auto* target = static_cast<RemoveTarget*>(g_object_get_data(G_OBJECT(button), "udpscope-remove"));
@@ -4009,10 +4066,16 @@ gboolean tick(gpointer user) {
} }
if (glance.size() > app->glance_max_chars) app->glance_max_chars = glance.size(); if (glance.size() > app->glance_max_chars) app->glance_max_chars = glance.size();
// A notice is not the glance. It is prefixed onto the same line, which is exactly where a reader
// skimming the routine text will not see it -- "there is no clear feedback" was that, with a refusal
// ("plot 2 holds four signals already") looking like part of the status. The stock `.warning` class is
// what makes a message look like a message, and it goes when the notice does.
if (!app->notice.empty() && g_get_monotonic_time() < app->notice_until_us) { if (!app->notice.empty() && g_get_monotonic_time() < app->notice_until_us) {
gtk_widget_add_css_class(app->status_label, "warning");
const std::string with_notice = app->notice + " | " + glance; const std::string with_notice = app->notice + " | " + glance;
gtk_label_set_text(GTK_LABEL(app->status_label), with_notice.c_str()); gtk_label_set_text(GTK_LABEL(app->status_label), with_notice.c_str());
} else { } else {
gtk_widget_remove_css_class(app->status_label, "warning");
app->notice.clear(); app->notice.clear();
gtk_label_set_text(GTK_LABEL(app->status_label), glance.c_str()); gtk_label_set_text(GTK_LABEL(app->status_label), glance.c_str());
} }
@@ -4114,6 +4177,32 @@ GtkWidget* build_window_control(App& app) {
return app.window_button; return app.window_button;
} }
/// Any press in the window closes the time-window popover.
///
/// `GtkPopover`'s autohide does this by itself for as long as GTK holds the popover's grab -- and the grab
/// is the thing that can go missing (a window manager moving it, a nested popover taking it for the unit
/// menu, and every headless run, where there is no manager to hand one out at all), after which a click
/// anywhere else in the window leaves the bubble up. That is the "sometimes it is awkward to close".
///
/// A press that reaches the window's own surface was *not* inside the popover: a popover is its own
/// surface, so a click within it never arrives here. That is what makes this the rule stated outright
/// rather than a grab away from working -- and it needs no hit test beyond leaving the button alone,
/// whose own click is what toggles the bubble shut.
void on_window_popover_press(GtkGestureClick*, int, double x, double y, gpointer user) {
App* app = static_cast<App*>(user);
if (app->window_button == nullptr ||
!gtk_menu_button_get_active(GTK_MENU_BUTTON(app->window_button))) {
return;
}
// The button itself is left to its own click, which toggles the bubble shut -- closing it here would
// only have the toggle open it again. Anything else on the window's surface closes it.
for (GtkWidget* w = gtk_widget_pick(app->window, x, y, GTK_PICK_DEFAULT);
w != nullptr && w != app->window; w = gtk_widget_get_parent(w)) {
if (w == app->window_button) return;
}
gtk_menu_button_popdown(GTK_MENU_BUTTON(app->window_button));
}
/// The one place a key turns into an action. Toggles go through /// The one place a key turns into an action. Toggles go through
/// gtk_toggle_button_set_active(), which emits ::toggled and so runs exactly the handler a /// gtk_toggle_button_set_active(), which emits ::toggled and so runs exactly the handler a
/// click would; the non-toggles call their click handlers with a null button, which they /// click would; the non-toggles call their click handlers with a null button, which they
@@ -4179,6 +4268,37 @@ gboolean on_app_key_pressed(GtkEventControllerKey*, guint keyval, guint, GdkModi
return TRUE; return TRUE;
} }
/// Re-reads the file a *file view* is showing; true when something was reloaded.
///
/// Any structural change -- a math channel added, a signal enabled, a plan change -- rebuilds the buffer
/// and drops its history. That is right for a stream, whose producer refills it from the next packet. A
/// file has no producer, so the same rebuild leaves *every* channel reading `0 samples` and the view is
/// simply empty: "adding a math channel breaks the plot" was this, and it is the same emptiness whichever
/// structural change caused it. The file's own path is the only thing that can refill it, so it is loaded
/// again -- which is also what computes the derived channels over it, since the load's own pushes run the
/// math once the set is in place.
bool reload_file_view(App& app) {
if (!app.replaying && app.model->config().open_waveform.empty()) return false;
// Nothing to restore: a change that really happened leaves the store empty in a file view -- the
// rebuild replaces it and there is no producer to refill it -- while one that found nothing to do
// leaves it full. Reading the file again for the second kind is the waste this avoids.
if (app.model->stored_slots() > 0) return false;
const std::string path =
!app.waveform_name.empty() ? app.waveform_name : app.model->config().open_waveform;
if (path.empty()) return false;
WaveformLoadReport report;
std::string error;
if (!load_waveform_into_model(*app.model, path, &report, &error)) {
set_notice(app, "cannot reload " + path + " after the change: " + error, 6);
return false;
}
// Said out loud because it is the one place a file is read twice: a structural change really did
// drop the buffer, and the file is the only thing that can put it back.
std::fprintf(stderr, "[udpscope] reloaded %s after a structural change: %llu samples back\n",
path.c_str(), static_cast<unsigned long long>(report.samples));
return true;
}
/// The header bar carries only the controls you reach for *while watching a signal*; /// The header bar carries only the controls you reach for *while watching a signal*;
/// everything you set up once lives in the overflow menu. Nine text buttons in a /// everything you set up once lives in the overflow menu. Nine text buttons in a
/// header bar is a wall of words to read past every time you want the one you need, /// header bar is a wall of words to read past every time you want the one you need,
@@ -4628,6 +4748,13 @@ void on_activate(GtkApplication* application, gpointer user) {
g_signal_connect(keys, "key-pressed", G_CALLBACK(on_app_key_pressed), app); g_signal_connect(keys, "key-pressed", G_CALLBACK(on_app_key_pressed), app);
gtk_widget_add_controller(app->window, keys); gtk_widget_add_controller(app->window, keys);
// Capture phase, unlike the keys above: a press belongs to whatever widget is under it (the plot's
// gestures among them), and the point here is to see every one of them, not to win none of them.
GtkGesture* press = gtk_gesture_click_new();
gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(press), GTK_PHASE_CAPTURE);
g_signal_connect(press, "pressed", G_CALLBACK(on_window_popover_press), app);
gtk_widget_add_controller(app->window, GTK_EVENT_CONTROLLER(press));
gtk_window_present(GTK_WINDOW(app->window)); gtk_window_present(GTK_WINDOW(app->window));
} }
@@ -5248,6 +5375,12 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_KEYS_MS")) { if (const char* env = g_getenv("UDPSCOPE_PROBE_KEYS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), keys_probe, &app); g_timeout_add(static_cast<guint>(std::atoi(env)), keys_probe, &app);
} }
if (const char* env = g_getenv("UDPSCOPE_PROBE_WINDOWPOP_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), windowpop_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_KEYS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), keys_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_COLOR_MS")) { if (const char* env = g_getenv("UDPSCOPE_PROBE_COLOR_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), color_probe, &app); g_timeout_add(static_cast<guint>(std::atoi(env)), color_probe, &app);
} }
+2 -2
View File
@@ -46,8 +46,6 @@
</child> </child>
</object> </object>
</child> </child>
</object>
</child>
<child> <child>
<object class="GtkBox" id="math_body"> <object class="GtkBox" id="math_body">
<property name="orientation">horizontal</property> <property name="orientation">horizontal</property>
@@ -80,5 +78,7 @@
</child> </child>
</object> </object>
</child> </child>
</object>
</child>
</object> </object>
</interface> </interface>
+11
View File
@@ -1189,6 +1189,10 @@ void place_measure_marks(App& app);
void place_search_marks(App& app); void place_search_marks(App& app);
void update_measures(App& app, bool force, bool accumulate); void update_measures(App& app, bool force, bool accumulate);
void apply_math_channels(App& app); void apply_math_channels(App& app);
/// Re-reads the file a file view is showing after a structural change rebuilt the buffer and dropped
/// its history -- see the definition in ui.cpp. True when something was reloaded.
bool reload_file_view(App& app);
void add_math_channel(App& app); void add_math_channel(App& app);
std::vector<std::string> math_source_names(App& app); std::vector<std::string> math_source_names(App& app);
std::string math_dialog_problem(const MathDialog* d); std::string math_dialog_problem(const MathDialog* d);
@@ -1246,6 +1250,8 @@ void on_y_auto_clicked(GtkButton* button, gpointer user);
void on_fixed_range_apply(GtkButton*, gpointer user); void on_fixed_range_apply(GtkButton*, gpointer user);
void on_y_fixed_clicked(GtkButton* button, gpointer user); void on_y_fixed_clicked(GtkButton* button, gpointer user);
void remove_trace(App& app, int row, int slot); void remove_trace(App& app, int row, int slot);
int remove_signal_from_plots(App& app, const std::string& name);
std::vector<std::string> disable_dependent_math_channels(App& app, const std::string& name);
void on_remove_clicked(GtkButton* button, gpointer user); void on_remove_clicked(GtkButton* button, gpointer user);
GtkWidget* menu_icon_button(const char* icon, const char* label); GtkWidget* menu_icon_button(const char* icon, const char* label);
void on_row_menu_show_remove(GtkButton*, gpointer user); void on_row_menu_show_remove(GtkButton*, gpointer user);
@@ -1345,6 +1351,11 @@ gboolean zoomkeys_probe(gpointer user);
void menus_probe_next(App& a, int step, guint delay_ms); void menus_probe_next(App& a, int step, guint delay_ms);
void menu_row_labels(GtkWidget* box, GtkWidget** label, GtkWidget** accel); void menu_row_labels(GtkWidget* box, GtkWidget** label, GtkWidget** accel);
gboolean menus_probe_step(gpointer user); gboolean menus_probe_step(gpointer user);
gboolean windowpop_probe_step(gpointer user);
/// Closes the time-window popover when a press lands elsewhere in the window (see ui.cpp). Declared for
/// the probe, which drives it and presses the button to check the one case that must *not* close it.
void on_window_popover_press(GtkGestureClick* gesture, int n_press, double x, double y, gpointer user);
gboolean color_probe(gpointer user); gboolean color_probe(gpointer user);
gboolean reseq_probe(gpointer user); gboolean reseq_probe(gpointer user);
void capture_probe_next(App& a, int step, guint delay_ms); void capture_probe_next(App& a, int step, guint delay_ms);
+4
View File
@@ -18,6 +18,10 @@ void apply_math_channels(App& app) {
// `set_math_channels()` takes the model's lock itself -- holding it here is a self-deadlock on a // `set_math_channels()` takes the model's lock itself -- holding it here is a self-deadlock on a
// non-recursive mutex, and it is exactly how the first version of the probe hung the app. // non-recursive mutex, and it is exactly how the first version of the probe hung the app.
app.model->set_math_channels(app.math); app.model->set_math_channels(app.math);
// The structural rebuild above replaced the buffer and dropped its history. A stream's producer
// refills that; a file's nothing does, so the view is re-read -- and with the math set now in place
// the reload computes the derived channels over the file as it goes.
reload_file_view(app);
rebuild_math_pane(app); rebuild_math_pane(app);
schedule_layout_save(app); schedule_layout_save(app);
} }
+195
View File
@@ -386,6 +386,35 @@ gboolean math_probe(gpointer user) {
if (ed->gain != nullptr) gtk_spin_button_set_value(GTK_SPIN_BUTTON(ed->gain), 2.0); if (ed->gain != nullptr) gtk_spin_button_set_value(GTK_SPIN_BUTTON(ed->gain), 2.0);
if (ed->offset != nullptr) gtk_spin_button_set_value(GTK_SPIN_BUTTON(ed->offset), 0.0); if (ed->offset != nullptr) gtk_spin_button_set_value(GTK_SPIN_BUTTON(ed->offset), 0.0);
on_math_param_changed(nullptr, ed); on_math_param_changed(nullptr, ed);
// The frame, structurally: the canvas being *in* the dialog and given an allocation. A `.ui`
// mistake this has already paid for once -- `math_body` nested under the window instead of the
// outer box -- leaves the canvas visible but parentless: it never maps, stays 0x0 and draws
// nothing, which a user reads as "the editor opens and the canvas is blank". The values below
// cannot see that, and the reading is delayed because an allocation only exists after a layout.
g_timeout_add(500, +[](gpointer u) -> gboolean {
App* app = static_cast<App*>(u);
if (app->math_dialog == nullptr) {
std::fprintf(stderr, "[math-probe] editor frame: (dialog closed before the reading)\n");
return G_SOURCE_REMOVE;
}
MathDialog* m = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(app->math_dialog), "udpscope-math-dialog"));
if (m == nullptr) return G_SOURCE_REMOVE;
const auto in_dialog = [app](GtkWidget* w) {
for (GtkWidget* p = w; p != nullptr; p = gtk_widget_get_parent(p)) {
if (p == app->math_dialog) return 1;
}
return 0;
};
std::fprintf(stderr,
"[math-probe] editor frame: canvas=%dx%d mapped=%d in_dialog=%d "
"name_in_dialog=%d\n",
m->canvas != nullptr ? gtk_widget_get_width(m->canvas) : -1,
m->canvas != nullptr ? gtk_widget_get_height(m->canvas) : -1,
m->canvas != nullptr ? gtk_widget_get_mapped(m->canvas) : -1,
in_dialog(m->canvas), in_dialog(m->name));
return G_SOURCE_REMOVE;
}, a);
const std::size_t built = ed->nodes.size(); const std::size_t built = ed->nodes.size();
const double gain_read = ed->nodes.empty() ? 0.0 : ed->nodes.back().gain; const double gain_read = ed->nodes.empty() ? 0.0 : ed->nodes.back().gain;
const double offset_read = ed->nodes.empty() ? 0.0 : ed->nodes.back().offset; const double offset_read = ed->nodes.empty() ? 0.0 : ed->nodes.back().offset;
@@ -395,7 +424,15 @@ gboolean math_probe(gpointer user) {
ed->name != nullptr ? gtk_editable_get_text(GTK_EDITABLE(ed->name)) : "-"; ed->name != nullptr ? gtk_editable_get_text(GTK_EDITABLE(ed->name)) : "-";
const std::string unit_read = const std::string unit_read =
ed->unit != nullptr ? gtk_editable_get_text(GTK_EDITABLE(ed->unit)) : "-"; ed->unit != nullptr ? gtk_editable_get_text(GTK_EDITABLE(ed->unit)) : "-";
// The edit must not cost the buffer. The plan is a partition over the channels and an edit does not
// move it, so every channel keeps its history and only the edited graph -- and whatever reads it -- is
// recomputed. Before this, `set_math_channels()` rebuilt the store and every channel started again.
const std::uint64_t slots_before = a->model->stored_slots();
on_math_apply(nullptr, ed); // frees the dialog: nothing below may touch `ed` on_math_apply(nullptr, ed); // frees the dialog: nothing below may touch `ed`
const std::uint64_t slots_after = a->model->stored_slots();
std::fprintf(stderr, "[math-probe] graph edit: stored_slots %llu -> %llu (want the second at least the first, and non-zero)\n",
static_cast<unsigned long long>(slots_before),
static_cast<unsigned long long>(slots_after));
const std::size_t saved = a->math.empty() const std::size_t saved = a->math.empty()
? 0u ? 0u
: a->math[static_cast<std::size_t>(index)].nodes.size(); : a->math[static_cast<std::size_t>(index)].nodes.size();
@@ -1835,6 +1872,64 @@ gboolean enable_all_probe(gpointer user) {
std::fprintf(stderr, "[enable-all-probe] %zu channel(s) enabled and assigned to plots\n", std::fprintf(stderr, "[enable-all-probe] %zu channel(s) enabled and assigned to plots\n",
a->model->channel_count()); a->model->channel_count());
std::fflush(stderr); 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.
g_timeout_add(2000, +[](gpointer u) -> gboolean {
App* app = static_cast<App*>(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;
}
}
}
if (tick == nullptr) {
std::fprintf(stderr, "[enable-all-probe] no acquire tick found for signal %zu\n", signal);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gtk_check_button_set_active(GTK_CHECK_BUTTON(tick), FALSE);
int left = 0;
for (int r = 0; r < app->rows->row_count(); ++r) {
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
if (app->rows->row(r).signal_name[static_cast<std::size_t>(s)] == name) ++left;
}
}
// Which *math* channels the disabled signal took with it: a derived channel cannot be computed from a
// signal nobody acquires, so the ones that read it follow it off -- and their traces go too.
std::string off_list;
for (const MathChannel& m : app->math) {
if (m.enabled) continue;
if (!off_list.empty()) off_list += ",";
off_list += m.name;
}
// Both halves: the channels that read it went off, and the ones that do not are still on.
std::string on_list;
for (const MathChannel& m : app->math) {
if (!m.enabled) continue;
if (!on_list.empty()) on_list += ",";
on_list += m.name;
}
std::fprintf(stderr,
"[enable-all-probe] switched '%s' off: traces_left=%d (want 0) math_off=[%s] "
"math_on=[%s] notice='%s'\n",
name.c_str(), left, off_list.c_str(), on_list.c_str(), app->notice.c_str());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}, a);
return G_SOURCE_REMOVE; return G_SOURCE_REMOVE;
} }
@@ -2471,6 +2566,63 @@ void menus_probe_next(App& a, int step, guint delay_ms) {
g_timeout_add(delay_ms, menus_probe_step, &a); g_timeout_add(delay_ms, menus_probe_step, &a);
} }
/// `UDPSCOPE_PROBE_WINDOWPOP_MS`: the time-window popover, closed by a press elsewhere in the window.
///
/// Autohide is turned off for the probe, exactly as the menu probe does it: a popover dismisses itself
/// when GTK holds its *grab*, and a bare `xvfb-run` has no window manager to hand one out -- so with
/// autohide on the bubble would not stay up to be tested. Autohide off is the state a user meets when
/// the grab goes missing, which is the report this guards: the press has to close it anyway. The second
/// half presses the button itself, which must *not* close it (its own click toggles).
gboolean windowpop_probe_step(gpointer user) {
static int step = 0;
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->window_button == nullptr) return G_SOURCE_REMOVE;
const auto active = [a] {
return gtk_menu_button_get_active(GTK_MENU_BUTTON(a->window_button)) ? 1 : 0;
};
if (step == 0) {
GtkPopover* pop = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->window_button));
if (pop != nullptr) gtk_popover_set_autohide(pop, FALSE);
gtk_menu_button_popup(GTK_MENU_BUTTON(a->window_button));
step = 1;
g_timeout_add(300, windowpop_probe_step, a);
return G_SOURCE_REMOVE;
}
// How many click gestures the window carries, so that "the handler works" and "the handler is
// connected" are read as two facts rather than one.
int clicks = 0;
{
GListModel* controllers = gtk_widget_observe_controllers(a->window);
const guint n = controllers != nullptr ? g_list_model_get_n_items(controllers) : 0;
for (guint i = 0; i < n; ++i) {
GObject* item = G_OBJECT(g_list_model_get_item(controllers, i));
if (item != nullptr) {
if (GTK_IS_GESTURE_CLICK(item)) ++clicks;
g_object_unref(item);
}
}
if (controllers != nullptr) g_object_unref(controllers);
}
std::fprintf(stderr, "[windowpop] opened=%d visible=%d click_gestures=%d\n", active(),
gtk_widget_is_visible(GTK_WIDGET(gtk_menu_button_get_popover(
GTK_MENU_BUTTON(a->window_button))))
? 1
: 0,
clicks);
on_window_popover_press(nullptr, 0, 20.0, 200.0, a);
std::fprintf(stderr, "[windowpop] after a press elsewhere: active=%d (want 0)\n", active());
gtk_menu_button_popup(GTK_MENU_BUTTON(a->window_button));
graphene_rect_t r = GRAPHENE_RECT_INIT(0, 0, 0, 0);
if (gtk_widget_compute_bounds(a->window_button, a->window, &r)) {
on_window_popover_press(nullptr, 0, r.origin.x + r.size.width / 2.0,
r.origin.y + r.size.height / 2.0, a);
}
std::fprintf(stderr, "[windowpop] after a press on the button: active=%d (want 1)\n", active());
gtk_menu_button_popdown(GTK_MENU_BUTTON(a->window_button));
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean menus_probe_step(gpointer user) { gboolean menus_probe_step(gpointer user) {
App* a = static_cast<App*>(user); App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE; if (a->window == nullptr) return G_SOURCE_REMOVE;
@@ -3281,6 +3433,49 @@ gboolean drop_probe(gpointer user) {
ok ? 1 : 0, a->rows->row(0).name[0].c_str(), a->rows->row(0).name[1].c_str(), ok ? 1 : 0, a->rows->row(0).name[0].c_str(), a->rows->row(0).name[1].c_str(),
a->rows->row(0).name[2].c_str(), a->rows->row(0).name[3].c_str()); a->rows->row(0).name[2].c_str(), a->rows->row(0).name[3].c_str());
} }
// Fill the row first, so that the drop below is the *refusal*: a drop can otherwise land in a slot
// whose signal is named but not acquired, which is a success rather than the case under test.
{
const std::vector<ChannelInfo>& channels = a->model->channels();
for (int slot = 0; slot < RowSet::kSlotsPerRow && !channels.empty(); ++slot) {
if (a->rows->slot_bound(0, slot)) continue;
const ChannelInfo& ch = channels[static_cast<std::size_t>(slot) % channels.size()];
a->rows->assign(0, slot, ch.name, ch.name, 0);
}
}
// The refusal's *feedback*, a tick later: the status line composes the notice into itself on the
// next tick, so this is where both the text and the class that makes it visible can be read.
g_timeout_add(500, +[](gpointer u) -> gboolean {
App* app = static_cast<App*>(u);
// And the drop that the full row has to refuse, fired here so its feedback is what the reading below
// sees rather than the last success's.
GValue refused_value = G_VALUE_INIT;
g_value_init(&refused_value, G_TYPE_STRING);
g_value_set_string(&refused_value, "1");
const gboolean refused = on_drop_channel(nullptr, &refused_value, 40.0, 20.0, app);
g_value_unset(&refused_value);
std::fprintf(stderr, "[drop-probe] full row refused the drop: %d (want 0)\n", refused ? 1 : 0);
// The message and its class, which are set the moment the drop is refused. The *label* is read in
// the later line instead: the status line composes the notice into itself on its own cadence, so
// here it may still hold the previous one.
std::fprintf(stderr, "[drop-probe] refused feedback: notice='%s' warning=%d\n",
app->notice.c_str(),
gtk_widget_has_css_class(app->status_label, "warning") ? 1 : 0);
std::fflush(stderr);
// Again a second and a half later: if the label is still the previous notice then the status line
// never recomposes it, which would be the whole of the "no clear feedback" report.
g_timeout_add(1500, +[](gpointer u) -> gboolean {
App* app = static_cast<App*>(u);
std::fprintf(stderr,
"[drop-probe] the status line, composed: warning=%d status='%s'\n",
gtk_widget_has_css_class(app->status_label, "warning") ? 1 : 0,
gtk_label_get_text(GTK_LABEL(app->status_label)));
std::fflush(stderr);
return G_SOURCE_REMOVE;
}, app);
return G_SOURCE_REMOVE;
}, a);
std::fflush(stderr); std::fflush(stderr);
return G_SOURCE_REMOVE; return G_SOURCE_REMOVE;
} }
+35 -4
View File
@@ -17,7 +17,35 @@ void on_signal_switch(GObject* obj, GParamSpec*, gpointer user) {
if (app->updating_panels) return; if (app->updating_panels) return;
const std::size_t index = static_cast<std::size_t>( const std::size_t index = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(obj, "udpscope-signal"))); GPOINTER_TO_SIZE(g_object_get_data(obj, "udpscope-signal")));
app->model->set_signal_enabled(index, gtk_check_button_get_active(GTK_CHECK_BUTTON(obj))); const bool on = gtk_check_button_get_active(GTK_CHECK_BUTTON(obj)) != FALSE;
// The name first: the switch may be the last reference to a signal the model is about to drop.
std::string name;
{
const std::vector<SignalInfo>& signals = app->model->signals();
if (index < signals.size()) name = signals[index].name;
}
app->model->set_signal_enabled(index, on);
if (!on) {
// A trace of a signal nobody acquires draws nothing, and holds the slot a live one could take.
int gone = remove_signal_from_plots(*app, name);
// ... and the derived channels that read it cannot be computed at all, so they follow it off, and their
// traces leave with them.
const std::vector<std::string> off = disable_dependent_math_channels(*app, name);
for (const std::string& channel : off) gone += remove_signal_from_plots(*app, channel);
if (!off.empty()) apply_math_channels(*app); // the single writer: the pane's switches follow it
if (gone > 0 || !off.empty()) {
app->relayout_pending = true;
rebuild_signal_panels(*app); // the chips of the signals that are still plotted
std::string what = "'" + name + "' is off: " + std::to_string(gone) +
(gone == 1 ? " trace left the plots" : " traces left the plots");
if (!off.empty()) {
// The channel names already read as what they are ("math x2"), so the sentence does not prefix them.
what += "; math off: " + off.front() +
(off.size() == 1 ? "" : " and " + std::to_string(off.size() - 1) + " more");
}
set_notice(*app, what, 5);
}
}
// The enable state is part of the session (a signal can be on only as a math channel's source). // The enable state is part of the session (a signal can be on only as a math channel's source).
schedule_layout_save(*app); schedule_layout_save(*app);
} }
@@ -290,8 +318,7 @@ gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y
if (signal >= app->model->signals().size()) return FALSE; if (signal >= app->model->signals().size()) return FALSE;
const auto notice = [&](const std::string& what) { const auto notice = [&](const std::string& what) {
app->notice = what; set_notice(*app, what, 4);
app->notice_until_us = g_get_monotonic_time() + 4 * G_TIME_SPAN_SECOND;
std::fprintf(stderr, "[udpscope] drop: %s\n", what.c_str()); std::fprintf(stderr, "[udpscope] drop: %s\n", what.c_str());
std::fflush(stderr); std::fflush(stderr);
}; };
@@ -334,7 +361,11 @@ gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y
} }
} }
if (slot < 0) { if (slot < 0) {
notice("plot " + std::to_string(row + 1) + " already holds four signals"); // Say what to do about it. A refusal a reader cannot act on is only half an answer: the two ways
// out are another plot or another row.
notice("plot " + 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 // The slot remembers the *signal* (and element), never the channel index: the