From 0b5ace95851c23e6da2df5ba06362bc3cd0d771f Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Wed, 7 Oct 2026 00:03:42 +0200 Subject: [PATCH] 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. --- src/ui.cpp | 273 ++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 227 insertions(+), 46 deletions(-) diff --git a/src/ui.cpp b/src/ui.cpp index 80e010c..7e441b0 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -3287,14 +3287,25 @@ constexpr double kNodeW = 132.0; constexpr double kNodeH = 46.0; constexpr double kPort = 10.0; +enum class MathDrag { None, Node, Wire, Pan }; + struct MathDialog { App* app = nullptr; int edit = -1; std::vector nodes; int output = 0; int selected = 0; - /// The node whose output square was clicked and is waiting for an input to connect to. - int pending_out = -1; + /// 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; /// Where the box being dragged *was* when the drag began, and which box it is. The gesture /// reports the total offset from its own start point, so adding it to a value that the previous /// update already moved is a runaway: the box accelerates away from the pointer and leaves the @@ -3549,25 +3560,34 @@ void math_canvas_draw(GtkDrawingArea*, cairo_t* cr, int width, int height, gpoin cairo_arc(cr, r.x + r.width - 12.0, r.y + 12.0, 4.0, 0.0, 2.0 * 3.14159265358979); cairo_fill(cr); } - // Ports: inputs on the left, the output on the right. 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); - const bool armed = d->pending_out >= 0 && static_cast(i) != d->pending_out; - cairo_set_source_rgb(cr, armed ? 1.0 : 0.45, armed ? 0.69 : 0.52, armed ? 0.13 : 0.62); + 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, d->pending_out == static_cast(i) ? 1.0 : 0.45, - d->pending_out == static_cast(i) ? 0.69 : 0.52, - d->pending_out == static_cast(i) ? 0.13 : 0.62); + 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; @@ -3579,60 +3599,135 @@ void math_canvas_pressed(GtkGestureClick*, int, double x, double y, gpointer use // and `Ctrl+0` to reach it. gtk_widget_grab_focus(d->canvas); int which = kPortOut; - const int node = math_port_at_canvas(d, x, y, 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); + 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) { - if (d->pending_out >= 0 && d->pending_out != node) { - MathNode& n = d->nodes[static_cast(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_canvas(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); } void math_canvas_drag_begin(GtkGestureDrag*, double x, double y, gpointer user) { MathDialog* d = static_cast(user); - // Only a drag that *began on a box* moves one. Dragging the background used to move whatever was - // selected, which reads as the window fighting the pointer. + 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(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); - d->drag_node = node; - if (node < 0) return; - 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); + 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); - if (d->drag_node < 0 || d->drag_node >= static_cast(d->nodes.size())) return; - MathNode& n = d->nodes[static_cast(d->drag_node)]; - // The *total* offset from where the box was when the drag began -- from `grab_x`/`grab_y`, which - // no update writes. Feeding each update's delta back into the origin is what made the box fly off. - // 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); + // 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->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(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(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(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); } @@ -3671,6 +3766,20 @@ gboolean math_canvas_key(GtkEventControllerKey*, guint keyval, guint, GdkModifie 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; + gtk_widget_queue_draw(d->canvas); + return TRUE; + } return FALSE; } @@ -3717,7 +3826,9 @@ void on_math_node_deleted(GtkButton*, gpointer user) { 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->pending_out = -1; + d->drag_mode = MathDrag::None; + d->wire_from = -1; + d->wire_detach = -1; math_dialog_sync(d); } @@ -4015,6 +4126,11 @@ void open_math_dialog(App& app, int edit) { math_canvas_drag(g, dx, dy, user); }), d); + g_signal_connect(drag, "drag-end", G_CALLBACK(+[](GtkGestureDrag* g, double dx, double dy, + gpointer user) { + math_canvas_drag_end(g, dx, dy, user); + }), + d); gtk_widget_add_controller(d->canvas, GTK_EVENT_CONTROLLER(drag)); // The wheel zooms. A scroll controller is the only way to get one on a drawing area, and @@ -7028,6 +7144,71 @@ gboolean math_probe(gpointer user) { v->view_scale, v->view_x, v->view_y); } } + if (a->math_dialog != nullptr) { + auto* g = static_cast( + 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(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(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(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(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(MathDrag::Pan)); + math_view_reset(g); + } + } std::fflush(stderr); return G_SOURCE_REMOVE; }