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.
This commit is contained in:
Martino Ferrari committed 2026-10-07 07:09:49 +02:00
1 parent 300b33159e
commit f238e717b2
5 files changed
+318 -57

No files matched your search

+4 -3
View File
@@ -706,9 +706,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 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 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 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 background to pan, `f` to fit, `Ctrl+0` to reset.** *tidy*, a button in the editor dialog beside
topological columns. The op is chosen when the node is added — the picker appears at the `+` — so *fit*, lays out all nodes in topological columns. The op is chosen when the node is added — *add
there is no opless placeholder to build past. The graph is a *list* walked in order, so a node can 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 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: 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 `math_connect()` checks the trial edge set with Kahn's algorithm before writing anything. The node
+53 -11
View File
@@ -23,9 +23,9 @@ 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 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 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 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()`, drag pans, `f` fits, `Ctrl+0` resets**.
so no rebuild can invalidate a live callback handle -- the same shape the measurements dialog Its widgets are created once and only shown, hidden and set by `math_dialog_sync()`, so no rebuild
uses. `UDPSCOPE_PROBE_MATH_MS` defines two graphs and opens the editor (a construction crash test 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 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 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) math is on the producer thread, so what it can break is packet capture, never the frame rate)
@@ -59,11 +59,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] 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: emptied_at_begin=-1 in_a=-1 (want -1 -1)`,
`[math-probe] detach: escape restores in_a=0 (want 0)`. `[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 [])` — * `[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. 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: self refused=1 why=[a node cannot read itself]` and
`[math-probe] wire: cycle refused=1 why=[...]` — and **nothing is written** on either. `[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: 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 `[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 **above** `math_dialog_sync()`'s early return; a stale message when there is no selection to
@@ -72,7 +91,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. 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: 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] 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: Four rules here were wrong first and are worth keeping:
* **A `Source` may only name an *acquired* channel** -- `ScopeModel::channels()`, which is what the * **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**. dialog's dropdown lists, **and both places that create a node must take one from there**.
@@ -103,10 +129,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 * **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 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 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; 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 and `math_add_node()` takes the **first free cell** rather than cell `nodes.size()`: placing by
for a wire's 40-unit horizontal tread — at 160 the bezier's control points cross and the wire the count reuses an occupied cell as soon as anything has been deleted — delete the middle of
doubles back on itself. The probe prints the box the drag *began over* for exactly this reason. 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) 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. 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 * **`prepare_math_locked()` runs from `rebuild_locked()`, so *every* plan change re-prepares the
@@ -158,6 +189,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 * **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 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. 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 * **`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 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 two happen to agree today. The session-file rule in this file already says an op is written as
+8 -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** `-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 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 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 generated file, which GCC itself supports). The fifth warning that used to appear here was an unused
unused `output_built` in `on_math_apply()` — Task 6's live validation check reads that value, so `output_built` in `math_probe()`; it was **deleted** as dead code — redundant with the
the variable is no longer unused. And an exit-dump run emits **no** `Gtk-CRITICAL` at all. A `output_after_add` already printed on the same line — and that deletion is what dropped the baseline
fifth warning, or any critical, is a finding. 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`, 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 from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump
+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> <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 The math button in the toolbar, or <tt>g</tt>. The pane's <tt>+</tt> adds a channel, and opening
chooses the op — and opening one shows the graph editor: nodes as boxes, click a box to select it one shows the graph editor: nodes as boxes, click a box to select it and edit its parameters beside
and edit its parameters beside the canvas. 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 <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 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 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 pan. <tt>f</tt> fits all nodes into view; <tt>Ctrl+0</tt> resets the view. <i>tidy</i>, the button
toolbar lays the graph out in topological columns. The orange dot marks the output node; click beside <i>fit</i> in the editor, lays the graph out in topological columns and frames it. The orange
<i>set as output</i> to move it. dot marks the output node; click <i>set as output</i> to move it.
<b>Worth knowing</b> <b>Worth knowing</b>
• The graph is evaluated as an ordered list, so a node may only read ones <i>above</i> it. A • 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 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, • 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. 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 • The math runs on the <b>stored</b> samples — decimated and averaged — so a cutoff you type in
+244 -32
View File
@@ -3282,8 +3282,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 // 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 // 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 // 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 // may only read ones above it -- but that order is the *evaluator's* contract, not a restriction on
// cycle cannot be drawn at all. // 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 kNodeW = 132.0;
constexpr double kNodeH = 46.0; constexpr double kNodeH = 46.0;
constexpr double kPort = 10.0; constexpr double kPort = 10.0;
@@ -3351,10 +3353,25 @@ struct MathDialog {
GtkWidget* hi = nullptr; GtkWidget* hi = nullptr;
GtkWidget* rows[6] = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}; GtkWidget* rows[6] = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr};
GtkWidget* inputs = nullptr; GtkWidget* inputs = nullptr;
/// The validation line and the button it governs. Both are set from `math_dialog_problem()` and /// The validation line and the button it governs. Both are set from `math_dialog_sync()` and from
/// from nowhere else, so what the line says and what the button allows cannot disagree. /// nowhere else, so what the line says and what the button allows cannot disagree.
GtkWidget* problem = nullptr; GtkWidget* problem = nullptr;
GtkWidget* apply = 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 /// The channels a math channel may read: the ones the model actually acquires. Offering the whole
@@ -3580,12 +3597,36 @@ int math_node_at_canvas(const MathDialog* d, double cx, double cy) {
return math_node_at(d, gx, gy); 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 /// 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 /// 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 /// 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) { MathWireRef math_wire_at(const MathDialog* d, double gx, double gy, double tol) {
constexpr int kSamples = 24; 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) { 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 MathNode& n = d->nodes[static_cast<std::size_t>(i)];
const int ins[2] = {n.in_a, n.in_b}; const int ins[2] = {n.in_a, n.in_b};
@@ -3600,18 +3641,24 @@ MathWireRef math_wire_at(const MathDialog* d, double gx, double gy, double tol)
math_input_pos(n, k, bx, by); math_input_pos(n, k, bx, by);
const double c1x = ax + 40.0; const double c1x = ax + 40.0;
const double c2x = bx - 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 t = static_cast<double>(s) / kSamples;
const double u = 1.0 - t; 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 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; 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) { const double d2 = math_seg_dist2(gx, gy, prev_x, prev_y, px, py);
return MathWireRef{i, k}; 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 /// The pick radius for a wire, in screen pixels. Smaller than a port's, so a wire that passes
@@ -3944,15 +3991,23 @@ void math_canvas_drag_end(GtkGestureDrag*, double dx, double dy, gpointer user)
// node may be a column away from the press point. // node may be a column away from the press point.
int which = kPortOut; int which = kPortOut;
const int target = math_port_at_canvas(d, d->press_cx + dx, d->press_cy + dy, which); 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; std::string why;
if (math_connect(d, d->wire_from, target, which, &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_connect()` may have permuted the list, so the target's index is no longer `target`.
math_dialog_sync(d); 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. // A refused wire must not also lose the one the drag detached to start with.
MathNode& back = d->nodes[static_cast<std::size_t>(d->wire_detach)]; if (d->wire_detach >= 0 && d->wire_detach < static_cast<int>(d->nodes.size())) {
(d->wire_detach_which == 0 ? back.in_a : back.in_b) = d->wire_from; 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); math_dialog_sync(d);
} }
} }
@@ -4009,6 +4064,10 @@ gboolean math_canvas_key(GtkEventControllerKey*, guint keyval, guint, GdkModifie
d->drag_mode = MathDrag::None; d->drag_mode = MathDrag::None;
d->wire_from = -1; d->wire_from = -1;
d->wire_detach = -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); gtk_widget_queue_draw(d->canvas);
return TRUE; return TRUE;
} }
@@ -4038,8 +4097,33 @@ void math_add_node(MathDialog* d, MathOp op) {
MathNode n; MathNode n;
n.op = op; n.op = op;
const std::size_t k = d->nodes.size(); const std::size_t k = d->nodes.size();
n.x = 24.0 + 160.0 * static_cast<double>(k % 2u); // The first *free* cell of the grid, not cell `k`. Placing by `nodes.size()` reuses an occupied
n.y = 20.0 + 58.0 * static_cast<double>(k / 2u); // 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); const int arity = math_op_arity(n.op);
if (arity >= 1 && k > 0) n.in_a = static_cast<int>(k) - 1; 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; if (arity >= 2 && k > 0) n.in_b = static_cast<int>(k) - 1;
@@ -4083,6 +4167,11 @@ void on_math_node_deleted(GtkButton*, gpointer user) {
d->drag_mode = MathDrag::None; d->drag_mode = MathDrag::None;
d->wire_from = -1; d->wire_from = -1;
d->wire_detach = -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); math_dialog_sync(d);
} }
@@ -4141,15 +4230,24 @@ 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 /// 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. /// nodes its inputs are wired to. Called only through `math_dialog_sync()`, which guards it against
void math_dialog_sync(MathDialog* d) { /// the re-entry the dropdowns' `notify::selected` would otherwise cause.
void math_dialog_sync_form(MathDialog* d) {
// Before the early return below: an out-of-range selection is exactly the state in which a stale // 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 // validation line is most misleading -- a graph with no node selected can still be broken, and the
// user has no form to read instead. // user has no form to read instead.
if (d->problem != nullptr) { if (d->problem != nullptr) {
const std::string why = math_dialog_problem(d); const std::string why = math_dialog_problem(d);
gtk_label_set_text(GTK_LABEL(d->problem), why.c_str()); // A refusal from the last gesture wins the *text*, because nothing in the graph's state records
gtk_widget_set_visible(d->problem, !why.empty()); // 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->apply != nullptr) gtk_widget_set_sensitive(d->apply, why.empty());
} }
if (d->selected < 0 || d->selected >= static_cast<int>(d->nodes.size())) { if (d->selected < 0 || d->selected >= static_cast<int>(d->nodes.size())) {
@@ -4221,6 +4319,20 @@ void math_dialog_sync(MathDialog* d) {
gtk_widget_queue_draw(d->canvas); 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) { void on_math_apply(GtkButton*, gpointer user) {
MathDialog* d = static_cast<MathDialog*>(user); MathDialog* d = static_cast<MathDialog*>(user);
on_math_param_changed(nullptr, d); on_math_param_changed(nullptr, d);
@@ -4483,10 +4595,14 @@ void open_math_dialog(App& app, int edit) {
gtk_widget_set_halign(d->inputs, GTK_ALIGN_START); gtk_widget_set_halign(d->inputs, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(form), math_row("wired", d->inputs, &d->rows[5])); 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, // Why the graph cannot be saved, in the form column where the user is already reading. Hidden
// so a long channel name does not widen the dialog, and hidden when there is nothing to say. // 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(""); 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_label_set_xalign(GTK_LABEL(d->problem), 0.0f);
gtk_widget_add_css_class(d->problem, "error"); gtk_widget_add_css_class(d->problem, "error");
gtk_widget_set_visible(d->problem, FALSE); gtk_widget_set_visible(d->problem, FALSE);
@@ -7521,27 +7637,53 @@ gboolean math_probe(gpointer user) {
p->view_x = 50.0; p->view_x = 50.0;
p->view_y = 20.0; p->view_y = 20.0;
p->nodes[1].in_a = 0; p->nodes[1].in_a = 0;
// Halfway along the wire in *graph* units: the bezier's own midpoint, which for a cubic // The two boxes are put several rows apart on purpose: that is the geometry a tidied graph
// with these control points is the average of the two ends plus three eighths of the // has, and it is the only one in which the pick can fail. Where consecutive curve samples are
// tread -- so sampling the curve is the only honest way to find a point on it. // 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 ax = 0.0;
double ay = 0.0; double ay = 0.0;
double bx = 0.0; double bx = 0.0;
double by = 0.0; double by = 0.0;
math_output_pos(p->nodes[0], ax, ay); math_output_pos(p->nodes[0], ax, ay);
math_input_pos(p->nodes[1], 0, bx, by); 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
const double u = 1.0 - t; // `t = 0.5` is exactly sample 12 and a point-distance test passes there by construction --
const double mx = u * u * u * ax + 3 * u * u * t * (ax + 40.0) + // the same mistake as a hit test taken at the identity view. `t = 0.52` falls between samples
3 * u * t * t * (bx - 40.0) + t * t * t * bx; // 12 and 13, and `off_sample` below is the measured distance from the pick to the nearest of
const double my = u * u * u * ay + 3 * u * u * t * ay + 3 * u * t * t * by + t * t * t * by; // 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;
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, const MathWireRef hit = math_wire_at_canvas(p, mx * p->view_scale + p->view_x,
my * p->view_scale + p->view_y); my * p->view_scale + p->view_y);
p->selected_wire = hit; p->selected_wire = hit;
math_canvas_key(nullptr, GDK_KEY_Delete, 0, static_cast<GdkModifierType>(0), p); math_canvas_key(nullptr, GDK_KEY_Delete, 0, static_cast<GdkModifierType>(0), p);
std::fprintf(stderr, std::fprintf(stderr,
"[math-probe] wire: pick node=%d which=%d -> in_a=%d (want 1 0 -1)\n", "[math-probe] wire: pick node=%d which=%d -> in_a=%d off_sample=%.1f tol=%.1f "
hit.node, hit.which, p->nodes[1].in_a); "(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->nodes[1].in_a = 0;
p->selected_wire = MathWireRef{-1, 0}; p->selected_wire = MathWireRef{-1, 0};
} }
@@ -7591,6 +7733,43 @@ gboolean math_probe(gpointer user) {
std::fprintf(stderr, "[math-probe] wire: self refused=%d why=[%s] (want 1)\n", std::fprintf(stderr, "[math-probe] wire: self refused=%d why=[%s] (want 1)\n",
self_ok ? 0 : 1, self_why.c_str()); 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. // 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 reader_idx = c->nodes[1].in_a == 0 ? 2 : 1;
const int feeder_idx = reader_idx == 2 ? 1 : 2; const int feeder_idx = reader_idx == 2 ? 1 : 2;
@@ -7680,6 +7859,39 @@ gboolean math_probe(gpointer user) {
if (math_op_index(math_op_at(i)) != i) round = 0; 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); 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) { if (a->math_dialog != nullptr) {