Nine tasks, each ending in a commit: the silent disarm in the port hit test, the view transform, the drag-based wiring, wire deletion, the topological re-sort that makes a backwards wire legal, the live validation line, the op picker, tidy, and the docs with the full verification run. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
106 KiB
Math Node Editor Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make udpscope's math node editor usable — edges can be added, deleted and detached; the canvas zooms and pans; the graph re-sorts itself so a backwards wire is legal; and the dialog says why it cannot be saved instead of refusing with a modal alert.
Architecture: Everything lives in src/ui.cpp's math-dialog block (currently 3280–3990) and is driven through the existing MathDialog struct. Three structural additions: a view transform (view_scale/view_x/view_y applied once to the cairo context, with math_to_graph() as the inverse used by every hit test); a gesture state machine (MathDrag::{None,Node,Wire,Pan}) replacing the two-click pending_out protocol; and math_connect(), which performs a topological re-sort so that a wire drawn right-to-left is accepted rather than refused. The MathChannel data model in src/math_channel.{hpp,cpp} is unchanged — the list-walked-in-order invariant stays, and the editor becomes the thing that maintains it.
Tech Stack: C++20, GTK4 (GtkDrawingArea, GtkGestureClick, GtkGestureDrag, GtkEventControllerScroll, GtkEventControllerKey, GtkMenuButton), cairo for the canvas, CMake + Ninja.
Global Constraints
- Every source file carries an SPDX MIT header. Both files touched already have one; do not add or remove.
- There is no unit test suite. The only automated check is the app's own headless probe under
xvfb-run. See "The TDD cycle in this project" below — it replaces the skill template'spyteststeps everywhere. - A clean build emits exactly five warnings and no more: two
-Wmissing-field-initializersonMathNode::source, one unusedoutput_built, and-Woverlength-stringson both generated GResource.cfiles. A sixth is a finding. (Note: Task 6 removes theoutput_builtwarning by using the variable; the baseline becomes four from Task 6 onward andCLAUDE.mdmust say so — Task 9.) - An exit-dump run emits no
Gtk-CRITICALat all. - Commit to
main. Never push. kNodeW = 132.0,kNodeH = 46.0,kPort = 10.0— existing constants, reused throughout.- View scale is clamped to
[0.25, 4.0]in one helper,math_set_scale(). Three callers: the wheel, Fit, reset. - Hit tolerances are screen distances, divided by
view_scalebefore use. A tolerance left in graph units makes a port unhittable when zoomed out. gtk_window_destroy()must not be used on the math dialog. It does not emitclose-request, so the handler that nullsapp.math_dialognever runs.- Never leave an unverified figure in a comment, a doc or an assertion. Every number quoted in
CLAUDE.mdorREADME.mdmust come from a run that was actually performed.
The TDD cycle in this project
There is no pytest. Each task's "failing test" is a new [math-probe] line in math_probe() (src/ui.cpp:6585). The cycle is:
- Add the probe line (with its
wantprinted beside the measured value). - Build and run it once; read the wrong value. That is the red step.
- Implement.
- Build and run it once; read the right value. That is the green step.
- Commit.
The run, every time, is two commands. Start the mock in the background, run the app once, kill the mock:
python3 tools/mock_udpstreamer.py --port 8111 --duration 25 &
MOCK=$!
xvfb-run -a env UDPSCOPE_ENABLE_ALL_MS=1000 UDPSCOPE_PROBE_MATH_MS=2000 \
UDPSCOPE_EXIT_AFTER_MS=8000 ./build/udpscope --server 127.0.0.1 --port 8111 \
2>&1 | grep -E '\[math-probe\]|Gtk-CRITICAL|SIGSEGV'
kill $MOCK 2>/dev/null
Run one self-check at a time. Two concurrent xvfb-run -a instances land on the same display and the second gets no GL context, which reads as a pipeline failure and is not one. The mock outlives its --duration, so always kill it.
The build is:
cmake --build build 2>&1 | grep -E 'warning|error' | sort | uniq -c
File Structure
| File | Responsibility | Change |
|---|---|---|
src/ui.cpp |
The whole editor: MathDialog, the canvas draw, the hit tests, the gestures, the form, the dialog construction, and math_probe(). |
Modified throughout the math block and the probe. |
src/math_channel.hpp |
MathOp, MathNode, MathChannel, the op metadata accessors. |
One addition: math_op_index(MathOp) (Task 7). |
src/math_channel.cpp |
The engine and the op metadata. | One addition: math_op_index()'s body (Task 7). |
CLAUDE.md |
The probe bullet and the invariants. | Modified (Task 9). |
README.md |
What was built. | Modified (Task 9). |
src/help/11-math.txt |
The user-facing page for the editor. | Modified (Task 9). |
src/ui.cpp is already by far the largest file in the project and the editor is a coherent block within it; splitting it out is not part of this plan. The project's established pattern is one large ui.cpp, and a split would make every one of these tasks a merge hazard for no gain.
Task 1: math_port_at() returns the node it found, and Cancel closes
This is the bug the user reported as "not possible to add edges", and it is independently shippable: the two-click protocol still works afterwards, it just works correctly. The second half is an unrelated live defect found in the same block.
Why it fails today. math_port_at() returns the sentinel -2 for an output-port hit, discarding which node was hit. math_canvas_pressed() recovers it with math_node_at(), which matches only inside the box — but the output square is drawn centred on x = n.x + kNodeW, i.e. straddling the right edge, with a ±kPort (10 px) hit radius against a ±kPort*0.5 (5 px) drawn half-width. So a click on the visible right half of the output square is a port hit whose math_node_at() comes back -1, and pending_out = -1 — a silent disarm. The first click of the two-click protocol usually does nothing.
Why Cancel is broken. src/ui.cpp:3962 connects gtk_window_destroy to Cancel. gtk_window_destroy() does not emit close-request, so the handler that nulls app.math_dialog never runs, and every later open_math_dialog() takes the "already open" branch and presents a dead widget. CLAUDE.md documents this defect as fixed; the fix reached on_math_apply() and the probe but not Cancel.
Files:
- Modify:
src/ui.cpp:3359-3378(math_port_at) - Modify:
src/ui.cpp:3455-3488(math_canvas_pressed) - Modify:
src/ui.cpp:3962(the Cancel connection) - Modify:
src/ui.cpp:6688-6743(the probe, inside theif (a->math_dialog != nullptr)block)
Interfaces:
-
Consumes: nothing from an earlier task.
-
Produces:
/// A port's identity: the node it belongs to and which port of that node. `which` is /// `kPortOut` for the output, 0 or 1 for an input. constexpr int kPortOut = -1; /// The port under a canvas position. Returns the **node index**, with `which` set to /// `kPortOut` or to the input number; `-1` when no port is under it. Scans topmost-first, /// so the node drawn on top wins — the same order as `math_node_at()`. int math_port_at(const MathDialog* d, double x, double y, double tol, int& which);Tasks 2–5 all call this signature.
-
Step 1: Add the failing probe line
In src/ui.cpp, inside math_probe()'s if (a->math_dialog != nullptr) block — put it immediately before the existing // The editor's two reported failures drag block, i.e. just after the [math-probe] reopened: lines, so it runs on a freshly opened dialog whose node positions are the grid defaults:
if (a->math_dialog != nullptr) {
auto* h = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (h != nullptr && !h->nodes.empty()) {
// The output square straddles the box's right edge: it is drawn centred on
// `n.x + kNodeW` with a 5 px half-width and hit with a 10 px radius. A hit test that
// recovers the node by a box test therefore misses every pixel past the edge, which
// is a *silent disarm* of the first click of a wiring gesture.
double ox = 0.0;
double oy = 0.0;
math_output_pos(h->nodes[0], ox, oy);
int w_in = 0;
const int n_in = math_port_at(h, ox - 2.0, oy, kPort, w_in);
int w_out = 0;
const int n_out = math_port_at(h, ox + 4.0, oy, kPort, w_out);
std::fprintf(stderr,
"[math-probe] hit: inside node=%d which=%d, past-edge node=%d which=%d "
"(want 0 %d 0 %d)\n",
n_in, w_in, n_out, w_out, kPortOut, kPortOut);
}
}
- Step 2: Run it and read the wrong value
It will not compile yet (kPortOut and the four-argument math_port_at do not exist), which is this project's form of a failing test — the probe names an interface the code has not got. Confirm:
cmake --build build 2>&1 | grep -E "kPortOut|math_port_at" | head -5
Expected: errors naming kPortOut as undeclared and math_port_at as taking 4 arguments where 3 are declared.
- Step 3: Rewrite
math_port_at()
Replace src/ui.cpp:3359-3378 entirely:
/// A port's identity: `kPortOut` for a node's output, 0 or 1 for one of its inputs.
constexpr int kPortOut = -1;
/// The port under a canvas position: the **node index** it belongs to, with `which` set to
/// `kPortOut` or the input number; `-1` when there is no port there. `tol` is the hit radius in
/// the same units as `x`/`y`.
///
/// It returns the node rather than a sentinel because the output square is drawn *straddling* the
/// box's right edge (centred on `n.x + kNodeW`, half-width `kPort * 0.5`) and hit with a radius of
/// `kPort` -- so a caller that recovered the node with `math_node_at()`, which matches only inside
/// the box, got `-1` for every pixel past the edge. That was a silent disarm of the first click of
/// a wiring gesture, and it is the whole of "edges cannot be added".
///
/// Topmost-first, like `math_node_at()`: when two boxes overlap, the one drawn on top owns the port.
int math_port_at(const MathDialog* d, double x, double y, double tol, int& which) {
which = kPortOut;
for (int i = static_cast<int>(d->nodes.size()) - 1; i >= 0; --i) {
const MathNode& n = d->nodes[static_cast<std::size_t>(i)];
double px = 0.0;
double py = 0.0;
math_output_pos(n, px, py);
if (std::fabs(x - px) <= tol && std::fabs(y - py) <= tol) {
which = kPortOut;
return i;
}
for (int k = 0; k < math_op_arity(n.op); ++k) {
math_input_pos(n, k, px, py);
if (std::fabs(x - px) <= tol && std::fabs(y - py) <= tol) {
which = k;
return i;
}
}
}
return -1;
}
- Step 4: Rewrite
math_canvas_pressed()for the new signature
Replace src/ui.cpp:3455-3488 entirely. The two-click protocol is preserved; only the node identification changes. (Task 3 replaces this body again with the drag-based protocol.)
void math_canvas_pressed(GtkGestureClick*, int, double x, double y, gpointer user) {
MathDialog* d = static_cast<MathDialog*>(user);
int which = kPortOut;
const int node = math_port_at(d, x, y, kPort, which);
if (node >= 0 && which == kPortOut) {
// An output square: arm it. Clicking it again disarms, so a mis-click needs no escape.
d->pending_out = d->pending_out == node ? -1 : node;
gtk_widget_queue_draw(d->canvas);
return;
}
if (node >= 0) {
if (d->pending_out >= 0 && d->pending_out != node) {
MathNode& n = d->nodes[static_cast<std::size_t>(node)];
(which == 0 ? n.in_a : n.in_b) = d->pending_out;
d->pending_out = -1;
d->selected = node;
math_dialog_sync(d);
gtk_widget_queue_draw(d->canvas);
return;
}
d->selected = node;
math_dialog_sync(d);
return;
}
const int body = math_node_at(d, x, y);
if (body >= 0) {
d->selected = body;
d->pending_out = -1;
math_dialog_sync(d);
return;
}
d->pending_out = -1;
gtk_widget_queue_draw(d->canvas);
}
Note d->pending_out != node rather than d->pending_out < node: a backwards wire is now written and left for math_channel_valid() to refuse at Apply. Task 5 makes it legal by re-sorting. This is deliberate — it is the state the next tasks build on, and the draw function's from < i gate already skips a wire it cannot draw.
- Step 5: Fix the Cancel button
At src/ui.cpp:3962, replace:
g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window);
with:
// `gtk_window_close()`, never `gtk_window_destroy()`: only a close emits `close-request`, which
// is what clears `app.math_dialog`. A destroy left that pointer at a dead widget, and every
// later open then took the "already open" branch and presented it -- "I cannot edit a math
// channel again". The same trap the other three windows in this file document.
g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_close), window);
- Step 6: Add the Cancel probe line
In math_probe(), immediately after the hit: block added in Step 1, append:
// Cancel must clear the pointer exactly as the close above does: it is the *button* that is
// clicked here, not the window, because the defect was in what the button was connected to.
if (a->math_dialog != nullptr) {
GtkWidget* cancel_button = GTK_WIDGET(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-cancel"));
if (cancel_button != nullptr) {
g_signal_emit_by_name(cancel_button, "clicked");
std::fprintf(stderr, "[math-probe] cancel: dialog=%s (want cleared)\n",
a->math_dialog == nullptr ? "cleared" : "STILL SET");
open_math_dialog(*a, 0);
} else {
std::fprintf(stderr, "[math-probe] cancel: NO BUTTON (the data key is not set)\n");
}
}
The probe reaches the button through a data key, so set it where the button is built. At src/ui.cpp:3962 (just above the g_signal_connect_swapped from Step 5), add:
// Named so the headless probe can click the *button* rather than call its handler: the defect
// this guards was in the connection, which a direct call cannot see.
g_object_set_data(G_OBJECT(window), "udpscope-math-cancel", cancel);
The open_math_dialog(*a, 0) at the end restores a dialog for the drag/sources/rows blocks that follow.
- Step 7: Build and run; read the right values
cmake --build build 2>&1 | grep -E 'warning|error' | sort | uniq -c
Expected: the five documented warnings, no errors.
python3 tools/mock_udpstreamer.py --port 8111 --duration 25 &
MOCK=$!
xvfb-run -a env UDPSCOPE_ENABLE_ALL_MS=1000 UDPSCOPE_PROBE_MATH_MS=2000 \
UDPSCOPE_EXIT_AFTER_MS=8000 ./build/udpscope --server 127.0.0.1 --port 8111 \
2>&1 | grep -E '\[math-probe\]|Gtk-CRITICAL'
kill $MOCK 2>/dev/null
Expected, among the existing lines:
[math-probe] hit: inside node=0 which=-1, past-edge node=0 which=-1 (want 0 -1 0 -1)
[math-probe] cancel: dialog=cleared (want cleared)
and no Gtk-CRITICAL. The past-edge node=0 is the assertion: before the fix it read -1.
- Step 8: Commit
git add src/ui.cpp
git commit -m "$(cat <<'EOF'
fix: a port hit names its own node, so the first click of a wire lands
`math_port_at()` returned a sentinel for an output hit and the caller
recovered the node with a box test -- but the output square straddles the
box's right edge, so every pixel past it disarmed the gesture silently.
That is the whole of "edges cannot be added".
Cancel also closed with `gtk_window_destroy()`, which emits no
`close-request`, so the dialog pointer was left dangling and no channel
could be edited twice.
EOF
)"
Task 2: The view transform — zoom and pan
The second reported failure. A single transform applied to the cairo context, with the inverse used by every hit test.
Files:
- Modify:
src/ui.cpp:3290-3319(MathDialog: three fields) - Modify:
src/ui.cpp:3330area (addmath_to_graph,math_set_scale,math_view_fit,math_view_reset) - Modify:
src/ui.cpp:3380-3453(math_canvas_draw: wrap in the transform) - Modify:
src/ui.cpp:3455-3512(the three gesture handlers: convert through the inverse) - Modify:
src/ui.cpp:3807-3894area (the canvas's controllers: scroll and key) - Modify:
src/ui.cpp:3946-3972area (a Fit button besideadd node/delete node/set as output) - Modify:
src/ui.cpp:6688+(the probe: aview:block)
Interfaces:
-
Consumes:
math_port_at(d, x, y, tol, which)andkPortOutfrom Task 1. -
Produces:
/// Canvas pixels per graph unit, and where graph (0,0) is drawn, in canvas pixels. double view_scale = 1.0; double view_x = 0.0; double view_y = 0.0; /// A canvas position in graph coordinates. The inverse of the transform `math_canvas_draw()` /// applies, and the only way a hit test may read the pointer. void math_to_graph(const MathDialog* d, double cx, double cy, double& gx, double& gy); /// Sets the scale, clamped to [0.25, 4.0]. Returns what it actually set. double math_set_scale(MathDialog* d, double want); /// Frames every node in the canvas, centred. void math_view_fit(MathDialog* d); /// Scale 1, offsets 0. void math_view_reset(MathDialog* d);Tasks 3–5 convert the pointer through
math_to_graph()and size their tolerances askPort / d->view_scale. -
Step 1: Add the failing probe lines
In math_probe(), after the cancel: block from Task 1, append:
if (a->math_dialog != nullptr) {
auto* v = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (v != nullptr && !v->nodes.empty()) {
// A wheel zoom must keep the graph point under the pointer under the pointer: that is the
// one thing a zoom can get wrong without looking broken, and it reads as the canvas
// sliding away from the hand.
const double px = 180.0;
const double py = 140.0;
double gx0 = 0.0;
double gy0 = 0.0;
math_to_graph(v, px, py, gx0, gy0);
math_canvas_scrolled(nullptr, 0.0, -1.0, v);
math_canvas_scrolled(nullptr, 0.0, -1.0, v);
double gx1 = 0.0;
double gy1 = 0.0;
math_to_graph(v, px, py, gx1, gy1);
std::fprintf(stderr,
"[math-probe] view: zoom scale=%.3f anchor kept dx=%.4f dy=%.4f "
"(want 0.0000 0.0000)\n",
v->view_scale, gx1 - gx0, gy1 - gy0);
// And the hit tests must go through the inverse. At the identity view a test that forgot
// it passes anyway, so the port is probed *after* a zoom and a pan -- which is the only
// arrangement that can fail.
v->view_x += 37.0;
v->view_y -= 19.0;
double ox = 0.0;
double oy = 0.0;
math_output_pos(v->nodes[0], ox, oy);
const double cx = ox * v->view_scale + v->view_x;
const double cy = oy * v->view_scale + v->view_y;
int which = 0;
const int node = math_port_at_canvas(v, cx + 3.0, cy, which);
std::fprintf(stderr,
"[math-probe] view: pan+zoom hit node=%d which=%d (want 0 %d)\n", node,
which, kPortOut);
math_view_fit(v);
int inside = 1;
int w_px = gtk_widget_get_width(v->canvas);
int h_px = gtk_widget_get_height(v->canvas);
if (w_px <= 0) w_px = 420;
if (h_px <= 0) h_px = 300;
for (const MathNode& n : v->nodes) {
const double x0 = n.x * v->view_scale + v->view_x;
const double y0 = n.y * v->view_scale + v->view_y;
const double x1 = (n.x + kNodeW) * v->view_scale + v->view_x;
const double y1 = (n.y + kNodeH) * v->view_scale + v->view_y;
if (x0 < -0.5 || y0 < -0.5 || x1 > w_px + 0.5 || y1 > h_px + 0.5) inside = 0;
}
std::fprintf(stderr, "[math-probe] view: fit scale=%.3f all_inside=%d (want 1)\n",
v->view_scale, inside);
math_view_reset(v);
std::fprintf(stderr,
"[math-probe] view: reset scale=%.3f offset=(%.1f,%.1f) "
"(want 1.000 (0.0,0.0))\n",
v->view_scale, v->view_x, v->view_y);
}
}
math_port_at_canvas() is a thin wrapper the next step adds, so that the probe — and every gesture handler — converts in exactly one place.
- Step 2: Run it and read the wrong value
cmake --build build 2>&1 | grep -E 'math_to_graph|math_canvas_scrolled|math_view_fit|view_scale' | head -8
Expected: errors naming math_to_graph, math_canvas_scrolled, math_port_at_canvas, math_view_fit, math_view_reset and MathDialog::view_scale as undeclared.
- Step 3: Add the three fields to
MathDialog
In src/ui.cpp, inside struct MathDialog (after int drag_node = -1;):
/// The view: canvas pixels per graph unit, and where graph (0,0) is drawn. Applied once to the
/// cairo context by `math_canvas_draw()`; every hit test runs the inverse (`math_to_graph()`)
/// rather than comparing a pointer position against a graph one, and sizes its tolerance in
/// *screen* pixels (`kPort / view_scale`) -- a tolerance left in graph units shrinks with the
/// view and makes a port unhittable zoomed out.
double view_scale = 1.0;
double view_x = 0.0;
double view_y = 0.0;
- Step 4: Add the view helpers
In src/ui.cpp, immediately after math_output_pos() (currently ending at 3349):
/// The smallest and largest the canvas may be scaled to. Below a quarter the op names are
/// unreadable; above four a box fills the canvas and there is nothing left to navigate.
constexpr double kMathScaleMin = 0.25;
constexpr double kMathScaleMax = 4.0;
constexpr double kMathZoomStep = 1.1;
/// A canvas position in graph coordinates: the inverse of the transform the draw applies.
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;
}
/// Sets the scale, clamped. One helper for all three callers (the wheel, Fit, reset) so the bounds
/// cannot drift apart. Returns what it actually set.
double math_set_scale(MathDialog* d, double want) {
d->view_scale = std::clamp(want, kMathScaleMin, kMathScaleMax);
return d->view_scale;
}
void math_view_reset(MathDialog* d) {
math_set_scale(d, 1.0);
d->view_x = 0.0;
d->view_y = 0.0;
if (d->canvas != nullptr) gtk_widget_queue_draw(d->canvas);
}
/// Frames every node in the canvas, centred, at a scale that fits with a little margin.
void math_view_fit(MathDialog* d) {
if (d->nodes.empty()) {
math_view_reset(d);
return;
}
double x0 = d->nodes[0].x;
double y0 = d->nodes[0].y;
double x1 = x0 + kNodeW;
double y1 = y0 + kNodeH;
for (const MathNode& n : d->nodes) {
x0 = std::min(x0, n.x);
y0 = std::min(y0, n.y);
x1 = std::max(x1, n.x + kNodeW);
y1 = std::max(y1, n.y + kNodeH);
}
// The port squares stick out past the box on both sides, and a wire needs a little air.
constexpr double kPad = 16.0;
x0 -= kPad;
y0 -= kPad;
x1 += kPad;
y1 += kPad;
double w_px = d->canvas != nullptr ? gtk_widget_get_width(d->canvas) : 0;
double h_px = d->canvas != nullptr ? gtk_widget_get_height(d->canvas) : 0;
// Before the first allocation the widget has no size; its size request is the honest fallback.
if (w_px <= 0.0) w_px = 420.0;
if (h_px <= 0.0) h_px = 300.0;
const double s = math_set_scale(d, std::min(w_px / (x1 - x0), h_px / (y1 - y0)));
d->view_x = (w_px - (x1 - x0) * s) * 0.5 - x0 * s;
d->view_y = (h_px - (y1 - y0) * s) * 0.5 - y0 * s;
if (d->canvas != nullptr) gtk_widget_queue_draw(d->canvas);
}
/// The port under a *canvas* position, with the tolerance in screen pixels. Every gesture goes
/// through this rather than converting for itself: `kPort` is a size on screen, so the graph-space
/// tolerance has to shrink as the view grows, and a handler that got that backwards makes a port
/// unhittable at one end of the zoom range and greedy at the other.
int math_port_at_canvas(const MathDialog* d, double cx, double cy, int& which) {
double gx = 0.0;
double gy = 0.0;
math_to_graph(d, cx, cy, gx, gy);
return math_port_at(d, gx, gy, kPort / d->view_scale, which);
}
/// The node body under a canvas position.
int math_node_at_canvas(const MathDialog* d, double cx, double cy) {
double gx = 0.0;
double gy = 0.0;
math_to_graph(d, cx, cy, gx, gy);
return math_node_at(d, gx, gy);
}
math_port_at_canvas and math_node_at_canvas must be declared after math_port_at and math_node_at; math_port_at is at 3361 and math_node_at at 3351, both above 3349's insertion point — so move this whole block to immediately after math_port_at()'s closing brace instead, and keep math_to_graph/math_set_scale/math_view_* with it. (math_view_fit reads kNodeW, declared at 3286, so the order is fine.)
- Step 5: Apply the transform in the draw
In math_canvas_draw(), after the background paint and before the wire loop:
cairo_set_source_rgb(cr, 0.06, 0.078, 0.094);
cairo_paint(cr);
// One transform for the whole graph. Everything below is in graph units; every hit test runs
// `math_to_graph()`, which is this inverted.
cairo_save(cr);
cairo_translate(cr, d->view_x, d->view_y);
cairo_scale(cr, d->view_scale, d->view_scale);
and at the end of the function, before the (void)width; lines:
cairo_restore(cr);
The line widths inside are now in graph units and so scale with the view, which is correct: a zoomed-in graph should have thicker wires, not hairlines.
- Step 6: Convert the gesture handlers
In math_canvas_pressed(), replace the two hit-test calls:
int which = kPortOut;
const int node = math_port_at_canvas(d, x, y, which);
and
const int body = math_node_at_canvas(d, x, y);
In math_canvas_drag_begin(), replace:
const int node = math_node_at(d, x, y);
with:
const int node = math_node_at_canvas(d, x, y);
In math_canvas_drag(), the offset arrives in canvas pixels and must be divided by the scale before it moves a graph position:
// The gesture's offset is in canvas pixels; a node's position is in graph units.
n.x = std::max(0.0, d->grab_x + dx / d->view_scale);
n.y = std::max(0.0, d->grab_y + dy / d->view_scale);
- Step 7: Add the scroll handler and the key controller
Add, after math_canvas_drag():
gboolean math_canvas_scrolled(GtkEventControllerScroll* scroll, double, double dy, gpointer user) {
MathDialog* d = static_cast<MathDialog*>(user);
if (dy == 0.0) return TRUE;
// About the pointer: the graph point under it must still be under it afterwards, or the canvas
// slides away from the hand. With no controller (the headless probe calls this directly) there
// is no pointer to read, so the canvas centre is the anchor.
double px = 0.0;
double py = 0.0;
bool have = false;
if (scroll != nullptr) {
GdkEvent* event = gtk_event_controller_get_current_event(GTK_EVENT_CONTROLLER(scroll));
if (event != nullptr) have = gdk_event_get_position(event, &px, &py);
}
if (!have) {
px = d->canvas != nullptr ? gtk_widget_get_width(d->canvas) * 0.5 : 210.0;
py = d->canvas != nullptr ? gtk_widget_get_height(d->canvas) * 0.5 : 150.0;
}
double gx = 0.0;
double gy = 0.0;
math_to_graph(d, px, py, gx, gy);
const double s = math_set_scale(d, d->view_scale * (dy < 0.0 ? kMathZoomStep
: 1.0 / kMathZoomStep));
d->view_x = px - gx * s;
d->view_y = py - gy * s;
gtk_widget_queue_draw(d->canvas);
return TRUE;
}
gboolean math_canvas_key(GtkEventControllerKey*, guint keyval, guint, GdkModifierType state,
gpointer user) {
MathDialog* d = static_cast<MathDialog*>(user);
const bool ctrl = (state & GDK_CONTROL_MASK) != 0;
if (ctrl && (keyval == GDK_KEY_0 || keyval == GDK_KEY_KP_0)) {
math_view_reset(d);
return TRUE;
}
if (keyval == GDK_KEY_f || keyval == GDK_KEY_F) {
math_view_fit(d);
return TRUE;
}
return FALSE;
}
Task 3 adds Escape and Task 4 adds Delete to math_canvas_key().
In the dialog construction (after the existing GtkGestureDrag is attached to d->canvas, around 3860):
// The wheel zooms. A scroll controller is the only way to get one on a drawing area, and
// `VERTICAL` is what a wheel reports.
GtkEventController* zoom = gtk_event_controller_scroll_new(GTK_EVENT_CONTROLLER_SCROLL_VERTICAL);
g_signal_connect(zoom, "scroll", G_CALLBACK(math_canvas_scrolled), d);
gtk_widget_add_controller(d->canvas, zoom);
// A `GtkDrawingArea` is not focusable by default, so a key controller on it never fires. It
// takes focus on a press rather than only on Tab, because every key this editor binds is about
// something the pointer just did.
gtk_widget_set_focusable(d->canvas, TRUE);
GtkEventController* keys = gtk_event_controller_key_new();
g_signal_connect(keys, "key-pressed", G_CALLBACK(math_canvas_key), d);
gtk_widget_add_controller(d->canvas, keys);
and at the top of math_canvas_pressed():
MathDialog* d = static_cast<MathDialog*>(user);
// The key controller is on the canvas, so the canvas has to hold focus for `Delete`, `Escape`
// and `Ctrl+0` to reach it.
gtk_widget_grab_focus(d->canvas);
- Step 8: Add the Fit button
Beside the existing add node / delete node / set as output buttons (around 3946):
GtkWidget* fit = gtk_button_new_with_label("fit");
gtk_widget_set_tooltip_text(fit, "Frame the whole graph (f). Ctrl+0 resets the zoom.");
g_signal_connect(fit, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
math_view_fit(static_cast<MathDialog*>(user));
}), d);
gtk_box_append(GTK_BOX(tools), fit);
Use whatever box the three existing buttons are appended to; read the surrounding lines for its name rather than assuming tools.
- Step 9: Build and run; read the right values
cmake --build build 2>&1 | grep -E 'warning|error' | sort | uniq -c
Expected: the five documented warnings, no errors.
Then the probe run from "The TDD cycle" above. Expected:
[math-probe] view: zoom scale=1.210 anchor kept dx=0.0000 dy=0.0000 (want 0.0000 0.0000)
[math-probe] view: pan+zoom hit node=0 which=-1 (want 0 -1)
[math-probe] view: fit scale=... all_inside=1 (want 1)
[math-probe] view: reset scale=1.000 offset=(0.0,0.0) (want 1.000 (0.0,0.0))
The fit scale depends on the canvas allocation and the node spread, so it is not an assertion — all_inside=1 is. scale=1.210 is 1.1²; read it as "two steps happened", not as a fixed figure.
- Step 10: Commit
git add src/ui.cpp
git commit -m "$(cat <<'EOF'
feat: the node canvas zooms about the pointer and pans, so a graph can be read
One view transform applied to the cairo context, with `math_to_graph()` as
the inverse that every hit test runs -- a tolerance is a screen distance,
so it is divided by the scale rather than left in graph units. A wheel
zooms about the pointer, a background drag pans (next commit), `f` fits and
Ctrl+0 resets. The drawing area is made focusable, without which a key
controller on it never fires.
EOF
)"
Task 3: The gesture state machine — drag a wire, drag the background to pan
Replaces the two-click wiring protocol with the one the user chose: drag from a port to a port. An input port that is already wired is dragged off, so detaching needs no separate verb. pending_out and the armed-port highlight are deleted.
Files:
- Modify:
src/ui.cpp:3290-3319(MathDialog:MathDrag, the wire fields; deletepending_out) - Modify:
src/ui.cpp:3380-3453(math_canvas_draw: the live wire; the armed highlight goes) - Modify:
src/ui.cpp:3455-3488(math_canvas_pressed: select only) - Modify:
src/ui.cpp:3490-3512(math_canvas_drag_begin,math_canvas_drag; addmath_canvas_drag_end) - Modify:
math_canvas_key(addEscape) - Modify: the dialog construction (connect
drag-end) - Modify:
src/ui.cpp:6688+(the probe: awire:/detach:block)
Interfaces:
-
Consumes:
math_port_at_canvas(),math_node_at_canvas(),math_to_graph(),view_scale,kPortOut(Tasks 1–2). -
Produces:
/// What a drag on the canvas is doing. Decided once at `drag-begin` from what is under the /// press, and not revisited: a gesture that changes its mind mid-drag is unusable. enum class MathDrag { None, Node, Wire, Pan }; MathDrag drag_mode = MathDrag::None; int wire_from = -1; ///< `Wire`: the node whose output is being dragged. int wire_detach = -1; ///< `Wire`: the consumer whose input was emptied to start it, or -1. int wire_detach_which = 0; double wire_x = 0.0; ///< `Wire`: the loose end, in graph units. double wire_y = 0.0; double pan_x0 = 0.0; ///< `Pan`: `view_x`/`view_y` when the gesture began. double pan_y0 = 0.0;Task 4 adds
selected_wire; Task 5 replaces this task's directin_a/in_bwrite withmath_connect(). -
Step 1: Add the failing probe lines
In math_probe(), after the view: block from Task 2, append:
if (a->math_dialog != nullptr) {
auto* g = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
// The editor's two graphs are source -> gain, so node 1 has one input and node 0 an output.
if (g != nullptr && g->nodes.size() >= 2) {
math_view_reset(g);
const auto canvas_of = [&](double gx, double gy, double& cx, double& cy) {
cx = gx * g->view_scale + g->view_x;
cy = gy * g->view_scale + g->view_y;
};
double ox = 0.0;
double oy = 0.0;
double ix = 0.0;
double iy = 0.0;
math_output_pos(g->nodes[0], ox, oy);
math_input_pos(g->nodes[1], 0, ix, iy);
double ocx = 0.0;
double ocy = 0.0;
double icx = 0.0;
double icy = 0.0;
canvas_of(ox, oy, ocx, ocy);
canvas_of(ix, iy, icx, icy);
// Connect: press the output, release over the input.
g->nodes[1].in_a = -1;
math_canvas_drag_begin(nullptr, ocx, ocy, g);
const int mode_wire = static_cast<int>(g->drag_mode);
math_canvas_drag(nullptr, icx - ocx, icy - ocy, g);
math_canvas_drag_end(nullptr, icx - ocx, icy - ocy, g);
std::fprintf(stderr,
"[math-probe] wire: connect mode=%d in_a=%d (want %d 0)\n", mode_wire,
g->nodes[1].in_a, static_cast<int>(MathDrag::Wire));
// Detach: a drag that starts on a *wired* input empties it at once, so a release over
// nothing is the detach. Dropping it back is what the connect above already covers.
math_canvas_drag_begin(nullptr, icx, icy, g);
const int emptied = g->nodes[1].in_a;
math_canvas_drag(nullptr, 0.0, 90.0, g);
math_canvas_drag_end(nullptr, 0.0, 90.0, g);
std::fprintf(stderr,
"[math-probe] detach: emptied_at_begin=%d in_a=%d (want -1 -1)\n", emptied,
g->nodes[1].in_a);
// And Escape during the same gesture must put it back.
g->nodes[1].in_a = 0;
math_canvas_drag_begin(nullptr, icx, icy, g);
math_canvas_drag(nullptr, 0.0, 90.0, g);
math_canvas_key(nullptr, GDK_KEY_Escape, 0, static_cast<GdkModifierType>(0), g);
std::fprintf(stderr, "[math-probe] detach: escape restores in_a=%d (want 0)\n",
g->nodes[1].in_a);
// A drag that begins on the background pans and moves nothing.
const double was_x = g->view_x;
const double node_x = g->nodes[0].x;
math_canvas_drag_begin(nullptr, 5.0, 5.0, g);
const int mode_pan = static_cast<int>(g->drag_mode);
math_canvas_drag(nullptr, 30.0, 0.0, g);
math_canvas_drag_end(nullptr, 30.0, 0.0, g);
std::fprintf(stderr,
"[math-probe] view: pan mode=%d dview=%.1f dnode=%.1f (want %d 30.0 0.0)\n",
mode_pan, g->view_x - was_x, g->nodes[0].x - node_x,
static_cast<int>(MathDrag::Pan));
math_view_reset(g);
}
}
The press at (5.0, 5.0) is the background only while the view is reset and the first node sits at the grid default x = 24; math_view_reset(g) above it is what guarantees that.
- Step 2: Run it and read the wrong value
cmake --build build 2>&1 | grep -E 'MathDrag|drag_mode|math_canvas_drag_end' | head -6
Expected: errors naming MathDrag, MathDialog::drag_mode and math_canvas_drag_end as undeclared.
- Step 3: Replace
pending_outwith the state machine fields
In struct MathDialog, delete:
/// The node whose output square was clicked and is waiting for an input to connect to.
int pending_out = -1;
and add, beside drag_node:
/// What a drag is doing, decided once at `drag-begin` from what is under the press and not
/// revisited: a gesture that changes its mind mid-drag is unusable. `Wire` carries a loose end
/// in graph units, which the draw shows so the user can see what is being connected.
MathDrag drag_mode = MathDrag::None;
int wire_from = -1;
int wire_detach = -1;
int wire_detach_which = 0;
double wire_x = 0.0;
double wire_y = 0.0;
double pan_x0 = 0.0;
double pan_y0 = 0.0;
Declare the enum immediately above struct MathDialog:
enum class MathDrag { None, Node, Wire, Pan };
- Step 4: Rewrite the three drag handlers and add
drag-end
Replace src/ui.cpp:3490-3512 (math_canvas_drag_begin and math_canvas_drag) with:
void math_canvas_drag_begin(GtkGestureDrag*, double x, double y, gpointer user) {
MathDialog* d = static_cast<MathDialog*>(user);
d->drag_mode = MathDrag::None;
d->drag_node = -1;
d->wire_from = -1;
d->wire_detach = -1;
// Precedence: a port, then a box, then the background. A port sticks out past the box edge, so
// asking the box first would make the output square unreachable -- the same geometry that made
// the old two-click protocol disarm itself.
int which = kPortOut;
const int port = math_port_at_canvas(d, x, y, which);
if (port >= 0) {
d->drag_mode = MathDrag::Wire;
double gx = 0.0;
double gy = 0.0;
math_to_graph(d, x, y, gx, gy);
d->wire_x = gx;
d->wire_y = gy;
if (which == kPortOut) {
d->wire_from = port;
} else {
// A wired input is dragged *off*: the input is emptied now, so releasing over nothing is
// itself the detach and needs no second verb. `Escape` puts it back.
MathNode& n = d->nodes[static_cast<std::size_t>(port)];
const int from = which == 0 ? n.in_a : n.in_b;
if (from < 0) {
d->drag_mode = MathDrag::None;
return;
}
d->wire_from = from;
d->wire_detach = port;
d->wire_detach_which = which;
(which == 0 ? n.in_a : n.in_b) = -1;
}
d->selected = d->wire_from;
math_dialog_sync(d);
gtk_widget_queue_draw(d->canvas);
return;
}
const int node = math_node_at_canvas(d, x, y);
if (node >= 0) {
d->drag_mode = MathDrag::Node;
d->drag_node = node;
d->selected = node;
d->grab_x = d->nodes[static_cast<std::size_t>(node)].x;
d->grab_y = d->nodes[static_cast<std::size_t>(node)].y;
math_dialog_sync(d);
return;
}
d->drag_mode = MathDrag::Pan;
d->pan_x0 = d->view_x;
d->pan_y0 = d->view_y;
}
void math_canvas_drag(GtkGestureDrag*, double dx, double dy, gpointer user) {
MathDialog* d = static_cast<MathDialog*>(user);
// The gesture reports the *total* offset from where it began, so every branch adds it to a value
// recorded at `drag-begin` and never to one a previous update wrote. Feeding a delta back into
// the origin is what made a box fly off the canvas.
switch (d->drag_mode) {
case MathDrag::Node: {
if (d->drag_node < 0 || d->drag_node >= static_cast<int>(d->nodes.size())) return;
MathNode& n = d->nodes[static_cast<std::size_t>(d->drag_node)];
// The offset is in canvas pixels and a position is in graph units.
n.x = std::max(0.0, d->grab_x + dx / d->view_scale);
n.y = std::max(0.0, d->grab_y + dy / d->view_scale);
break;
}
case MathDrag::Wire: {
// The loose end, so the user can see what is being connected.
double gx = 0.0;
double gy = 0.0;
math_to_graph(d, d->wire_x * d->view_scale + d->view_x + dx,
d->wire_y * d->view_scale + d->view_y + dy, gx, gy);
d->wire_x = gx;
d->wire_y = gy;
break;
}
case MathDrag::Pan:
d->view_x = d->pan_x0 + dx;
d->view_y = d->pan_y0 + dy;
break;
case MathDrag::None:
return;
}
gtk_widget_queue_draw(d->canvas);
}
void math_canvas_drag_end(GtkGestureDrag*, double dx, double dy, gpointer user) {
MathDialog* d = static_cast<MathDialog*>(user);
if (d->drag_mode != MathDrag::Wire) {
d->drag_mode = MathDrag::None;
d->drag_node = -1;
return;
}
// Where the release landed, recomputed from the gesture's own offset rather than from `wire_x`:
// `drag-end` carries the whole offset, and the update that would have moved `wire_x` to it is
// not guaranteed to have arrived.
double ox = 0.0;
double oy = 0.0;
math_output_pos(d->nodes[static_cast<std::size_t>(d->wire_from)], ox, oy);
int which = kPortOut;
const int target = math_port_at_canvas(d, ox * d->view_scale + d->view_x + dx,
oy * d->view_scale + d->view_y + dy, which);
if (target >= 0 && which != kPortOut && target != d->wire_from) {
MathNode& n = d->nodes[static_cast<std::size_t>(target)];
(which == 0 ? n.in_a : n.in_b) = d->wire_from;
d->selected = target;
math_dialog_sync(d);
}
// A release over nothing leaves the input the drag emptied empty: that *is* the detach.
d->drag_mode = MathDrag::None;
d->wire_from = -1;
d->wire_detach = -1;
gtk_widget_queue_draw(d->canvas);
}
The drag-end release point is recomputed from the output port rather than reusing wire_x because wire_x was seeded at the press position, which may be a few pixels off the port centre; the release has to be measured from the same origin the offset is relative to. For a detach drag wire_from is the original source, so that origin is still a real port.
math_connect() (Task 5) replaces the direct in_a/in_b write above; until then a backwards wire is written and refused at Apply, exactly as in Task 1.
- Step 5:
Escaperestores a detach
In math_canvas_key(), before the return FALSE;:
if (keyval == GDK_KEY_Escape) {
// A detach empties the input at `drag-begin`, so the undo is to put it back. Anything else is
// a cancel of a gesture that has changed nothing yet.
if (d->drag_mode == MathDrag::Wire && d->wire_detach >= 0 &&
d->wire_detach < static_cast<int>(d->nodes.size())) {
MathNode& n = d->nodes[static_cast<std::size_t>(d->wire_detach)];
(d->wire_detach_which == 0 ? n.in_a : n.in_b) = d->wire_from;
}
d->drag_mode = MathDrag::None;
d->wire_from = -1;
d->wire_detach = -1;
gtk_widget_queue_draw(d->canvas);
return TRUE;
}
- Step 6: Strip
pending_outfrom the press handler and the draw
math_canvas_pressed() becomes selection only — the wiring is the drag's job now:
void math_canvas_pressed(GtkGestureClick*, int, double x, double y, gpointer user) {
MathDialog* d = static_cast<MathDialog*>(user);
// The key controller is on the canvas, so the canvas has to hold focus for `Delete`, `Escape`
// and `Ctrl+0` to reach it.
gtk_widget_grab_focus(d->canvas);
int which = kPortOut;
const int port = math_port_at_canvas(d, x, y, which);
if (port >= 0) {
d->selected = port;
math_dialog_sync(d);
return;
}
const int node = math_node_at_canvas(d, x, y);
if (node >= 0) {
d->selected = node;
math_dialog_sync(d);
return;
}
gtk_widget_queue_draw(d->canvas);
}
In math_canvas_draw(), the armed-port highlight has nothing left to read, so both port colours become unconditional. Replace the input-port block:
for (int k = 0; k < math_op_arity(n.op); ++k) {
double px = 0.0;
double py = 0.0;
math_input_pos(n, k, px, py);
cairo_set_source_rgb(cr, 0.45, 0.52, 0.62);
cairo_rectangle(cr, px - kPort * 0.5, py - kPort * 0.5, kPort, kPort);
cairo_fill(cr);
}
double ox = 0.0;
double oy = 0.0;
math_output_pos(n, ox, oy);
cairo_set_source_rgb(cr, 0.45, 0.52, 0.62);
cairo_rectangle(cr, ox - kPort * 0.5, oy - kPort * 0.5, kPort, kPort);
cairo_fill(cr);
and add the live wire at the very end of the transformed block, after the node loop and before cairo_restore(cr):
// The wire being dragged, drawn last so it is over the boxes: without it the gesture gives no
// sign of what it is about to connect.
if (d->drag_mode == MathDrag::Wire && d->wire_from >= 0 &&
d->wire_from < static_cast<int>(d->nodes.size())) {
double ax = 0.0;
double ay = 0.0;
math_output_pos(d->nodes[static_cast<std::size_t>(d->wire_from)], ax, ay);
cairo_set_source_rgb(cr, 1.0, 0.69, 0.13);
cairo_set_line_width(cr, 1.5);
cairo_move_to(cr, ax, ay);
cairo_curve_to(cr, ax + 40.0, ay, d->wire_x - 40.0, d->wire_y, d->wire_x, d->wire_y);
cairo_stroke(cr);
}
- Step 7: Connect
drag-end
In the dialog construction, beside the existing drag-begin and drag-update connections:
g_signal_connect(drag, "drag-end", G_CALLBACK(math_canvas_drag_end), d);
Read the surrounding lines for the gesture variable's actual name rather than assuming drag.
- Step 8: Build and run; read the right values
cmake --build build 2>&1 | grep -E 'warning|error' | sort | uniq -c
Expected: the five documented warnings, no errors. (pending_out is gone from every reference; a leftover is a build error, which is the point of deleting the field rather than leaving it unused.)
Then the probe run. Expected:
[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] view: pan mode=3 dview=30.0 dnode=0.0 (want 3 30.0 0.0)
mode=2 is MathDrag::Wire and mode=3 is MathDrag::Pan — the probe prints the enum's own value beside the measurement so a reordering of the enum cannot make the line lie.
- Step 9: Commit
git add src/ui.cpp
git commit -m "$(cat <<'EOF'
feat: a wire is dragged from port to port, and a wired input is dragged off
One state machine decided at `drag-begin` -- a port starts a wire, a box
moves it, the background pans -- so the two-click protocol and its armed-port
highlight are gone. A drag from an input empties it immediately, which makes
a release over nothing the detach gesture; `Escape` puts it back.
EOF
)"
Task 4: Pick a wire and delete it
A wire needs to be selectable to be deleted by anything other than a detach drag, and the user asked for deletion explicitly.
Files:
- Modify:
src/ui.cpp:3290-3319(MathDialog:selected_wire) - Modify: after
math_port_at(addMathWireRef,math_wire_at,math_wire_at_canvas) - Modify:
math_canvas_draw(the selected wire is drawn highlighted) - Modify:
math_canvas_pressed(a wire is picked between the port test and the box test) - Modify:
math_canvas_key(addDelete/BackSpace) - Modify:
src/ui.cpp:6688+(the probe: awire: pickline)
Interfaces:
-
Consumes:
math_port_at_canvas(),math_node_at_canvas(),math_to_graph(),MathDrag(Tasks 1–3). -
Produces:
/// A wire, named by the node that *consumes* it and which of its inputs. The consumer is the /// identity because that is where the wire is stored -- a producer may feed several. struct MathWireRef { int node = -1; int which = 0; }; /// The wire under a graph position, or `{-1, 0}`. `tol` is in graph units. MathWireRef math_wire_at(const MathDialog* d, double gx, double gy, double tol); /// The same, from a canvas position, with the tolerance in screen pixels. MathWireRef math_wire_at_canvas(const MathDialog* d, double cx, double cy); MathWireRef selected_wire{-1, 0}; -
Step 1: Add the failing probe lines
In math_probe(), after Task 3's block:
if (a->math_dialog != nullptr) {
auto* p = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (p != nullptr && p->nodes.size() >= 2) {
math_view_reset(p);
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.
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;
const MathWireRef hit = math_wire_at_canvas(p, mx * p->view_scale + p->view_x,
my * p->view_scale + p->view_y);
p->selected_wire = hit;
math_canvas_key(nullptr, GDK_KEY_Delete, 0, static_cast<GdkModifierType>(0), p);
std::fprintf(stderr,
"[math-probe] wire: pick node=%d which=%d -> in_a=%d (want 1 0 -1)\n",
hit.node, hit.which, p->nodes[1].in_a);
p->nodes[1].in_a = 0;
p->selected_wire = MathWireRef{-1, 0};
}
}
- Step 2: Run it and read the wrong value
cmake --build build 2>&1 | grep -E 'MathWireRef|math_wire_at_canvas|selected_wire' | head -6
Expected: errors naming MathWireRef, math_wire_at_canvas and MathDialog::selected_wire as undeclared.
- Step 3: Add
MathWireRefand the hit test
Declare MathWireRef above struct MathDialog (it is a member's type), beside enum class MathDrag:
/// A wire, named by the node that *consumes* it and which of its inputs. The consumer is the
/// identity because that is where a wire is stored: `in_a`/`in_b` on the reader. A producer may
/// feed several inputs and so cannot name one.
struct MathWireRef {
int node = -1;
int which = 0;
};
and add to struct MathDialog:
/// The wire `Delete` would remove, or `{-1, 0}`.
MathWireRef selected_wire{-1, 0};
Then, after math_port_at_canvas() / math_node_at_canvas():
/// 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.
MathWireRef math_wire_at(const MathDialog* d, double gx, double gy, double tol) {
constexpr int kSamples = 24;
for (int i = static_cast<int>(d->nodes.size()) - 1; i >= 0; --i) {
const MathNode& n = d->nodes[static_cast<std::size_t>(i)];
const int ins[2] = {n.in_a, n.in_b};
for (int k = 0; k < math_op_arity(n.op); ++k) {
const int from = ins[k];
if (from < 0 || from >= static_cast<int>(d->nodes.size())) continue;
double ax = 0.0;
double ay = 0.0;
double bx = 0.0;
double by = 0.0;
math_output_pos(d->nodes[static_cast<std::size_t>(from)], ax, ay);
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) {
const double t = static_cast<double>(s) / kSamples;
const double u = 1.0 - t;
const double px = u * u * u * ax + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * bx;
const double py = u * u * u * ay + 3 * u * u * t * ay + 3 * u * t * t * by + t * t * t * by;
if (std::fabs(gx - px) <= tol && std::fabs(gy - py) <= tol) {
return MathWireRef{i, k};
}
}
}
}
return MathWireRef{-1, 0};
}
/// The pick radius for a wire, in screen pixels. Smaller than a port's, so a wire that passes
/// behind a port never wins the press -- the port is tested first anyway, but a user aiming at a
/// port should not be able to miss it onto a wire either.
constexpr double kWirePick = 6.0;
MathWireRef math_wire_at_canvas(const MathDialog* d, double cx, double cy) {
double gx = 0.0;
double gy = 0.0;
math_to_graph(d, cx, cy, gx, gy);
return math_wire_at(d, gx, gy, kWirePick / d->view_scale);
}
Note the draw's cubic has ay for both of the first control point's y and by for the second's; the sampler must use the same, or it tests a curve the user cannot see.
- Step 4: Pick a wire on a press
In math_canvas_pressed(), between the port test and the box test:
const MathWireRef wire = math_wire_at_canvas(d, x, y);
if (wire.node >= 0) {
d->selected_wire = wire;
d->selected = wire.node;
math_dialog_sync(d);
gtk_widget_queue_draw(d->canvas);
return;
}
and clear it in the two paths that are not a wire — add d->selected_wire = MathWireRef{-1, 0}; to the port branch, the box branch and the background fall-through.
- Step 5: Delete the selected wire
In math_canvas_key(), before the return FALSE;:
if (keyval == GDK_KEY_Delete || keyval == GDK_KEY_BackSpace) {
if (d->selected_wire.node >= 0 &&
d->selected_wire.node < static_cast<int>(d->nodes.size())) {
MathNode& n = d->nodes[static_cast<std::size_t>(d->selected_wire.node)];
(d->selected_wire.which == 0 ? n.in_a : n.in_b) = -1;
d->selected_wire = MathWireRef{-1, 0};
math_dialog_sync(d);
gtk_widget_queue_draw(d->canvas);
return TRUE;
}
}
It deliberately does not fall through to deleting the selected node: a key that removes one thing or another depending on an invisible selection state is how a graph gets destroyed by accident. The delete node button stays the only way to remove a node.
- Step 6: Draw the selected wire highlighted
In math_canvas_draw()'s wire loop, replace the stroke colour/width lines:
const bool picked = d->selected_wire.node == static_cast<int>(i) &&
d->selected_wire.which == k;
cairo_set_source_rgb(cr, picked ? 1.0 : 0.55, picked ? 0.69 : 0.62, picked ? 0.13 : 0.72);
cairo_set_line_width(cr, picked ? 3.0 : 1.5);
- Step 7: Build and run; read the right values
cmake --build build 2>&1 | grep -E 'warning|error' | sort | uniq -c
Expected: the five documented warnings, no errors. Then the probe run. Expected:
[math-probe] wire: pick node=1 which=0 -> in_a=-1 (want 1 0 -1)
- Step 8: Commit
git add src/ui.cpp
git commit -m "$(cat <<'EOF'
feat: a wire can be picked and deleted, so a graph can be rewired
`math_wire_at()` samples the same cubic the draw lays down -- 24 segments,
well under a pixel of chord error at the scales this canvas allows -- and
`Delete` removes the picked one. It deliberately does not fall through to
deleting the selected node: a key whose target depends on an invisible
selection is how a graph gets destroyed by accident.
EOF
)"
Task 5: math_connect() — a backwards wire re-sorts the graph
The user chose "reorder silently". The list-walked-in-order invariant stays; the editor becomes what maintains it.
Files:
- Modify:
src/ui.cpp(addmath_connect()aftermath_wire_at_canvas()) - Modify:
math_canvas_drag_end(call it instead of writingin_a/in_b) - Modify:
src/ui.cpp:6688+(the probe:wire: backwards,wire: self,wire: cycle)
Interfaces:
-
Consumes:
MathDrag,MathWireRef,selected_wire,drag_node(Tasks 3–4). -
Produces:
/// Wires `from`'s output into `to`'s input `which`, re-sorting the node list when the wire runs /// backwards. Returns false and sets `why` when the wire would make a cycle, leaving the graph /// untouched. bool math_connect(MathDialog* d, int from, int to, int which, std::string* why);Task 6's validation line reads the graph
math_connect()leaves behind; nothing else calls it. -
Step 1: Add the failing probe lines
In math_probe(), after Task 4's block:
if (a->math_dialog != nullptr) {
auto* c = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (c != nullptr && c->nodes.size() >= 2) {
// A wire drawn right-to-left is legal: the editor re-sorts so that the reader still sits
// after what it reads. Node 0 is the source and node 1 the gain, so wiring the gain into
// the source is impossible (a source has no inputs) -- a second gain reading the first,
// placed *before* it, is the case.
c->nodes.clear();
MathNode src;
src.op = MathOp::Source;
src.source = name;
MathNode late;
late.op = MathOp::Gain;
MathNode early;
early.op = MathOp::Gain;
c->nodes = {src, late, early}; // early reads late: backwards
c->nodes[1].in_a = 0;
c->output = 2;
c->selected = 2;
std::string why;
const bool ok = math_connect(c, 1, 2, 0, &why);
// After the sort node 1 must still be the one that reads 0, and the reader of it must sit
// after it. The op is the same for both, so the *wiring* is the reading.
std::string order;
for (std::size_t i = 0; i < c->nodes.size(); ++i) {
order += (i != 0 ? "," : "");
order += std::to_string(i) + ":" + std::to_string(c->nodes[i].in_a);
}
std::string valid_why;
const bool valid = math_channel_valid_nodes(c->nodes, c->output, &valid_why);
std::fprintf(stderr,
"[math-probe] wire: backwards ok=%d order=[%s] output=%d valid=%d why=[%s] "
"(want 1 ... 1 [])\n",
ok ? 1 : 0, order.c_str(), c->output, valid ? 1 : 0, valid_why.c_str());
std::string self_why;
const bool self_ok = math_connect(c, 1, 1, 0, &self_why);
std::fprintf(stderr, "[math-probe] wire: self refused=%d why=[%s] (want 1)\n",
self_ok ? 0 : 1, self_why.c_str());
// A cycle: the node that already feeds the other is asked to read it back.
const int reader = c->nodes[1].in_a == 0 ? 2 : 1;
const int feeder = reader == 2 ? 1 : 2;
std::string cyc_why;
const bool cyc_ok = math_connect(c, reader, feeder, 0, &cyc_why);
std::fprintf(stderr, "[math-probe] wire: cycle refused=%d why=[%s] (want 1)\n",
cyc_ok ? 0 : 1, cyc_why.c_str());
}
}
math_channel_valid_nodes() does not exist; it is a thin overload the next step adds beside math_channel_valid() so the probe can validate a bare node list without building a MathChannel.
- Step 2: Run it and read the wrong value
cmake --build build 2>&1 | grep -E 'math_connect|math_channel_valid_nodes' | head -4
Expected: errors naming math_connect and math_channel_valid_nodes as undeclared.
- Step 3: Add
math_channel_valid_nodes()
In src/math_channel.hpp, beside the existing declaration:
/// The same check over a bare node list and output index, for a caller that has a graph but not yet
/// a `MathChannel` -- the editor, and the headless probe that drives it.
bool math_channel_valid_nodes(const std::vector<MathNode>& nodes, int output,
std::string* why = nullptr);
In src/math_channel.cpp, make the existing body the new function and have the old one forward to it. Replace math_channel_valid() (currently 218-245) with:
bool math_channel_valid_nodes(const std::vector<MathNode>& nodes, int output, std::string* why) {
const std::size_t n = nodes.size();
const auto fail = [&](const std::string& text) {
if (why != nullptr) *why = text;
return false;
};
if (n == 0) return fail("empty graph");
for (std::size_t i = 0; i < n; ++i) {
const MathNode& node = nodes[i];
if (node.op == MathOp::Source && node.source.empty()) {
return fail("node " + std::to_string(i + 1) + ": no channel chosen");
}
const int arity = math_op_arity(node.op);
const int ins[2] = {node.in_a, node.in_b};
for (int k = 0; k < arity; ++k) {
// Only nodes *before* this one: the evaluator walks the list in order, so a forward or
// self reference is not a cycle to detect, it is a node reading a value that is not there.
if (ins[k] < 0 || ins[k] >= static_cast<int>(i)) {
return fail("node " + std::to_string(i + 1) + ": input " + std::to_string(k + 1) +
" is not connected");
}
}
}
if (output < 0 || static_cast<std::size_t>(output) >= n) {
return fail("no output chosen");
}
if (why != nullptr) why->clear();
return true;
}
bool math_channel_valid(const MathChannel& channel, std::string* why) {
return math_channel_valid_nodes(channel.nodes, channel.output, why);
}
The why->clear() on success is new and load-bearing for Task 6: the validation line prints why unconditionally, and a stale message from a previous call would be shown beside a valid graph.
- Step 4: Add
math_connect()
After math_wire_at_canvas() in src/ui.cpp:
/// Wires `from`'s output into `to`'s input `which`. The evaluator walks the node list in order, so
/// a reader must sit after what it reads -- and rather than refuse a wire drawn right to left (the
/// old editor did not even draw such an input as connectable, which made the order the *user's*
/// problem), the list is re-sorted so that it holds. Returns false and sets `why` for a wire that
/// cannot be ordered at all, i.e. one that would close a cycle; nothing is written in that case.
bool math_connect(MathDialog* d, int from, int to, int which, std::string* why) {
const int n = static_cast<int>(d->nodes.size());
if (from < 0 || from >= n || to < 0 || to >= n) return false;
if (from == to) {
if (why != nullptr) *why = "a node cannot read itself";
return false;
}
// Kahn's algorithm over the *trial* edge set, so a cycle is found before anything is written.
std::vector<int> in_a(static_cast<std::size_t>(n));
std::vector<int> in_b(static_cast<std::size_t>(n));
for (int i = 0; i < n; ++i) {
in_a[static_cast<std::size_t>(i)] = d->nodes[static_cast<std::size_t>(i)].in_a;
in_b[static_cast<std::size_t>(i)] = d->nodes[static_cast<std::size_t>(i)].in_b;
}
(which == 0 ? in_a : in_b)[static_cast<std::size_t>(to)] = from;
std::vector<int> indegree(static_cast<std::size_t>(n), 0);
const auto edges_of = [&](int i, int (&out)[2]) {
const MathNode& node = d->nodes[static_cast<std::size_t>(i)];
const int arity = math_op_arity(node.op);
out[0] = arity >= 1 ? in_a[static_cast<std::size_t>(i)] : -1;
out[1] = arity >= 2 ? in_b[static_cast<std::size_t>(i)] : -1;
};
for (int i = 0; i < n; ++i) {
int e[2] = {-1, -1};
edges_of(i, e);
for (int k = 0; k < 2; ++k) {
if (e[k] >= 0 && e[k] < n && e[k] != i) ++indegree[static_cast<std::size_t>(i)];
}
}
// Ties broken by the current list index, so a graph that is already in order is not permuted:
// the positions are the user's layout as much as the evaluator's order, and shuffling a valid
// graph on every wire would read as the editor rearranging itself.
std::vector<int> order;
order.reserve(static_cast<std::size_t>(n));
std::vector<bool> done(static_cast<std::size_t>(n), false);
for (int step = 0; step < n; ++step) {
int pick = -1;
for (int i = 0; i < n; ++i) {
if (!done[static_cast<std::size_t>(i)] && indegree[static_cast<std::size_t>(i)] == 0) {
pick = i;
break;
}
}
if (pick < 0) {
if (why != nullptr) *why = "that wire would make a loop";
return false;
}
done[static_cast<std::size_t>(pick)] = true;
order.push_back(pick);
for (int i = 0; i < n; ++i) {
if (done[static_cast<std::size_t>(i)]) continue;
int e[2] = {-1, -1};
edges_of(i, e);
for (int k = 0; k < 2; ++k) {
if (e[k] == pick) --indegree[static_cast<std::size_t>(i)];
}
}
}
// `order[p]` is the node that moves to position `p`; `where[old]` is where it ends up. Every
// index the dialog holds is in the old space and has to be remapped in one block -- a missed one
// silently renames an operation or re-points a wire.
std::vector<int> where(static_cast<std::size_t>(n), -1);
for (int p = 0; p < n; ++p) where[static_cast<std::size_t>(order[static_cast<std::size_t>(p)])] = p;
std::vector<MathNode> sorted;
sorted.reserve(static_cast<std::size_t>(n));
for (int p = 0; p < n; ++p) sorted.push_back(d->nodes[static_cast<std::size_t>(order[static_cast<std::size_t>(p)])]);
const auto remap = [&](int i) { return i >= 0 && i < n ? where[static_cast<std::size_t>(i)] : -1; };
for (int p = 0; p < n; ++p) {
MathNode& node = sorted[static_cast<std::size_t>(p)];
const int old = order[static_cast<std::size_t>(p)];
node.in_a = math_op_arity(node.op) >= 1 ? remap(in_a[static_cast<std::size_t>(old)]) : -1;
node.in_b = math_op_arity(node.op) >= 2 ? remap(in_b[static_cast<std::size_t>(old)]) : -1;
}
d->nodes = std::move(sorted);
d->output = remap(d->output);
d->selected = remap(d->selected);
d->drag_node = remap(d->drag_node);
d->wire_from = remap(d->wire_from);
d->wire_detach = remap(d->wire_detach);
d->selected_wire.node = remap(d->selected_wire.node);
if (why != nullptr) why->clear();
return true;
}
The edges_of lambda masks each input by the op's arity, which matters: a node whose op was changed from binary to unary keeps a stale in_b, and counting it would make an indegree that never reaches zero and so report a loop that is not there.
- Step 5: Call it from
drag-end
In math_canvas_drag_end(), replace the direct write:
if (target >= 0 && which != kPortOut && target != d->wire_from) {
MathNode& n = d->nodes[static_cast<std::size_t>(target)];
(which == 0 ? n.in_a : n.in_b) = d->wire_from;
d->selected = target;
math_dialog_sync(d);
}
with:
if (target >= 0 && which != kPortOut && target != d->wire_from) {
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) {
// 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)];
(d->wire_detach_which == 0 ? back.in_a : back.in_b) = d->wire_from;
math_dialog_sync(d);
}
}
d->selected is no longer written here: math_connect() remaps it and leaving it on wire_from — which drag-begin set — is the honest answer, since the user grabbed that node.
- Step 6: Build and run; read the right values
cmake --build build 2>&1 | grep -E 'warning|error' | sort | uniq -c
Expected: the five documented warnings, no errors. Then the probe run. Expected:
[math-probe] wire: backwards ok=1 order=[0:-1,1:0,2:1] output=2 valid=1 why=[] (want 1 ... 1 [])
[math-probe] wire: self refused=1 why=[a node cannot read itself] (want 1)
[math-probe] wire: cycle refused=1 why=[that wire would make a loop] (want 1)
valid=1 is the assertion on the first line — the order string is the reading that explains it. order=[0:-1,1:0,2:1] says the source feeds the node at position 1 which feeds the node at position 2, which is the chain the graph was wired as regardless of how it was laid out.
- Step 7: Commit
git add src/ui.cpp src/math_channel.cpp src/math_channel.hpp
git commit -m "$(cat <<'EOF'
feat: a backwards wire re-sorts the graph instead of being refused
The evaluator walks the node list in order, which used to make the order the
user's problem -- an input of an earlier node was not even drawn as
connectable. `math_connect()` runs Kahn's algorithm over the trial edge set,
with ties broken by the current index so a graph already in order is not
permuted, and remaps every index the dialog holds in one block. A self wire
and a cycle are refused with a message and change nothing.
EOF
)"
Task 6: The live validation line replaces the modal alerts
The user chose "live validation line". The dialog says why it cannot be saved, all the time, instead of refusing with a modal once Apply is pressed. Two latent defects are fixed in the same pass: math_dialog_sync() returns early on an out-of-range selection, so the line has to be written before that return; and the source dropdown is populated from signals() (every signal, including the unacquirable timestamp) while Apply validates against math_source_names() (acquired channels only) — an inconsistency that lets a user pick a source they cannot fix.
Files:
- Modify:
src/ui.cpp:3290-3319(MathDialog:problem,apply) - Modify:
src/ui.cpp(addmath_dialog_problem()abovemath_dialog_sync()) - Modify:
src/ui.cpp:3621-3684(math_dialog_sync: the line before the early return; the dropdown's list) - Modify:
src/ui.cpp:3571-3590(on_math_param_changed: the same list) - Modify:
src/ui.cpp:3686-3719(on_math_apply: the two alerts go) - Modify: the dialog construction (the label, and
d->apply) - Modify:
src/ui.cpp:6688+(the probe: avalidate:block)
Interfaces:
-
Consumes:
math_connect()(Task 5) only in that it reads the graph left behind;math_channel_valid_nodes()(Task 5). -
Produces:
/// Why the graph as it stands could not be saved, or "" when it could. The graph check and the /// acquired-source check in one place, so the line and the Apply button cannot disagree. std::string math_dialog_problem(const MathDialog* d); GtkWidget* problem = nullptr; ///< the validation line GtkWidget* apply = nullptr; ///< desensitized while `math_dialog_problem()` is non-empty -
Step 1: Add the failing probe lines
In math_probe(), after Task 5's block:
if (a->math_dialog != nullptr) {
auto* v2 = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (v2 != nullptr && v2->nodes.size() >= 2) {
// Broken: an unwired input. The line must say so and Apply must be insensitive -- the old
// behaviour was a modal alert *after* the press, which is a refusal rather than guidance.
v2->nodes[1].in_a = -1;
math_dialog_sync(v2);
const std::string bad = math_dialog_problem(v2);
const int bad_sensitive = gtk_widget_get_sensitive(v2->apply) ? 1 : 0;
const char* bad_text = gtk_label_get_text(GTK_LABEL(v2->problem));
v2->nodes[1].in_a = 0;
math_dialog_sync(v2);
const std::string good = math_dialog_problem(v2);
const int good_sensitive = gtk_widget_get_sensitive(v2->apply) ? 1 : 0;
std::fprintf(stderr,
"[math-probe] validate: broken problem=[%s] label=[%s] apply=%d; "
"fixed problem=[%s] apply=%d (want non-empty 0; empty 1)\n",
bad.c_str(), bad_text != nullptr ? bad_text : "(null)", bad_sensitive,
good.c_str(), good_sensitive);
// The selection out of range is the case `math_dialog_sync()` returns early on, so the
// line must be written above that return or it goes stale exactly when it matters.
const int was_sel = v2->selected;
v2->selected = 999;
v2->nodes[1].in_a = -1;
math_dialog_sync(v2);
const char* stale = gtk_label_get_text(GTK_LABEL(v2->problem));
std::fprintf(stderr, "[math-probe] validate: no-selection label=[%s] (want non-empty)\n",
stale != nullptr ? stale : "(null)");
v2->selected = was_sel;
v2->nodes[1].in_a = 0;
math_dialog_sync(v2);
// And the source dropdown must list the *acquired* channels, which is what Apply checks
// against: listing every signal let a user pick one that could never be made valid.
const std::vector<std::string> acquired = math_source_names(*a);
std::size_t signals_n = 0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
signals_n = a->model->signals().size();
}
v2->selected = 0;
v2->nodes[0].op = MathOp::Source;
math_dialog_sync(v2);
GListModel* lm = gtk_drop_down_get_model(GTK_DROP_DOWN(v2->source_drop));
const guint listed = lm != nullptr ? g_list_model_get_n_items(lm) : 0u;
std::fprintf(stderr,
"[math-probe] validate: source list=%u acquired=%zu signals=%zu "
"(want list==acquired)\n",
listed, acquired.size(), signals_n);
v2->selected = was_sel;
}
}
- Step 2: Run it and read the wrong value
cmake --build build 2>&1 | grep -E 'math_dialog_problem|->problem|->apply' | head -5
Expected: errors naming math_dialog_problem, MathDialog::problem and MathDialog::apply as undeclared.
- Step 3: Add the two widget pointers
In struct MathDialog, beside inputs:
/// 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.
GtkWidget* problem = nullptr;
GtkWidget* apply = nullptr;
- Step 4: Add
math_dialog_problem()
Immediately above math_dialog_sync()'s definition (it needs math_source_names(), declared at 3324, and math_channel_valid_nodes() from the header):
/// Why this graph could not be saved, or "" when it could. Both checks in one place: the graph's own
/// (an output that exists, every input wired to a node that exists) and that a `Source` names a
/// channel the scope actually *acquires* -- which the engine's validator cannot see, since it knows
/// nothing about the acquired list. The two used to be a pair of modal alerts raised after Apply was
/// pressed, which is a refusal rather than guidance: there is nothing in the message a user could
/// not have been told while they were still building the graph.
std::string math_dialog_problem(const MathDialog* d) {
std::string why;
if (!math_channel_valid_nodes(d->nodes, d->output, &why)) return why;
const std::vector<std::string> acquired = math_source_names(*d->app);
for (std::size_t i = 0; i < d->nodes.size(); ++i) {
if (d->nodes[i].op != MathOp::Source) continue;
if (std::find(acquired.begin(), acquired.end(), d->nodes[i].source) != acquired.end()) continue;
return "node " + std::to_string(i + 1) + ": '" + d->nodes[i].source +
"' is not an acquired channel";
}
return std::string();
}
- Step 5: Write the line before
math_dialog_sync()'s early return
At the very top of math_dialog_sync(), above the if (d->selected < 0 ...) return;:
void math_dialog_sync(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());
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->canvas != nullptr) gtk_widget_queue_draw(d->canvas);
return;
}
The added queue_draw on that path is deliberate: the canvas state (a deleted node, a re-sorted list) changes on exactly the frames the selection goes out of range.
- Step 6: Make both source lists the acquired channels
In math_dialog_sync(), replace the list build:
std::vector<std::string> names;
{
std::lock_guard<std::mutex> lock(d->app->model->mutex());
for (const SignalInfo& sig : d->app->model->signals()) names.push_back(sig.name);
}
with:
// The *acquired* channels, which is what Apply validates against. Listing every signal let a
// user pick a source that could never be made valid -- and put the stream's timestamp signal,
// which has no samples at all, at the top of the list.
const std::vector<std::string> names = math_source_names(*d->app);
Do the same in on_math_param_changed(): read its current list build (around 3578) and replace it with math_source_names(*d->app). The variable is used by index against gtk_drop_down_get_selected(), so the two lists must be built the same way or the index names a different channel — which is why both have to change in one commit.
- Step 7: Delete the two alerts from
on_math_apply()
Replace src/ui.cpp:3689-3719's two blocks with a single guard:
// The validation line has said what is wrong for as long as it has been wrong, and Apply is
// insensitive while it is non-empty -- so this is a guard against a programmatic call (the
// headless probe makes one), not a message to the user.
const std::string why = math_dialog_problem(d);
if (!why.empty()) {
std::fprintf(stderr, "[math] apply refused: %s\n", why.c_str());
return;
}
MathChannel m;
m.name = gtk_editable_get_text(GTK_EDITABLE(d->name));
m.unit = gtk_editable_get_text(GTK_EDITABLE(d->unit));
m.nodes = d->nodes;
m.output = d->output;
Keep the on_math_param_changed(nullptr, d); call at the top. The std::string why; and MathChannel m; declarations that were above move into this block, so read the whole function and make it one coherent body rather than patching around the old order.
- Step 8: Build the label and record the Apply button
In the dialog construction, append the label to the form column, below the inputs label:
// 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.
d->problem = gtk_label_new("");
gtk_label_set_wrap(GTK_LABEL(d->problem), TRUE);
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);
gtk_box_append(GTK_BOX(form), d->problem);
and where the Apply button is created (around 3964), record it:
d->apply = apply;
placed after apply is created and before the math_dialog_sync(d) call at the end of the function — that call is what first sets its sensitivity.
- Step 9: Build and run; read the right values
cmake --build build 2>&1 | grep -E 'warning|error' | sort | uniq -c
Expected: four warnings now, not five — on_math_apply() no longer leaves output_built unused, because the probe reads it. If it still reads five, check which: an unused variable 'why' from the Step 7 rewrite is a finding, not the baseline.
Then the probe run. Expected:
[math-probe] validate: broken problem=[node 2: input 1 is not connected] label=[node 2: input 1 is not connected] apply=0; fixed problem=[] apply=1 (want non-empty 0; empty 1)
[math-probe] validate: no-selection label=[node 2: input 1 is not connected] (want non-empty)
[math-probe] validate: source list=N acquired=N signals=M (want list==acquired)
list == acquired is the assertion; signals is printed only to show that it is the larger number and so that the change is visible.
- Step 10: Commit
git add src/ui.cpp
git commit -m "$(cat <<'EOF'
feat: the editor says why a graph cannot be saved instead of refusing on Apply
One `math_dialog_problem()` drives both the line and Apply's sensitivity, so
they cannot disagree, and the two modal alerts are gone -- there was nothing
in either message a user could not have been told while still building the
graph. Written above `math_dialog_sync()`'s early return, since an
out-of-range selection is exactly when a stale line misleads most.
The source dropdown now lists the acquired channels, which is what Apply
validates against: listing every signal let a user pick a source that could
never be made valid, and put the stream's timestamp signal first.
EOF
)"
Task 7: Pick the op when adding a node
The user chose this. add node always makes a MathOp::Gain today, so building anything else is add-then-change-the-dropdown.
Files:
- Modify:
src/math_channel.hpp(declaremath_op_index) - Modify:
src/math_channel.cpp(define it) - Modify:
src/ui.cpp:3514-3534(on_math_node_addedtakes an op) - Modify:
src/ui.cpp:3666-3667(math_dialog_syncuses it instead of the cast) - Modify: the dialog construction (
add nodebecomes aGtkMenuButton) - Modify:
src/ui.cpp:6644(the probe's existingon_math_node_added(nullptr, ed)call)
Interfaces:
-
Consumes: nothing new.
-
Produces:
/// An op's position in `math_op_at()`'s order -- the dialog's order, which is not the enum's /// declaration order by contract even though it happens to match today. int math_op_index(MathOp op);/// Adds a node of `op`. The button carries its op in the widget's data. void math_add_node(MathDialog* d, MathOp op); -
Step 1: Add the failing probe line
In math_probe(), after Task 6's block:
if (a->math_dialog != nullptr) {
auto* o = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (o != nullptr) {
const std::size_t before = o->nodes.size();
math_add_node(o, MathOp::BandPass);
const int op_added = o->nodes.empty() ? -1 : math_op_index(o->nodes.back().op);
// The op picker must make the node the user asked for, and the `rows` the form shows must
// follow it: a band pass is the one op that shows the Q row, so it is the case worth the
// check.
const int q_row = gtk_widget_get_visible(o->rows[3]) ? 1 : 0;
std::fprintf(stderr,
"[math-probe] op pick: nodes %zu->%zu op_index=%d q_row=%d "
"(want %zu %d 1)\n",
before, o->nodes.size(), op_added, q_row, before + 1,
math_op_index(MathOp::BandPass));
// And the identity the whole picker rests on: a round trip through both accessors.
int round = 1;
for (int i = 0; i < math_op_count(); ++i) {
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);
}
}
- Step 2: Run it and read the wrong value
cmake --build build 2>&1 | grep -E 'math_op_index|math_add_node' | head -4
Expected: errors naming math_op_index and math_add_node as undeclared.
- Step 3: Add
math_op_index()
In src/math_channel.hpp, beside math_op_at():
/// An op's position in `math_op_at()`'s order. That order is the *dialog's* -- what a dropdown or a
/// menu lists, and what a session file stores -- and it is deliberately not promised to be the
/// enum's declaration order, so a caller that needs a position must ask rather than cast.
int math_op_index(MathOp op);
In src/math_channel.cpp, after math_op_at():
int math_op_index(MathOp op) {
for (int i = 0; i < math_op_count(); ++i) {
if (math_op_at(i) == op) return i;
}
return 0;
}
In src/ui.cpp, replace math_dialog_sync()'s cast:
if (d->op_drop != nullptr) gtk_drop_down_set_selected(GTK_DROP_DOWN(d->op_drop),
static_cast<guint>(op));
with:
// Through `math_op_index()`, not a cast: the dropdown is built from `math_op_at()`, whose order
// is the dialog's rather than the enum's, and the cast worked only because they happen to agree.
if (d->op_drop != nullptr) {
gtk_drop_down_set_selected(GTK_DROP_DOWN(d->op_drop),
static_cast<guint>(math_op_index(op)));
}
- Step 4: Make
on_math_node_added()take an op
Replace src/ui.cpp:3514-3534 with:
/// Adds a node of `op` and selects it. The grid placement is the same one the auto-place uses, so a
/// node added here lands clear of the others -- 160 is narrower than `kNodeW` plus a wire's tread,
/// which is why the tidy layout uses a wider pitch; this is only a first guess at a free cell.
void math_add_node(MathDialog* d, MathOp op) {
// A node added to a graph that was *linear* -- its output was the last node -- becomes the output,
// which is what "add a gain and it applies" means. A graph whose output is an earlier node is a
// deliberate choice and is left alone.
const bool was_linear =
d->output == static_cast<int>(d->nodes.size()) - 1 || d->nodes.empty();
MathNode n;
n.op = op;
const std::size_t k = d->nodes.size();
n.x = 24.0 + 160.0 * static_cast<double>(k % 2u);
n.y = 20.0 + 58.0 * static_cast<double>(k / 2u);
const int arity = math_op_arity(n.op);
if (arity >= 1 && k > 0) n.in_a = static_cast<int>(k) - 1;
if (arity >= 2 && k > 0) n.in_b = static_cast<int>(k) - 1;
if (op == MathOp::Source) {
// Only an *acquired* channel: `signals().front()` is the stream's timestamp signal, which has
// no samples, so a graph sourced from it validates and then reads NaN for ever.
const std::vector<std::string> acquired = math_source_names(*d->app);
if (!acquired.empty()) n.source = acquired.front();
}
d->nodes.push_back(n);
d->selected = static_cast<int>(d->nodes.size()) - 1;
if (was_linear) d->output = d->selected;
math_dialog_sync(d);
}
void on_math_node_added(GtkButton*, gpointer user) {
math_add_node(static_cast<MathDialog*>(user), MathOp::Gain);
}
on_math_node_added() is kept because the existing probe block at src/ui.cpp:6644 calls it and that call is an assertion about the output defaulting to the added node. Leave it.
Also delete d->pending_out = -1; from on_math_node_deleted() (3557) — the field is gone as of Task 3, so this is a build error until it is removed. (If Task 3 was done correctly it is already gone; check.)
- Step 5: Turn
add nodeinto a menu button
Replace the add node button's construction with:
// A menu of the twelve ops rather than one button that always makes a gain: building anything
// else was add-then-change-the-dropdown, which is two steps for the common case.
GtkWidget* add = gtk_menu_button_new();
gtk_menu_button_set_label(GTK_MENU_BUTTON(add), "add node");
GtkWidget* add_menu = gtk_popover_new();
GtkWidget* add_list = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
for (int i = 0; i < math_op_count(); ++i) {
const MathOp op = math_op_at(i);
GtkWidget* item = gtk_button_new_with_label(math_op_name(op));
gtk_widget_add_css_class(item, "flat");
gtk_widget_set_halign(gtk_button_get_child(GTK_BUTTON(item)), GTK_ALIGN_START);
// The op travels in the widget's own data, as an `int` through `math_op_index()`'s space: a
// pointer-sized enum smuggled through `gpointer` is the kind of cast that survives a
// reordering of the enum and then means something else.
g_object_set_data(G_OBJECT(item), "udpscope-math-op", GINT_TO_POINTER(i));
g_object_set_data(G_OBJECT(item), "udpscope-math-popover", add_menu);
g_signal_connect(item, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
MathDialog* dd = static_cast<MathDialog*>(user);
const int index =
GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), "udpscope-math-op"));
auto* pop = GTK_POPOVER(
g_object_get_data(G_OBJECT(b), "udpscope-math-popover"));
if (pop != nullptr) gtk_popover_popdown(pop);
math_add_node(dd, math_op_at(index));
}), d);
gtk_box_append(GTK_BOX(add_list), item);
}
gtk_popover_set_child(GTK_POPOVER(add_menu), add_list);
gtk_menu_button_set_popover(GTK_MENU_BUTTON(add), add_menu);
and append add to the same box the old button went into. Delete the old g_signal_connect(add, "clicked", ...) line; on_math_node_added() now has only the probe as a caller.
- Step 6: Build and run; read the right values
cmake --build build 2>&1 | grep -E 'warning|error' | sort | uniq -c
Expected: four warnings, no errors. Then the probe run. Expected:
[math-probe] op pick: nodes 3->4 op_index=7 q_row=1 (want 4 7 1)
[math-probe] op pick: index round trip=1 (want 1)
The node counts depend on what the earlier blocks left behind, so read nodes N->N+1 against the want, not against 3→4.
- Step 7: Commit
git add src/ui.cpp src/math_channel.cpp src/math_channel.hpp
git commit -m "$(cat <<'EOF'
feat: the op is picked when a node is added, not changed afterwards
`add node` made a gain and nothing else, so every other op was two steps. It
is a menu of the twelve now, each carrying its index through
`math_op_index()`'s space rather than a cast of the enum -- which is also
what `math_dialog_sync()` now uses to drive the op dropdown, since that
order is the dialog's and not the declaration's.
EOF
)"
Task 8: The tidy button
The user chose "tidy / auto-layout". The add-node grid wraps at two columns and overlaps as a graph grows; a graph loaded from a session file has whatever positions it was saved with.
Files:
- Modify:
src/ui.cpp(addmath_tidy()aftermath_view_fit()) - Modify: the dialog construction (a
tidybutton) - Modify:
src/ui.cpp:6688+(the probe: atidy:line)
Interfaces:
-
Consumes:
math_view_fit()(Task 2). -
Produces:
/// Lays the graph out left to right by depth, then frames it. void math_tidy(MathDialog* d); -
Step 1: Add the failing probe line
In math_probe(), after Task 7's block:
if (a->math_dialog != nullptr) {
auto* ty = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (ty != nullptr && ty->nodes.size() >= 2) {
// Pile every node on one spot, so a layout that did nothing would be visible.
for (MathNode& n : ty->nodes) {
n.x = 10.0;
n.y = 10.0;
}
math_tidy(ty);
// No two boxes may overlap -- `math_node_at()` searches topmost-first, so an overlap means
// a click or a drag lands on the wrong node, which reads as the dialog ignoring the pointer.
int overlaps = 0;
std::string xs;
for (std::size_t i = 0; i < ty->nodes.size(); ++i) {
xs += (i != 0 ? "," : "") + std::to_string(static_cast<int>(ty->nodes[i].x));
for (std::size_t j = i + 1; j < ty->nodes.size(); ++j) {
const MathNode& p = ty->nodes[i];
const MathNode& q = ty->nodes[j];
if (p.x < q.x + kNodeW && q.x < p.x + kNodeW && p.y < q.y + kNodeH &&
q.y < p.y + kNodeH) {
++overlaps;
}
}
}
std::fprintf(stderr, "[math-probe] tidy: xs=[%s] overlaps=%d (want 0)\n", xs.c_str(),
overlaps);
}
}
- Step 2: Run it and read the wrong value
cmake --build build 2>&1 | grep -E 'math_tidy' | head -3
Expected: an error naming math_tidy as undeclared.
- Step 3: Add
math_tidy()
After math_view_fit():
/// Lays the graph out left to right by depth: a node sits one column right of the deepest thing it
/// reads, and its row is its position among the nodes at that depth. It needs no recursion and no
/// visited set -- the list is already in evaluation order, so a single forward pass has every
/// input's depth in hand by the time it reaches the node that reads it.
void math_tidy(MathDialog* d) {
const std::size_t n = d->nodes.size();
if (n == 0) return;
// Wider than the add-node grid's 160: a column pitch of `kNodeW + 48` leaves the wire its 40-unit
// tread on each side with room to curve. At 160 there were 28 units for a 40-unit tread, so the
// bezier's control points crossed and the wire doubled back on itself.
constexpr double kColPitch = kNodeW + 48.0;
constexpr double kRowPitch = kNodeH + 22.0;
std::vector<int> depth(n, 0);
std::vector<int> row(n, 0);
std::vector<int> used;
for (std::size_t i = 0; i < n; ++i) {
const MathNode& node = d->nodes[i];
int deepest = -1;
const int ins[2] = {node.in_a, node.in_b};
for (int k = 0; k < math_op_arity(node.op); ++k) {
if (ins[k] >= 0 && ins[k] < static_cast<int>(i)) {
deepest = std::max(deepest, depth[static_cast<std::size_t>(ins[k])]);
}
}
const int dep = deepest + 1;
depth[i] = dep;
if (static_cast<std::size_t>(dep) >= used.size()) used.resize(static_cast<std::size_t>(dep) + 1, 0);
row[i] = used[static_cast<std::size_t>(dep)]++;
}
for (std::size_t i = 0; i < n; ++i) {
d->nodes[i].x = 24.0 + static_cast<double>(depth[i]) * kColPitch;
d->nodes[i].y = 20.0 + static_cast<double>(row[i]) * kRowPitch;
}
// And frame it, since a tidy that leaves the graph off screen has moved the problem rather than
// solved it.
math_view_fit(d);
}
A Source and an unwired node both get depth 0, which is correct: neither reads anything.
- Step 4: Add the button
Beside the fit button from Task 2:
GtkWidget* tidy = gtk_button_new_with_label("tidy");
gtk_widget_set_tooltip_text(tidy, "Lay the graph out left to right and frame it.");
g_signal_connect(tidy, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
math_tidy(static_cast<MathDialog*>(user));
}), d);
gtk_box_append(GTK_BOX(tools), tidy);
Use the same box name the fit button used.
- Step 5: Build and run; read the right value
cmake --build build 2>&1 | grep -E 'warning|error' | sort | uniq -c
Expected: four warnings, no errors. Then the probe run. Expected:
[math-probe] tidy: xs=[24,204,384,...] overlaps=0 (want 0)
overlaps=0 is the assertion. The xs list shows the 180-unit column pitch and is a reading, not an assertion — a graph whose nodes are all at the same depth legitimately reads one x repeated, with the rows separating them.
- Step 6: Commit
git add src/ui.cpp
git commit -m "$(cat <<'EOF'
feat: a tidy button lays the graph out by depth, so no two boxes overlap
A single forward pass -- the list is already in evaluation order, so every
input's depth is in hand by the time the reader is reached -- then a fit. The
column pitch is `kNodeW + 48` rather than the add-node grid's 160, which left
28 units for a wire's 40-unit tread and made the bezier double back.
EOF
)"
Task 9: Documentation and the full verification run
Files:
- Modify:
CLAUDE.md(theUDPSCOPE_PROBE_MATH_MSbullet under Verification; the math-channel invariant block; the warning baseline) - Modify:
README.md(the math-channel section) - Modify:
src/help/11-math.txt
Interfaces: consumes everything; produces nothing in code.
- Step 1: Update the warning baseline in
CLAUDE.md
Under Verification, the paragraph beginning "Two baselines every run is read against. A clean build emits five warnings". Task 6 removes the unused output_built. Change the count and the list to four — two -Wmissing-field-initializers on MathNode::source and -Woverlength-strings on both generated GResource .c files — and say that the fifth was the editor's probe not reading a value it computed, now read by the validation check. Verify the count from the build before writing it; if the real figure is not four, write the real figure.
- Step 2: Extend the
UDPSCOPE_PROBE_MATH_MSbullet inCLAUDE.md
The existing bullet is the A math channel *is* a channel. invariant block's final paragraph plus its sub-bullets. Append to the probe's description the new assertions, each with what a failure means:
-
hit:— the output square straddles the box edge, so a hit test that recovers the node by a box test misses every pixel past it.past-edge node=-1is the silent disarm that was the whole of "edges cannot be added". -
cancel: dialog=cleared— Cancel mustgtk_window_close(), notgtk_window_destroy(). It clicks the button, since the defect was in the connection and a direct handler call cannot see it. -
view: zoom … anchor kept dx dy— must be0.0000 0.0000. A wheel zoom that does not keep the graph point under the pointer reads as the canvas sliding away from the hand. -
view: pan+zoom hit— the hit tests run throughmath_to_graph(). At the identity view a test that forgot the inverse passes anyway, which is why this one is taken after a pan and a zoom. -
view: fit … all_inside=1andview: reset scale=1.000 offset=(0.0,0.0). -
view: pan mode=3 dview=30.0 dnode=0.0— a background drag pans and moves no node. -
wire: connect mode=2 in_a=0,detach: emptied_at_begin=-1 in_a=-1,detach: escape restores in_a=0. -
wire: pick … -> in_a=-1. -
wire: backwards ok=1 … valid=1— the re-sort.valid=1is the assertion; theorder=string is the reading that explains it. -
wire: self refused=1,wire: cycle refused=1— and nothing is written on either. -
validate: broken problem=[..] apply=0; fixed problem=[] apply=1,validate: no-selection label=[..](non-empty — the line is written abovemath_dialog_sync()'s early return),validate: source list=N acquired=N. -
op pick: … op_index=.. q_row=1andop pick: index round trip=1. -
tidy: … overlaps=0. -
Step 3: Add the new invariants to
CLAUDE.md
Inside the A math channel *is* a channel. block, add sub-bullets in that block's existing voice (a rule, then what went wrong, then the guard):
-
A port hit must name its own node. The output square is drawn centred on
n.x + kNodeW, straddling the box's right edge, with a±kPorthit radius against a±kPort*0.5drawn half-width — so recovering the node withmath_node_at(), which matches only inside the box, returns-1for every pixel past the edge.math_port_at()returns the node directly for that reason. -
Every hit test goes through
math_to_graph(), and every tolerance is a screen distance divided byview_scale. A tolerance left in graph units makes a port unhittable zoomed out and greedy zoomed in. A probe that tests a hit at the identity view asserts nothing — the check has to be taken after a pan and a zoom. -
A drag's mode is decided once, at
drag-begin, and the precedence is port, box, background. Asking the box first makes the output square unreachable, which is the same geometry as the bullet above. -
A detach empties the input at
drag-begin. That is what makes a release over nothing the detach gesture rather than a second verb, and it is whyEscapehas something to restore. -
A backwards wire re-sorts the list; it is not refused. The evaluator walks the nodes in order, and until now that made the order the user's problem — an earlier node's input was not even drawn as connectable.
math_connect()runs Kahn's algorithm over the trial edge set, so a cycle is found before anything is written, breaks ties by the current list index so a graph already in order is not permuted, and remapsoutput,selected, everyin_a/in_b,drag_node,wire_from,wire_detachandselected_wire.nodein one block. A missed index silently renames an operation or re-points a wire. Itsedges_ofmasks each input by the op's arity: a node changed from binary to unary keeps a stalein_b, and counting it makes an indegree that never reaches zero and so reports a loop that is not there. -
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. -
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 — andstatic_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 its index throughmath_op_at(); this is the other direction of the same rule. -
A
Deletekey removes the selected wire and never falls through to the selected node. A key whose target depends on an invisible selection state is how a graph gets destroyed by accident; thedelete nodebutton stays the only way to remove a node. -
The tidy layout's column pitch is
kNodeW + 48, not the add grid's 160. At 160 a wire has 28 units for its 40-unit tread, so the bezier's control points cross and the wire doubles back on itself. -
Step 4: Update
README.md
In the math-channel section, describe the editor as it now is: drag a wire from an output to an input; drag a wired input off to detach it; click a wire and Delete to remove it; wheel to zoom about the pointer, drag the background to pan, f to fit, Ctrl+0 to reset; tidy to lay out; the op is chosen when the node is added; the line under the form says why the graph cannot be saved and Apply is insensitive while it is there; a wire drawn right to left re-sorts the list silently. State the deviation from the old behaviour explicitly — the two-click protocol and the two modal alerts are gone.
- Step 5: Update
src/help/11-math.txt
Read the file first for its Pango-markup conventions and the voice of the other thirteen pages. Rewrite the editor paragraph to describe the gestures above. Every & must be &, every < < — the body is Pango markup validated with pango_parse_markup() at load, and a page that fails renders a one-line stand-in. The [help] probe is the check.
- Step 6: The full verification run
Four runs, one at a time, each killing its mock.
The math probe, against the fast mock (the two standing assertions for math are gaps=0 and each math '<name>': ... N samples vs M time slots being equal):
python3 tools/mock_udpstreamer.py --port 8111 --rate 1300000 --batch 2000 --duration 30 &
MOCK=$!
xvfb-run -a env UDPSCOPE_ENABLE_ALL_MS=1000 UDPSCOPE_PROBE_MATH_MS=2000 \
UDPSCOPE_EXIT_AFTER_MS=12000 ./build/udpscope --server 127.0.0.1 --port 8111 \
2>&1 | grep -E "\[math-probe\]|math '|gaps|math cost|Gtk-CRITICAL"
kill $MOCK 2>/dev/null
The help probe, which is what validates the rewritten page (parsed must equal pages):
python3 tools/mock_udpstreamer.py --port 8112 --duration 20 &
MOCK=$!
xvfb-run -a env UDPSCOPE_PROBE_HELP_MS=1500 UDPSCOPE_EXIT_AFTER_MS=8000 \
./build/udpscope --server 127.0.0.1 --port 8112 \
2>&1 | grep -E '\[help\]|Failed to parse|Gtk-CRITICAL'
kill $MOCK 2>/dev/null
Expected: [help] pages=14 parsed=14 ... and no Failed to parse.
A plain exit-dump run, for the layout and icon baselines nothing here should have touched:
python3 tools/mock_udpstreamer.py --port 8113 --duration 20 &
MOCK=$!
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
./build/udpscope --server 127.0.0.1 --port 8113 \
2>&1 | grep -E 'layout over|axes.w changes|x-desync|toolbar icons|app icons|Gtk-CRITICAL'
kill $MOCK 2>/dev/null
Expected: x-desync 0, every icon (ok), axes.w changes small, no Gtk-CRITICAL.
And the measures probe with no unit, which is the run that exercises format_measure() on a math channel's unitless values:
python3 tools/mock_udpstreamer.py --port 8114 --unit '' --duration 25 &
MOCK=$!
xvfb-run -a env UDPSCOPE_ENABLE_ALL_MS=1000 UDPSCOPE_PROBE_MEASURES_MS=2500 \
UDPSCOPE_EXIT_AFTER_MS=10000 ./build/udpscope --server 127.0.0.1 --port 8114 \
2>&1 | grep -E '\[measures-probe\]|measure [0-9]|measures pane|Gtk-CRITICAL'
kill $MOCK 2>/dev/null
Delete any session files the runs left behind so a later run does not inherit a layout:
rm -f ~/.config/udpscope/127.0.0.1_811[1-4].toml
- Step 7: Commit
git add CLAUDE.md README.md src/help/11-math.txt
git commit -m "$(cat <<'EOF'
docs: the node editor's gestures, its new probe assertions and its invariants
Each invariant records what went wrong rather than only the rule: a port hit
that lost its node, a hit test at the identity view that asserts nothing, a
`Delete` that must not fall through to the selected node, and a column pitch
too narrow for a wire's own tread. The warning baseline drops to four.
EOF
)"
Self-review
Spec coverage. Every section of docs/superpowers/specs/2026-10-06-math-node-editor-design.md maps to a task: "Why edges cannot be added today" → Task 1; "The view transform" → Task 2; "The gesture state machine" → Task 3; "The port and wire hit tests" → Tasks 1 and 4; "The edge model" → Tasks 3–5; "The live validation line" → Task 6; "The op picker on add" → Task 7; "Tidy / auto-layout" → Task 8; "The Cancel button closes rather than destroys" → Task 1; "What [math-probe] must assert" → distributed across Tasks 1–8 and summarised in Task 9; "Documentation" → Task 9.
Two things the spec listed as checks are constraints here, not assertions, because no headless probe can see them: that the canvas takes focus on press (stated in Task 2 Step 7 and Task 3 Step 6, with the reason), and that the live wire tracks the pointer visually (Task 3 Step 6 draws it; nothing asserts its appearance).
Out of scope and deliberately not in any task: the item-2 feature shortlist (measurement statistics, the extra trigger types, PNG export, FFT, event history, search, reference waveforms) — those need their own design round. The publish_pause_view() values/stamps shear is a separate, unrelated defect and is not touched here.