Files
udpscope/src/ui_math.cpp
T

1064 lines
47 KiB
C++

// 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 <gtk/gtk.h>
#include <string>
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);
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<std::string> math_source_names(App& app) {
std::vector<std::string> names;
std::lock_guard<std::mutex> 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<std::string> 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<int>(n.x), static_cast<int>(n.y), static_cast<int>(kNodeW),
static_cast<int>(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<int>(d->nodes.size()) - 1; i >= 0; --i) {
const GdkRectangle r = math_node_rect(d->nodes[static_cast<std::size_t>(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<int>(d->nodes.size()) - 1; i >= 0; --i) {
const MathNode& n = d->nodes[static_cast<std::size_t>(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<int> depth(n, 0);
std::vector<int> row(n, 0);
std::vector<int> 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<int>(i)) {
deepest = std::max(deepest, depth[static_cast<std::size_t>(ins[k])]);
}
}
const int dep = deepest + 1;
depth[i] = dep;
if (static_cast<std::size_t>(dep) >= used.size()) used.resize(static_cast<std::size_t>(dep) + 1, 0);
row[i] = used[static_cast<std::size_t>(dep)]++;
}
for (std::size_t i = 0; i < n; ++i) {
d->nodes[i].x = 24.0 + static_cast<double>(depth[i]) * kColPitch;
d->nodes[i].y = 20.0 + static_cast<double>(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<int>(d->nodes.size()) - 1; i >= 0; --i) {
const MathNode& n = d->nodes[static_cast<std::size_t>(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<int>(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<std::size_t>(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<double>(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<int>(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<int> in_a(static_cast<std::size_t>(n));
std::vector<int> in_b(static_cast<std::size_t>(n));
for (int i = 0; i < n; ++i) {
in_a[static_cast<std::size_t>(i)] = d->nodes[static_cast<std::size_t>(i)].in_a;
in_b[static_cast<std::size_t>(i)] = d->nodes[static_cast<std::size_t>(i)].in_b;
}
(which == 0 ? in_a : in_b)[static_cast<std::size_t>(to)] = from;
std::vector<int> indegree(static_cast<std::size_t>(n), 0);
const auto edges_of = [&](int i, int (&out)[2]) {
const MathNode& node = d->nodes[static_cast<std::size_t>(i)];
const int arity = math_op_arity(node.op);
out[0] = arity >= 1 ? in_a[static_cast<std::size_t>(i)] : -1;
out[1] = arity >= 2 ? in_b[static_cast<std::size_t>(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<std::size_t>(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<int> order;
order.reserve(static_cast<std::size_t>(n));
std::vector<bool> done(static_cast<std::size_t>(n), false);
for (int step = 0; step < n; ++step) {
int pick = -1;
for (int i = 0; i < n; ++i) {
if (!done[static_cast<std::size_t>(i)] && indegree[static_cast<std::size_t>(i)] == 0) {
pick = i;
break;
}
}
if (pick < 0) {
if (why != nullptr) *why = "that wire would make a loop";
return false;
}
done[static_cast<std::size_t>(pick)] = true;
order.push_back(pick);
for (int i = 0; i < n; ++i) {
if (done[static_cast<std::size_t>(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<std::size_t>(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<int> where(static_cast<std::size_t>(n), -1);
for (int p = 0; p < n; ++p) where[static_cast<std::size_t>(order[static_cast<std::size_t>(p)])] = p;
std::vector<MathNode> sorted;
sorted.reserve(static_cast<std::size_t>(n));
for (int p = 0; p < n; ++p) sorted.push_back(d->nodes[static_cast<std::size_t>(order[static_cast<std::size_t>(p)])]);
const auto remap = [&](int i) { return i >= 0 && i < n ? where[static_cast<std::size_t>(i)] : -1; };
for (int p = 0; p < n; ++p) {
MathNode& node = sorted[static_cast<std::size_t>(p)];
const int old = order[static_cast<std::size_t>(p)];
node.in_a = math_op_arity(node.op) >= 1 ? remap(in_a[static_cast<std::size_t>(old)]) : -1;
node.in_b = math_op_arity(node.op) >= 2 ? remap(in_b[static_cast<std::size_t>(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<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) {
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<int>(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<std::size_t>(from)], ax, ay);
math_input_pos(n, k, bx, by);
const bool picked = d->selected_wire.node == static_cast<int>(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<int>(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<int>(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<int>(d->nodes.size())) {
double ax = 0.0;
double ay = 0.0;
math_output_pos(d->nodes[static_cast<std::size_t>(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<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 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<MathDialog*>(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<std::size_t>(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<std::size_t>(node)].x;
d->grab_y = d->nodes[static_cast<std::size_t>(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<MathDialog*>(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<int>(d->nodes.size())) return;
MathNode& n = d->nodes[static_cast<std::size_t>(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<MathDialog*>(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<int>(d->nodes.size())) {
MathNode& back = d->nodes[static_cast<std::size_t>(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<MathDialog*>(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<double>(gtk_widget_get_width(d->canvas)) : 0.0;
const double h = d->canvas != nullptr ? static_cast<double>(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<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;
}
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<int>(d->nodes.size())) {
MathNode& n = d->nodes[static_cast<std::size_t>(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<int>(d->nodes.size())) {
MathNode& n = d->nodes[static_cast<std::size_t>(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<int>(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<double>(c % 2u);
const double cy = 20.0 + 58.0 * static_cast<double>(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<double>(cell % 2u);
n.y = 20.0 + 58.0 * static_cast<double>(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<int>(k) - 1;
if (arity >= 2 && k > 0) n.in_b = static_cast<int>(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<std::string> acquired = math_source_names(*d->app);
if (!acquired.empty()) n.source = acquired.front();
}
d->nodes.push_back(n);
d->selected = static_cast<int>(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<MathDialog*>(user), MathOp::Gain);
}
void on_math_node_deleted(GtkButton*, gpointer user) {
MathDialog* d = static_cast<MathDialog*>(user);
if (d->selected < 0 || d->selected >= static_cast<int>(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<int>(gone, static_cast<int>(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<MathDialog*>(user);
if (d->selected < 0 || d->selected >= static_cast<int>(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<MathDialog*>(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<int>(d->nodes.size())) return;
MathNode& n = d->nodes[static_cast<std::size_t>(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<std::string> 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::<prop>` 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<MathDialog*>(user);
if (d->selected < 0 || d->selected >= static_cast<int>(d->nodes.size())) return;
if (d->op_drop == nullptr || object != G_OBJECT(d->op_drop)) return;
MathNode& n = d->nodes[static_cast<std::size_t>(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<std::string> 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<std::string> names;
const bool have_form =
d->selected >= 0 && d->selected < static_cast<int>(d->nodes.size());
if (d->source_drop != nullptr && have_form) {
names = math_source_names(*d->app);
MathNode& sel = d->nodes[static_cast<std::size_t>(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<int>(d->nodes.size())) {
if (d->canvas != nullptr) gtk_widget_queue_draw(d->canvas);
return;
}
MathNode& n = d->nodes[static_cast<std::size_t>(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<guint>(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<guint>(math_op_index(op)));
}
std::string inputs;
const auto describe = [&](int index) -> std::string {
if (index < 0) return "—";
if (index >= static_cast<int>(d->nodes.size())) return "—";
const MathNode& other = d->nodes[static_cast<std::size_t>(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<MathDialog*>(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<int>(d->app->math.size())) {
m.enabled = d->app->math[static_cast<std::size_t>(d->edit)].enabled;
d->app->math[static_cast<std::size_t>(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) {
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
gtk_widget_set_margin_top(box, 6);
gtk_widget_set_margin_bottom(box, 6);
gtk_widget_set_margin_start(box, 6);
gtk_widget_set_margin_end(box, 6);
GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
GtkWidget* title = gtk_label_new("Math channels");
gtk_widget_set_halign(title, GTK_ALIGN_START);
gtk_widget_set_hexpand(title, true);
gtk_box_append(GTK_BOX(head), title);
GtkWidget* add = gtk_button_new_from_icon_name("list-add-symbolic");
gtk_widget_add_css_class(add, "flat");
gtk_widget_set_tooltip_text(add, "add a math channel");
g_signal_connect(add, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
add_math_channel(*static_cast<App*>(user));
}),
&app);
gtk_box_append(GTK_BOX(head), add);
gtk_box_append(GTK_BOX(box), head);
app.math_list = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
gtk_box_append(GTK_BOX(box), app.math_list);
return box;
}
} // namespace ui_impl
} // namespace udpscope