Compare commits
3
Commits
300b33159e
...
0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4003084648 | ||
|
|
8bc1fe8b5c | ||
|
|
f238e717b2 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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
@@ -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().
|
||||
|
||||
Reference in new issue
Block a user