From f238e717b2c3122605f0dc05a2247074f812ffc4 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Wed, 7 Oct 2026 07:09:49 +0200 Subject: [PATCH] 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. --- README.md | 7 +- docs/claude/invariants-math-channels.md | 64 +++++- docs/claude/verification.md | 12 +- src/help/11-math.txt | 16 +- src/ui.cpp | 276 +++++++++++++++++++++--- 5 files changed, 318 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index b2b16a2..9284d98 100644 --- a/README.md +++ b/README.md @@ -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 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 diff --git a/docs/claude/invariants-math-channels.md b/docs/claude/invariants-math-channels.md index d264663..92906e5 100644 --- a/docs/claude/invariants-math-channels.md +++ b/docs/claude/invariants-math-channels.md @@ -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 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 +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] 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 +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. * `[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 +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 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 +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 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(op)` worked only because the two happen to agree today. The session-file rule in this file already says an op is written as diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 01470a1..af8a9b5 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -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 diff --git a/src/help/11-math.txt b/src/help/11-math.txt index 5254348..31a7027 100644 --- a/src/help/11-math.txt +++ b/src/help/11-math.txt @@ -5,22 +5,24 @@ nodes, operations, one output — and udpscope then treats the result as an ordi How to reach it -The math button in the toolbar, or g. The pane's + 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 g. The pane's + 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, add node is a menu button whose popover picks the op, so the +node arrives as the operation you asked for. Graph gestures: drag 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 Delete to remove it; drag a box to move it. Wheel zooms about the pointer. Drag the background to -pan. f fits all nodes into view; Ctrl+0 resets the view. Tidy in the -toolbar lays the graph out in topological columns. The orange dot marks the output node; click -set as output to move it. +pan. f fits all nodes into view; Ctrl+0 resets the view. tidy, the button +beside fit in the editor, lays the graph out in topological columns and frames it. The orange +dot marks the output node; click set as output to move it. Worth knowing • The graph is evaluated as an ordered list, so a node may only read ones above 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 is 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 stored samples — decimated and averaged — so a cutoff you type in diff --git a/src/ui.cpp b/src/ui.cpp index 9f10466..b334b7b 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -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 // 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 +3353,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 +3597,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(d->nodes.size()) - 1; i >= 0; --i) { const MathNode& n = d->nodes[static_cast(i)]; 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); 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(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 +3991,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. - MathNode& back = d->nodes[static_cast(d->wire_detach)]; - (d->wire_detach_which == 0 ? back.in_a : back.in_b) = d->wire_from; + if (d->wire_detach >= 0 && d->wire_detach < static_cast(d->nodes.size())) { + MathNode& back = d->nodes[static_cast(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 +4064,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; } @@ -4038,8 +4097,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(k % 2u); - n.y = 20.0 + 58.0 * static_cast(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(c % 2u); + const double cy = 20.0 + 58.0 * static_cast(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(cell % 2u); + n.y = 20.0 + 58.0 * static_cast(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(k) - 1; if (arity >= 2 && k > 0) n.in_b = static_cast(k) - 1; @@ -4083,6 +4167,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 +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 -/// 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) { // 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(d->nodes.size())) { @@ -4221,6 +4319,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(user); 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_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 +7637,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; - 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; + // **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; + 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(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(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 +7733,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(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(i); + } + if (self_node >= 0) { + double sx = 0.0; + double sy = 0.0; + math_input_pos(c->nodes[static_cast(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 +7859,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(i % 2u); + o->nodes[i].y = 20.0 + 58.0 * static_cast(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(p2.x)) + "," + + std::to_string(static_cast(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) {