`rebuild_signal_list()` is handed `model.signals()`, and `append_math_signals_locked()` already appends one synthetic entry per math channel -- "they go through the ordinary signal path on purpose: a derived channel is a channel" -- so **every math channel was already a row of the signal list**, with its tick, chip, name, unit and menu. The math pane listed the same channels a second time, so this is mostly a deletion: `build_math_panel()`, `rebuild_math_pane()`, `src/ui/math-panel.ui` and its resource entry, `App::math_list`, and the `math panel:` dump line. What the one list gains: - a non-selectable `MATH` divider before the first math row, carrying the `+` that creates a channel -- appended even when there are no math channels at all, because a `+` conditional on one existing could never create the first one; - a math row's own menu: *Edit graph…*, *Default colour*, *Delete*. No Information and no Calibrate (calibration is applied at ingest, and a derived channel has no stream to describe), and no rename, because the node editor owns a channel's name and unit -- a second place to change them would be a second answer to the same question. An item dismisses the row's popover before opening or destroying anything from it; - `App::math` mirrored from a math row's tick: `enable_locked()` mirrors it into the model, but `App::math` is the copy the session file is written from. The mirror sits *before* the `apply_math_channels()` in the `!on` path, which would otherwise push a stale `enabled = true` straight back into the model; - a tick visible in a file view too: a derived channel is computed, and a file view has no acquisition to start, which is the other tick's question. `rebuild_signal_list()` also publishes the list's own shape -- `rows`, `signals`, `math` -- and the dump prints `divider=1` when `rows == signals + 1 + math`: the reading for a list that lost its `+`. Verified: `make test-ui` 14 files (was 15); `signals panel: labels=2 list=1 rows=9 signals=4 math=4 divider=1`; `[math-probe] 3 math channels`, every other line unchanged; no `math panel:` line, and zero `Gtk-CRITICAL`.
1048 lines
47 KiB
C++
1048 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);
|
|
// The structural rebuild above replaced the buffer and dropped its history. A stream's producer
|
|
// refills that; a file's nothing does, so the view is re-read -- and with the math set now in place
|
|
// the reload computes the derived channels over the file as it goes.
|
|
reload_file_view(app);
|
|
// The math channels are rows of the signal list too -- `append_math_signals_locked()` puts them
|
|
// in `signals()` -- so the list is stale until it is rebuilt.
|
|
rebuild_signal_panels(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.med_n = static_cast<int>(gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->med)));
|
|
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);
|
|
// The corner row is shown for anything that reads `fc`, which is now the three biquads *and* the
|
|
// de-noise node; `q` stays a band-pass-only row.
|
|
gtk_widget_set_visible(d->rows[2], math_op_uses_fc(op));
|
|
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_widget_set_visible(d->rows[6], math_op_uses_median(op));
|
|
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->med), static_cast<double>(n.med_n));
|
|
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;
|
|
}
|
|
|
|
} // namespace ui_impl
|
|
} // namespace udpscope
|