From b8d178eec6fd1467e0c11fa6f0ead2c7896f520c Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 6 Oct 2026 23:47:11 +0200 Subject: [PATCH] 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. Co-Authored-By: Claude Opus 4.6 --- src/ui.cpp | 235 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 230 insertions(+), 5 deletions(-) diff --git a/src/ui.cpp b/src/ui.cpp index c68d260..32d8625 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -3302,6 +3302,14 @@ struct MathDialog { double grab_x = 0.0; double grab_y = 0.0; 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; GtkWidget* window = nullptr; GtkWidget* canvas = nullptr; GtkWidget* name = nullptr; @@ -3392,10 +3400,93 @@ int math_port_at(const MathDialog* d, double x, double y, double tol, int& which return -1; } +/// 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); +} + 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) { @@ -3463,14 +3554,18 @@ void math_canvas_draw(GtkDrawingArea*, cairo_t* cr, int width, int height, gpoin cairo_rectangle(cr, ox - kPort * 0.5, oy - kPort * 0.5, kPort, kPort); cairo_fill(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 node = math_port_at(d, x, y, kPort, which); + 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; @@ -3491,7 +3586,7 @@ void math_canvas_pressed(GtkGestureClick*, int, double x, double y, gpointer use math_dialog_sync(d); return; } - const int body = math_node_at(d, x, y); + const int body = math_node_at_canvas(d, x, y); if (body >= 0) { d->selected = body; d->pending_out = -1; @@ -3506,7 +3601,7 @@ 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. - const int node = math_node_at(d, x, y); + const int node = math_node_at_canvas(d, x, y); d->drag_node = node; if (node < 0) return; d->selected = node; @@ -3521,11 +3616,59 @@ void math_canvas_drag(GtkGestureDrag*, double dx, double dy, gpointer user) { 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. - n.x = std::max(0.0, d->grab_x + dx); - n.y = std::max(0.0, d->grab_y + dy); + // 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); 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; + // 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) { + double w = d->canvas != nullptr ? gtk_widget_get_width(d->canvas) : 0; + double h = d->canvas != nullptr ? gtk_widget_get_height(d->canvas) : 0; + if (w <= 0.0) w = 420.0; + if (h <= 0.0) h = 300.0; + px = w * 0.5; + py = h * 0.5; + } + 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(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; +} + void on_math_node_added(GtkButton*, gpointer user) { MathDialog* d = static_cast(user); // A node added to a graph that was *linear* -- its output was the last node -- becomes the output, @@ -3868,6 +4011,21 @@ void open_math_dialog(App& app, int edit) { }), 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 + // `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); + gtk_box_append(GTK_BOX(body), d->canvas); // The form: every row exists from the start and is shown or hidden per op, so no rebuild can @@ -3964,10 +4122,16 @@ void open_math_dialog(App& app, int edit) { g_signal_connect(add, "clicked", G_CALLBACK(on_math_node_added), d); g_signal_connect(del, "clicked", G_CALLBACK(on_math_node_deleted), d); g_signal_connect(out, "clicked", G_CALLBACK(on_math_output_set), d); + 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(user)); + }), d); GtkWidget* buttons = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); gtk_box_append(GTK_BOX(buttons), add); gtk_box_append(GTK_BOX(buttons), del); gtk_box_append(GTK_BOX(buttons), out); + gtk_box_append(GTK_BOX(buttons), fit); gtk_box_append(GTK_BOX(form), buttons); gtk_box_append(GTK_BOX(body), form); gtk_box_append(GTK_BOX(outer), body); @@ -6798,6 +6962,67 @@ gboolean math_probe(gpointer user) { gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->gain))); } } + if (a->math_dialog != nullptr) { + auto* v = static_cast( + 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); + } + } std::fflush(stderr); return G_SOURCE_REMOVE; }