3 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 4003084648 feat: a signal's chip is clickable, so a trace's colour is the user's to choose
Clicking the coloured chip in the signal list opens a GtkColorDialog and the
colour it returns overrides that signal's palette entry. Held by name, like
every other identity here, and persisted as a session `[[color]]` table.

RowSet::signal_color() becomes the one resolver: install() builds the trace
through it and all three chip sites read it, because a chip exists to say which
trace a row is about and an override is exactly the state in which two routes to
a colour would disagree. A grey chip names no trace and so is not clickable, and
the way back is `Default colour` in the signal's menu -- a setting with no undo
is a trap.

The gesture sits on the existing label rather than in a GtkButton: padding on
this row is what made the signal list twice as tall once. It claims its sequence
so a click on the chip cannot become the start of a row drag.

UDPSCOPE_PROBE_COLOR_MS reads the result out of plot::Plot::trace() and out of
the chip's own markup, with an override in no palette slot, so neither leg can
pass by asking the resolver what it already said.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-07 08:22:32 +02:00
Martino Ferrari 8bc1fe8b5c fix: a repaired source is reconciled above the validation line, not below it
`math_dialog_sync_form()` wrote the label and then moved a node whose source
names an unacquired channel -- a mutation that changes what
`math_dialog_problem()` reports. With the sync re-entry now guarded, the nested
call that used to repair the label is suppressed, so the line kept describing a
source that had just been fixed and left `Apply` insensitive until the next
mutation. The reconcile moves above the write and the dropdown reuses its list.
2026-10-07 07:17:46 +02:00
Martino Ferrari f238e717b2 fix: a wire is picked by distance and a refusal is spoken, so the editor answers
A review of the whole node editor. The hit test measured the distance to the
24 curve samples rather than to the segments between them, so a wire spanning
several rows was unpickable between two samples -- the probe now places its
boxes 280 units apart and prints the nearest sample's own 8.4 against a 3.0
tolerance, which the point test would have missed. A refused connection said
nothing: the message is now carried in `MathDialog::refusal` and shown once by
the single writer of the label, and a self-wire is refused through the real
drag-end path instead of only through `math_connect()`.

Two defects were found only by the new assertions. `math_dialog_sync()`
re-entered itself -- `gtk_drop_down_set_selected()` emits `notify::selected`
for a programmatic set too, and the handler answers by syncing again, which
consumed the refusal before it could be drawn -- so the body is now guarded
one level deep. And a deleted node left `selected_wire` naming an index that
afterwards belongs to a different wire, so `Delete` cleared an input on the
wrong node.

A node is placed in the first free grid cell rather than at the end of the
list's own cell, so a node added after a delete no longer lands on top of a
survivor; the label ellipsizes with a tooltip rather than wrapping, because a
wrapped line makes the dialog's height depend on the text in it.
2026-10-07 07:09:49 +02:00
13 changed files with 742 additions and 91 deletions

No files matched your search

+21 -5
View File
@@ -250,8 +250,9 @@ A sample exactly *at* a threshold is still on neither side, so a signal that mer
does not count every sample as an event.
Each measurement's row carries **two badges**: its own colour (the same one its marks use on
the plot) and the signal's chip, in the palette colour every other view names that signal
with. The dialog's fields and their wording follow the type — the split row is absent for
the plot) and the signal's chip, in the colour every other view names that signal with —
through `RowSet::signal_color()`, so a signal the user re-coloured is re-coloured here too and
not left at the palette entry. The dialog's fields and their wording follow the type — the split row is absent for
`average`, `min` and `max`, the side row only for `pulse duration`, and the "show on plot"
line says what would be drawn.
@@ -706,9 +707,10 @@ and its output the square on its right. Click a box to select it and its paramet
form beside the canvas; **drag from an output square to an input square to wire them** (the old
two-click protocol is gone); drag a wired input square off its port to detach it; click a wire and
press `Delete` to remove it; drag a box to move it. **Wheel zooms about the pointer, drag the
background to pan, `f` to fit, `Ctrl+0` to reset.** `Tidy` in the toolbar lays out all nodes in
topological columns. The op is chosen when the node is added — the picker appears at the `+` — so
there is no opless placeholder to build past. The graph is a *list* walked in order, so a node can
background to pan, `f` to fit, `Ctrl+0` to reset.** *tidy*, a button in the editor dialog beside
*fit*, lays out all nodes in topological columns. The op is chosen when the node is added — *add
node* in the same row of the dialog is a menu button whose popover lists every op — so there is no
opless placeholder to build past. The graph is a *list* walked in order, so a node can
only read the ones above it; a wire drawn right to left re-sorts the list silently, so the graph
stays evaluable without requiring the user to add nodes in order. A cycle is unrepresentable:
`math_connect()` checks the trial edge set with Kahn's algorithm before writing anything. The node
@@ -1202,6 +1204,20 @@ shape and the column of chips reads as a legend. Because `plot::Plot` fixes a tr
colour at `add_trace()`, the index is stored on the row (`Row::color_index`) and a
colour change goes through a figure rebuild, which a drop already forces.
**Clicking the chip of a signal that is on a plot opens a colour chooser** (`GtkColorDialog`,
with the alpha slider off — a trace is stroked opaque, so a transparency setting would do
nothing), and the colour it returns *overrides* the palette entry for that signal. The chip of
a signal on no plot is not clickable: a grey chip names no trace, so there is nothing to
recolour. A chosen colour is held by signal **name** (`RowSet::set_signal_color()`), saved in
the session file's `[[color]]` tables and resolved — for the trace and for every chip — by the
one call `RowSet::signal_color()`, so a chip cannot claim a colour the trace is not drawn in.
The way back is **Default colour** in the signal's `⋯` menu, sensitive only while an override
exists; it is there rather than on the chip because a second gesture on a one-glyph target is
not something anybody would find. The chooser is opened by a `GtkGestureClick` on the existing
label rather than by wrapping it in a `GtkButton`, because a button's padding is exactly what
made this row twice as tall once (below), and the gesture claims its sequence so a click on the
chip cannot become the start of a row drag.
A `GtkSwitch` and two
always-visible buttons made a row about twice as tall (46 px against 26 px now),
which is what turned a four-signal stream into a panel you have to scroll; Adwaita's
+54 -11
View File
@@ -23,9 +23,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
The editor is the *compact graph*: nodes as boxes on a `GtkDrawingArea`, click to select (its
parameters in the form beside it), **drag from an output port to an input port to wire** (the old
two-click protocol is gone), drag a box to move it; **wheel zooms about the pointer, background
drag pans, `f` fits, `Ctrl+0` resets**. Its widgets are created once and only shown, hidden and set by `math_dialog_sync()`,
so no rebuild can invalidate a live callback handle -- the same shape the measurements dialog
uses. `UDPSCOPE_PROBE_MATH_MS` defines two graphs and opens the editor (a construction crash test
drag pans, `f` fits, `Ctrl+0` resets**.
Its widgets are created once and only shown, hidden and set by `math_dialog_sync()`, so no rebuild
can invalidate a live callback handle -- the same shape the measurements dialog uses.
`UDPSCOPE_PROBE_MATH_MS` defines two graphs and opens the editor (a construction crash test
as much as a state check), and the dump prints each derived channel's extremes beside the
source's. Read it on the fast mock, where the two assertions that matter are **`gaps=0`** (the
math is on the producer thread, so what it can break is packet capture, never the frame rate)
@@ -59,11 +60,30 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
* `[math-probe] wire: connect mode=2 in_a=0 (want 2 0)`,
`[math-probe] detach: emptied_at_begin=-1 in_a=-1 (want -1 -1)`,
`[math-probe] detach: escape restores in_a=0 (want 0)`.
* `[math-probe] wire: pick node=1 which=0 -> in_a=-1 (want 1 0 -1)`.
* `[math-probe] wire: pick node=1 which=0 -> in_a=-1 off_sample=8.4 tol=3.0 (want 1 0 -1,
off_sample > tol)` — the pick is taken at `t = 0.52`, **between** samples 12 and 13 of the 24 the
curve is walked at, on a wire whose two boxes are several rows apart. `off_sample` is the measured
distance from the pick to the nearest sample point and `tol` the pick radius in the same units:
with `off_sample` the larger, a hit test that measured to the sample *points* rather than to the
*segments* between them would miss. Taken at `t = 0.5` — exactly sample 12 — the line passed for
both implementations and asserted nothing, the same mistake as a hit test taken at the identity
view.
* `[math-probe] wire: backwards ok=1 order=[0:-1,1:0,2:1] output=2 valid=1 why=[] (want 1 ... 1 [])` —
the re-sort. `valid=1` is the assertion; the `order=` string is the reading that explains it.
* `[math-probe] wire: self refused=1 why=[...]` and
`[math-probe] wire: cycle refused=1 why=[...]` — and **nothing is written** on either.
* `[math-probe] wire: self refused=1 why=[a node cannot read itself]` and
`[math-probe] wire: cycle refused=1 why=[that wire would make a loop]` — and **nothing is
written** on either. Both call `math_connect()` directly, so both assert its out-parameter and
neither says anything about the UI.
* `[math-probe] wire: self through drag-end node=1 problem=[a node cannot read itself] (want the
refusal text, not empty)` — the other half, and the one that matters: the refusal has to reach
the **validation line**. The UI path used to screen the self case out before `math_connect()` and
then drop its `why` on the floor, so the user drew a wire, nothing appeared and nothing was said
while the two lines above passed. No synthetic GTK gesture is reachable headlessly, so the probe
sets `drag_mode`/`wire_from`/`press_cx`/`press_cy` and calls `math_canvas_drag_end()` — which is
still the handler under test. An empty `problem=[]` here is also how the sync **re-entrancy** was
found: setting the op dropdown emits `notify::selected`, whose handler syncs, so the form's own
write re-entered `math_dialog_sync()` and the inner call rewrote the label the outer had just
written. `MathDialog::syncing` guards it.
* `[math-probe] validate: broken problem=[..] label=[..] apply=0; fixed problem=[] apply=1 (want non-empty 0; empty 1)`,
`[math-probe] validate: no-selection label=[..] (want non-empty)` — the label is written
**above** `math_dialog_sync()`'s early return; a stale message when there is no selection to
@@ -72,7 +92,14 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
dropdown lists exactly the acquired channels, not all signals.
* `[math-probe] op pick: nodes 3->4 op_index=7 q_row=1 (want 4 7 1)` and
`[math-probe] op pick: index round trip=1 (want 1)`.
* `[math-probe] tidy: xs=[24,204,384,564] overlaps=0 (want 0)`.
* `[math-probe] add node: after a delete cells=[(24,20) (24,78) (184,78) (184,20)] overlaps=0
(want 0)` — the boxes are put back on the grid, the middle one deleted and one added. The new box
takes cell 1, `(184,20)`, which the deletion freed; placing by `nodes.size()` would have put it on
`(184,78)`, which is occupied, and an overlapped box is an unclickable one. `overlaps` is the
assertion, `cells` the reading that explains it.
* `[math-probe] tidy: xs=[24,24,204,384] overlaps=0 (want 0)` — four nodes in three depth columns
(the delete above left two roots), so two share the first column and the x values repeat. Only
`overlaps=0` is the assertion.
Four rules here were wrong first and are worth keeping:
* **A `Source` may only name an *acquired* channel** -- `ScopeModel::channels()`, which is what the
dialog's dropdown lists, **and both places that create a node must take one from there**.
@@ -103,10 +130,15 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
* **Boxes must not overlap on the canvas.** The first auto-place staggered nodes by 24 px for a
132 px box, so every box sat on the one before it, `math_node_at()`'s topmost-first search always
returned the last, and a drag or a click landed on the wrong node -- which reads as the dialog
ignoring the pointer. The **add-node grid** places in two columns spaced 160 px apart, rows 58;
the **tidy layout** uses a column pitch of `kNodeW + 48` (narrower), which is enough clearance
for a wire's 40-unit horizontal tread — at 160 the bezier's control points cross and the wire
doubles back on itself. The probe prints the box the drag *began over* for exactly this reason.
ignoring the pointer. The **add-node grid** places in two columns spaced 160 px apart, rows 58,
and `math_add_node()` takes the **first free cell** rather than cell `nodes.size()`: placing by
the count reuses an occupied cell as soon as anything has been deleted — delete the middle of
three and add one and the new box lands exactly on an existing one. The free-cell search uses the
same overlap test the tidy probe asserts with, and falls through to cell `nodes.size()` after 64
cells rather than searching for ever. The **tidy layout** uses a column pitch of `kNodeW + 48` =
180, which is *wider* than that 160 and is what gives a wire's 40-unit horizontal tread its
clearance — at 160 the bezier's control points cross and the wire doubles back on itself. The
probe prints the box the drag *began over* for exactly this reason.
Offering the whole signal list let a graph name something nobody is acquiring (no data for ever)
and put the stream's timestamp signal, which has no samples, in it.
* **`prepare_math_locked()` runs from `rebuild_locked()`, so *every* plan change re-prepares the
@@ -158,6 +190,17 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
* **The validation line is written above `math_dialog_sync()`'s early return.** An out-of-range
selection is exactly the state in which a stale message misleads most: the graph can still be
broken and there is no form to read instead.
* **The line is written from `math_dialog_sync()` and from nowhere else, it ellipsizes, and its
tooltip carries the full text.** Three consequences. (1) A *refusal* — "a node cannot read
itself", a cycle — is not a state `math_dialog_problem()` can describe, since it is a thing that
did not happen, so `drag-end` leaves it on `MathDialog::refusal`, which the sync shows in
preference to the graph's own problem and **consumes as it writes**: the message lasts until the
next sync, and every mutation syncs. `Apply`'s sensitivity still follows the graph alone, because
a refused wire wrote nothing. (2) The label **ellipsizes rather than wraps**, and the tooltip is
set beside the text so the two cannot disagree — layout must not depend on content, and this
label sits in a dialog whose canvas is `hexpand`. (3) The sync is **not re-entrant**
(`MathDialog::syncing`): writing the op dropdown emits `notify::selected`, whose handler syncs,
and the inner call rewrote the label the outer one had just written.
* **`math_op_index()`, never a cast.** `math_op_at()`'s order is the dialog's — what a dropdown
lists and what a session file stores — and `static_cast<guint>(op)` worked only because the
two happen to agree today. The session-file rule in this file already says an op is written as
+11
View File
@@ -104,3 +104,14 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
figure rebuild — which `assign()` already forces. A signal on no plot gets
`RowSet::unplotted_color()` (grey) rather than no chip, so the panel's rows stay the
same height.
- **`RowSet::signal_color()` is the only route to a trace's colour.** The user can override the
palette entry per signal (clicking its chip; `set_signal_color()` / `clear_signal_color()`,
keyed by **name**, because a palette index is positional and a CONFIG renumbers), and that
override is exactly the state in which two routes to a colour disagree. So `install()` builds
the trace through this call and all three chip sites — the signal panel, the measures pane and
the cursors pane — read it too; none of them may index `palette_color()` itself. An override
marks every row holding the signal dirty (`mark_signal_dirty()`, not `assign()`: what the slot
*holds* did not change) and the rebuild happens on the frame tick via `relayout_pending`,
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
chip's own markup, since asking the resolver twice would assert nothing.
+4 -1
View File
@@ -6,7 +6,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
- **The session is the layout.** `LayoutState` persists what makes a reopened scope look like the
one that was closed: the window, the rows and their slot signals, the calibration, **the enabled
signals**, **the math channels with their graphs** and **the measurements pane**. Signal identity
signals**, **the chosen trace colours** (`[[color]]`, `name` + `rgb = "#RRGGBB"` — only the
*overrides*, so a stream's new signals still take the positional palette and a saved session does
not freeze a colour that is meant to follow the signal list), **the math channels with their
graphs** and **the measurements pane**. Signal identity
is the *name* throughout (a CONFIG renumbers channels), 'enabled' is read off the channels the
model built -- an enabled signal is exactly one that produced a channel -- and `apply_layout()`
installs the graphs **before** assigning the rows, since a row may hold a math channel's name and
+1
View File
@@ -571,5 +571,6 @@ Two lines of the self-check output are assertions in disguise:
itself when it loses the grab, and `xvfb-run` has no window manager to hand it one — and restores
it afterwards.
- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `<key>: <widget-description>: <before>-><after>` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values).
- `[color] '<name>' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1)`, then `[color] override: trace=#123456 chip_agrees=1`, `[color] session: entries=1 rgb=#123456` and `[color] default: trace=#RRGGBB chip_agrees=1` (stderr, `UDPSCOPE_PROBE_COLOR_MS`) — **all three `chip_agrees` must be 1**, the override line's `trace` must be `#123456`, the session line must read `entries=1 rgb=#123456`, and the default line's `trace` must be back to the first line's. Run it with `UDPSCOPE_ENABLE_ALL_MS` ahead of it, or there is no bound trace and the probe prints `no bound trace to recolour` instead of asserting anything; on the standard mock the first line reads `'sine' at row 0 slot 0: trace=#4FA3FF`. Two things make this an assertion rather than a tautology. The colours are read out of **`plot::Plot::trace().color`** — what the renderer will stroke, reached through `Row::plot` *after* `relayout_areas()`, since a new colour is a new figure — and out of the chip's own `<span foreground="…">` markup, not by asking `RowSet::signal_color()` twice, which would pass against a chip that never reached the trace. And the override is `#123456`, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips `collect_layout()` → `save_layout()` → `load_layout()` on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit.
- `[zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1` (stderr, `UDPSCOPE_PROBE_ZOOMKEYS_MS`) — **both `_held` must be 1 and both zooms must still happen.** Run it inside a pinned state, where a frame cannot re-frame the other axis for its own reasons: `UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200`. A `_held=0` is the modifier not reaching the plotter's controller; an unchanged range on the *zoomed* axis is the opposite failure, the key doing nothing. Ctrl restricts to x and Alt to y; Ctrl+Alt zooms neither (no special case needed — neither flag alone fires).
- `status bar: glance=[● live · 1.09 MS/s · 2 s window] max_glance_chars=53 detail_fields=11 popover_rebuilds=0` (exit dump) — **`max_glance_chars` must stay small** (under about 60) and **`popover_rebuilds` must be 0**. The status row is directly above the plot, so this is the "layout must not depend on content" rule read from the bar's own side: the state field is one word from a fixed set (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`, `disarmed` — a word added to it must be **no wider than the widest already there**, which is what let `connecting` join at ten characters beside `event held`; the dead-port glance reads `max_glance_chars=51` against a live run's 54), the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), and the two fields a *state* adds are both elapsed times — trigger mode's `last event … ago` and a pause's `for <elapsed>` — which step units rather than growing digits (`format_elapsed()`). A **paused** glance is not in the dump at all: it is read by `[pause-probe]`'s `glance=` field, since the dump's own line is taken live. `detail_fields` is the popover's label count, a literal, so adding a label without thinking about layout shows up here. The glance rate format matters: `%.3g` drops the trailing zero that a `%.4g` or a fixed-precision format would keep — a rate that reads differently in the dump and in the help page is a format disagreement, and `14-status.txt`'s example must match it.
+13 -4
View File
@@ -17,10 +17,14 @@ Two baselines every run is read against. A clean build emits **four** warnings a
`-Wmissing-field-initializers` on `MathNode::source` and `-Woverlength-strings` on **both**
generated GResource `.c` files — the help pages' and the icons', whose blob crossed C99's
4095-byte string-literal limit when the two app icons joined the four action ones (a note about a
generated file, which GCC itself supports). The fifth warning that used to appear here was an
unused `output_built` in `on_math_apply()` — Task 6's live validation check reads that value, so
the variable is no longer unused. And an exit-dump run emits **no** `Gtk-CRITICAL` at all. A
fifth warning, or any critical, is a finding.
generated file, which GCC itself supports). The fifth warning that used to appear here was an unused
`output_built` in `math_probe()`; it was **deleted** as dead code — redundant with the
`output_after_add` already printed on the same line — and that deletion is what dropped the baseline
from five to four. Nothing started reading it. And an exit-dump run emits **no** `Gtk-CRITICAL` at
all. A fifth warning, or any critical, is a finding.
An *incremental* build reads **two** of those four: the generated GResource `.c` files are not
recompiled when only `src/` changed, so their `-Woverlength-strings` do not reappear.
That second baseline used to be "exactly two, `gtk_widget_get_height` and `gtk_widget_get_width`,
from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump
@@ -157,6 +161,11 @@ precedent, not an exception.
stops while paused and "how long have I been looking at this" is a question about the user, not
about the stream; it is written through `format_elapsed()` so the field steps to a coarser unit
instead of growing digits (see the layout rule).
- `UDPSCOPE_PROBE_COLOR_MS` — recolour one plotted signal and read the colour back out of the
trace and the chip, round-trip it through the session writer, then clear it. Schedule
`UDPSCOPE_ENABLE_ALL_MS` before it: with nothing bound it has no trace to recolour and asserts
nothing. Its four lines are in
[probe-readings.md](probe-readings.md).
- `UDPSCOPE_EXPORT_MS` (+ optional `UDPSCOPE_EXPORT_PATH`, default
`/tmp/udpscope-export`) — collect the export table and write it both ways, skipping
only the file chooser.
+3
View File
@@ -22,6 +22,9 @@ the overflow menu opens the editor where the unit and the calibration live.
• A signal's colour follows the <i>signal</i>, not the slot, so the same signal on two rows
is one colour and the side panel's chip identifies it unambiguously. A signal on no row
gets a grey chip rather than none, so the panel's rows keep their height.
• <b>Click a coloured chip to change that signal's colour.</b> A grey chip names no trace, so
it is not clickable. The colour is saved with the session; <b>Default colour</b> in the
signal's ⋯ menu gives the palette entry back.
<b>Example</b>
+9 -7
View File
@@ -5,22 +5,24 @@ nodes, operations, one output — and udpscope then treats the result as an ordi
<b>How to reach it</b>
The math button in the toolbar, or <tt>g</tt>. The pane's <tt>+</tt> adds a channel — a picker
chooses the op — and opening one shows the graph editor: nodes as boxes, click a box to select it
and edit its parameters beside the canvas.
The math button in the toolbar, or <tt>g</tt>. The pane's <tt>+</tt> adds a channel, and opening
one shows the graph editor: nodes as boxes, click a box to select it and edit its parameters beside
the canvas. Inside the editor, <i>add node</i> is a menu button whose popover picks the op, so the
node arrives as the operation you asked for.
<b>Graph gestures:</b> <b>drag</b> from an output port to an input port to wire them; drag a
wired input port off its connection to detach it; click a wire and press <tt>Delete</tt> to remove
it; drag a box to move it. <b>Wheel</b> zooms about the pointer. <b>Drag the background</b> to
pan. <tt>f</tt> fits all nodes into view; <tt>Ctrl+0</tt> resets the view. <tt>Tidy</tt> in the
toolbar lays the graph out in topological columns. The orange dot marks the output node; click
<i>set as output</i> to move it.
pan. <tt>f</tt> fits all nodes into view; <tt>Ctrl+0</tt> resets the view. <i>tidy</i>, the button
beside <i>fit</i> in the editor, lays the graph out in topological columns and frames it. The orange
dot marks the output node; click <i>set as output</i> to move it.
<b>Worth knowing</b>
• The graph is evaluated as an ordered list, so a node may only read ones <i>above</i> it. A
wire drawn right to left re-sorts the list silently so the graph stays valid. A cycle is
unrepresentable: the connection is refused rather than created.
unrepresentable: the connection is refused rather than created, and the line beneath the form
says why — a loop, or a node asked to read itself.
• A derived channel <i>is</i> a channel. It appears in the signal panel, can be plotted,
measured, exported, and used as a source for another math channel.
• The math runs on the <b>stored</b> samples — decimated and averaged — so a cutoff you type in
+27 -1
View File
@@ -239,13 +239,14 @@ bool load_layout(const std::string& path, LayoutState& out) {
st.window_s = 0.0;
st.rows.clear();
st.calibration.clear();
st.colors.clear();
st.enabled.clear();
st.math.clear();
st.measures.clear();
std::string line;
// What the last `[[...]]` header opened, so keys land in the right record.
enum class Section { Top, Plot, Calibration, Math, Measure } section = Section::Top;
enum class Section { Top, Plot, Calibration, Color, Math, Measure } section = Section::Top;
int plot_count = 0;
bool plot_section_seen = false;
while (std::getline(in, line)) {
@@ -259,6 +260,9 @@ bool load_layout(const std::string& path, LayoutState& out) {
} else if (t == "[[calibration]]") {
section = Section::Calibration;
st.calibration.emplace_back();
} else if (t == "[[color]]") {
section = Section::Color;
st.colors.emplace_back();
} else if (t == "[[math]]") {
section = Section::Math;
st.math.emplace_back();
@@ -378,6 +382,23 @@ bool load_layout(const std::string& path, LayoutState& out) {
} catch (...) {
}
}
} else if (section == Section::Color && !st.colors.empty()) {
if (key == "name") {
st.colors.back().name = strip_quote(value);
} else if (key == "rgb") {
// `"#RRGGBB"`, so the file says what it means to a reader hand-editing it -- a bare decimal
// would be the one number in this file nobody could check by eye. Anything unparseable
// leaves the entry at 0, and `apply_layout()` drops an entry with no name anyway.
const std::string hex = strip_quote(value);
const std::size_t at = hex.find('#');
try {
st.colors.back().rgb =
static_cast<std::uint32_t>(std::stoul(
at == std::string::npos ? hex : hex.substr(at + 1), nullptr, 16)) &
0x00FFFFFFu;
} catch (...) {
}
}
}
}
if (!plot_section_seen && plot_count > 0) {
@@ -422,6 +443,11 @@ bool save_layout(const std::string& path, const LayoutState& state) {
o << "\n[[calibration]]\nname = " << toml_escape(cal.name) << "\ngain = " << cal.gain
<< "\noffset = " << cal.offset << "\n";
}
for (const auto& color : state.colors) {
char rgb[16];
std::snprintf(rgb, sizeof(rgb), "\"#%06X\"", color.rgb & 0x00FFFFFFu);
o << "\n[[color]]\nname = " << toml_escape(color.name) << "\nrgb = " << rgb << "\n";
}
for (const MathChannel& m : state.math) {
o << "\n[[math]]\nname = " << toml_escape(m.name) << "\nunit = " << toml_escape(m.unit)
<< "\nenabled = " << (m.enabled ? "true" : "false") << "\noutput = " << m.output
+9
View File
@@ -32,6 +32,15 @@ struct LayoutState {
};
std::vector<Calibration> calibration;
/// The trace colours the user chose, `0xRRGGBB`. Only the *overrides*: a signal with no entry
/// takes the positional palette entry, which is what keeps a stream's new signals coloured
/// without a session having to name them. Saved by name like every identity here.
struct Color {
std::string name;
std::uint32_t rgb = 0;
};
std::vector<Color> colors;
/// Signals the session had enabled. Enabling is what starts acquisition, so this is what makes a
/// reopened scope look like the one that was closed -- and it is saved by *name*, like every other
/// identity here, because a CONFIG renumbers the channels.
+36 -2
View File
@@ -69,6 +69,40 @@ plot::Color RowSet::palette_color(int index) {
plot::Color RowSet::unplotted_color() { return plot::Color::hex(kUnplottedColor); }
plot::Color RowSet::signal_color(const std::string& signal, int palette_index) const {
const auto it = signal_colors_.find(signal);
if (it != signal_colors_.end()) return plot::Color::hex(it->second);
return palette_color(palette_index);
}
void RowSet::mark_signal_dirty(const std::string& signal) {
for (int i = 0; i < kMaxRows; ++i) {
const Row& r = row(i);
for (int s = 0; s < kSlotsPerRow; ++s) {
if (r.signal_name[static_cast<std::size_t>(s)] != signal) continue;
row_dirty_[static_cast<std::size_t>(i)] = true;
break;
}
}
}
void RowSet::set_signal_color(const std::string& signal, plot::u32 rgb) {
if (signal.empty()) return;
// Masked to 24 bits: the palette is `0xRRGGBB` and `Color::hex()` reads it as such, so an alpha
// byte left in the high octet by a caller converting from a `GdkRGBA` would be silently ignored
// by the plot and silently written to the session file.
const plot::u32 want = rgb & 0x00FFFFFFu;
const auto it = signal_colors_.find(signal);
if (it != signal_colors_.end() && it->second == want) return;
signal_colors_[signal] = want;
mark_signal_dirty(signal);
}
void RowSet::clear_signal_color(const std::string& signal) {
if (signal_colors_.erase(signal) == 0) return;
mark_signal_dirty(signal);
}
RowSet::RowSet() { rebuild_all(); }
void RowSet::set_row_count(int rows) {
@@ -130,8 +164,8 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
{"", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t i = static_cast<std::size_t>(s);
r.plot->add_trace(
{&r.slots[i], palette_color(r.color_index[i]), 1.2f, r.axis_x, r.axis_y, r.name[i]});
r.plot->add_trace({&r.slots[i], signal_color(r.signal_name[i], r.color_index[i]), 1.2f,
r.axis_x, r.axis_y, r.name[i]});
}
r.cursor_a = r.plot->add_cursor(0.0, plot::Color::hex(kCursorAColor));
r.cursor_b = r.plot->add_cursor(0.0, plot::Color::hex(kCursorBColor));
+28
View File
@@ -9,6 +9,7 @@
#include <plot/plot/plot.hpp>
#include <array>
#include <map>
#include <memory>
#include <string>
@@ -399,7 +400,30 @@ class RowSet {
/// names beside it shifted sideways as signals were placed.
static plot::Color unplotted_color();
/// The colour a signal is drawn in: the one the user chose for it if they chose one, the
/// positional palette entry otherwise.
///
/// **The one resolver**, used by `install()` for the trace and by the panels for their chips.
/// A chip exists to say which trace on screen a panel row is about, so a chip that resolved
/// a colour by a different route than the trace is the single failure the chip cannot
/// survive -- and an override is exactly the state in which two routes would disagree.
plot::Color signal_color(const std::string& signal, int palette_index) const;
/// Gives `signal` the colour `rgb` (0xRRGGBB), by *name*: a palette entry is positional, so an
/// override held by index would move to another signal on the next CONFIG. Marks every row that
/// plots it dirty, because a trace's colour is fixed at `add_trace()` -- a new colour is a new
/// figure, which is the same path `assign()` already takes.
void set_signal_color(const std::string& signal, plot::u32 rgb);
/// Back to the palette entry. A no-op, and no rebuild, when there was no override.
void clear_signal_color(const std::string& signal);
/// Every override, for the session file. Ordered by name, so a saved session's colour
/// tables do not reshuffle between writes.
const std::map<std::string, plot::u32>& signal_colors() const noexcept { return signal_colors_; }
private:
/// Marks every row with `signal` in a slot for rebuild. Not `assign()`: nothing about what
/// the slot *holds* changed, only the colour its trace has to be built with.
void mark_signal_dirty(const std::string& signal);
/// Moves one row's *persistent* state -- what it plots, in which colours, where its
/// cursors are -- onto another row. Deliberately not a `Row` assignment: a `Row` also
/// carries the figure handles (`plot`, the axis and cursor ids) and the `SlotSeries`
@@ -440,6 +464,10 @@ class RowSet {
double right_gutter_px_ = 0.0;
bool y_gutter_reserved_ = false;
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
/// The colours the user chose, by signal name (see `set_signal_color()`). Sparse on purpose:
/// an absent entry means "the palette decides", which is what keeps a new CONFIG's signals
/// coloured without anything having to seed them.
std::map<std::string, plot::u32> signal_colors_;
CursorMode cursor_mode_ = CursorMode::Vertical;
bool cursors_on_ = false;
bool relative_x_ = false;
+526 -60
View File
@@ -719,6 +719,11 @@ LayoutState collect_layout(App& app) {
if (model.signals()[si].math || !on[si]) continue;
st.enabled.push_back(model.signals()[si].name);
}
// Only the signals the user actually re-coloured: the rest take the positional palette, and
// writing those out would freeze a colour that is meant to follow the signal list.
for (const auto& [name, rgb] : app.rows->signal_colors()) {
st.colors.push_back({name, rgb});
}
// The app's own lists are the pane's source of truth for both of these.
st.math = app.math;
st.measures = app.measures;
@@ -822,6 +827,16 @@ void apply_layout(App& app, const LayoutState& st) {
}
}
// Skipped for a name the stream no longer carries, like the calibration above: an override the
// signal list cannot show is one `collect_layout()` would write back every session for ever.
for (const auto& color : st.colors) {
for (std::size_t si = 0; si < signals.size(); ++si) {
if (signals[si].name != color.name) continue;
app.rows->set_signal_color(color.name, color.rgb);
break;
}
}
if (st.window_s > 0.0) apply_time_window(app, st.window_s);
// set_row_count() rebuilt the figure (its row count is fixed at construction):
@@ -1735,14 +1750,33 @@ int signal_color_index(const std::vector<SignalInfo>& signals, std::size_t signa
return index;
}
std::string chip_markup(plot::Color c) {
char buf[64];
std::snprintf(buf, sizeof(buf), "<span foreground=\"#%02X%02X%02X\">\u25CF</span>",
unsigned(c.r * 255.0f + 0.5f), unsigned(c.g * 255.0f + 0.5f),
unsigned(c.b * 255.0f + 0.5f));
/// A trace colour as `#RRGGBB`: what a chip is drawn with and what the session file stores. One
/// formatter for both, so a chip and the colour it claims to name cannot be written differently --
/// and so the probe can read a chip's own markup rather than recomputing what it should say.
std::string color_hex(plot::Color c) {
char buf[16];
std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", unsigned(c.r * 255.0f + 0.5f),
unsigned(c.g * 255.0f + 0.5f), unsigned(c.b * 255.0f + 0.5f));
return buf;
}
std::string chip_markup(plot::Color c) {
return "<span foreground=\"" + color_hex(c) + "\">\u25CF</span>";
}
/// Whether any row's slot holds `name` -- i.e. whether the signal has a trace on screen, which is
/// what decides between its own colour and the grey of an unplotted one, and what decides whether
/// there is a colour for the user to change.
bool signal_plotted(App& app, const std::string& name) {
for (int r = 0; r < app.rows->row_count(); ++r) {
const RowSet::Row& row = app.rows->row(r);
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
if (row.signal_name[static_cast<std::size_t>(s)] == name) return true;
}
}
return false;
}
/// The chip for a row of the signal list, as Pango markup: the signal's own colour when
/// it is on a plot, grey when it is not (whether that is because it is not acquired or
/// because it is acquired and not placed). Always a filled disc, never a blank: a blank
@@ -1751,14 +1785,78 @@ std::string chip_markup(plot::Color c) {
std::string signal_chip_markup(App& app, const std::vector<SignalInfo>& signals,
std::size_t signal) {
const std::string& name = signals[signal].name;
for (int r = 0; r < app.rows->row_count(); ++r) {
const RowSet::Row& row = app.rows->row(r);
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
if (row.signal_name[static_cast<std::size_t>(s)] != name) continue;
return chip_markup(RowSet::palette_color(signal_color_index(signals, signal)));
if (!signal_plotted(app, name)) return chip_markup(RowSet::unplotted_color());
// Through `RowSet::signal_color()`, which is also what `install()` builds the trace with: the
// chip's whole job is to say which trace this row is about, so it may not resolve a colour by
// its own route. A user's override is exactly the state in which two routes would disagree.
return chip_markup(app.rows->signal_color(name, signal_color_index(signals, signal)));
}
/// What an open colour chooser is choosing for. The signal's **name**, not its index: the chooser
/// is asynchronous and a CONFIG can renumber the signal list while it is open, which is the same
/// reason every persisted identity in this app is a name.
struct ColorPick {
App* app;
std::string signal;
};
/// Applies a chosen colour, or leaves everything alone if the chooser was dismissed.
void on_signal_color_chosen(GObject* source, GAsyncResult* res, gpointer user) {
std::unique_ptr<ColorPick> pick(static_cast<ColorPick*>(user));
GError* error = nullptr;
GdkRGBA* picked = gtk_color_dialog_choose_rgba_finish(GTK_COLOR_DIALOG(source), res, &error);
if (picked == nullptr) {
// A dismissed chooser arrives here as an error (`GTK_DIALOG_ERROR_DISMISSED`, or a cancelled
// `GError` if the window went away), and a cancel must leave the colour as it was. There is
// nothing to report either way: the user closed a chooser.
if (error != nullptr) g_error_free(error);
return;
}
const auto octet = [](double v) {
return static_cast<plot::u32>(std::clamp(v, 0.0, 1.0) * 255.0 + 0.5);
};
const plot::u32 rgb = (octet(picked->red) << 16) | (octet(picked->green) << 8) |
octet(picked->blue);
gdk_rgba_free(picked);
App& app = *pick->app;
app.rows->set_signal_color(pick->signal, rgb);
// The rebuild is deferred to the frame tick, like a drop's: a trace's colour is fixed at
// `add_trace()`, so a new colour is a new figure, and `relayout_areas()` is the one place that
// builds one and re-points the area at it.
app.relayout_pending = true;
rebuild_signal_panels(app);
schedule_layout_save(app);
}
/// Opens the colour chooser for one signal's trace.
///
/// `GtkColorDialog`, not the `GtkColorChooserDialog` deprecated in GTK 4.10: libadwaita 1.8 already
/// puts this build's floor at GTK 4.18, so the modern API is simply available. Alpha is off --
/// `plot::Color` carries one but a trace is stroked opaque, so a transparency slider would offer a
/// setting that does nothing.
void open_signal_color(App& app, std::size_t signal) {
std::string name;
int palette_index = 0;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
const std::vector<SignalInfo>& signals = app.model->signals();
if (signal >= signals.size()) return;
name = signals[signal].name;
palette_index = signal_color_index(signals, signal);
}
return chip_markup(RowSet::unplotted_color());
if (name.empty()) return;
const plot::Color current = app.rows->signal_color(name, palette_index);
const GdkRGBA start{current.r, current.g, current.b, 1.0f};
GtkColorDialog* dialog = gtk_color_dialog_new();
gtk_color_dialog_set_title(dialog, ("Colour for " + name).c_str());
gtk_color_dialog_set_with_alpha(dialog, FALSE);
gtk_color_dialog_choose_rgba(dialog, GTK_WINDOW(app.window), &start, nullptr,
on_signal_color_chosen, new ColorPick{&app, name});
// The async call holds its own reference for the operation's life, which is the documented
// idiom for every `Gtk*Dialog` in GTK 4.10's async family.
g_object_unref(dialog);
}
/// One line per signal: colour chip, checkbox, name, unit, and a menu for the things
@@ -1786,11 +1884,34 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
gtk_widget_set_margin_start(box, 6);
gtk_widget_set_margin_end(box, 2);
const bool plotted = signal_plotted(app, sig.name);
GtkWidget* chip = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(chip), signal_chip_markup(app, signals, i).c_str());
gtk_widget_set_tooltip_text(chip, app.model->signal_enabled(i)
? "the colour this signal is plotted in"
: "not acquired");
gtk_widget_set_tooltip_text(
chip, !app.model->signal_enabled(i) ? "not acquired"
: plotted ? "the colour this signal is plotted in -- click to change it"
: "on no plot, so there is no trace to colour");
if (plotted) {
// A gesture on the label rather than a `GtkButton` around it: a button brings its own
// padding, and padding on this row is what made the list twice as tall once already (see
// this function's note). The chip also has to stay exactly one disc wide, or the names
// beside it shift as signals are placed.
gtk_widget_set_cursor_from_name(chip, "pointer");
GtkGesture* pick = gtk_gesture_click_new();
g_object_set_data(G_OBJECT(pick), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(pick, "pressed",
G_CALLBACK(+[](GtkGestureClick* g, int, double, double, gpointer user) {
App* a = static_cast<App*>(user);
// Claimed, so the row's `GtkDragSource` cannot take the same sequence
// and turn a click on the chip into the start of a drag.
gtk_gesture_set_state(GTK_GESTURE(g), GTK_EVENT_SEQUENCE_CLAIMED);
open_signal_color(*a, static_cast<std::size_t>(GPOINTER_TO_SIZE(
g_object_get_data(G_OBJECT(g),
"udpscope-signal"))));
}),
&app);
gtk_widget_add_controller(chip, GTK_EVENT_CONTROLLER(pick));
}
gtk_box_append(GTK_BOX(box), chip);
GtkWidget* enable = gtk_check_button_new();
@@ -1825,6 +1946,33 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
g_signal_connect(info, "clicked", G_CALLBACK(on_info_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), info);
// The way back from a chosen colour. The chooser itself cannot carry a "default" button, and a
// setting with no undo is a trap; it lives here rather than on the chip because a second
// gesture on a one-glyph target is not something a user would find.
GtkWidget* recolor = menu_button("Default colour", nullptr);
gtk_widget_set_sensitive(recolor, app.rows->signal_colors().count(sig.name) != 0);
gtk_widget_set_tooltip_text(recolor, "back to the palette colour for this signal's position");
g_object_set_data(G_OBJECT(recolor), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(recolor, "clicked",
G_CALLBACK(+[](GtkButton* b, gpointer user) {
App* a = static_cast<App*>(user);
const std::size_t idx = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(b), "udpscope-signal")));
std::string name;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
const std::vector<SignalInfo>& sigs = a->model->signals();
if (idx >= sigs.size()) return;
name = sigs[idx].name;
}
a->rows->clear_signal_color(name);
a->relayout_pending = true;
rebuild_signal_panels(*a);
schedule_layout_save(*a);
}),
&app);
gtk_box_append(GTK_BOX(menu_box), recolor);
GtkWidget* cal = menu_button("Calibrate...", nullptr);
gtk_widget_set_tooltip_text(cal, "gain and offset, applied at ingest");
g_object_set_data(G_OBJECT(cal), "udpscope-signal", GSIZE_TO_POINTER(i));
@@ -2365,7 +2513,8 @@ GtkWidget* measures_row(App& app, std::size_t index, const Measure& m) {
}
}
gtk_label_set_markup(GTK_LABEL(sig_chip),
chip_markup(color_index >= 0 ? RowSet::palette_color(color_index)
chip_markup(color_index >= 0
? app.rows->signal_color(m.signal, color_index)
: RowSet::unplotted_color())
.c_str());
}
@@ -2597,7 +2746,8 @@ void rebuild_cursors_pane(App& app) {
// what "the pane shows a lot of empty signals that do not exist" was.
if (row.signal_name[si].empty() || row.channel[si] < 0) continue;
app.cursors_lines.push_back(cursors_line(
app, row.name[si].c_str(), RowSet::palette_color(row.color_index[si]), true));
app, row.name[si].c_str(),
app.rows->signal_color(row.signal_name[si], row.color_index[si]), true));
app.cursors_lines.back().row = r;
app.cursors_lines.back().slot = s;
}
@@ -3282,8 +3432,10 @@ void add_math_channel(App& app) {
// the output the square on its right. Click a box to select it (its parameters appear in the form
// beside the canvas), drag from an output square to an input square to wire them, drag a wired
// input off to detach it, drag a box to move it. The graph is a *list*, walked in order, so a node
// may only read ones above it -- which is why an input square offers only the nodes before it and a
// cycle cannot be drawn at all.
// may only read ones above it -- but that order is the *evaluator's* contract, not a restriction on
// the gesture: a wire may be drawn in either direction and `math_connect()` re-sorts the list
// (Kahn's algorithm) so the contract still holds. Only a wire that cannot be ordered at all, i.e.
// one that closes a cycle, is refused, and it is refused at connect time with a message.
constexpr double kNodeW = 132.0;
constexpr double kNodeH = 46.0;
constexpr double kPort = 10.0;
@@ -3351,10 +3503,25 @@ struct MathDialog {
GtkWidget* hi = nullptr;
GtkWidget* rows[6] = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr};
GtkWidget* inputs = nullptr;
/// The validation line and the button it governs. Both are set from `math_dialog_problem()` and
/// from nowhere else, so what the line says and what the button allows cannot disagree.
/// The validation line and the button it governs. Both are set from `math_dialog_sync()` and from
/// nowhere else, so what the line says and what the button allows cannot disagree.
GtkWidget* problem = nullptr;
GtkWidget* apply = nullptr;
/// Why the last gesture was *refused* -- "a node cannot read itself", or a cycle. A refusal has no
/// standing state to be read back from: it is a thing that did not happen, so `math_dialog_problem()`
/// cannot describe it and a refused wire otherwise just fails to appear, which reads as the editor
/// being broken. `math_dialog_sync()` shows this in preference to the graph's own problem and
/// **consumes it as it writes**, so the next sync -- and every mutation syncs -- replaces it with
/// the graph's state. `Apply`'s sensitivity still follows `math_dialog_problem()` alone: a refused
/// wire changed nothing, so a graph that was saveable still is.
std::string refusal;
/// True while the form is being written. `gtk_drop_down_set_selected()` emits `notify::selected`
/// for a programmatic set exactly as for a user one, and the op handler answers a notify by
/// syncing -- so writing the form **re-entered** `math_dialog_sync()`, and the inner call rewrote
/// the validation label the outer one had just written. That is how a refusal vanished in the same
/// call that put it up (the probe's `wire: self through drag-end` read an empty line). A
/// programmatic set is not a user edit.
bool syncing = false;
};
/// The channels a math channel may read: the ones the model actually acquires. Offering the whole
@@ -3580,12 +3747,36 @@ int math_node_at_canvas(const MathDialog* d, double cx, double cy) {
return math_node_at(d, gx, gy);
}
/// The squared distance from a point to a line *segment* -- clamped to the segment, so the ends do
/// not extend it. A degenerate segment falls back to the distance to its first point.
double math_seg_dist2(double px, double py, double ax, double ay, double bx, double by) {
const double dx = bx - ax;
const double dy = by - ay;
const double len2 = dx * dx + dy * dy;
double t = 0.0;
if (len2 > 0.0) t = std::clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0.0, 1.0);
const double cx = px - (ax + t * dx);
const double cy = py - (ay + t * dy);
return cx * cx + cy * cy;
}
/// The wire under a graph position, or `{-1, 0}`. The wire is the cubic the draw lays down, so it
/// is *sampled* rather than solved: 24 segments is well under a pixel of chord error at the scales
/// this canvas allows, and a closed form for "nearest point on a cubic" is a quartic root find for
/// no gain. Topmost-first, matching every other hit test here.
/// no gain.
///
/// The 24 samples are the ends of 24 **segments**, and it is the distance to the segment that is
/// measured. Testing the sample *points* instead leaves a gap wherever two of them are more than
/// `2 * tol` apart -- which a wire spanning several rows of a tidied graph is, in y -- and in that
/// gap a click lands on the drawn wire and picks nothing. That is half of "it is not possible to
/// delete edges".
///
/// The *nearest* wire wins rather than the first one found, so a crossing picks the one actually
/// under the pointer; topmost-first iteration is the tie-break, matching every other hit test here.
MathWireRef math_wire_at(const MathDialog* d, double gx, double gy, double tol) {
constexpr int kSamples = 24;
MathWireRef best{-1, 0};
double best_d2 = tol * tol;
for (int i = static_cast<int>(d->nodes.size()) - 1; i >= 0; --i) {
const MathNode& n = d->nodes[static_cast<std::size_t>(i)];
const int ins[2] = {n.in_a, n.in_b};
@@ -3600,18 +3791,24 @@ MathWireRef math_wire_at(const MathDialog* d, double gx, double gy, double tol)
math_input_pos(n, k, bx, by);
const double c1x = ax + 40.0;
const double c2x = bx - 40.0;
for (int s = 0; s <= kSamples; ++s) {
double prev_x = ax;
double prev_y = ay;
for (int s = 1; s <= kSamples; ++s) {
const double t = static_cast<double>(s) / kSamples;
const double u = 1.0 - t;
const double px = u * u * u * ax + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * bx;
const double py = u * u * u * ay + 3 * u * u * t * ay + 3 * u * t * t * by + t * t * t * by;
if (std::fabs(gx - px) <= tol && std::fabs(gy - py) <= tol) {
return MathWireRef{i, k};
const double d2 = math_seg_dist2(gx, gy, prev_x, prev_y, px, py);
if (d2 < best_d2) {
best_d2 = d2;
best = MathWireRef{i, k};
}
prev_x = px;
prev_y = py;
}
}
}
}
return MathWireRef{-1, 0};
return best;
}
/// The pick radius for a wire, in screen pixels. Smaller than a port's, so a wire that passes
@@ -3944,15 +4141,23 @@ void math_canvas_drag_end(GtkGestureDrag*, double dx, double dy, gpointer user)
// node may be a column away from the press point.
int which = kPortOut;
const int target = math_port_at_canvas(d, d->press_cx + dx, d->press_cy + dy, which);
if (target >= 0 && which != kPortOut && target != d->wire_from) {
// Released on *any* input port, `wire_from`'s own included: the self case has to reach
// `math_connect()` to be refused there by name. Screening it out here meant the one refusal the
// user is most likely to provoke never got a message at all.
if (target >= 0 && which != kPortOut) {
std::string why;
if (math_connect(d, d->wire_from, target, which, &why)) {
// `math_connect()` may have permuted the list, so the target's index is no longer `target`.
math_dialog_sync(d);
} else if (d->wire_detach >= 0) {
} else {
// A refused wire must not also lose the one the drag detached to start with.
if (d->wire_detach >= 0 && d->wire_detach < static_cast<int>(d->nodes.size())) {
MathNode& back = d->nodes[static_cast<std::size_t>(d->wire_detach)];
(d->wire_detach_which == 0 ? back.in_a : back.in_b) = d->wire_from;
}
// And it must *say* why. `why` used to be filled and dropped here, so neither "a node cannot
// read itself" nor the cycle refusal ever reached the user.
d->refusal = why.empty() ? std::string("that wire cannot be made") : why;
math_dialog_sync(d);
}
}
@@ -4009,6 +4214,10 @@ gboolean math_canvas_key(GtkEventControllerKey*, guint keyval, guint, GdkModifie
d->drag_mode = MathDrag::None;
d->wire_from = -1;
d->wire_detach = -1;
// Restoring the input is a mutation like any other, so the validation line has to be
// re-evaluated: `drag-begin` emptied the input and synced, leaving "input is not connected" on
// screen with `Apply` insensitive, and without this the undo left both there.
math_dialog_sync(d);
gtk_widget_queue_draw(d->canvas);
return TRUE;
}
@@ -4026,9 +4235,9 @@ gboolean math_canvas_key(GtkEventControllerKey*, guint keyval, guint, GdkModifie
return FALSE;
}
/// Adds a node of `op` and selects it. The grid placement is the same one the auto-place uses, so a
/// node added here lands clear of the others -- 160 is narrower than `kNodeW` plus a wire's tread,
/// which is why the tidy layout uses a wider pitch; this is only a first guess at a free cell.
/// Adds a node of `op` and selects it, at the first grid cell whose box overlaps nothing, so a node
/// added here lands clear of the others -- 160 is narrower than `kNodeW` plus a wire's tread, which
/// is why the tidy layout uses a wider pitch.
void math_add_node(MathDialog* d, MathOp op) {
// A node added to a graph that was *linear* -- its output was the last node -- becomes the output,
// which is what "add a gain and it applies" means. A graph whose output is an earlier node is a
@@ -4038,8 +4247,33 @@ void math_add_node(MathDialog* d, MathOp op) {
MathNode n;
n.op = op;
const std::size_t k = d->nodes.size();
n.x = 24.0 + 160.0 * static_cast<double>(k % 2u);
n.y = 20.0 + 58.0 * static_cast<double>(k / 2u);
// The first *free* cell of the grid, not cell `k`. Placing by `nodes.size()` reuses an occupied
// cell as soon as anything has been deleted -- delete the middle of three and add one and the new
// box lands exactly on an existing one, which breaks the documented rule that boxes must not
// overlap: `math_node_at()` searches topmost-first, so the covered box becomes unclickable. Same
// 160/58 pitch and two-column wrap as before; only the choice of cell changes. A graph that has
// taken every cell up to the bound falls through to cell `k` rather than searching for ever.
constexpr std::size_t kCellLimit = 64;
std::size_t cell = k;
for (std::size_t c = 0; c < kCellLimit; ++c) {
const double cx = 24.0 + 160.0 * static_cast<double>(c % 2u);
const double cy = 20.0 + 58.0 * static_cast<double>(c / 2u);
bool free_cell = true;
for (const MathNode& other : d->nodes) {
if (cx < other.x + kNodeW && other.x < cx + kNodeW && cy < other.y + kNodeH &&
other.y < cy + kNodeH) {
free_cell = false;
break;
}
}
if (free_cell) {
cell = c;
break;
}
}
n.x = 24.0 + 160.0 * static_cast<double>(cell % 2u);
n.y = 20.0 + 58.0 * static_cast<double>(cell / 2u);
// `k`, not `cell`: the new node's *index* is the end of the list whatever cell it was drawn in.
const int arity = math_op_arity(n.op);
if (arity >= 1 && k > 0) n.in_a = static_cast<int>(k) - 1;
if (arity >= 2 && k > 0) n.in_b = static_cast<int>(k) - 1;
@@ -4083,6 +4317,11 @@ void on_math_node_deleted(GtkButton*, gpointer user) {
d->drag_mode = MathDrag::None;
d->wire_from = -1;
d->wire_detach = -1;
// `MathWireRef::node` is an index into `d->nodes`, so a selection held across the erase names a
// *different* wire afterwards and `Delete` would clear an input on the wrong node. Cleared rather
// than remapped because the selected wire may have been one of the deleted node's own: a selection
// the user can no longer see, pointing at a surviving edge, is worse than no selection.
d->selected_wire = MathWireRef{-1, 0};
math_dialog_sync(d);
}
@@ -4141,15 +4380,38 @@ void on_math_op_changed(GObject* object, GParamSpec*, gpointer user) {
}
/// The form and the canvas from the selected node: which rows an op uses, what they hold, and which
/// nodes its inputs are wired to.
void math_dialog_sync(MathDialog* d) {
/// nodes its inputs are wired to. Called only through `math_dialog_sync()`, which guards it against
/// the re-entry the dropdowns' `notify::selected` would otherwise cause.
void math_dialog_sync_form(MathDialog* d) {
// A source naming a channel nobody acquires is moved to the first acquired one -- see the dropdown
// below for why. It happens *here*, above the validation line, because the move is a mutation that
// changes what `math_dialog_problem()` reports: reconciled after the label is written, a repaired
// source would be described as broken, with `Apply` insensitive, until some later sync.
std::vector<std::string> names;
const bool have_form =
d->selected >= 0 && d->selected < static_cast<int>(d->nodes.size());
if (d->source_drop != nullptr && have_form) {
names = math_source_names(*d->app);
MathNode& sel = d->nodes[static_cast<std::size_t>(d->selected)];
if (!names.empty() && std::find(names.begin(), names.end(), sel.source) == names.end()) {
sel.source = names.front();
}
}
// Before the early return below: an out-of-range selection is exactly the state in which a stale
// validation line is most misleading -- a graph with no node selected can still be broken, and the
// user has no form to read instead.
if (d->problem != nullptr) {
const std::string why = math_dialog_problem(d);
gtk_label_set_text(GTK_LABEL(d->problem), why.c_str());
gtk_widget_set_visible(d->problem, !why.empty());
// A refusal from the last gesture wins the *text*, because nothing in the graph's state records
// it; `Apply` still follows `why`, since a refused wire wrote nothing. Consumed here, so the
// message lasts exactly until the next sync.
const std::string shown = d->refusal.empty() ? why : d->refusal;
d->refusal.clear();
gtk_label_set_text(GTK_LABEL(d->problem), shown.c_str());
// The label ellipsizes (see its construction), so the tooltip is the only place the full text
// can be read. Set here, beside the text, so the two cannot disagree.
gtk_widget_set_tooltip_text(d->problem, shown.empty() ? nullptr : shown.c_str());
gtk_widget_set_visible(d->problem, !shown.empty());
if (d->apply != nullptr) gtk_widget_set_sensitive(d->apply, why.empty());
}
if (d->selected < 0 || d->selected >= static_cast<int>(d->nodes.size())) {
@@ -4177,22 +4439,15 @@ void math_dialog_sync(MathDialog* d) {
if (d->source_drop != nullptr) {
// The *acquired* channels, which is what Apply validates against. Listing every signal let a
// user pick a source that could never be made valid -- and put the stream's timestamp signal,
// which has no samples at all, at the top of the list.
const std::vector<std::string> names = math_source_names(*d->app);
// which has no samples at all, at the top of the list. A source that is not in this list would
// read NaN for ever while the dropdown showed the first entry as if it were selected, so the
// node was already moved to the first entry at the top of this function.
GtkStringList* list = gtk_string_list_new(nullptr);
guint pick = 0;
bool found = false;
for (std::size_t i = 0; i < names.size(); ++i) {
gtk_string_list_append(list, names[i].c_str());
if (names[i] == n.source) {
pick = static_cast<guint>(i);
found = true;
if (names[i] == n.source) pick = static_cast<guint>(i);
}
}
// A source that is not in the list is a graph naming a channel nobody acquires -- it would read
// NaN for ever while the dropdown showed the first entry as if it were selected. The node is
// moved to what the form is actually showing, so what is on screen is what the graph says.
if (!found && !names.empty()) n.source = names.front();
gtk_drop_down_set_model(GTK_DROP_DOWN(d->source_drop), G_LIST_MODEL(list));
gtk_drop_down_set_selected(GTK_DROP_DOWN(d->source_drop), pick);
g_object_unref(list);
@@ -4221,6 +4476,20 @@ void math_dialog_sync(MathDialog* d) {
gtk_widget_queue_draw(d->canvas);
}
/// The one entry point, and the re-entrancy guard: setting the op dropdown emits `notify::selected`,
/// whose handler syncs, so the form's own write re-entered the sync and the inner call rewrote the
/// validation label -- erasing a refusal in the same call that displayed it. One level only; the
/// flag is reset by a scope guard, so the early return inside cannot leave it set.
void math_dialog_sync(MathDialog* d) {
if (d->syncing) return;
struct Guard {
MathDialog* d;
~Guard() { d->syncing = false; }
} guard{d};
d->syncing = true;
math_dialog_sync_form(d);
}
void on_math_apply(GtkButton*, gpointer user) {
MathDialog* d = static_cast<MathDialog*>(user);
on_math_param_changed(nullptr, d);
@@ -4483,10 +4752,14 @@ void open_math_dialog(App& app, int edit) {
gtk_widget_set_halign(d->inputs, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(form), math_row("wired", d->inputs, &d->rows[5]));
// Why the graph cannot be saved, in the form column where the user is already reading. Wrapped,
// so a long channel name does not widen the dialog, and hidden when there is nothing to say.
// Why the graph cannot be saved, in the form column where the user is already reading. Hidden
// when there is nothing to say, and **ellipsized rather than wrapped**, with the full text on the
// tooltip (`math_dialog_sync()` sets both together): layout must not depend on content. This label
// sits in a dialog whose canvas is `hexpand`, so a wrapping message re-heights the form -- and a
// long channel name in it would widen the dialog -- on a mere change of graph. The same rule the
// status bar and the y gutter are built to.
d->problem = gtk_label_new("");
gtk_label_set_wrap(GTK_LABEL(d->problem), TRUE);
gtk_label_set_ellipsize(GTK_LABEL(d->problem), PANGO_ELLIPSIZE_END);
gtk_label_set_xalign(GTK_LABEL(d->problem), 0.0f);
gtk_widget_add_css_class(d->problem, "error");
gtk_widget_set_visible(d->problem, FALSE);
@@ -7521,27 +7794,53 @@ gboolean math_probe(gpointer user) {
p->view_x = 50.0;
p->view_y = 20.0;
p->nodes[1].in_a = 0;
// Halfway along the wire in *graph* units: the bezier's own midpoint, which for a cubic
// with these control points is the average of the two ends plus three eighths of the
// tread -- so sampling the curve is the only honest way to find a point on it.
// The two boxes are put several rows apart on purpose: that is the geometry a tidied graph
// has, and it is the only one in which the pick can fail. Where consecutive curve samples are
// close together, a hit test that measures to the sample *points* and one that measures to the
// *segments* between them give the same answer, so a probe taken on a short wire asserts
// nothing about either.
p->nodes[0].x = 24.0;
p->nodes[0].y = 20.0;
p->nodes[1].x = 260.0;
p->nodes[1].y = 300.0;
double ax = 0.0;
double ay = 0.0;
double bx = 0.0;
double by = 0.0;
math_output_pos(p->nodes[0], ax, ay);
math_input_pos(p->nodes[1], 0, bx, by);
const double t = 0.5;
// **Between two samples, not at one.** `math_wire_at()` samples the cubic at `s / 24`, so
// `t = 0.5` is exactly sample 12 and a point-distance test passes there by construction --
// the same mistake as a hit test taken at the identity view. `t = 0.52` falls between samples
// 12 and 13, and `off_sample` below is the measured distance from the pick to the nearest of
// the 25 sample points, in graph units, beside the tolerance the pick is allowed: it reads
// off_sample=8.4 against tol=3.0, so a point test misses and only a segment test can hit.
const auto curve_at = [&](double t, double& px, double& py) {
const double u = 1.0 - t;
const double mx = u * u * u * ax + 3 * u * u * t * (ax + 40.0) +
3 * u * t * t * (bx - 40.0) + t * t * t * bx;
const double my = u * u * u * ay + 3 * u * u * t * ay + 3 * u * t * t * by + t * t * t * by;
px = u * u * u * ax + 3 * u * u * t * (ax + 40.0) + 3 * u * t * t * (bx - 40.0) +
t * t * t * bx;
py = u * u * u * ay + 3 * u * u * t * ay + 3 * u * t * t * by + t * t * t * by;
};
const double t = 0.52;
double mx = 0.0;
double my = 0.0;
curve_at(t, mx, my);
double off_sample = 1e300;
for (int s = 0; s <= 24; ++s) {
double sx = 0.0;
double sy = 0.0;
curve_at(static_cast<double>(s) / 24.0, sx, sy);
off_sample = std::min(off_sample, std::max(std::fabs(mx - sx), std::fabs(my - sy)));
}
const MathWireRef hit = math_wire_at_canvas(p, mx * p->view_scale + p->view_x,
my * p->view_scale + p->view_y);
p->selected_wire = hit;
math_canvas_key(nullptr, GDK_KEY_Delete, 0, static_cast<GdkModifierType>(0), p);
std::fprintf(stderr,
"[math-probe] wire: pick node=%d which=%d -> in_a=%d (want 1 0 -1)\n",
hit.node, hit.which, p->nodes[1].in_a);
"[math-probe] wire: pick node=%d which=%d -> in_a=%d off_sample=%.1f tol=%.1f "
"(want 1 0 -1, off_sample > tol)\n",
hit.node, hit.which, p->nodes[1].in_a, off_sample,
kWirePick / p->view_scale);
p->nodes[1].in_a = 0;
p->selected_wire = MathWireRef{-1, 0};
}
@@ -7591,6 +7890,43 @@ gboolean math_probe(gpointer user) {
std::fprintf(stderr, "[math-probe] wire: self refused=%d why=[%s] (want 1)\n",
self_ok ? 0 : 1, self_why.c_str());
// The same refusal again, through the **handler**. The line above asserts only
// `math_connect()`'s out-parameter: the UI path used to screen the self case out before the
// call and then drop `why` on the floor, so a user drew the wire, nothing appeared and nothing
// was said -- which is the whole of "the editor is broken" -- while the direct call passed.
// This half asserts what the *validation line* reads after `drag-end`, which is the only place
// that defect is visible. A synthetic GTK gesture is not reachable headlessly, so the drag's
// state is set up by hand and the real handler called: `press_cx`/`press_cy` plus a zero offset
// is a release exactly on the input port of `wire_from` itself.
for (std::size_t i = 0; i < c->nodes.size(); ++i) {
// Spread out, so the release point belongs to one port and not to three stacked at (0,0).
c->nodes[i].x = 24.0 + 200.0 * static_cast<double>(i);
c->nodes[i].y = 20.0;
}
math_set_scale(c, 1.0);
c->view_x = 0.0;
c->view_y = 0.0;
int self_node = -1;
for (std::size_t i = 0; i < c->nodes.size() && self_node < 0; ++i) {
if (math_op_arity(c->nodes[i].op) >= 1) self_node = static_cast<int>(i);
}
if (self_node >= 0) {
double sx = 0.0;
double sy = 0.0;
math_input_pos(c->nodes[static_cast<std::size_t>(self_node)], 0, sx, sy);
c->drag_mode = MathDrag::Wire;
c->wire_from = self_node;
c->wire_detach = -1;
c->press_cx = sx;
c->press_cy = sy;
math_canvas_drag_end(nullptr, 0.0, 0.0, c);
const char* line = gtk_label_get_text(GTK_LABEL(c->problem));
std::fprintf(stderr,
"[math-probe] wire: self through drag-end node=%d problem=[%s] "
"(want the refusal text, not empty)\n",
self_node, line != nullptr ? line : "(null)");
}
// A cycle: the node that already feeds the other is asked to read it back.
const int reader_idx = c->nodes[1].in_a == 0 ? 2 : 1;
const int feeder_idx = reader_idx == 2 ? 1 : 2;
@@ -7680,6 +8016,39 @@ gboolean math_probe(gpointer user) {
if (math_op_index(math_op_at(i)) != i) round = 0;
}
std::fprintf(stderr, "[math-probe] op pick: index round trip=%d (want 1)\n", round);
// And placement: the first *free* grid cell, not cell `nodes.size()`. The boxes are put back
// on the grid, the middle one deleted and one added -- under the old placement the new box
// landed on the cell the deleted node's *successor* had moved out of the count for, i.e. on an
// occupied one, and `math_node_at()` searches topmost-first, so the covered box becomes
// unclickable. `cells=` is where the boxes ended up; `overlaps` is the assertion.
if (o->nodes.size() >= 3) {
for (std::size_t i = 0; i < o->nodes.size(); ++i) {
o->nodes[i].x = 24.0 + 160.0 * static_cast<double>(i % 2u);
o->nodes[i].y = 20.0 + 58.0 * static_cast<double>(i / 2u);
}
o->selected = 1;
on_math_node_deleted(nullptr, o);
math_add_node(o, MathOp::Gain);
int overlaps = 0;
std::string cells;
for (std::size_t i = 0; i < o->nodes.size(); ++i) {
const MathNode& p2 = o->nodes[i];
cells += (i != 0 ? " " : "");
cells += "(" + std::to_string(static_cast<int>(p2.x)) + "," +
std::to_string(static_cast<int>(p2.y)) + ")";
for (std::size_t j = i + 1; j < o->nodes.size(); ++j) {
const MathNode& q2 = o->nodes[j];
if (p2.x < q2.x + kNodeW && q2.x < p2.x + kNodeW && p2.y < q2.y + kNodeH &&
q2.y < p2.y + kNodeH) {
++overlaps;
}
}
}
std::fprintf(stderr,
"[math-probe] add node: after a delete cells=[%s] overlaps=%d (want 0)\n",
cells.c_str(), overlaps);
}
}
}
if (a->math_dialog != nullptr) {
@@ -8966,6 +9335,100 @@ gboolean menus_probe_step(gpointer user) {
return G_SOURCE_REMOVE;
}
/// Checks that a chosen trace colour reaches the **trace** and not merely the resolver.
///
/// It reads the colour back out of `plot::Plot::trace()` -- what the renderer will stroke -- and out
/// of the signal list's chip markup, because the chip's only job is to name a trace and the two
/// resolving a colour by separate routes is the one failure a chip cannot survive. The override is
/// `#123456`, deliberately in no palette slot: an override that happened to equal the entry it
/// replaced would pass against an implementation that ignored it entirely.
gboolean color_probe(gpointer user) {
App* a = static_cast<App*>(user);
// Any slot with a *bound* trace: an unbound one has no colour on screen to check.
int probe_row = -1;
int probe_slot = -1;
std::string name;
for (int r = 0; r < a->rows->row_count() && probe_row < 0; ++r) {
const RowSet::Row& row = a->rows->row(r);
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (row.signal_name[si].empty() || row.channel[si] < 0) continue;
probe_row = r;
probe_slot = s;
name = row.signal_name[si];
break;
}
}
if (probe_row < 0) {
std::fprintf(stderr, "[color] no bound trace to recolour\n");
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
// The chip as the signal list actually writes it, and the trace as the renderer will read it.
const auto chip_of = [&]() {
std::lock_guard<std::mutex> lock(a->model->mutex());
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == name) return signal_chip_markup(*a, signals, i);
}
return std::string();
};
const auto trace_of = [&]() {
const plot::Plot* p = a->rows->row(probe_row).plot;
if (p == nullptr) return std::string("#??????");
return color_hex(p->trace(static_cast<plot::u32>(probe_slot)).color);
};
// A chip carries its colour inside `<span foreground="...">`, so "the chip says X" is the
// substring test and not a recomputation of what it ought to say.
const auto chip_says = [](const std::string& markup, const std::string& hex) {
return markup.find("\"" + hex + "\"") != std::string::npos ? 1 : 0;
};
const std::string before = trace_of();
std::fprintf(stderr, "[color] '%s' at row %d slot %d: trace=%s chip_agrees=%d (want 1)\n",
name.c_str(), probe_row, probe_slot, before.c_str(),
chip_says(chip_of(), before));
constexpr plot::u32 kPick = 0x123456u;
a->rows->set_signal_color(name, kPick);
// The same path the frame tick takes: a trace's colour is fixed at `add_trace()`, so this is a
// new figure, and reading the old `Row::plot` after it would read the figure that was replaced.
relayout_areas(*a);
const std::string after = trace_of();
std::fprintf(stderr, "[color] override: trace=%s chip_agrees=%d (want #123456 1)\n",
after.c_str(), chip_says(chip_of(), after));
// The session file, which is where a colour has to survive to be worth choosing. Through the real
// writer and reader on a temp path, not the live session's -- a probe must not leave a colour in
// the file a later run would inherit.
const std::string tmp = std::string(g_get_tmp_dir()) + "/udpscope-color-probe.toml";
LayoutState saved = collect_layout(*a);
int round_trip = -1;
std::size_t entries = 0;
if (save_layout(tmp, saved)) {
LayoutState back;
if (load_layout(tmp, back)) {
entries = back.colors.size();
for (const auto& c : back.colors) {
if (c.name == name) round_trip = static_cast<int>(c.rgb);
}
}
}
std::remove(tmp.c_str());
std::fprintf(stderr, "[color] session: entries=%zu rgb=#%06X (want 1 #123456)\n", entries,
round_trip < 0 ? 0u : static_cast<unsigned>(round_trip));
a->rows->clear_signal_color(name);
relayout_areas(*a);
const std::string restored = trace_of();
std::fprintf(stderr, "[color] default: trace=%s chip_agrees=%d (want %s 1)\n", restored.c_str(),
chip_says(chip_of(), restored), before.c_str());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// Fires a representative accel from each group and reports the state it changed, then
/// checks the GtkEditable refusal. It goes through run_app_action() for the actions and
/// through the real handler for the refusal, because the refusal lives in the handler.
@@ -9172,6 +9635,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
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")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), color_probe, &app);
}
// Our own options were consumed by parse_args(); GTK would reject them, so only
// the program name is handed to g_application_run().