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:
1 parent
6797b31d2e
commit
b8d178eec6
1 file changed
+230
-5
+230
-5
@@ -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<MathDialog*>(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<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 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<MathDialog*>(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<std::size_t>(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<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) {
|
||||
MathDialog* d = static_cast<MathDialog*>(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<MathDialog*>(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<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);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user