The reported "cannot add edges" has a specific cause: the output port's hit radius reaches past the box it belongs to, and the press handler re-derives the owning node with a box test, so clicking the visible dot's outer half sets pending_out to -1 and silently disarms. Edge deletion has no code path at all. The design replaces the two-click protocol with a drag gesture, makes the list order the editor's own invariant via a topological re-sort rather than a rule the user must obey, adds a view transform with every hit test routed through its inverse, and trades the modal alert on Apply for a live validation line. It also records that the Cancel button still calls gtk_window_destroy -- the dangling dialog pointer CLAUDE.md documents as fixed was only fixed on the Apply path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
24 KiB
The math node editor: wires you can draw and delete, and a canvas you can navigate
The math channel editor works as a form and fails as a graph editor. Three things were reported — edges cannot be added, edges cannot be deleted, and the canvas cannot be zoomed or panned — and all three are true. The first is a defect with a specific cause, the second has no code path at all, and the third is an absence. Beyond them the editor asks the user to obey a rule the data structure has (a node may only read nodes above it in the list) rather than maintaining that rule itself, and it reports an invalid graph in a modal alert at the end instead of on the canvas as it is being built.
This redesigns the canvas interaction. Nothing below changes MathNode, MathChannel, the
evaluator in src/math_channel.cpp, the session format, or the producer's path.
Scope
In: the canvas view transform, the port and wire hit tests, the gesture state machine
(node drag / wire drag / background pan / wheel zoom), edge creation and deletion, a
topological re-sort that lets a wire be drawn in either direction, an op picker on the add
button, a live validation line that replaces the modal alert, a tidy/auto-layout pass, the
Cancel button's close path, and the [math-probe] assertions for all of it.
Out: parameters drawn on the node boxes (explicitly declined — the form beside the canvas is
where a node's values live). No new ops. No change to math_channel_valid()'s rules, to the
evaluator, to MathRuntime, or to how a derived channel is acquired. No node library panel,
no copy/paste, no undo.
Why edges cannot be added today
math_canvas_pressed (src/ui.cpp:3455) handles an output-port hit like this:
const int port = math_port_at(d, x, y, which);
if (port == -2) {
const int node = math_node_at(d, x, y);
d->pending_out = (node >= 0 && d->pending_out == node) ? -1 : node;
...
}
math_port_at throws away which node's output was hit — it returns the sentinel -2 — and
the handler recovers the node with math_node_at(), which matches only inside the box. The
output square is drawn at x = n.x + kNodeW, i.e. straddling the right edge: half of it is
outside. Its hit radius is kPort (±10) while it is drawn at ±kPort * 0.5 (±5), so the
clickable region extends 10 px past the edge and math_node_at() answers -1 for every pixel
of it beyond the first. pending_out is then set to -1 — a silent disarm. Clicking the
visible dot's outer half, which is most of what the eye aims at, arms nothing, and the input
squares never light up. That is the whole of "it is not possible to add edges": the mechanism
exists and the first click of the two-click sequence usually fails.
It is worth fixing the cause and not only the symptom, because the same re-derivation would break again under the view transform below.
Edge deletion needs no diagnosis: no path in the file ever writes -1 back into in_a or
in_b except on_math_node_deleted, which is deleting the whole node.
The view transform
MathDialog gains three doubles:
double view_scale = 1.0; ///< canvas pixels per graph unit
double view_x = 0.0; ///< canvas pixels: where graph (0,0) is drawn
double view_y = 0.0;
Graph coordinates are what MathNode::x/y already are and what the session file stores, so a
view change is never persisted — reopening the editor shows the graph at 1:1, which is the
right default for a window whose size the user did not choose either.
Two conversions, and every hit test goes through the second:
// canvas <- graph: applied once to the cairo context, never per item.
cairo_translate(cr, d->view_x, d->view_y);
cairo_scale(cr, d->view_scale, d->view_scale);
// graph <- canvas: the inverse, for a pointer position.
void math_to_graph(const MathDialog* d, double cx, double cy, double& gx, double& gy) {
gx = (cx - d->view_x) / d->view_scale;
gy = (cy - d->view_y) / d->view_scale;
}
Applying the transform to the context rather than to each item is what keeps the drawing code
unchanged: math_canvas_draw keeps every coordinate it has today, and the line widths and font
sizes scale with the graph, which is what a zoom should do to a diagram.
A hit tolerance is a screen distance, not a graph distance. A port is aimed at with a
pointer, so its clickable radius must be the same number of canvas pixels at every zoom —
which in graph units is kPort / view_scale. The same for the wire tolerance below. Getting
this backwards makes the ports unhittable when zoomed out, which is exactly when a user is
looking for them.
Scale is clamped to [0.25, 4.0]. Below a quarter the op names are unreadable and the
boxes are smaller than the pointer tolerance; above four a single node fills the canvas. The
clamp lives in one place, a math_set_scale() helper, because three callers reach it (wheel,
Fit, reset).
Wheel zoom about the pointer
A GtkEventControllerScroll with GTK_EVENT_CONTROLLER_SCROLL_VERTICAL. The anchor is the
graph point under the pointer, and it must not move:
double gx = 0.0, gy = 0.0;
math_to_graph(d, px, py, gx, gy); // before
d->view_scale = clamp(d->view_scale * (dy < 0 ? 1.1 : 1.0 / 1.1), 0.25, 4.0);
d->view_x = px - gx * d->view_scale; // restore the anchor
d->view_y = py - gy * d->view_scale;
The pointer position comes from the controller's own GtkEventController current event rather
than from a tracked motion position: a scroll event carries one, and tracking motion to supply
it is a second source of truth that can be stale.
No modifier is involved. The canvas has no other use for the wheel, so a bare wheel zoom is unambiguous here — unlike the plot, where the wheel's modifiers are already spoken for.
Pan, Fit, and reset
Dragging the empty background pans: view_x = pan_x0 + dx, from a pan_x0 recorded at
drag-begin, for the same total-offset reason the node drag already documents at
src/ui.cpp:3507.
A Fit button frames every node:
bbox over all math_node_rect() (empty graph -> identity view)
scale = clamp(min(w_canvas / (bbox.w + 2*pad), h_canvas / (bbox.h + 2*pad)), 0.25, 4.0)
view_* = centre the scaled bbox in the canvas
Ctrl+0 resets to scale = 1, view_x = view_y = 0. Both are one call to a shared
math_fit_view() / math_reset_view() so the button and the accelerator cannot drift apart.
The port and wire hit tests
math_port_at is replaced by one that names the node it found:
/// The port under a *graph* position. Returns the node index, with `which` set to
/// `kPortOut` (-1) for its output or 0/1 for an input; `-1` when no port is under it.
int math_port_at(const MathDialog* d, double gx, double gy, double tol, int& which);
Two changes beyond the signature. It scans backward, topmost first, so it agrees with
math_node_at() about which of two overlapping nodes owns a shared pixel — a disagreement
between the two searches is how a click lands on one node and a drag on another. And the
caller never re-derives the owner, which is the defect above made structurally impossible.
The hit radius stays larger than the drawn square: ±kPort canvas pixels around the port's
centre, against a square drawn at ±kPort * 0.5 — a 20 px target for a 10 px mark. That is
deliberate and, once the owner is no longer re-derived, harmless: a generous radius is the
difference between a diagram that can be wired and one that cannot. The asymmetry is written
down in a comment at the hit test, because it is what the old defect fed on.
A wire is picked by distance to the curve it is drawn as. The curve is the same cubic the
renderer uses (src/ui.cpp:3401), so the pick and the ink cannot disagree:
/// The wire under a graph position, as (consumer node, which input), or {-1, 0}.
/// The bezier is sampled at 24 points and the nearest segment measured; a curve 40 units of
/// tread wide over a node pitch of 160 is smooth at that sampling, and the tolerance is 6
/// canvas pixels, so a miss by a pixel is not a wrong edge deleted.
MathWireRef math_wire_at(const MathDialog* d, double gx, double gy, double tol);
Sampling rather than solving: the exact distance to a cubic is a fifth-degree root-find, and 24 samples of a curve whose control points are 40 units out is well inside a 6 px tolerance. The sample count is a named constant so a future wider tread can raise it.
Order of precedence on a press: port, then wire, then node body, then background. A port sits on a node's edge and a wire ends on a port, so each later test must only see what the earlier ones refused.
The gesture state machine
One GtkGestureDrag serves four gestures, decided entirely by what the press landed on. A
GtkGestureClick is kept for the selection-only cases, because a click is a drag of zero
length and GTK delivers both; drag-begin records the mode and pressed only ever selects.
enum class MathDrag { None, Node, Wire, Pan };
MathDrag drag_mode = MathDrag::None;
int wire_from = -1; ///< Wire: the node whose output the live wire leaves
int wire_detach = -1; ///< Wire: the consumer whose input was detached to start it, or -1
int wire_detach_which = 0;
double wire_x = 0.0; ///< Wire: the pointer, in graph units, for the preview
double wire_y = 0.0;
double pan_x0 = 0.0;
double pan_y0 = 0.0;
MathWireRef selected_wire{-1, 0};
drag-begin, in precedence order:
- An output port →
Wire,wire_from = node,wire_detach = -1. - A wired input port →
Wire,wire_from = <the node it was wired from>,wire_detach = node,wire_detach_which = which, and the input is cleared immediately. Dragging an existing wire off its input and dropping it elsewhere is one gesture, and clearing on begin is what makes the preview show the truth: the wire the user is holding. Dropping it on nothing therefore detaches, which is the agreed detach gesture, and needs no separate case. - An unwired input port →
None(select the node; there is nothing to drag from an empty input). Wiring is always output→input, so this is not a connect gesture. - A node body →
Node, exactly as today. - Anything else →
Pan.
drag-update moves the node, the live wire's endpoint, or the view, by mode.
drag-end resolves Wire:
- Released on an input port of a node that is not
wire_from→math_connect(). - Released on an input port of
wire_fromitself → refused, with "a node cannot read itself" on the validation line. A self-edge is the one cycle a topological sort cannot break, and refusing it by name reads better than "the graph has a cycle". - Released anywhere else → nothing further. If the drag began by detaching, the detachment stands.
Escape during a Wire drag cancels and restores a detached input: a cancel must undo the
clear-on-begin, or Escape would be a way to delete an edge by accident.
A GtkEventControllerKey on the canvas handles Delete/BackSpace (clear selected_wire),
Escape (cancel a drag, else clear the wire selection) and Ctrl+0 (reset the view). It is
added to the canvas and the canvas is given gtk_widget_set_focusable(TRUE) plus focus on
press, because a drawing area is not focusable by default and a key controller on an unfocused
widget never fires. The key controller must not swallow text: the dialog's name and unit
entries are GtkEditables, and the controller is on the canvas, so a key only reaches it
while the canvas has focus — the same reasoning as the app-level accelerators' bubble phase,
reached by a narrower route.
The edge model
One function owns connection:
/// Wire `from`'s output into `to`'s input `which`. Re-sorts the node list when the wire runs
/// backwards. Returns false and sets `why` when the wire would make a cycle.
bool math_connect(MathDialog* d, int from, int to, int which, std::string* why);
and deletion is a one-liner at the call site (in_a/in_b back to -1), because there is
nothing to validate in removing an edge: a disconnected input is a state the validation line
already names.
Backwards wires re-sort the list
MathChannel::nodes is a list walked in order, and the evaluator's correctness rests on a node
reading only nodes before it. Today that rule is the user's problem: the input ports of nodes
above the armed output are not even drawn as armed, so a wire that would run backwards cannot
be drawn at all. In a two-column auto-placed graph that is a real obstruction — the node you
want to feed is frequently the one that happens to sit earlier in the list.
The rule becomes the editor's invariant instead. math_connect() on a backwards wire
(from > to) builds the new edge set, runs a topological sort, and commits only if it is
acyclic:
- Trial edge set = every node's existing inputs, plus the new one.
- Kahn's algorithm over it. Ties broken by current list index, so a sort that changes
nothing leaves the list alone and a sort that must move something moves as little as
possible. (A stable order matters: the node positions on the canvas do not change, so a
gratuitous renumber would silently re-point
outputandselectedfor no reason the user can see.) - On a cycle: nothing is written,
whyis set, and the validation line shows it. The edge is not created — this is the one refusal left, and it is the honest one. - On success: permute
nodes, then remap through the permutation everything that holds an index —output,selected, everyin_a/in_b,drag_node,selected_wire.node. A forgotten one is a silent mis-wire, so they are remapped in a single block with a comment naming the set.
The session format is untouched: what is written is still a list in evaluation order. A file written by an older build still loads, because the sort is an editor operation and an already-ordered list sorts to itself.
math_canvas_draw stops gating a wire on from < i once this holds — but keeps the gate as a
defensive skip, because a MathChannel loaded from a file the editor did not write could
hold a forward reference, and drawing it would be drawing an edge the evaluator ignores.
The armed-port highlight goes away
pending_out and the "input ports of later nodes light up" highlight are both deleted. They
exist to serve the two-click protocol and the list-order rule, and neither survives: with a
drag gesture the feedback is the live wire following the pointer, and with the re-sort every
input is a legal target. What replaces the highlight is a hover accent on the port under the
pointer during a Wire drag, which says the one thing that still matters — whether releasing
here will connect.
The live validation line
on_math_apply validates in two steps today (math_channel_valid(), then an acquired-source
check the validator cannot make) and reports each in a modal GtkAlertDialog. A modal alert at
the end of a build is the worst time and place to be told; the graph is right there and can say
so continuously.
One extraction:
/// Why the graph as it stands could not be saved, or "" when it could. Both checks the Apply
/// path made: `math_channel_valid()`'s graph rules and the dialog's own "a Source must name an
/// acquired channel", which the validator cannot make because only the dialog sees that list.
std::string math_dialog_problem(const MathDialog* d);
math_dialog_sync() — already the one place that rewrites the form from the model, and already
called from every mutation — sets the line's text and gtk_widget_set_sensitive(apply, problem .empty()). An insensitive Apply with the reason beside it is a better refusal than a dialog
that must be dismissed.
on_math_apply keeps the check and loses the dialogs: it calls math_dialog_problem() and
returns on a non-empty answer, with no GtkAlertDialog. The guard has to stay even though the
button cannot be clicked while it would fire, because the probe calls on_math_apply() directly
and must not be able to save a broken graph through it. That d->apply pointer is new on
MathDialog; the button is built after the canvas today, so math_dialog_sync()'s first call
(at src/ui.cpp:3972, after the whole tree) already runs late enough to set it.
The line is a GtkLabel under the canvas, error CSS class when non-empty, and
gtk_label_set_ellipsize(END) with a tooltip carrying the full text — the layout-must-not-
depend-on-content rule applies here as much as to the status bar: this label sits in a dialog
whose canvas is hexpand, and a wrapping error message would otherwise widen the dialog on a
mere change of graph.
The op picker on add
add node always makes a MathOp::Gain (src/ui.cpp:3516), so adding a low pass is two
actions and a dropdown hunt. It becomes a GtkMenuButton labelled add whose popover holds a
flat button per op, built once from math_op_count() / math_op_at(i) — the accessor order,
not the enum order, for the reason CLAUDE.md already records about writing an op's index.
Each button carries its op index with g_object_set_data, so there is no action-name plumbing
and nothing to parse.
The two behaviours that already work are kept: the new node becomes the output when the graph
was_linear, and placement is the existing grid. Placement gains one thing — a node is placed
at the first free grid cell rather than at nodes.size(), so the slot left by a deletion is
reused instead of the grid growing for ever. The overlap rule stands (CLAUDE.md: boxes must
not overlap, or math_node_at()'s topmost-first search returns the wrong one).
Tidy / auto-layout
A tidy button lays the graph out left to right by dependency depth:
depth(i) = 0 for a Source or an unwired node
= 1 + max(depth of its wired inputs) // well-defined: inputs precede i
x = 24 + depth * (kNodeW + 48)
y = 20 + row_within_depth * (kNodeH + 22)
It needs no recursion and no visited set: the list is in evaluation order — the invariant the
re-sort now maintains — so the depth of node i depends only on nodes before it and a single
forward pass computes every depth. Then math_fit_view(), because a tidied graph that is off
screen is not tidy.
The column pitch is kNodeW + 48, which is 180 against the 160 the current grid uses: 160
leaves 28 units for the wire's 40-unit tread, so the curve doubles back on itself between
adjacent columns.
The Cancel button closes rather than destroys
src/ui.cpp:3962 connects Cancel to gtk_window_destroy:
g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window);
This is the defect CLAUDE.md documents under the math channel invariants — "a Cancel that
destroyed the window left app.math_dialog pointing at a dead widget: every later
open_math_dialog() took the 'already open' branch and presented it" — and it is still live.
The fix for it reached the Apply path (src/ui.cpp:3731) and the probe (src/ui.cpp:6672) and
never reached the button. It is gtk_window_close, one word, and the probe must assert it,
which it does not today: it only ever closes programmatically.
What [math-probe] must assert
Every gesture above is reachable from the probe, because the handlers take the MathDialog*
and plain coordinates — which is already how the drag test at src/ui.cpp:6694 works. The
probe is extended with one line per claim; each is an assertion stated as a want beside the
reading, in the style of the existing lines.
[math-probe] hit: output at edge+1 -> node N which=-1, at edge+9 -> node N which=-1 (want N N)— the silent-disarm defect, read at two offsets inside the hit radius and outside the box. Both must name the node. This is the one line that would have caught the reported bug, and it is the reason the hit test returns the node rather than a sentinel.[math-probe] wire: connect 0->1.a wired=W (want 0)then a backwards[math-probe] wire: backwards connect 2->0.a order=[..] wired=W reordered=D (want 1)— the re-sort, with the permuted op order printed so a wrong remap is visible as a wrong list.[math-probe] wire: self refused=D problem=[..] (want 1)and[math-probe] wire: cycle refused=D problem=[..] (want 1)— the two refusals, with the cycle built by wiring a two-node pair both ways.refused=1and the edge absent.[math-probe] wire: pick at (x,y) -> (node,which)=(N,K) delete -> in=-1 (want -1)— the pick samples the curve at its midpoint, which is the one place a wrong tolerance or a wrong curve shows.[math-probe] detach: drag from in 1.a to empty -> in=-1 (want -1), and[math-probe] detach: escape restores in=F (want F)— the clear-on-begin and its undo, which is the half that silently deletes an edge if Escape is forgotten.[math-probe] view: zoom scale=S anchor kept dx=.. dy=.. (want 0.0 0.0)— the graph point under the pointer before and after a wheel step, which is the only reading that distinguishes "zoomed about the pointer" from "zoomed about the origin". Both must be zero to a rounding.[math-probe] view: fit scale=S box=[..] all_inside=D (want 1), and[math-probe] view: reset scale=S offset=(..) (want 1.000 (0,0)).[math-probe] view: pan dx=.. dy=.. offset=(..) (want ..)plus a hit test after a pan and a zoom —port at the same screen point -> node N— because a hit test that forgot the inverse transform passes every test taken at the identity view.[math-probe] tidy: depths=[..] columns=[..] overlaps=D (want 0)— the overlap count is the assertion (CLAUDE.md's own rule), and the depths say the layout followed the graph.[math-probe] validate: problem=[..] apply_sensitive=Dbefore and after wiring the missing input:(want non-empty 0)then(want empty 1).[math-probe] cancel: dialog=cleared— theCancelbutton's::clickedemitted directly, thenapp.math_dialogread back.STILL SETis the regression.
Two things the probe deliberately cannot assert, and which are therefore stated as design
constraints rather than checks: that the canvas takes focus on press (a synthetic press
does not run GTK's focus machinery), and that the live wire looks like it follows the pointer
(a draw is not readable from a probe). The second is bounded by the first assertion set — the
endpoint it is drawn from is wire_x/wire_y, which the drag test writes and can print.
The existing lines are all kept. drag: in particular stays as it is: the total-offset rule is
unchanged by any of this, and the gesture that exercises it is still a node-body drag.
Documentation
Three places, following the project's existing split:
CLAUDE.md— the[math-probe]bullet under Verification gains the new readings and theirwants; the math channel invariants gain the hit-test-names-its-own-node rule (a port hit must not be re-derived with a box test), the tolerance-is-a-screen-distance rule, the list order is the editor's invariant, maintained by a topological re-sort rule with the index-remap set named, and a note that theCancelpath is now asserted.README.md— a subsection under the math channels describing the editor as it now behaves, and the diagnosis of the silent disarm, since README is where "what was actually built and why" lives.src/help/11-math.txtgains the four canvas gestures (drag a port to wire, drag a wired input away to detach, click a wire and press Delete, wheel to zoom and drag the background to pan) and the two buttons. It is an edit to an existing page, sopagesstays 14 and[help] pages=14 parsed=14must still hold — the markup is Pango and a page that fails to parse renders a stand-in.
Risks
- The re-sort touching an index nothing remapped. The mitigation is that the remap is one
block, the probe prints the permuted op order, and
selected/outputare both read back. - The key controller stealing a keystroke from the name entry. It is on the canvas, which must have focus; the probe cannot see this, so it is checked by hand once, by typing in the name field with a wire selected.
- The wheel controller fighting a future scrolled window. There is none and this design
removes the reason for one, which is worth stating so a later "wrap the canvas in a
GtkScrolledWindow" is recognised as undoing the zoom rather than adding to it. GtkGestureDragandGtkGestureClickon one widget both claiming a press. They already coexist on this canvas; the change is thatpressednow only selects and never mutates the graph, which removes the ordering question rather than answering it.