// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari // // The math channels: the pane that lists them and the node editor (its canvas, its hit tests, its\n// gestures and its node/param forms) that edits the graph the producer evaluates. #include "ui_app.hpp" #include #include namespace udpscope { namespace ui_impl { /// Hands the app's list to the model. A structural change -- the derived channels are rebuilt and /// the producer starts or stops evaluating each one -- so it is the single writer for both copies. void apply_math_channels(App& app) { // `set_math_channels()` takes the model's lock itself -- holding it here is a self-deadlock on a // non-recursive mutex, and it is exactly how the first version of the probe hung the app. app.model->set_math_channels(app.math); // The structural rebuild above replaced the buffer and dropped its history. A stream's producer // refills that; a file's nothing does, so the view is re-read -- and with the math set now in place // the reload computes the derived channels over the file as it goes. reload_file_view(app); rebuild_math_pane(app); schedule_layout_save(app); } /// The channels a math channel may read: the ones the model actually acquires. Offering the whole /// signal list would let a graph name a signal nobody is acquiring -- which reads as no data for /// ever -- and would put the stream's timestamp signal in the list, which has no samples at all. std::vector math_source_names(App& app) { std::vector names; std::lock_guard lock(app.model->mutex()); for (const ChannelInfo& c : app.model->channels()) names.push_back(c.name); return names; } /// 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 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(); } /// The box a node is drawn in, in canvas pixels. GdkRectangle math_node_rect(const MathNode& n) { return GdkRectangle{static_cast(n.x), static_cast(n.y), static_cast(kNodeW), static_cast(kNodeH)}; } /// Where an input square sits: on the left edge, split when there are two. void math_input_pos(const MathNode& n, int which, double& x, double& y) { const int arity = math_op_arity(n.op); x = n.x; y = n.y + kNodeH * (arity == 2 ? (which == 0 ? 1.0 : 2.0) / 3.0 : 0.5); } void math_output_pos(const MathNode& n, double& x, double& y) { x = n.x + kNodeW; y = n.y + kNodeH * 0.5; } int math_node_at(const MathDialog* d, double x, double y) { for (int i = static_cast(d->nodes.size()) - 1; i >= 0; --i) { const GdkRectangle r = math_node_rect(d->nodes[static_cast(i)]); if (x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height) return i; } return -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(d->nodes.size()) - 1; i >= 0; --i) { const MathNode& n = d->nodes[static_cast(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; } /// 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; } /// Zooms about a canvas position: one step in for `dy < 0`, one out otherwise. The graph point /// under `(px, py)` is still under it afterwards -- which is what makes the canvas follow the /// hand rather than slide away from it. void math_zoom_at(MathDialog* d, double px, double py, double dy) { 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); } 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); } /// 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 depth(n, 0); std::vector row(n, 0); std::vector 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(i)) { deepest = std::max(deepest, depth[static_cast(ins[k])]); } } const int dep = deepest + 1; depth[i] = dep; if (static_cast(dep) >= used.size()) used.resize(static_cast(dep) + 1, 0); row[i] = used[static_cast(dep)]++; } for (std::size_t i = 0; i < n; ++i) { d->nodes[i].x = 24.0 + static_cast(depth[i]) * kColPitch; d->nodes[i].y = 20.0 + static_cast(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); } /// 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); } /// The squared distance from a point to a line *segment* -- clamped to the segment, so the ends do /// not extend it. A degenerate segment falls back to the distance to its first point. double math_seg_dist2(double px, double py, double ax, double ay, double bx, double by) { const double dx = bx - ax; const double dy = by - ay; const double len2 = dx * dx + dy * dy; double t = 0.0; if (len2 > 0.0) t = std::clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0.0, 1.0); const double cx = px - (ax + t * dx); const double cy = py - (ay + t * dy); return cx * cx + cy * cy; } /// The wire under a graph position, or `{-1, 0}`. The wire is the cubic the draw lays down, so it /// is *sampled* rather than solved: 24 segments is well under a pixel of chord error at the scales /// this canvas allows, and a closed form for "nearest point on a cubic" is a quartic root find for /// no gain. /// /// The 24 samples are the ends of 24 **segments**, and it is the distance to the segment that is /// measured. Testing the sample *points* instead leaves a gap wherever two of them are more than /// `2 * tol` apart -- which a wire spanning several rows of a tidied graph is, in y -- and in that /// gap a click lands on the drawn wire and picks nothing. That is half of "it is not possible to /// delete edges". /// /// The *nearest* wire wins rather than the first one found, so a crossing picks the one actually /// under the pointer; topmost-first iteration is the tie-break, matching every other hit test here. MathWireRef math_wire_at(const MathDialog* d, double gx, double gy, double tol) { constexpr int kSamples = 24; MathWireRef best{-1, 0}; double best_d2 = tol * tol; for (int i = static_cast(d->nodes.size()) - 1; i >= 0; --i) { const MathNode& n = d->nodes[static_cast(i)]; const int ins[2] = {n.in_a, n.in_b}; for (int k = 0; k < math_op_arity(n.op); ++k) { const int from = ins[k]; if (from < 0 || from >= static_cast(i)) continue; double ax = 0.0; double ay = 0.0; double bx = 0.0; double by = 0.0; math_output_pos(d->nodes[static_cast(from)], ax, ay); math_input_pos(n, k, bx, by); const double c1x = ax + 40.0; const double c2x = bx - 40.0; double prev_x = ax; double prev_y = ay; for (int s = 1; s <= kSamples; ++s) { const double t = static_cast(s) / kSamples; const double u = 1.0 - t; const double px = u * u * u * ax + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * bx; const double py = u * u * u * ay + 3 * u * u * t * ay + 3 * u * t * t * by + t * t * t * by; const double d2 = math_seg_dist2(gx, gy, prev_x, prev_y, px, py); if (d2 < best_d2) { best_d2 = d2; best = MathWireRef{i, k}; } prev_x = px; prev_y = py; } } } return best; } 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); } /// 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(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 in_a(static_cast(n)); std::vector in_b(static_cast(n)); for (int i = 0; i < n; ++i) { in_a[static_cast(i)] = d->nodes[static_cast(i)].in_a; in_b[static_cast(i)] = d->nodes[static_cast(i)].in_b; } (which == 0 ? in_a : in_b)[static_cast(to)] = from; std::vector indegree(static_cast(n), 0); const auto edges_of = [&](int i, int (&out)[2]) { const MathNode& node = d->nodes[static_cast(i)]; const int arity = math_op_arity(node.op); out[0] = arity >= 1 ? in_a[static_cast(i)] : -1; out[1] = arity >= 2 ? in_b[static_cast(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(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 order; order.reserve(static_cast(n)); std::vector done(static_cast(n), false); for (int step = 0; step < n; ++step) { int pick = -1; for (int i = 0; i < n; ++i) { if (!done[static_cast(i)] && indegree[static_cast(i)] == 0) { pick = i; break; } } if (pick < 0) { if (why != nullptr) *why = "that wire would make a loop"; return false; } done[static_cast(pick)] = true; order.push_back(pick); for (int i = 0; i < n; ++i) { if (done[static_cast(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(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 where(static_cast(n), -1); for (int p = 0; p < n; ++p) where[static_cast(order[static_cast(p)])] = p; std::vector sorted; sorted.reserve(static_cast(n)); for (int p = 0; p < n; ++p) sorted.push_back(d->nodes[static_cast(order[static_cast(p)])]); const auto remap = [&](int i) { return i >= 0 && i < n ? where[static_cast(i)] : -1; }; for (int p = 0; p < n; ++p) { MathNode& node = sorted[static_cast(p)]; const int old = order[static_cast(p)]; node.in_a = math_op_arity(node.op) >= 1 ? remap(in_a[static_cast(old)]) : -1; node.in_b = math_op_arity(node.op) >= 2 ? remap(in_b[static_cast(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; } void math_canvas_draw(GtkDrawingArea*, cairo_t* cr, int width, int height, gpointer user) { MathDialog* d = static_cast(user); 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); // The links first, so a box is never crossed by its own wires: from the source box's output // square to the destination's input square, with a horizontal tread in between. for (std::size_t i = 0; i < d->nodes.size(); ++i) { const MathNode& n = d->nodes[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(i)) continue; double ax = 0.0; double ay = 0.0; double bx = 0.0; double by = 0.0; math_output_pos(d->nodes[static_cast(from)], ax, ay); math_input_pos(n, k, bx, by); const bool picked = d->selected_wire.node == static_cast(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); cairo_move_to(cr, ax, ay); cairo_curve_to(cr, ax + 40.0, ay, bx - 40.0, by, bx, by); cairo_stroke(cr); } } for (std::size_t i = 0; i < d->nodes.size(); ++i) { const MathNode& n = d->nodes[i]; const GdkRectangle r = math_node_rect(n); const bool sel = static_cast(i) == d->selected; cairo_set_source_rgb(cr, sel ? 0.24 : 0.13, sel ? 0.29 : 0.16, sel ? 0.36 : 0.20); cairo_rectangle(cr, r.x, r.y, r.width, r.height); cairo_fill_preserve(cr); cairo_set_source_rgb(cr, sel ? 0.94 : 0.55, sel ? 0.96 : 0.60, sel ? 1.0 : 0.68); cairo_set_line_width(cr, sel ? 2.0 : 1.0); cairo_stroke(cr); cairo_set_source_rgb(cr, 0.90, 0.92, 0.96); cairo_select_font_face(cr, "sans", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_NORMAL); cairo_set_font_size(cr, 12.0); cairo_move_to(cr, r.x + 8.0, r.y + 18.0); cairo_show_text(cr, math_op_name(n.op)); if (n.op == MathOp::Source) { cairo_set_font_size(cr, 10.0); cairo_set_source_rgb(cr, 0.70, 0.76, 0.84); cairo_move_to(cr, r.x + 8.0, r.y + 34.0); cairo_show_text(cr, n.source.c_str()); } if (static_cast(i) == d->output) { // The output node carries the channel's value, so it is marked rather than merely selected. cairo_set_source_rgb(cr, 1.0, 0.69, 0.13); cairo_arc(cr, r.x + r.width - 12.0, r.y + 12.0, 4.0, 0.0, 2.0 * 3.14159265358979); cairo_fill(cr); } 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); } // 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(d->nodes.size())) { double ax = 0.0; double ay = 0.0; math_output_pos(d->nodes[static_cast(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); } cairo_restore(cr); (void)width; (void)height; } void math_canvas_pressed(GtkGestureClick*, int, double x, double y, gpointer user) { MathDialog* d = static_cast(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_wire = MathWireRef{-1, 0}; d->selected = port; math_dialog_sync(d); return; } 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; } const int node = math_node_at_canvas(d, x, y); if (node >= 0) { d->selected_wire = MathWireRef{-1, 0}; d->selected = node; math_dialog_sync(d); return; } d->selected_wire = MathWireRef{-1, 0}; gtk_widget_queue_draw(d->canvas); } void math_canvas_drag_begin(GtkGestureDrag*, double x, double y, gpointer user) { MathDialog* d = static_cast(user); d->drag_mode = MathDrag::None; d->drag_node = -1; d->wire_from = -1; d->wire_detach = -1; d->press_cx = x; d->press_cy = y; // 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(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(node)].x; d->grab_y = d->nodes[static_cast(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(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(d->nodes.size())) return; MathNode& n = d->nodes[static_cast(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->press_cx + dx, d->press_cy + 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(user); if (d->drag_mode != MathDrag::Wire) { d->drag_mode = MathDrag::None; d->drag_node = -1; return; } // Where the release landed: the press point plus the gesture's total offset is the pointer in // every case, including a detach where `wire_from` is a node the press never touched. Using // `wire_x` instead would be wrong when `drag-end` arrives without a preceding final `drag-update` // (GTK makes no guarantee); using the output port would be wrong on a detach, where the source // node may be a column away from the press point. int which = kPortOut; const int target = math_port_at_canvas(d, d->press_cx + dx, d->press_cy + dy, which); // Released on *any* input port, `wire_from`'s own included: the self case has to reach // `math_connect()` to be refused there by name. Screening it out here meant the one refusal the // user is most likely to provoke never got a message at all. if (target >= 0 && which != kPortOut) { std::string why; if (math_connect(d, d->wire_from, target, which, &why)) { // `math_connect()` may have permuted the list, so the target's index is no longer `target`. math_dialog_sync(d); } else { // A refused wire must not also lose the one the drag detached to start with. if (d->wire_detach >= 0 && d->wire_detach < static_cast(d->nodes.size())) { MathNode& back = d->nodes[static_cast(d->wire_detach)]; (d->wire_detach_which == 0 ? back.in_a : back.in_b) = d->wire_from; } // And it must *say* why. `why` used to be filled and dropped here, so neither "a node cannot // read itself" nor the cycle refusal ever reached the user. d->refusal = why.empty() ? std::string("that wire cannot be made") : why; math_dialog_sync(d); } } // 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); } gboolean math_canvas_scrolled(GtkEventControllerScroll* scroll, double, double dy, gpointer user) { MathDialog* d = static_cast(user); if (dy == 0.0) return TRUE; // Resolve the anchor: the pointer position from the current event, or the canvas centre as a // fallback (a real scroll event always arrives on an allocated widget, so the fallback is only // reached in degenerate cases). 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) { const double w = d->canvas != nullptr ? static_cast(gtk_widget_get_width(d->canvas)) : 0.0; const double h = d->canvas != nullptr ? static_cast(gtk_widget_get_height(d->canvas)) : 0.0; px = w > 0.0 ? w * 0.5 : 0.0; py = h > 0.0 ? h * 0.5 : 0.0; } math_zoom_at(d, px, py, dy); return TRUE; } gboolean math_canvas_key(GtkEventControllerKey*, guint keyval, guint, GdkModifierType state, gpointer user) { MathDialog* d = static_cast(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; } 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(d->nodes.size())) { MathNode& n = d->nodes[static_cast(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; // Restoring the input is a mutation like any other, so the validation line has to be // re-evaluated: `drag-begin` emptied the input and synced, leaving "input is not connected" on // screen with `Apply` insensitive, and without this the undo left both there. math_dialog_sync(d); gtk_widget_queue_draw(d->canvas); return TRUE; } if (keyval == GDK_KEY_Delete || keyval == GDK_KEY_BackSpace) { if (d->selected_wire.node >= 0 && d->selected_wire.node < static_cast(d->nodes.size())) { MathNode& n = d->nodes[static_cast(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; } } return FALSE; } /// Adds a node of `op` and selects it, at the first grid cell whose box overlaps nothing, so a node /// added here lands clear of the others -- 160 is narrower than `kNodeW` plus a wire's tread, which /// is why the tidy layout uses a wider pitch. void math_add_node(MathDialog* d, MathOp op) { // A node added to a graph that was *linear* -- its output was the last node -- becomes the output, // which is what "add a gain and it applies" means. A graph whose output is an earlier node is a // deliberate choice and is left alone. const bool was_linear = d->output == static_cast(d->nodes.size()) - 1 || d->nodes.empty(); MathNode n; n.op = op; const std::size_t k = d->nodes.size(); // The first *free* cell of the grid, not cell `k`. Placing by `nodes.size()` reuses an occupied // cell as soon as anything has been deleted -- delete the middle of three and add one and the new // box lands exactly on an existing one, which breaks the documented rule that boxes must not // overlap: `math_node_at()` searches topmost-first, so the covered box becomes unclickable. Same // 160/58 pitch and two-column wrap as before; only the choice of cell changes. A graph that has // taken every cell up to the bound falls through to cell `k` rather than searching for ever. constexpr std::size_t kCellLimit = 64; std::size_t cell = k; for (std::size_t c = 0; c < kCellLimit; ++c) { const double cx = 24.0 + 160.0 * static_cast(c % 2u); const double cy = 20.0 + 58.0 * static_cast(c / 2u); bool free_cell = true; for (const MathNode& other : d->nodes) { if (cx < other.x + kNodeW && other.x < cx + kNodeW && cy < other.y + kNodeH && other.y < cy + kNodeH) { free_cell = false; break; } } if (free_cell) { cell = c; break; } } n.x = 24.0 + 160.0 * static_cast(cell % 2u); n.y = 20.0 + 58.0 * static_cast(cell / 2u); // `k`, not `cell`: the new node's *index* is the end of the list whatever cell it was drawn in. const int arity = math_op_arity(n.op); if (arity >= 1 && k > 0) n.in_a = static_cast(k) - 1; if (arity >= 2 && k > 0) n.in_b = static_cast(k) - 1; 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 acquired = math_source_names(*d->app); if (!acquired.empty()) n.source = acquired.front(); } d->nodes.push_back(n); d->selected = static_cast(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(user), MathOp::Gain); } void on_math_node_deleted(GtkButton*, gpointer user) { MathDialog* d = static_cast(user); if (d->selected < 0 || d->selected >= static_cast(d->nodes.size())) return; const int gone = d->selected; d->nodes.erase(d->nodes.begin() + gone); // Re-point every reference: a node after the removed one shifts down, and one reading it is left // unconnected rather than quietly reading its neighbour. for (MathNode& n : d->nodes) { const int ins[2] = {n.in_a, n.in_b}; int* slots[2] = {&n.in_a, &n.in_b}; for (int k = 0; k < 2; ++k) { if (ins[k] == gone) { *slots[k] = -1; } else if (ins[k] > gone) { *slots[k] = ins[k] - 1; } } } if (d->output == gone) d->output = -1; else if (d->output > gone) d->output -= 1; d->selected = std::min(gone, static_cast(d->nodes.size()) - 1); d->drag_mode = MathDrag::None; d->wire_from = -1; d->wire_detach = -1; // `MathWireRef::node` is an index into `d->nodes`, so a selection held across the erase names a // *different* wire afterwards and `Delete` would clear an input on the wrong node. Cleared rather // than remapped because the selected wire may have been one of the deleted node's own: a selection // the user can no longer see, pointing at a surviving edge, is worse than no selection. d->selected_wire = MathWireRef{-1, 0}; math_dialog_sync(d); } void on_math_output_set(GtkButton*, gpointer user) { MathDialog* d = static_cast(user); if (d->selected < 0 || d->selected >= static_cast(d->nodes.size())) return; d->output = d->selected; math_dialog_sync(d); } /// Reads every parameter widget into the selected node. One writer for the whole form, so a change /// cannot leave two copies of a value disagreeing -- and the widgets are created *once* and merely /// shown, hidden and set here, which is what keeps a rebuild from invalidating a live handle. void on_math_param_changed(GtkWidget*, gpointer user) { MathDialog* d = static_cast(user); // A programmatic form write is not a user edit, and this reader must not answer one. Loading a // node sets *every* widget, and the source dropdown is set last -- so the spin buttons' own // change signals ran this reader while the dropdown still held the previous node's selection, and // it stamped `names[0]`, the first acquired channel, into the node the user had just clicked. // That is why clicking a `channel` node renamed it to the first signal every time. if (d->syncing) return; if (d->selected < 0 || d->selected >= static_cast(d->nodes.size())) return; MathNode& n = d->nodes[static_cast(d->selected)]; n.gain = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->gain)); n.offset = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->offset)); n.fc = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->fc)); n.q = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->q)); n.lo = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->lo)); n.hi = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->hi)); if (d->source_drop != nullptr) { const guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(d->source_drop)); const std::vector names = math_source_names(*d->app); // Only a `channel` node *has* a source. Writing one onto a gain or a filter stored a name the // graph never reads, and it is how a stale selection spread to nodes with no dropdown at all. if (n.op == MathOp::Source && sel < names.size()) n.source = names[sel]; } gtk_widget_queue_draw(d->canvas); } /// The same read, for the source dropdown, and it needs its own entry point for the reason /// `on_trigger_drop_changed()` gives: `notify::` passes a `GParamSpec*` ahead of the user /// data and `GtkSpinButton::value-changed` does not, so wiring the two-parameter reader above to /// `notify::selected` made it read the pspec as its `MathDialog*`. That is not a latent hazard -- /// it segfaulted on the first field it touched, on the first change of the source dropdown, which /// is to say on opening the math editor and picking a source. void on_math_source_changed(GObject*, GParamSpec*, gpointer user) { on_math_param_changed(nullptr, user); } /// The op changed: the new op may take a different number of inputs, so the ones it cannot use are /// cleared rather than left pointing at a node the graph would then evaluate from anyway. void on_math_op_changed(GObject* object, GParamSpec*, gpointer user) { MathDialog* d = static_cast(user); if (d->selected < 0 || d->selected >= static_cast(d->nodes.size())) return; if (d->op_drop == nullptr || object != G_OBJECT(d->op_drop)) return; MathNode& n = d->nodes[static_cast(d->selected)]; const MathOp op = math_op_at(gtk_drop_down_get_selected(GTK_DROP_DOWN(d->op_drop))); if (op != n.op) { n.op = op; if (math_op_arity(op) < 2) n.in_b = -1; if (math_op_arity(op) < 1) { n.in_a = -1; n.in_b = -1; } // A node may only read the ones above it, so a new op keeps only the inputs that still qualify. if (n.in_a >= d->selected) n.in_a = -1; if (n.in_b >= d->selected) n.in_b = -1; if (op == MathOp::Source && n.source.empty()) { const std::vector names = math_source_names(*d->app); if (!names.empty()) n.source = names.front(); } } math_dialog_sync(d); } /// The form and the canvas from the selected node: which rows an op uses, what they hold, and which /// nodes its inputs are wired to. Called only through `math_dialog_sync()`, which guards it against /// the re-entry the dropdowns' `notify::selected` would otherwise cause. void math_dialog_sync_form(MathDialog* d) { // A source naming a channel nobody acquires is moved to the first acquired one -- see the dropdown // below for why. It happens *here*, above the validation line, because the move is a mutation that // changes what `math_dialog_problem()` reports: reconciled after the label is written, a repaired // source would be described as broken, with `Apply` insensitive, until some later sync. std::vector names; const bool have_form = d->selected >= 0 && d->selected < static_cast(d->nodes.size()); if (d->source_drop != nullptr && have_form) { names = math_source_names(*d->app); MathNode& sel = d->nodes[static_cast(d->selected)]; if (!names.empty() && std::find(names.begin(), names.end(), sel.source) == names.end()) { sel.source = names.front(); } } // Before the early return below: an out-of-range selection is exactly the state in which a stale // validation line is most misleading -- a graph with no node selected can still be broken, and the // user has no form to read instead. if (d->problem != nullptr) { const std::string why = math_dialog_problem(d); // A refusal from the last gesture wins the *text*, because nothing in the graph's state records // it; `Apply` still follows `why`, since a refused wire wrote nothing. Consumed here, so the // message lasts exactly until the next sync. const std::string shown = d->refusal.empty() ? why : d->refusal; d->refusal.clear(); gtk_label_set_text(GTK_LABEL(d->problem), shown.c_str()); // The label ellipsizes (see its construction), so the tooltip is the only place the full text // can be read. Set here, beside the text, so the two cannot disagree. gtk_widget_set_tooltip_text(d->problem, shown.empty() ? nullptr : shown.c_str()); gtk_widget_set_visible(d->problem, !shown.empty()); if (d->apply != nullptr) gtk_widget_set_sensitive(d->apply, why.empty()); } if (d->selected < 0 || d->selected >= static_cast(d->nodes.size())) { if (d->canvas != nullptr) gtk_widget_queue_draw(d->canvas); return; } MathNode& n = d->nodes[static_cast(d->selected)]; const MathOp op = n.op; const bool gain = math_op_uses_gain(op); const bool filter = math_op_uses_filter(op); const bool clamp = math_op_uses_clamp(op); const bool source = op == MathOp::Source; gtk_widget_set_visible(d->rows[0], source); // the source channel gtk_widget_set_visible(d->rows[1], gain); gtk_widget_set_visible(d->rows[2], filter); gtk_widget_set_visible(d->rows[3], filter && op == MathOp::BandPass); gtk_widget_set_visible(d->rows[4], clamp); gtk_widget_set_visible(d->rows[5], true); // the inputs, always: they say what is wired gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->gain), n.gain); gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->offset), n.offset); gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->fc), n.fc); gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->q), n.q); gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->lo), n.lo); gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->hi), n.hi); if (d->source_drop != nullptr) { // The *acquired* channels, which is what Apply validates against. Listing every signal let a // user pick a source that could never be made valid -- and put the stream's timestamp signal, // which has no samples at all, at the top of the list. A source that is not in this list would // read NaN for ever while the dropdown showed the first entry as if it were selected, so the // node was already moved to the first entry at the top of this function. GtkStringList* list = gtk_string_list_new(nullptr); guint pick = 0; for (std::size_t i = 0; i < names.size(); ++i) { gtk_string_list_append(list, names[i].c_str()); if (names[i] == n.source) pick = static_cast(i); } gtk_drop_down_set_model(GTK_DROP_DOWN(d->source_drop), G_LIST_MODEL(list)); gtk_drop_down_set_selected(GTK_DROP_DOWN(d->source_drop), pick); g_object_unref(list); } // 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(math_op_index(op))); } std::string inputs; const auto describe = [&](int index) -> std::string { if (index < 0) return "—"; if (index >= static_cast(d->nodes.size())) return "—"; const MathNode& other = d->nodes[static_cast(index)]; std::string text = std::to_string(index + 1) + ": " + math_op_name(other.op); if (other.op == MathOp::Source) text += " (" + other.source + ")"; return text; }; const int arity = math_op_arity(op); inputs = arity == 0 ? "this node reads a channel" : arity == 1 ? ("input: " + describe(n.in_a)) : ("inputs: " + describe(n.in_a) + " " + describe(n.in_b)); gtk_label_set_text(GTK_LABEL(d->inputs), inputs.c_str()); gtk_widget_set_visible(d->inputs, true); gtk_widget_queue_draw(d->canvas); } /// The one entry point, and the re-entrancy guard: setting the op dropdown emits `notify::selected`, /// whose handler syncs, so the form's own write re-entered the sync and the inner call rewrote the /// validation label -- erasing a refusal in the same call that displayed it. One level only; the /// flag is reset by a scope guard, so the early return inside cannot leave it set. void math_dialog_sync(MathDialog* d) { if (d->syncing) return; struct Guard { MathDialog* d; ~Guard() { d->syncing = false; } } guard{d}; d->syncing = true; math_dialog_sync_form(d); } void on_math_apply(GtkButton*, gpointer user) { MathDialog* d = static_cast(user); on_math_param_changed(nullptr, d); // 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; if (m.name.empty()) m.name = "math"; if (d->edit >= 0 && d->edit < static_cast(d->app->math.size())) { m.enabled = d->app->math[static_cast(d->edit)].enabled; d->app->math[static_cast(d->edit)] = m; } else { m.enabled = true; d->app->math.push_back(m); } apply_math_channels(*d->app); // Last statement: `close` emits `close-request`, whose handler clears `app.math_dialog`, and the // default then destroys the window -- which frees `d` through the data's destroy notify. gtk_window_close(GTK_WINDOW(d->window)); } GtkWidget* math_row(const char* label, GtkWidget* widget, GtkWidget** row_out) { GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget* text = gtk_label_new(label); gtk_widget_set_size_request(text, 74, -1); gtk_widget_set_halign(text, GTK_ALIGN_START); gtk_box_append(GTK_BOX(row), text); gtk_widget_set_hexpand(widget, true); gtk_box_append(GTK_BOX(row), widget); if (row_out != nullptr) *row_out = row; return row; } GtkWidget* build_math_panel(App& app) { // The panel's frame -- the heading row with its add button, and the list -- is a UI file; the rows are // not, because each one is a channel and the whole list is rebuilt whenever the set changes. `make test-ui` // checks the file's types against the GIR; the ids below are what keeps the code and the file agreeing. GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/math-panel.ui"); GtkWidget* box = GTK_WIDGET(gtk_builder_get_object(ui, "math_panel")); GtkWidget* add = GTK_WIDGET(gtk_builder_get_object(ui, "math_add")); app.math_list = GTK_WIDGET(gtk_builder_get_object(ui, "math_list")); if (box == nullptr || add == nullptr || app.math_list == nullptr) { // A missing id is a mistake in the `.ui`: the panel comes up empty rather than taking the window with // it, and `[udpscope] math panel:` reads the difference. return box != nullptr ? box : gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); } g_signal_connect(add, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { add_math_channel(*static_cast(user)); }), &app); return box; } } // namespace ui_impl } // namespace udpscope