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 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-06 23:47:11 +02:00
1 parent 6797b31d2e
commit b8d178eec6
1 file changed
+230 -5
+230 -5
View File
@@ -3302,6 +3302,14 @@ struct MathDialog {
double grab_x = 0.0; double grab_x = 0.0;
double grab_y = 0.0; double grab_y = 0.0;
int drag_node = -1; 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* window = nullptr;
GtkWidget* canvas = nullptr; GtkWidget* canvas = nullptr;
GtkWidget* name = 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; 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) { void math_canvas_draw(GtkDrawingArea*, cairo_t* cr, int width, int height, gpointer user) {
MathDialog* d = static_cast<MathDialog*>(user); MathDialog* d = static_cast<MathDialog*>(user);
cairo_set_source_rgb(cr, 0.06, 0.078, 0.094); cairo_set_source_rgb(cr, 0.06, 0.078, 0.094);
cairo_paint(cr); 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 // 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. // square to the destination's input square, with a horizontal tread in between.
for (std::size_t i = 0; i < d->nodes.size(); ++i) { 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_rectangle(cr, ox - kPort * 0.5, oy - kPort * 0.5, kPort, kPort);
cairo_fill(cr); cairo_fill(cr);
} }
cairo_restore(cr);
(void)width; (void)width;
(void)height; (void)height;
} }
void math_canvas_pressed(GtkGestureClick*, int, double x, double y, gpointer user) { void math_canvas_pressed(GtkGestureClick*, int, double x, double y, gpointer user) {
MathDialog* d = static_cast<MathDialog*>(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; 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) { if (node >= 0 && which == kPortOut) {
// An output square: arm it. Clicking it again disarms, so a mis-click needs no escape. // 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; 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); math_dialog_sync(d);
return; return;
} }
const int body = math_node_at(d, x, y); const int body = math_node_at_canvas(d, x, y);
if (body >= 0) { if (body >= 0) {
d->selected = body; d->selected = body;
d->pending_out = -1; d->pending_out = -1;
@@ -3506,7 +3601,7 @@ void math_canvas_drag_begin(GtkGestureDrag*, double x, double y, gpointer user)
MathDialog* d = static_cast<MathDialog*>(user); MathDialog* d = static_cast<MathDialog*>(user);
// Only a drag that *began on a box* moves one. Dragging the background used to move whatever was // 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. // 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; d->drag_node = node;
if (node < 0) return; if (node < 0) return;
d->selected = node; d->selected = node;
@@ -3521,11 +3616,59 @@ void math_canvas_drag(GtkGestureDrag*, double dx, double dy, gpointer user) {
MathNode& n = d->nodes[static_cast<std::size_t>(d->drag_node)]; MathNode& n = d->nodes[static_cast<std::size_t>(d->drag_node)];
// The *total* offset from where the box was when the drag began -- from `grab_x`/`grab_y`, which // 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. // 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); // The gesture's offset is in canvas pixels; a node's position is in graph units.
n.y = std::max(0.0, d->grab_y + dy); 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); gtk_widget_queue_draw(d->canvas);
} }
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) {
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<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;
}
void on_math_node_added(GtkButton*, gpointer user) { void on_math_node_added(GtkButton*, gpointer user) {
MathDialog* d = static_cast<MathDialog*>(user); MathDialog* d = static_cast<MathDialog*>(user);
// A node added to a graph that was *linear* -- its output was the last node -- becomes the output, // 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); d);
gtk_widget_add_controller(d->canvas, GTK_EVENT_CONTROLLER(drag)); 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); 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 // 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(add, "clicked", G_CALLBACK(on_math_node_added), d);
g_signal_connect(del, "clicked", G_CALLBACK(on_math_node_deleted), 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); 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<MathDialog*>(user));
}), d);
GtkWidget* buttons = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget* buttons = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_box_append(GTK_BOX(buttons), add); gtk_box_append(GTK_BOX(buttons), add);
gtk_box_append(GTK_BOX(buttons), del); gtk_box_append(GTK_BOX(buttons), del);
gtk_box_append(GTK_BOX(buttons), out); 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(form), buttons);
gtk_box_append(GTK_BOX(body), form); gtk_box_append(GTK_BOX(body), form);
gtk_box_append(GTK_BOX(outer), body); 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))); gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->gain)));
} }
} }
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);
}
}
std::fflush(stderr); std::fflush(stderr);
return G_SOURCE_REMOVE; return G_SOURCE_REMOVE;
} }