From aa2508e4313f359894ced0148c29b8108f63121d Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sat, 3 Oct 2026 17:28:17 +0200 Subject: [PATCH] added math signals --- CLAUDE.md | 107 +++- CMakeLists.txt | 1 + README.md | 71 +++ src/acquisition.cpp | 15 + src/acquisition.hpp | 3 + src/layout_store.cpp | 161 +++++- src/layout_store.hpp | 23 + src/model.cpp | 106 +++- src/model.hpp | 45 +- src/ui.cpp | 1268 +++++++++++++++++++++++++++++++++++++++++- 10 files changed, 1773 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 764ee05..a931d95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -727,6 +727,99 @@ far the largest file. pins the same way (`pin_pause_window()` sets `pinned_` from the frozen range), so the frozen picture is equally immovable; resuming or leaving trigger mode clears it. +- **The session is the layout.** `LayoutState` persists what makes a reopened scope look like the + one that was closed: the window, the rows and their slot signals, the calibration, **the enabled + signals**, **the math channels with their graphs** and **the measurements pane**. Signal identity + is the *name* throughout (a CONFIG renumbers channels), 'enabled' is read off the channels the + model built -- an enabled signal is exactly one that produced a channel -- and `apply_layout()` + installs the graphs **before** assigning the rows, since a row may hold a math channel's name and + that name is only a signal once its graph is in. Two format rules, both learned the hard way: + write an op or a measure *type* as its index through `math_op_at()`/`measure_type_at()`, never as + the raw enum value -- those accessors' orders are the dialog's, not the declaration order, so + writing one and reading the other silently renames operations. And a top-level key written after + a `[[table]]` belongs to *that table* in TOML: `enabled = [...]` emitted after `[[calibration]]` + was parsed into the last calibration record and the enable state was lost without a word. + `mkdirs()` used to create only components with a trailing slash, so `~/.config/udpscope` was never + made and the *first* save failed silently -- worth knowing when a layout change "does not stick". + +- **A math channel *is* a channel.** `ScopeModel::set_math_channels()` appends one *synthetic* + `SignalInfo` (with `math = true`) per math channel after the stream's own, so the panel's switch, + the plot binding, the measurements and the export all handle a derived channel without knowing it + is derived -- and another math channel is a valid `Source` for exactly the same reason. The + *samples* come from the producer: `Acquisition::on_data()` collects each channel's value for the + slot and calls `ScopeModel::push_math_sample()` **once per stored slot**, before the deferred + latch (`publish_event()` clamps its snapshot to every ring's `produced()`, so a derived sample + pushed later would be missing from the capture's newest slot). The math therefore runs on the + *stored* (filtered, decimated) samples, which is also why `math_prepare()` gets `slot_dt() * + time_stride`: a cutoff the user typed in Hz means the rate the filter will actually run at. + `src/math_channel.cpp` is the engine: a graph evaluated as a **list walked in order**, so a node + may only read ones *before* it and a cycle is impossible by construction rather than by a check + (`math_channel_valid()` states that as a message the dialog shows). `/tmp/check_math.cpp`-style + checks verified the biquads against known responses (LP fc=100 Hz: 50 Hz -> 0.97, 2 kHz -> 0.002; + BP fc=1 kHz q=2: 200 Hz -> 0.05, 1 kHz -> 0.95, 4 kHz -> 0.05) -- **keep test sines below + Nyquist**: at `dt = 1/10000` a "5 kHz" test tone is the Nyquist point itself and says nothing. + The editor is the *compact graph*: nodes as boxes on a `GtkDrawingArea`, click to select (its + parameters in the form beside it), click an output port then an input port to wire, drag to move; + no pan/zoom. Its widgets are created once and only shown, hidden and set by `math_dialog_sync()`, + so no rebuild can invalidate a live callback handle -- the same shape the measurements dialog + uses. `UDPSCOPE_PROBE_MATH_MS` defines two graphs and opens the editor (a construction crash test + as much as a state check), and the dump prints each derived channel's extremes beside the + source's. Four rules here were wrong first and are worth keeping: + * **A `Source` may only name an *acquired* channel** -- `ScopeModel::channels()`, which is what the + dialog's dropdown lists, **and both places that create a node must take one from there**. + `signals().front()` is the stream's *timestamp* signal: it is first in every CONFIG, it is not + acquired, and a graph sourced from it validates and then reads NaN for ever. The dialog's + default was fixed first and the pane's `+` was not, which is what "[ch] -> [gain x2] produces + only nan" was. The dropdown also *reconciles*: a node whose source is not in the list is moved + to what the form is showing, rather than the form showing one channel while the node holds + another, and `Apply` refuses a source that is not acquired (the engine's validator is about the + graph -- an output, every input wired above it -- and cannot see the acquired list). + * **The ingest must skip a derived channel.** `Acquisition::on_data()` iterates every channel and + reads each one from the packet, so a math channel with no signal read *zero* and pushed it -- + while `push_math_sample()` pushed the real value on the same slot. Two pushes per slot into a + ring whose time ring advances once: the values alternated between the real one and 0, and once + both rings wrapped the value ring had drifted a whole window off the time ring, so the derived + trace disappeared. `ChannelInfo::math` is the flag (`channel.math`) and the loop `continue`s on + it. **A derived channel's `values(c).produced()` must equal `live().time().produced()`** -- one + push per stored slot -- and the dump prints both as `N samples vs M time slots` for exactly + this: the arithmetic on the plot can look plausible long after the pairing is wrong. + * **`gtk_window_destroy()` does not emit `close-request`.** The math dialog's pointer is cleared + by a `close-request` handler, so a Cancel that *destroyed* the window left `app.math_dialog` + pointing at a dead widget: every later `open_math_dialog()` took the "already open" branch and + presented it -- a GTK critical, and nothing on screen, which is "I cannot edit a math channel + again". Both paths close (`gtk_window_close()`), which emits the signal, lets the handler clear + the pointer, and then destroys. The same lesson as the other GTK traps here: a programmatic + destroy is not a user close, and only one of them runs the handlers. + * **Boxes must not overlap on the canvas.** The first auto-place staggered nodes by 24 px for a + 132 px box, so every box sat on the one before it, `math_node_at()`'s topmost-first search always + returned the last, and a drag or a click landed on the wrong node -- which reads as the dialog + ignoring the pointer. Two columns spaced 160 px apart, rows 58. The probe prints the box the + drag *began over* for exactly this reason. Offering the whole signal list let a graph name something nobody is + acquiring (no data for ever) and put the stream's timestamp signal, which has no samples, in it. + * **`prepare_math_locked()` runs from `rebuild_locked()`, so *every* plan change re-prepares the + filters**: a CONFIG, an enable/disable, and a window change that moves the decimation (that path + is `set_target_span()` -> `rebuild_locked()`). Coefficients are a function of the *stored* + interval and the filter's state is the old rate's history, so both are rebuilt together. + Preparing only on an edit left the cutoff wrong and the state ringing after any window change -- + that is what "I tested a low pass and it was a mess" was. + * **The graph's output defaults to the *last* node, and a node added to a linear graph becomes + it.** The output began at index 0 -- the *source* -- so every operation a user built was + computed and then thrown away: a gain of 2 and a low pass both produced a channel equal to its + input. The output is drawn as the orange dot and settable explicitly, so an earlier node chosen + deliberately is kept; this is the default for a graph that has never been given a choice. + * **`gtk_spin_button_new_with_range()` can leave the value at the range's *minimum*.** The offset + field began at -1e6, and `on_math_param_changed()` reads the widgets, so `x*2` saved as + `x*2 - 2000000`. Every numeric field is seeded with its own identity value; the range-minimum + default is never a useful starting point for a gain or a corner frequency. + * **The form's rows are grouped per *op*, not per field**: gain and offset are one group because + `gain + offset` is one op. The offset was a row of its own that nothing ever hid, which read as + a property of every node. + * **A `GtkSpinButton` holds zero decimals by default**, so every numeric field sets its digits + (4 for gain/offset/clamp, 3 for `fc`/`Q`); without that a 12.5 Hz cutoff was stored as 12 and a + gain of 0.25 as 0. And the canvas drag reports the *total* offset from where the gesture began, + so adding each update's delta to a value the handler then writes back is a runaway box -- it + must record the position at `drag-begin` and ignore a drag that did not begin on a box. + - **Every instrument this app *computed* is out of the pointer's reach.** The trigger marker and the measurement marks are cursors created with `Plot::add_cursor()`/`add_h_ruler()`, and a cursor is grabbable by default -- so each takes `set_cursor_selectable(id, false)`. **Parking it outside @@ -874,5 +967,17 @@ far the largest file. would clamp a legitimately short capture up to the requested window. - **The header bar's buttons are icon-only**, so headless probes must reach them through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via - `find_labelled_button()`. The Arm/Disarm button in the trigger panel still has text. + `find_labelled_button()`. The **pane switches are toolbar buttons** too (`cursors_toggle`, + `measures_toggle`, `math_toggle` -- packed with `header_bar_pack_end()`, where the **first** packed + is the rightmost, and the bar is an *ancestor* rather than the parent because packing parents into + an internal box), and the cursors pane owns its own + **Reset** button rather than the overflow menu owning one for it. The dump prints + `panel controls: … ; toolbar, left to right: …` to say where they are and in what order. The widget + tree's child order *is* the visual order left to right, so that line reads as the *reverse* of the + pack order -- it is how an arrangement gets checked with no screenshot, and it is what finally + settled the pack-order rule above (two comments in `build_toolbar()` disagreed with each other and + one of them with reality): it is computed on the first *tick*, because the dump + runs after the window is destroyed and asking GTK about a widget then is four criticals, which is + how the first version of that line earned them. What stays in the menu is what acts on the *data* + (Fit, Export) and the two editors that are not panes (Layout, Signal list). The Arm/Disarm button in the trigger panel still has text. - Every source file carries an SPDX MIT header; match it in new files. diff --git a/CMakeLists.txt b/CMakeLists.txt index 0ade4ba..d9b0220 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -42,6 +42,7 @@ add_executable(udpscope src/export.cpp src/layout_store.cpp src/measure.cpp + src/math_channel.cpp src/ui.cpp ) diff --git a/README.md b/README.md index 59484b7..02741d4 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,77 @@ it holds more than the window the bound became the whole ring, and a zoom-out wa view past the window into data the window was never set to show. A partly filled window simply has an empty left-hand side. +**The panel switches are on the toolbar, and the panes own their own controls.** Cursors, +Measurements and Math channels are icon toggles in the header bar rather than rows in the overflow +menu, and **Reset cursors** sits in the cursors pane beside the mode it resets (it acts on that +pane's instruments and is only visible while they exist). The rows read, left to right: **window control, trigger, pause** — the trigger and the pause are +read against the span on screen, so they sit with the control that sets it — then, on the right, +**cursors, measurements, math, sidebar** and **the menu last**. With `HeaderBar::pack_end()` the +**first** packed is the rightmost, so the list is written right to left; that took a run of the dump +to settle (two comments in the builder disagreed), and the line it prints +(`panel controls: … ; toolbar, left to right: …`) is how a layout claim gets checked without a +screenshot. The menu keeps what acts on the *data* (Fit, Export) and the two +editors that are not panes (Layout, Signal list). The dump reports the +arrangement as `panel controls: cursors=1 measurements=1 math=1 in the toolbar, reset=1 in the +cursors pane`, computed on the first frame — a widget's parent is the only thing that says it, and +the dump runs after the window is gone. + +**The session is saved with the layout**, per data source, in +`~/.config/udpscope/_.toml`: the display window, the rows and which signal each slot +holds, the per-signal calibration, **which signals were enabled**, **the math channels with their +graphs** and **the measurements pane**. Identity is the signal *name* everywhere, because a CONFIG +renumbers the channels; the graphs are installed before the rows are assigned, since a row can hold +a math channel's name and that name only exists as a signal once its graph is in. So a scope +reopened on the same source comes back as the one that was closed — including a signal that was +enabled only to feed a math channel. + +**Math channels.** A math channel is derived from the others by a graph of operations and is then +an *ordinary channel*: the model synthesises a signal entry for it, so an enabled one is acquired, +plotted, measured, exported and listed in the signal panel exactly like a streamed signal — and +another math channel can be its source. The operations are `channel` (a source), `gain + offset`, +`abs`, `sqrt`, `clamp`, `low pass`, `high pass`, `band pass` (biquads, cutoffs in Hz against the +stored rate), `sum`, `difference`, `product` and `ratio`; the arithmetic ops take two inputs, which +is what makes branching — the sum of a raw and a filtered channel — expressible at all. + +The editor is a compact node graph: each node is a box, its inputs are the squares on its left edge +and its output the square on its right. Click a box to select it and its parameters appear in the +form beside the canvas; click an output square and then an input square to wire them; drag a box to +move it. The graph is a *list* walked in order, so a node can only read the ones above it — a cycle +is not detected, it is unrepresentable, and an input square is only offered the nodes that qualify. +The node carrying the channel's value is the **last one** of a graph built top to bottom (as the +orange dot on the canvas says, and settable with *set as output*): it defaulted to the first node — +the source — so every operation was computed and discarded, and a gain or a filter produced a +channel identical to its input. Numeric fields are seeded with their own identity values too, since +`gtk_spin_button_new_with_range()` leaves a spin button at its range's minimum: the offset started +at -1e6, which turned `x*2` into `x*2 - 2000000`. + +Wiring is checked before the channel is created, and the reason is shown rather than the dialog +closing on the work. + +A `Source` node lists only the channels the scope is actually **acquiring**: naming a disabled +signal would read as no data for ever, and the stream's timestamp signal has no samples at all. The +filters are re-prepared from the *stored* sample interval on **every** plan change — a CONFIG, an +enable/disable, and a window change that moves the decimation — because a biquad's coefficients are +a function of that interval and its state belongs to the rate it was running at; preparing them +only when the graph is edited left a wrong cutoff and a ringing transient after any window change. + +The samples are produced at **ingest**, once per stored slot, from the values the other channels +just pushed — and a derived channel is *skipped* by the loop that reads the stream, since it has no +packet signal to read. That mattered more than it looks: reading it gave zero, and pushing that +alongside the real value advanced its sample ring twice per time slot, so the trace alternated +between its value and 0 while the buffer filled and then slid a whole window off the time ring once +both wrapped and vanished. One push per slot is the invariant, and the probe's dump prints it as `N +samples vs M time slots` — a ratio the plot cannot show you. That is what makes the filters real: an IIR has memory, so a low pass computed from a +window would be a different (and wrong) answer every time the window moved, while one fed the +stream sample by sample is the filter the user asked for. It also means the pane's numbers and the +plot agree by construction — both come from the same stored samples. + +**Closing the editor clears it.** The dialog's pointer is cleared by a `close-request` handler, and +`gtk_window_destroy()` — which is what Cancel and Save used to call — does **not** emit that signal. +The pointer therefore stayed set on a destroyed window, and every later Edit took the "already open" +branch and presented the corpse: a GTK critical and nothing on screen. Both buttons call +`gtk_window_close()`, which emits the signal, clears the pointer, and destroys. + **The trigger marker is a readout, not a handle.** It is a cursor, and a cursor is grabbable by default — so parking it outside the axis, which is how it is hidden when no event is on screen, did not stop a *drag*: the controller holds a marker for the gesture's life and follows the pointer diff --git a/src/acquisition.cpp b/src/acquisition.cpp index f194675..e087323 100644 --- a/src/acquisition.cpp +++ b/src/acquisition.cpp @@ -395,6 +395,7 @@ void Acquisition::on_data(const udps_frame_t* frame) { acc_channels_ = channels.size(); value_acc_.assign(channels.size(), 0.0); value_acc_n_.assign(channels.size(), 0); + math_sample_.assign(channels.size(), std::nanf("")); time_acc_ = 0.0; time_acc_n_ = 0; } @@ -528,6 +529,12 @@ void Acquisition::on_data(const udps_frame_t* frame) { for (std::size_t i = 0; i < channels.size(); ++i) { const ChannelInfo& ch = channels[i]; + // A derived channel is filled by `push_math_sample()` below, once per stored slot, from the + // values the streamed ones just pushed. Reading it from the packet would give zero (it has no + // signal) and pushing that would advance its value ring twice per time slot -- which is an + // alternation between the value and 0 while the buffer is filling, and a complete loss of the + // pairing once both rings wrap. + if (ch.math) continue; // A signal's columns are consecutive samples of the same channel: a scalar // (one element) carries one value per batched cycle, a block signal carries // the cycle's sample at element `k`. Either way element 0 is the channel. @@ -549,6 +556,9 @@ void Acquisition::on_data(const udps_frame_t* frame) { value_acc_[i] = 0.0; value_acc_n_[i] = 0; store.values(i).push(value); + // This slot's value, for the math channels below. Every channel is pushed on the same slot + // (per-channel strides are 1 by design), so the vector is complete by the time the loop ends. + if (i < math_sample_.size()) math_sample_[i] = value; // The trigger sees the *stored* signal, not the wire signal. An edge found in a // sample that is not in the buffer would be cut from data that does not contain it, // and with the filter in front of it a spike above the stored Nyquist is exactly @@ -560,6 +570,11 @@ void Acquisition::on_data(const udps_frame_t* frame) { } } } + // The derived channels: once per *stored* slot, and **before** the latch publishes. + // `publish_event()` clamps its snapshot to every value ring's `produced()`, so a derived + // channel pushed after it would be missing its newest sample from the capture -- the same + // one-sample clamp the deferred latch above exists for. + if (tick % g == g - 1) model_.push_math_sample(math_sample_); if (latch_here) latch(latch_edge); } last_stamp_ = last_stamp_used; diff --git a/src/acquisition.hpp b/src/acquisition.hpp index e65dac5..024c2fc 100644 --- a/src/acquisition.hpp +++ b/src/acquisition.hpp @@ -96,6 +96,9 @@ class Acquisition { /// instead of reappearing as a false low frequency. Cleared whenever the partition /// changes, since the group length is `time_stride`. std::vector value_acc_; + /// The channels' values for the current *stored* slot, which the math channels are evaluated from + /// once the slot's samples have all been pushed. One entry per channel, refilled every slot. + std::vector math_sample_; std::vector value_acc_n_; double time_acc_ = 0.0; std::uint64_t time_acc_n_ = 0; diff --git a/src/layout_store.cpp b/src/layout_store.cpp index 98747e8..c6fbcad 100644 --- a/src/layout_store.cpp +++ b/src/layout_store.cpp @@ -37,13 +37,15 @@ std::string config_dir() { } void mkdirs(const std::string& path) { + // **Every** component, the last one included. The condition used to be "the path has a trailing + // slash", so the final directory was never created: `~/.config/udpscope` did not exist, the + // first `save_layout()` failed silently, and the session appeared to persist only on a machine + // where something else had already made the directory. std::string cur; - for (const char c : path) { - cur.push_back(c); - if (c == '/' || cur.size() == path.size()) { - if (cur == "/" || cur.back() != '/') continue; - ::mkdir(cur.c_str(), 0755); - } + for (std::size_t i = 0; i < path.size(); ++i) { + cur.push_back(path[i]); + if (path[i] != '/' && i + 1 != path.size()) continue; + ::mkdir(cur.c_str(), 0755); } } @@ -115,6 +117,57 @@ std::string layout_path(const std::string& host, std::uint16_t port) { return dir + "/" + sanitize(host) + "_" + std::to_string(port) + ".toml"; } +std::string math_node_text(const MathNode& node) { + // The op is written as its index through `math_op_at()`, not as the raw enum value: that accessor + // is the contract the dialog, the engine and this file share, so the format is tied to it rather + // than to the declaration order of an enum nobody else has to know about. + int kind = 0; + for (int i = 0; i < math_op_count(); ++i) { + if (math_op_at(i) == node.op) kind = i; + } + std::ostringstream o; + o << kind << '|' << node.source << '|' << node.in_a << '|' << node.in_b << '|' << node.gain << '|' + << node.offset << '|' << node.fc << '|' << node.q << '|' << node.lo << '|' << node.hi << '|' + << node.x << '|' << node.y; + return o.str(); +} + +bool math_node_from_text(const std::string& text, MathNode& out) { + std::vector f; + std::string cur; + for (const char c : text) { + if (c == '|') { + f.push_back(cur); + cur.clear(); + } else { + cur.push_back(c); + } + } + f.push_back(cur); + if (f.size() != 12u) return false; + const auto number = [&](std::size_t i, double& v) { + try { + v = std::stod(f[i]); + return true; + } catch (...) { + return false; + } + }; + double kind = 0.0; + double in_a = -1.0; + double in_b = -1.0; + if (!number(0, kind) || !number(2, in_a) || !number(3, in_b)) return false; + const int k = static_cast(kind); + if (k < 0 || k >= math_op_count()) return false; + out = MathNode{}; + out.op = math_op_at(k); + out.source = f[1]; + out.in_a = static_cast(in_a); + out.in_b = static_cast(in_b); + return number(4, out.gain) && number(5, out.offset) && number(6, out.fc) && number(7, out.q) && + number(8, out.lo) && number(9, out.hi) && number(10, out.x) && number(11, out.y); +} + bool load_layout(const std::string& path, LayoutState& out) { std::ifstream in(path); if (!in) return false; @@ -123,10 +176,13 @@ bool load_layout(const std::string& path, LayoutState& out) { st.window_s = 0.0; st.rows.clear(); st.calibration.clear(); + st.enabled.clear(); + st.math.clear(); + st.measures.clear(); std::string line; // What the last `[[...]]` header opened, so keys land in the right record. - enum class Section { Top, Plot, Calibration } section = Section::Top; + enum class Section { Top, Plot, Calibration, Math, Measure } section = Section::Top; int plot_count = 0; bool plot_section_seen = false; while (std::getline(in, line)) { @@ -140,6 +196,12 @@ bool load_layout(const std::string& path, LayoutState& out) { } else if (t == "[[calibration]]") { section = Section::Calibration; st.calibration.emplace_back(); + } else if (t == "[[math]]") { + section = Section::Math; + st.math.emplace_back(); + } else if (t == "[[measure]]") { + section = Section::Measure; + st.measures.emplace_back(); } else { section = Section::Top; // an unknown table: keep its keys out of ours } @@ -168,6 +230,10 @@ bool load_layout(const std::string& path, LayoutState& out) { st.window_s = std::stod(value); } catch (...) { } + } else if (key == "enabled") { + if (value.size() >= 2 && value.front() == '[' && value.back() == ']') { + parse_string_array(value.substr(1, value.size() - 2), st.enabled); + } } else if (key == "plots") { // Only a fallback for a file with no [[plot]] sections: the writer emits // both, and honouring both appended the sections onto the pre-created @@ -185,6 +251,52 @@ bool load_layout(const std::string& path, LayoutState& out) { } st.rows.back().signals = names; } + } else if (section == Section::Math && !st.math.empty()) { + MathChannel& m = st.math.back(); + if (key == "name") { + m.name = strip_quote(value); + } else if (key == "unit") { + m.unit = strip_quote(value); + } else if (key == "enabled") { + m.enabled = value == "true"; + } else if (key == "output") { + try { + m.output = std::stoi(value); + } catch (...) { + } + } else if (key == "nodes") { + std::vector texts; + if (value.size() >= 2 && value.front() == '[' && value.back() == ']') { + parse_string_array(value.substr(1, value.size() - 2), texts); + } + m.nodes.clear(); + for (const std::string& text : texts) { + MathNode node; + // A node this version cannot read is dropped rather than failing the whole file: a graph + // with a hole in it is invalid and the editor says so, which beats losing the session. + if (math_node_from_text(text, node)) m.nodes.push_back(node); + } + } + } else if (section == Section::Measure && !st.measures.empty()) { + Measure& m = st.measures.back(); + if (key == "signal") { + m.signal = strip_quote(value); + } else if (key == "type") { + try { + const int index = std::stoi(value); + if (index >= 0 && index < measure_type_count()) m.type = measure_type_at(index); + } catch (...) { + } + } else if (key == "threshold") { + try { + m.options.threshold_pct = std::stod(value); + } catch (...) { + } + } else if (key == "above") { + m.options.above = value == "true"; + } else if (key == "show") { + m.options.show_on_plot = value == "true"; + } } else if (section == Section::Calibration && !st.calibration.empty()) { if (key == "name") { st.calibration.back().name = strip_quote(value); @@ -213,6 +325,17 @@ bool save_layout(const std::string& path, const LayoutState& state) { o << "# udpscope session layout (written by the app; safe to edit)\n"; if (state.window_s > 0.0) o << "window_s = " << state.window_s << "\n"; o << "plots = " << state.rows.size() << "\n"; + // Top level, before any table. Emitted after the `[[calibration]]` tables it belonged to the last + // of them -- a bare key after a header is that table's key in TOML -- so the parser, correctly, + // never saw it and a session's enable state was silently lost. + if (!state.enabled.empty()) { + o << "enabled = ["; + for (std::size_t i = 0; i < state.enabled.size(); ++i) { + if (i != 0) o << ", "; + o << toml_escape(state.enabled[i]); + } + o << "]\n"; + } for (const auto& row : state.rows) { o << "\n[[plot]]\nsignals = ["; for (std::size_t i = 0; i < row.signals.size(); ++i) { @@ -225,6 +348,30 @@ bool save_layout(const std::string& path, const LayoutState& state) { o << "\n[[calibration]]\nname = " << toml_escape(cal.name) << "\ngain = " << cal.gain << "\noffset = " << cal.offset << "\n"; } + for (const MathChannel& m : state.math) { + o << "\n[[math]]\nname = " << toml_escape(m.name) << "\nunit = " << toml_escape(m.unit) + << "\nenabled = " << (m.enabled ? "true" : "false") << "\noutput = " << m.output + << "\nnodes = ["; + for (std::size_t i = 0; i < m.nodes.size(); ++i) { + if (i != 0) o << ", "; + o << toml_escape(math_node_text(m.nodes[i])); + } + o << "]\n"; + } + for (const Measure& m : state.measures) { + // The *accessor's* index, not the enum's value: `measure_type_at()` is the mapping the dialog + // and this file share, and its order is the dropdown's -- which is not the declaration order of + // the enum. Writing the raw value and reading it back through the accessor turned `top` into + // whatever sat at that position (the same mistake the math nodes' op index was written to avoid). + int kind = 0; + for (int i = 0; i < measure_type_count(); ++i) { + if (measure_type_at(i) == m.type) kind = i; + } + o << "\n[[measure]]\nsignal = " << toml_escape(m.signal) << "\ntype = " << kind + << "\nthreshold = " << m.options.threshold_pct + << "\nabove = " << (m.options.above ? "true" : "false") << "\nshow = " + << (m.options.show_on_plot ? "true" : "false") << "\n"; + } // A sibling temp file + rename: a crash mid-write leaves the old layout, not a // truncated one that would fail to parse next start. diff --git a/src/layout_store.hpp b/src/layout_store.hpp index 96a1f7d..b70bfd6 100644 --- a/src/layout_store.hpp +++ b/src/layout_store.hpp @@ -6,6 +6,9 @@ #include #include +#include "math_channel.hpp" +#include "measure.hpp" + namespace udpscope { /// The user-arranged part of a session, persisted per data source in @@ -28,8 +31,28 @@ struct LayoutState { double offset = 0.0; }; std::vector calibration; + + /// Signals the session had enabled. Enabling is what starts acquisition, so this is what makes a + /// reopened scope look like the one that was closed -- and it is saved by *name*, like every other + /// identity here, because a CONFIG renumbers the channels. + std::vector enabled; + + /// The derived channels, graphs and all: the editor's work is part of the session, not of the + /// process. A graph is stored as one string per node so the file stays in this store's TOML + /// subset (tables, string arrays, scalars) rather than growing a nested-table parser for it; see + /// `math_node_text()` for the field order. + std::vector math; + + /// The measurements pane, by signal *name* and with the type and options a row was built with. + std::vector measures; }; +/// One `MathNode` as `kind|source|in_a|in_b|gain|offset|fc|q|lo|hi|x|y`, and back. The kind is the +/// op's *index* into `math_op_at()`, which is why the enum's order is a stored format and not just +/// an implementation detail: reordering it renames every saved graph's operations. +std::string math_node_text(const MathNode& node); +bool math_node_from_text(const std::string& text, MathNode& out); + /// `~/.config/udpscope/_.toml`, with the directory created. /// Characters unsafe in a filename are replaced in the host part. std::string layout_path(const std::string& host, std::uint16_t port); diff --git a/src/model.cpp b/src/model.cpp index ea68fb2..582c136 100644 --- a/src/model.cpp +++ b/src/model.cpp @@ -148,11 +148,18 @@ ScopeModel::ScopeModel(const Config& cfg) : cfg_(cfg) { void ScopeModel::set_signals(std::vector signals) { std::lock_guard lock(mutex_); - // Keep the enable state across CONFIG updates for signals that kept their name. + // Keep the enable state across CONFIG updates for signals that kept their name. The math + // channels' synthetic entries carry theirs in `math_`, so they are skipped here -- a CONFIG must + // not be able to turn a derived channel off, and must not lose it either (it is re-appended + // below). std::unordered_map was_enabled; for (std::size_t i = 0; i < signals_.size(); ++i) { + if (signals_[i].math) continue; was_enabled.emplace(signals_[i].name, i < enabled_.size() ? enabled_[i] : false); } + signals.erase(std::remove_if(signals.begin(), signals.end(), + [](const SignalInfo& s) { return s.math; }), + signals.end()); signals_ = std::move(signals); enabled_.assign(signals_.size(), false); for (std::size_t i = 0; i < signals_.size(); ++i) { @@ -162,17 +169,103 @@ void ScopeModel::set_signals(std::vector signals) { // chose for a signal that kept its name. enabled_[i] = it != was_enabled.end() ? it->second : false; } + append_math_signals_locked(); rebuild_channels(); rebuild_locked(); generation_.fetch_add(1, std::memory_order_release); } +void ScopeModel::append_math_signals_locked() { + // One synthetic signal per math channel, appended after the stream's own. They go through the + // ordinary signal path on purpose: a derived channel is a channel, so the panel's switch, the + // plot binding, the measurements and the export all work on it without knowing it is derived. + // A `Source` in a graph names a *signal*, so another math channel is a valid source too. + math_signal_names_.clear(); + for (const MathChannel& m : math_) { + if (m.name.empty()) continue; + SignalInfo sig; + sig.name = m.name; + sig.unit = m.unit; + sig.math = true; + sig.sampling_rate = 0.0; // not a streamed signal: the model's own dt applies + math_signal_names_.push_back(m.name); + signals_.push_back(std::move(sig)); + enabled_.push_back(m.enabled); + } +} + +void ScopeModel::set_math_channels(std::vector channels) { + std::lock_guard lock(mutex_); + math_ = std::move(channels); + // Lift the previous synthetic entries out of the signal list; `append_math_signals_locked()` + // puts the current ones back, so an edit is the same kind of structural rebuild as a CONFIG. + signals_.erase(std::remove_if(signals_.begin(), signals_.end(), + [](const SignalInfo& s) { return s.math; }), + signals_.end()); + enabled_.resize(signals_.size()); + append_math_signals_locked(); + rebuild_channels(); + rebuild_locked(); + // Prepare the runtimes for the rate this plan chose: a cutoff the user typed in Hz is only + // meaningful against the rate the filter will actually run at, and decimation can have changed + // it under them. + prepare_math_locked(); + generation_.fetch_add(1, std::memory_order_release); +} + +void ScopeModel::prepare_math_locked() { + // Called from `rebuild_locked()`, so it runs on **every** plan change -- a CONFIG, an + // enable/disable, and a window change that moves the decimation. A biquad's coefficients are a + // function of the sample interval, so one prepared for the old rate is a filter with the wrong + // cutoff the moment the plan moves; and its state is the old rate's history, which is not a + // starting point for the new one. Both are reset here, together, so the two cannot drift apart. + math_rt_.assign(math_.size(), MathRuntime{}); + const double dt = part_.slot_dt(); + for (std::size_t i = 0; i < math_.size(); ++i) math_prepare(math_[i], math_rt_[i], dt); +} + +void ScopeModel::push_math_sample(const std::vector& sample) { + // Values for this slot, extended as the derived ones are computed: a graph may name another math + // channel, and nodes are evaluated in list order, so by the time one is asked for it is there. + std::vector values = sample; + const auto value_of = [&](const std::string& name) -> double { + for (std::size_t c = 0; c < channels_.size(); ++c) { + if (channels_[c].name != name) continue; + return c < values.size() ? static_cast(values[c]) : std::nan(""); + } + return std::nan(""); + }; + for (std::size_t m = 0; m < math_.size(); ++m) { + const int channel = m < math_channel_.size() ? math_channel_[m] : -1; + if (channel < 0 || m >= math_rt_.size()) continue; + const double out = math_step(math_[m], math_rt_[m], value_of); + live().values(static_cast(channel)).push(static_cast(out)); + if (values.size() <= static_cast(channel)) { + values.resize(static_cast(channel) + 1, 0.0f); + } + values[static_cast(channel)] = static_cast(out); + } +} + void ScopeModel::rebuild_channels() { channels_.clear(); + // Which channel each math channel ended up with, by name: a disabled one has none, and the + // producer must not push a derived sample into another channel's ring. + math_channel_.assign(math_.size(), -1); + const auto math_index_of = [&](const std::string& name) -> int { + for (std::size_t k = 0; k < math_.size(); ++k) { + if (math_[k].name == name) return static_cast(k); + } + return -1; + }; for (std::size_t s = 0; s < signals_.size(); ++s) { const SignalInfo& sig = signals_[s]; if (s >= enabled_.size() || !enabled_[s]) continue; if (is_time_signal(signals_, s)) continue; + if (sig.math) { + const int k = math_index_of(sig.name); + if (k >= 0) math_channel_[static_cast(k)] = static_cast(channels_.size()); + } ChannelInfo ch; ch.signal = s; ch.enabled = true; @@ -182,6 +275,7 @@ void ScopeModel::rebuild_channels() { // exactly `elements` values per packet, which is the block of samples. ch.name = sig.name; ch.unit = sig.unit; + ch.math = sig.math; ch.elements = sig.elements(); ch.dt = sig.sampling_rate > 0.0 ? 1.0 / sig.sampling_rate : 0.0; // A calibration the user already set for this name (before it was enabled, or @@ -210,6 +304,7 @@ void ScopeModel::rebuild_locked() { store_ = std::make_unique(part_, part_.span_s); if (trigger_.channel >= static_cast(channels_.size())) trigger_.channel = -1; + prepare_math_locked(); } bool ScopeModel::set_target_span(double seconds) { @@ -262,6 +357,15 @@ void ScopeModel::set_signal_enabled(std::size_t signal, bool on) { std::lock_guard lock(mutex_); if (signal >= enabled_.size() || enabled_[signal] == on) return; enabled_[signal] = on; + // A math channel's synthetic signal is enabled *here* -- it is the panel's switch -- while + // `append_math_signals_locked()` re-states `enabled_` from `math_` on the next edit. The two have + // to agree, or an edit would quietly turn a derived channel off (and the rebuild below reads + // `enabled_`, so the channel itself would go with it). + if (signal < signals_.size() && signals_[signal].math) { + for (MathChannel& m : math_) { + if (m.name == signals_[signal].name) m.enabled = on; + } + } // The trigger names a channel by its display name, which is stable across // renumbering: when its own signal is disabled the channel vanishes (the trigger // then waits, and comes back when the signal is re-enabled). diff --git a/src/model.hpp b/src/model.hpp index ab28cf8..172495c 100644 --- a/src/model.hpp +++ b/src/model.hpp @@ -16,6 +16,8 @@ #include #include #include + +#include "math_channel.hpp" #include namespace udpscope { @@ -23,6 +25,10 @@ namespace udpscope { /// One streamed signal, as the CONFIG packet described it. struct SignalInfo { std::string name; + /// True for the entries the model *synthesised* for a math channel: the stream never sends them, + /// the producer fills their samples by evaluating the graph. The flag is what lets a CONFIG + /// update tell them from the stream's own signals and put them back afterwards. + bool math = false; std::string unit; std::uint8_t type_code = 0; std::uint8_t quant_type = 0; @@ -50,7 +56,13 @@ struct ChannelInfo { double offset = 0.0; std::string name; ///< the signal's name - std::string unit; + /// True for a channel the producer *derives* rather than reads. The ingest must skip it: it has + /// no packet signal, so reading one gives zero -- and pushing that zero into a ring whose time + /// ring advances once per slot is what made a math channel alternate between its value and 0, and + /// then drift off the time ring entirely once the buffer was full. + bool math = false; + + std::string unit; ///< the signal's unit, or a math channel's own /// Samples of this channel per DATA packet (`elements()` of the signal). A /// UDPStreamer signal is one channel: its columns are consecutive samples of the @@ -178,6 +190,20 @@ class ScopeModel { /// Takes a new CONFIG: rebuilds the signal and channel lists and the two /// buffers. Clears the trigger's channel selection if the stream changed. void set_signals(std::vector signals); + + /// The user's math channels, as configured. Read it under the lock, like the other accessors + /// here -- the UI copies what it needs while it holds it. + std::vector math_channels() const { return math_; } + /// Replaces the math channels. A *structural* change, exactly like a CONFIG or an + /// enable/disable: the enabled ones are re-appended to the signal list, their channels are + /// rebuilt and their runtimes re-prepared for the new stored rate. **Takes the lock itself** -- + /// do not call it holding `mutex()`, which is not recursive. + void set_math_channels(std::vector channels); + + /// One stored sample's worth of derived values, called by the producer right after it has pushed + /// the streamed channels. `sample[c]` is channel `c`'s value for this slot; the math channels are + /// evaluated from those, in order, so a math channel may use another one. + void push_math_sample(const std::vector& sample); const std::vector& signals() const noexcept { return signals_; } const std::vector& channels() const noexcept { return channels_; } @@ -387,10 +413,27 @@ class ScopeModel { private: void rebuild_channels(); + /// Re-appends one synthetic signal per math channel, after the stream's own. Called under the + /// lock by both `set_signals()` (a CONFIG) and `set_math_channels()` (an edit). + void append_math_signals_locked(); + /// Rebuilds the math runtimes for the *current* plan: the biquad coefficients follow the stored + /// sample interval, and their state is reset with them. Called at the end of `rebuild_locked()`, + /// so a window change that moves the decimation re-prepares the filters exactly as an edit does. + void prepare_math_locked(); Config cfg_; std::vector signals_; std::vector channels_; + // --- math channels -------------------------------------------------------- + std::vector math_; + std::vector math_rt_; + /// The channel index each math channel was given by `rebuild_channels()`, -1 while it has none + /// (disabled, or before the first rebuild). + std::vector math_channel_; + /// The names of the synthetic signal entries, so they can be lifted out of `signals_`/`enabled_` + /// before the stream's own are re-appended -- a CONFIG must not leave a stale derived channel + /// behind, and must not drop a live one either. + std::vector math_signal_names_; std::vector enabled_; Partition part_; diff --git a/src/ui.cpp b/src/ui.cpp index d24b54a..45baaa0 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -134,6 +134,12 @@ struct App { GtkWidget* pause_button = nullptr; GtkWidget* menu_button = nullptr; GtkWidget* cursors_toggle = nullptr; + /// Where the panel controls ended up, decided once at build time: the dump runs after the window + /// is destroyed and cannot ask GTK anything about a widget. + std::string dbg_panel_controls; + /// The header bar, so the dump can report the order the controls are in: a header bar is a + /// *titlebar*, not a child of the window, so walking down from the window does not reach it. + GtkWidget* toolbar = nullptr; GtkWidget* cursors_reset = nullptr; /// The cursors pane: revealed with the toggle, and listing what the instruments /// read -- T0/T1/dT always, per row an Y1/Y2/dY where rulers are up, and in `mix` mode @@ -164,6 +170,15 @@ struct App { GtkWidget* measures_revealer = nullptr; GtkWidget* measures_list = nullptr; GtkWidget* measures_toggle = nullptr; + // --- math channels -------------------------------------------------------- + /// The app's copy of the math channels, and the source of truth for the pane. Every change goes + /// through `apply_math_channels()`, which writes this and hands it to the model -- the model's own + /// copy is what the producer evaluates. + std::vector math; + GtkWidget* math_toggle = nullptr; + GtkWidget* math_revealer = nullptr; + GtkWidget* math_list = nullptr; + GtkWidget* math_dialog = nullptr; /// One value label per measure, in `measures` order; rebuilt with the list. std::vector measure_labels; /// The text each label was last set to. The dump runs from the destroy handler, where the @@ -456,6 +471,22 @@ LayoutState collect_layout(App& app) { break; } } + // The *enable* state, not just what is plotted: a signal can be enabled only as a math channel's + // source, and a session that came back with it off would come back with a NaN trace. "Enabled" is + // read off the channels the model built -- it has no per-signal accessor and does not need one, + // since an enabled signal is exactly one that produced a channel. + std::vector on(model.signals().size(), false); + for (const ChannelInfo& ch : model.channels()) { + if (ch.math) continue; + if (ch.signal < on.size()) on[ch.signal] = true; + } + for (std::size_t si = 0; si < model.signals().size(); ++si) { + if (model.signals()[si].math || !on[si]) continue; + st.enabled.push_back(model.signals()[si].name); + } + // The app's own lists are the pane's source of truth for both of these. + st.math = app.math; + st.measures = app.measures; return st; } @@ -478,6 +509,11 @@ gboolean layout_save_tick(gpointer user) { return G_SOURCE_REMOVE; } +// Defined below; `apply_layout()` uses them, and it is above both. +void apply_math_channels(App& app); +void rebuild_measures_pane(App& app); +void rebuild_math_pane(App& app); + void schedule_layout_save(App& app) { if (app.source_port == 0) return; if (app.layout_save_source != 0) return; @@ -491,6 +527,21 @@ void apply_layout(App& app, const LayoutState& st) { ScopeModel& model = *app.model; const auto& signals = model.signals(); + // The session's own state first: enabling is what starts acquisition, and a math channel's + // *name* is only a signal once its graph is installed -- a row that holds one would otherwise be + // skipped as a name the stream no longer has. + for (const std::string& name : st.enabled) { + for (std::size_t si = 0; si < signals.size(); ++si) { + if (signals[si].name != name || signals[si].math) continue; + model.set_signal_enabled(si, true); + break; + } + } + app.math = st.math; + apply_math_channels(app); + app.measures = st.measures; + rebuild_measures_pane(app); + int rows = static_cast(st.rows.size()); if (rows > RowSet::kMaxRows) rows = RowSet::kMaxRows; if (rows > 0) app.rows->set_row_count(rows); @@ -1086,6 +1137,8 @@ void on_signal_switch(GObject* obj, GParamSpec*, gpointer user) { const std::size_t index = static_cast( GPOINTER_TO_SIZE(g_object_get_data(obj, "udpscope-signal"))); app->model->set_signal_enabled(index, gtk_check_button_get_active(GTK_CHECK_BUTTON(obj))); + // The enable state is part of the session (a signal can be on only as a math channel's source). + schedule_layout_save(*app); } void on_arm_clicked(GtkButton* button, gpointer user) { @@ -1224,7 +1277,11 @@ void open_calibration(App& app, GtkWindow* parent, std::size_t signal) { auto* ctx = new CalibContext{&app, signal, gain, offset, window}; GtkWidget* cancel = gtk_button_new_with_label("Cancel"); - g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window); + // `gtk_window_close()`, not `gtk_window_destroy()`: the destroy path does **not** emit + // `close-request`, so the handler that clears `app.math_dialog` never ran and every later open + // was a `present` on a dead widget -- a critical, and nothing on screen. Closing emits the signal, + // the handler clears the pointer, and the default handler then destroys the window. + g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_close), window); GtkWidget* apply = gtk_button_new_with_label("Apply"); g_object_set_data_full(G_OBJECT(apply), "udpscope-ctx", ctx, [](gpointer p) { delete static_cast(p); }); @@ -2454,6 +2511,847 @@ void update_measures(App& app, bool force) { } /// The measurements panel: a header with the `[+]`, and one row per measurement. +// ----------------------------------------------------------------- math channels +// +// A math channel is a *channel*: the model synthesises a signal for it, so the panel's switch, the +// plot binding, the measurements and the export all treat it like a streamed one, and the pane only +// has to say which graphs exist. The editor is the compact node graph: nodes as boxes on a canvas, +// click a box to select it and edit its parameters in the form beside the canvas, click an output +// port and then an input port to wire them, drag a box to move it. + +void rebuild_math_pane(App& app); +void open_math_dialog(App& app, int edit); + +/// 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 panel's `+`: a new channel with one source node, so the editor opens on something valid and +/// the user has a box to connect. +void add_math_channel(App& app) { + // From the *acquired* channels, never `signals().front()`: the first signal of a stream is its + // timestamp, which is not acquired, so a node sourced from it reads NaN for ever -- a graph that + // validates and then shows nothing. (The dialog's own default was fixed for this; the pane's `+` + // was not.) + std::string name; + { + std::lock_guard lock(app.model->mutex()); + if (!app.model->channels().empty()) name = app.model->channels().front().name; + } + MathChannel m; + // A name of its own: calling it after its first source would put a second entry with that name in + // the signal panel, where the user cannot tell the derived one from the streamed one. + m.name = "math" + std::to_string(app.math.size() + 1); + m.enabled = true; + MathNode src; + src.op = MathOp::Source; + src.source = name; + src.x = 30.0; + src.y = 40.0; + m.nodes.push_back(src); + m.output = 0; + app.math.push_back(std::move(m)); + apply_math_channels(app); +} + +// The node editor: a compact graph. Each node is a box; inputs are the squares on its left edge, +// the output the square on its right. Click a box to select it (its parameters appear in the form +// beside the canvas), click an output square and then an input square to wire them, drag a box to +// move it. The graph is a *list*, walked in order, so a node may only read ones above it -- which +// is why an input square offers only the nodes before it and a cycle cannot be drawn at all. +constexpr double kNodeW = 132.0; +constexpr double kNodeH = 46.0; +constexpr double kPort = 10.0; + +struct MathDialog { + App* app = nullptr; + int edit = -1; + std::vector nodes; + int output = 0; + int selected = 0; + /// The node whose output square was clicked and is waiting for an input to connect to. + int pending_out = -1; + /// Where the box being dragged *was* when the drag began, and which box it is. The gesture + /// reports the total offset from its own start point, so adding it to a value that the previous + /// update already moved is a runaway: the box accelerates away from the pointer and leaves the + /// canvas. `-1` means the drag did not begin on a box. + double grab_x = 0.0; + double grab_y = 0.0; + int drag_node = -1; + GtkWidget* window = nullptr; + GtkWidget* canvas = nullptr; + GtkWidget* name = nullptr; + GtkWidget* unit = nullptr; + GtkWidget* op_drop = nullptr; + GtkWidget* source_drop = nullptr; + GtkWidget* gain = nullptr; + GtkWidget* offset = nullptr; + GtkWidget* fc = nullptr; + GtkWidget* q = nullptr; + GtkWidget* lo = nullptr; + GtkWidget* hi = nullptr; + GtkWidget* rows[6] = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}; + GtkWidget* inputs = nullptr; +}; + +/// 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 math_source_names(App& app) { + std::vector names; + std::lock_guard lock(app.model->mutex()); + for (const ChannelInfo& c : app.model->channels()) names.push_back(c.name); + return names; +} + +void math_dialog_sync(MathDialog* d); + +/// The box a node is drawn in, in canvas pixels. +GdkRectangle math_node_rect(const MathNode& n) { + return GdkRectangle{static_cast(n.x), static_cast(n.y), static_cast(kNodeW), + static_cast(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(d->nodes.size()) - 1; i >= 0; --i) { + const GdkRectangle r = math_node_rect(d->nodes[static_cast(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 the pointer: `-1` none, `-2` the selected node's output, otherwise the index of +/// the node whose *input* `which` was hit. +int math_port_at(const MathDialog* d, double x, double y, int& which) { + which = 0; + for (std::size_t i = 0; i < d->nodes.size(); ++i) { + const MathNode& n = d->nodes[i]; + double px = 0.0; + double py = 0.0; + math_output_pos(n, px, py); + if (std::fabs(x - px) <= kPort && std::fabs(y - py) <= kPort) return -2; + for (int k = 0; k < math_op_arity(n.op); ++k) { + math_input_pos(n, k, px, py); + if (std::fabs(x - px) <= kPort && std::fabs(y - py) <= kPort) { + which = k; + return static_cast(i); + } + } + } + return -1; +} + +void math_canvas_draw(GtkDrawingArea*, cairo_t* cr, int width, int height, gpointer user) { + MathDialog* d = static_cast(user); + cairo_set_source_rgb(cr, 0.06, 0.078, 0.094); + cairo_paint(cr); + // 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(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(from)], ax, ay); + math_input_pos(n, k, bx, by); + cairo_set_source_rgb(cr, 0.55, 0.62, 0.72); + cairo_set_line_width(cr, 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(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(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); + } + // Ports: inputs on the left, the output on the right. + 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); + const bool armed = d->pending_out >= 0 && static_cast(i) > d->pending_out; + cairo_set_source_rgb(cr, armed ? 1.0 : 0.45, armed ? 0.69 : 0.52, armed ? 0.13 : 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, d->pending_out == static_cast(i) ? 1.0 : 0.45, + d->pending_out == static_cast(i) ? 0.69 : 0.52, + d->pending_out == static_cast(i) ? 0.13 : 0.62); + cairo_rectangle(cr, ox - kPort * 0.5, oy - kPort * 0.5, kPort, kPort); + cairo_fill(cr); + } + (void)width; + (void)height; +} + +void math_canvas_pressed(GtkGestureClick*, int, double x, double y, gpointer user) { + MathDialog* d = static_cast(user); + int which = 0; + const int port = math_port_at(d, x, y, which); + if (port == -2) { + // An output square: arm it. Clicking it again disarms, so a mis-click needs no escape. + const int node = math_node_at(d, x, y); + d->pending_out = (node >= 0 && d->pending_out == node) ? -1 : node; + gtk_widget_queue_draw(d->canvas); + return; + } + if (port >= 0) { + if (d->pending_out >= 0 && d->pending_out < port) { + MathNode& n = d->nodes[static_cast(port)]; + (which == 0 ? n.in_a : n.in_b) = d->pending_out; + d->pending_out = -1; + d->selected = port; + math_dialog_sync(d); + return; + } + d->selected = port; + math_dialog_sync(d); + return; + } + const int node = math_node_at(d, x, y); + if (node >= 0) { + d->selected = node; + d->pending_out = -1; + math_dialog_sync(d); + return; + } + d->pending_out = -1; + gtk_widget_queue_draw(d->canvas); +} + +void math_canvas_drag_begin(GtkGestureDrag*, double x, double y, gpointer user) { + MathDialog* d = static_cast(user); + // Only a drag that *began on a box* moves one. Dragging the background used to move whatever was + // selected, which reads as the window fighting the pointer. + const int node = math_node_at(d, x, y); + d->drag_node = node; + if (node < 0) return; + d->selected = node; + d->grab_x = d->nodes[static_cast(node)].x; + d->grab_y = d->nodes[static_cast(node)].y; + math_dialog_sync(d); +} + +void math_canvas_drag(GtkGestureDrag*, double dx, double dy, gpointer user) { + MathDialog* d = static_cast(user); + if (d->drag_node < 0 || d->drag_node >= static_cast(d->nodes.size())) return; + MathNode& n = d->nodes[static_cast(d->drag_node)]; + // The *total* offset from where the box was when the drag began -- from `grab_x`/`grab_y`, which + // no update writes. Feeding each update's delta back into the origin is what made the box fly off. + n.x = std::max(0.0, d->grab_x + dx); + n.y = std::max(0.0, d->grab_y + dy); + gtk_widget_queue_draw(d->canvas); +} + +void on_math_node_added(GtkButton*, gpointer user) { + MathDialog* d = static_cast(user); + // A node added to a graph that was *linear* -- its output was the last node -- becomes the output, + // 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(d->nodes.size()) - 1 || d->nodes.empty(); + MathNode n; + n.op = MathOp::Gain; + const std::size_t k = d->nodes.size(); + // The same grid as the auto-place, so a node added here lands clear of the others. + n.x = 24.0 + 160.0 * static_cast(k % 2u); + n.y = 20.0 + 58.0 * static_cast(k / 2u); + const int arity = math_op_arity(n.op); + if (arity >= 1 && k > 0) n.in_a = static_cast(k) - 1; + if (arity >= 2 && k > 0) n.in_b = static_cast(k) - 1; + d->nodes.push_back(n); + d->selected = static_cast(d->nodes.size()) - 1; + if (was_linear) d->output = d->selected; + math_dialog_sync(d); +} + +void on_math_node_deleted(GtkButton*, gpointer user) { + MathDialog* d = static_cast(user); + if (d->selected < 0 || d->selected >= static_cast(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(gone, static_cast(d->nodes.size()) - 1); + d->pending_out = -1; + math_dialog_sync(d); +} + +void on_math_output_set(GtkButton*, gpointer user) { + MathDialog* d = static_cast(user); + if (d->selected < 0 || d->selected >= static_cast(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(user); + if (d->selected < 0 || d->selected >= static_cast(d->nodes.size())) return; + MathNode& n = d->nodes[static_cast(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)); + std::vector names; + { + std::lock_guard lock(d->app->model->mutex()); + for (const SignalInfo& sig : d->app->model->signals()) names.push_back(sig.name); + } + if (sel < names.size()) n.source = names[sel]; + } + gtk_widget_queue_draw(d->canvas); +} + +/// 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(user); + if (d->selected < 0 || d->selected >= static_cast(d->nodes.size())) return; + if (d->op_drop == nullptr || object != G_OBJECT(d->op_drop)) return; + MathNode& n = d->nodes[static_cast(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 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. +void math_dialog_sync(MathDialog* d) { + if (d->selected < 0 || d->selected >= static_cast(d->nodes.size())) return; + MathNode& n = d->nodes[static_cast(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) { + std::vector names; + { + std::lock_guard lock(d->app->model->mutex()); + for (const SignalInfo& sig : d->app->model->signals()) names.push_back(sig.name); + } + GtkStringList* list = gtk_string_list_new(nullptr); + guint pick = 0; + bool found = false; + 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(i); + found = true; + } + } + // A source that is not in the list is a graph naming a channel nobody acquires -- it would read + // NaN for ever while the dropdown showed the first entry as if it were selected. The node is + // moved to what the form is actually showing, so what is on screen is what the graph says. + if (!found && !names.empty()) n.source = names.front(); + 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); + } + // The op dropdown follows the selection without firing its own handler back into the node. + if (d->op_drop != nullptr) gtk_drop_down_set_selected(GTK_DROP_DOWN(d->op_drop), + static_cast(op)); + std::string inputs; + const auto describe = [&](int index) -> std::string { + if (index < 0) return "—"; + if (index >= static_cast(d->nodes.size())) return "—"; + const MathNode& other = d->nodes[static_cast(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); +} + +void on_math_apply(GtkButton*, gpointer user) { + MathDialog* d = static_cast(user); + on_math_param_changed(nullptr, d); + std::string why; + 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 (!math_channel_valid(m, &why)) { + // The dialog stays up: an invalid graph is a thing to fix, and closing on it would throw the + // work away with no message. + GtkAlertDialog* alert = gtk_alert_dialog_new("%s", why.c_str()); + gtk_alert_dialog_show(alert, GTK_WINDOW(d->window)); + g_object_unref(alert); + return; + } + // `math_channel_valid()` is about the *graph* (an output, every input wired to a node above it); + // that a `Source` names a channel the scope actually acquires is the dialog's business, since only + // it can see the acquired list. A graph naming a disabled signal is one that would read NaN for + // ever, and it must not be saved. + { + const std::vector acquired = math_source_names(*d->app); + for (std::size_t i = 0; i < m.nodes.size(); ++i) { + if (m.nodes[i].op != MathOp::Source) continue; + if (std::find(acquired.begin(), acquired.end(), m.nodes[i].source) != acquired.end()) continue; + const std::string text = "node " + std::to_string(i + 1) + ": '" + m.nodes[i].source + + "' is not an acquired channel"; + GtkAlertDialog* alert = gtk_alert_dialog_new("%s", text.c_str()); + gtk_alert_dialog_show(alert, GTK_WINDOW(d->window)); + g_object_unref(alert); + return; + } + } + if (m.name.empty()) m.name = "math"; + if (d->edit >= 0 && d->edit < static_cast(d->app->math.size())) { + m.enabled = d->app->math[static_cast(d->edit)].enabled; + d->app->math[static_cast(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; +} + +void open_math_dialog(App& app, int edit) { + if (app.math_dialog != nullptr) { + gtk_window_present(GTK_WINDOW(app.math_dialog)); + return; + } + MathDialog* d = new MathDialog(); + d->app = &app; + d->edit = edit; + { + std::lock_guard lock(app.model->mutex()); + d->nodes = edit >= 0 && edit < static_cast(app.math.size()) + ? app.math[static_cast(edit)].nodes + : std::vector{}; + } + if (d->nodes.empty()) { + MathNode src; + src.op = MathOp::Source; + const std::vector names = math_source_names(app); + if (!names.empty()) src.source = names.front(); + d->nodes.push_back(src); + } + // A node with no position yet -- a graph built by a probe, or one whose box was never moved -- + // would otherwise sit in the canvas's top-left corner, stacked under whatever is drawn after it. + // Laid out in a column, which is also the order the graph is walked in. + // Two columns, spaced by more than a box is wide. The first version staggered them by 24 px for a + // 132 px box, so every node sat on top of the one before it and the click always found the last -- + // which reads as the dialog ignoring the pointer. + for (std::size_t i = 0; i < d->nodes.size(); ++i) { + if (d->nodes[i].x != 0.0 || d->nodes[i].y != 0.0) continue; + d->nodes[i].x = 24.0 + 160.0 * static_cast(i % 2u); + d->nodes[i].y = 20.0 + 58.0 * static_cast(i / 2u); + } + d->output = edit >= 0 && edit < static_cast(app.math.size()) + ? app.math[static_cast(edit)].output + : 0; + // **The last node, not the first.** A new graph is built source -> operation, and the *output* + // defaulted to node 0 -- the source -- so every operation was computed and then thrown away: a + // gain of 2 and a low pass both produced a channel equal to its input. The output is drawn as the + // orange dot and settable explicitly, so a graph whose output is genuinely an earlier node keeps + // it; this is only the default for one that has never been given a choice. + if (d->output < 0 || d->output >= static_cast(d->nodes.size())) { + d->output = static_cast(d->nodes.size()) - 1; + } + d->selected = d->output; + + GtkWidget* window = gtk_window_new(); + d->window = window; + gtk_window_set_title(GTK_WINDOW(window), edit >= 0 ? "Edit math channel" : "New math channel"); + gtk_window_set_default_size(GTK_WINDOW(window), 720, 420); + gtk_window_set_modal(GTK_WINDOW(window), true); + g_object_set_data_full(G_OBJECT(window), "udpscope-math-dialog", d, + +[](gpointer p) { delete static_cast(p); }); + g_signal_connect(window, "close-request", G_CALLBACK(+[](GtkWindow* w, gpointer) { + GtkWidget* dd = GTK_WIDGET(w); + MathDialog* md = static_cast( + g_object_get_data(G_OBJECT(dd), "udpscope-math-dialog")); + if (md != nullptr && md->app != nullptr) md->app->math_dialog = nullptr; + return false; + }), + nullptr); + + GtkWidget* outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_margin_top(outer, 10); + gtk_widget_set_margin_bottom(outer, 10); + gtk_widget_set_margin_start(outer, 10); + gtk_widget_set_margin_end(outer, 10); + gtk_window_set_child(GTK_WINDOW(window), outer); + + // The channel's own identity: the name is also how another graph names it as a source. + GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + d->name = gtk_entry_new(); + gtk_entry_set_placeholder_text(GTK_ENTRY(d->name), "name"); + if (edit >= 0 && edit < static_cast(app.math.size())) { + gtk_editable_set_text(GTK_EDITABLE(d->name), app.math[static_cast(edit)].name.c_str()); + } + d->unit = gtk_entry_new(); + gtk_entry_set_placeholder_text(GTK_ENTRY(d->unit), "unit (optional)"); + if (edit >= 0 && edit < static_cast(app.math.size())) { + gtk_editable_set_text(GTK_EDITABLE(d->unit), app.math[static_cast(edit)].unit.c_str()); + } + gtk_widget_set_hexpand(d->name, true); + gtk_box_append(GTK_BOX(head), d->name); + gtk_box_append(GTK_BOX(head), d->unit); + gtk_box_append(GTK_BOX(outer), head); + + GtkWidget* body = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10); + d->canvas = gtk_drawing_area_new(); + gtk_widget_set_size_request(d->canvas, 420, 300); + gtk_widget_set_hexpand(d->canvas, true); + gtk_widget_set_vexpand(d->canvas, true); + gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(d->canvas), math_canvas_draw, d, nullptr); + GtkGesture* click = gtk_gesture_click_new(); + g_signal_connect(click, "pressed", G_CALLBACK(+[](GtkGestureClick* g, int, double x, double y, + gpointer user) { + math_canvas_pressed(g, 0, x, y, user); + }), + d); + gtk_widget_add_controller(d->canvas, GTK_EVENT_CONTROLLER(click)); + GtkGesture* drag = gtk_gesture_drag_new(); + g_signal_connect(drag, "drag-begin", G_CALLBACK(+[](GtkGestureDrag* g, double x, double y, + gpointer user) { + math_canvas_drag_begin(g, x, y, user); + }), + d); + g_signal_connect(drag, "drag-update", G_CALLBACK(+[](GtkGestureDrag* g, double dx, double dy, + gpointer user) { + math_canvas_drag(g, dx, dy, user); + }), + d); + gtk_widget_add_controller(d->canvas, GTK_EVENT_CONTROLLER(drag)); + gtk_box_append(GTK_BOX(body), d->canvas); + + // The form: every row exists from the start and is shown or hidden per op, so no rebuild can + // invalidate a callback's handle. + GtkWidget* form = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_widget_set_size_request(form, 250, -1); + + GtkWidget* op_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget* op_text = gtk_label_new("operation"); + gtk_widget_set_size_request(op_text, 74, -1); + gtk_widget_set_halign(op_text, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(op_row), op_text); + std::vector op_names; + for (int i = 0; i < math_op_count(); ++i) op_names.push_back(math_op_name(math_op_at(i))); + op_names.push_back(nullptr); + d->op_drop = gtk_drop_down_new_from_strings(op_names.data()); + gtk_widget_set_hexpand(d->op_drop, true); + g_signal_connect(d->op_drop, "notify::selected", G_CALLBACK(on_math_op_changed), d); + gtk_box_append(GTK_BOX(op_row), d->op_drop); + gtk_box_append(GTK_BOX(form), op_row); + + d->source_drop = gtk_drop_down_new_from_strings(nullptr); + g_signal_connect(d->source_drop, "notify::selected", G_CALLBACK(on_math_param_changed), d); + gtk_box_append(GTK_BOX(form), math_row("channel", d->source_drop, &d->rows[0])); + + d->gain = gtk_spin_button_new_with_range(-1e6, 1e6, 0.1); + d->offset = gtk_spin_button_new_with_range(-1e6, 1e6, 0.1); + // Digits, not just a range: a `GtkSpinButton` holds zero decimals by default, so a 12.5 Hz cutoff + // was stored as 12 and a gain of 0.25 as 0 -- the values looked wrong because they *were*. + gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->gain), 4); + gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->offset), 4); + // An explicit starting value, because `gtk_spin_button_new_with_range()` leaves the value at the + // range's *minimum* when zero is not one of the bounds it can pick: the offset began at -1e6, and + // `on_math_param_changed()` reads the widgets -- so a gain of 2 saved as `x*2 - 2000000`. Every + // field is seeded from its own identity value; `math_dialog_sync()` then sets the node's. + gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->gain), 1.0); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->offset), 0.0); + g_signal_connect(d->gain, "value-changed", G_CALLBACK(on_math_param_changed), d); + g_signal_connect(d->offset, "value-changed", G_CALLBACK(on_math_param_changed), d); + { + // One box for both: `gain + offset` is a single op and the two fields are only meaningful + // together, so they show and hide as one. The offset used to be a row of its own that nothing + // ever hid, which is what made it look like a property of every node instead of of this one op. + GtkWidget* gain_rows = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_box_append(GTK_BOX(gain_rows), math_row("gain", d->gain, nullptr)); + gtk_box_append(GTK_BOX(gain_rows), math_row("offset", d->offset, nullptr)); + d->rows[1] = gain_rows; + gtk_box_append(GTK_BOX(form), gain_rows); + } + + d->fc = gtk_spin_button_new_with_range(0.001, 1e7, 0.5); + d->q = gtk_spin_button_new_with_range(0.01, 100.0, 0.05); + gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->fc), 3); + gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->q), 3); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->fc), 100.0); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->q), 0.707); + g_signal_connect(d->fc, "value-changed", G_CALLBACK(on_math_param_changed), d); + g_signal_connect(d->q, "value-changed", G_CALLBACK(on_math_param_changed), d); + { + GtkWidget* filter_rows = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_box_append(GTK_BOX(filter_rows), math_row("corner Hz", d->fc, nullptr)); + gtk_box_append(GTK_BOX(filter_rows), math_row("Q", d->q, &d->rows[3])); + gtk_box_append(GTK_BOX(form), filter_rows); + d->rows[2] = filter_rows; + // The Q row is only meaningful for the band pass, which is the only op that reads it. + gtk_widget_set_visible(d->rows[3], false); + } + + d->lo = gtk_spin_button_new_with_range(-1e6, 1e6, 0.1); + d->hi = gtk_spin_button_new_with_range(-1e6, 1e6, 0.1); + gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->lo), 4); + gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->hi), 4); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->lo), -1.0); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->hi), 1.0); + g_signal_connect(d->lo, "value-changed", G_CALLBACK(on_math_param_changed), d); + g_signal_connect(d->hi, "value-changed", G_CALLBACK(on_math_param_changed), d); + { + GtkWidget* clamp_rows = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_box_append(GTK_BOX(clamp_rows), math_row("min", d->lo, nullptr)); + gtk_box_append(GTK_BOX(clamp_rows), math_row("max", d->hi, nullptr)); + d->rows[4] = clamp_rows; + gtk_box_append(GTK_BOX(form), clamp_rows); + } + + d->inputs = gtk_label_new(""); + gtk_label_set_wrap(GTK_LABEL(d->inputs), true); + gtk_widget_add_css_class(d->inputs, "dim-label"); + gtk_widget_set_halign(d->inputs, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(form), math_row("wired", d->inputs, &d->rows[5])); + + GtkWidget* add = gtk_button_new_with_label("add node"); + GtkWidget* del = gtk_button_new_with_label("delete node"); + GtkWidget* out = gtk_button_new_with_label("set as output"); + g_signal_connect(add, "clicked", G_CALLBACK(on_math_node_added), d); + g_signal_connect(del, "clicked", G_CALLBACK(on_math_node_deleted), d); + g_signal_connect(out, "clicked", G_CALLBACK(on_math_output_set), d); + GtkWidget* buttons = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_box_append(GTK_BOX(buttons), add); + gtk_box_append(GTK_BOX(buttons), del); + gtk_box_append(GTK_BOX(buttons), out); + gtk_box_append(GTK_BOX(form), buttons); + gtk_box_append(GTK_BOX(body), form); + gtk_box_append(GTK_BOX(outer), body); + + GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget* cancel = gtk_button_new_with_label("Cancel"); + g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window); + GtkWidget* apply = gtk_button_new_with_label(edit >= 0 ? "Save" : "Add"); + gtk_widget_add_css_class(apply, "suggested-action"); + g_signal_connect(apply, "clicked", G_CALLBACK(on_math_apply), d); + gtk_widget_set_halign(actions, GTK_ALIGN_END); + gtk_box_append(GTK_BOX(actions), cancel); + gtk_box_append(GTK_BOX(actions), apply); + gtk_box_append(GTK_BOX(outer), actions); + + app.math_dialog = window; + math_dialog_sync(d); + gtk_window_present(GTK_WINDOW(window)); +} + +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(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; +} + +void rebuild_math_pane(App& app) { + if (app.math_list == nullptr) return; + while (GtkWidget* child = gtk_widget_get_first_child(app.math_list)) { + gtk_box_remove(GTK_BOX(app.math_list), child); + } + if (app.math.empty()) { + GtkWidget* none = gtk_label_new("— none —"); + gtk_widget_add_css_class(none, "dim-label"); + gtk_widget_set_halign(none, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(app.math_list), none); + return; + } + for (std::size_t i = 0; i < app.math.size(); ++i) { + const MathChannel& m = app.math[i]; + GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget* on = gtk_check_button_new(); + gtk_check_button_set_active(GTK_CHECK_BUTTON(on), m.enabled); + gtk_widget_set_tooltip_text(on, "enable this math channel: off means it is not acquired, not\n" + "plotted and not measured, exactly like a signal's switch"); + const std::size_t index = i; + g_signal_connect(on, "toggled", G_CALLBACK(+[](GtkToggleButton* b, gpointer user) { + App* a = static_cast(user); + const std::size_t k = GPOINTER_TO_SIZE( + g_object_get_data(G_OBJECT(b), "udpscope-math-index")); + if (k >= a->math.size()) return; + a->math[k].enabled = gtk_toggle_button_get_active(b); + apply_math_channels(*a); + }), + &app); + g_object_set_data(G_OBJECT(on), "udpscope-math-index", GSIZE_TO_POINTER(index)); + gtk_box_append(GTK_BOX(row), on); + + std::string text = m.name; + if (!m.unit.empty()) text += " [" + m.unit + "]"; + GtkWidget* label = gtk_label_new(text.c_str()); + gtk_label_set_ellipsize(GTK_LABEL(label), PANGO_ELLIPSIZE_MIDDLE); + gtk_widget_set_hexpand(label, true); + gtk_widget_set_halign(label, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(row), label); + + GtkWidget* edit = gtk_button_new_from_icon_name("document-edit-symbolic"); + gtk_widget_add_css_class(edit, "flat"); + gtk_widget_set_tooltip_text(edit, "edit this math channel's graph"); + g_object_set_data(G_OBJECT(edit), "udpscope-math-index", GSIZE_TO_POINTER(index)); + g_signal_connect(edit, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { + App* a = static_cast(user); + const std::size_t k = GPOINTER_TO_SIZE( + g_object_get_data(G_OBJECT(b), "udpscope-math-index")); + open_math_dialog(*a, static_cast(k)); + }), + &app); + gtk_box_append(GTK_BOX(row), edit); + + GtkWidget* del = gtk_button_new_from_icon_name("window-close-symbolic"); + gtk_widget_add_css_class(del, "flat"); + gtk_widget_set_tooltip_text(del, "delete this math channel"); + g_object_set_data(G_OBJECT(del), "udpscope-math-index", GSIZE_TO_POINTER(index)); + g_signal_connect(del, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { + App* a = static_cast(user); + const std::size_t k = GPOINTER_TO_SIZE( + g_object_get_data(G_OBJECT(b), "udpscope-math-index")); + if (k < a->math.size()) a->math.erase(a->math.begin() + k); + apply_math_channels(*a); + }), + &app); + gtk_box_append(GTK_BOX(row), del); + gtk_box_append(GTK_BOX(app.math_list), row); + } +} + GtkWidget* build_measures_panel(App& app) { GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); gtk_widget_set_margin_top(box, 6); @@ -2483,6 +3381,8 @@ GtkWidget* build_measures_panel(App& app) { /// The panel's frame: the title row (the mode) and the readouts. The time line is not /// part of the body, because it is there in every mode. +void on_cursors_reset(GtkButton*, gpointer user); // defined below, used by the pane + GtkWidget* build_cursors_panel(App& app) { GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); gtk_widget_set_margin_top(box, 6); @@ -2506,6 +3406,13 @@ GtkWidget* build_cursors_panel(App& app) { g_signal_connect(app.cursors_mode_drop, "notify::selected", G_CALLBACK(on_cursor_mode_changed), &app); gtk_box_append(GTK_BOX(head), app.cursors_mode_drop); + // Reset lives here, beside the mode it resets, rather than in the overflow menu: it acts on this + // pane's instruments and is only visible while they are on, both of which are this pane's facts. + app.cursors_reset = gtk_button_new_from_icon_name("edit-clear-symbolic"); + gtk_widget_add_css_class(app.cursors_reset, "flat"); + gtk_widget_set_tooltip_text(app.cursors_reset, "re-place the cursors at the window's edges"); + g_signal_connect(app.cursors_reset, "clicked", G_CALLBACK(on_cursors_reset), &app); + gtk_box_append(GTK_BOX(head), app.cursors_reset); gtk_box_append(GTK_BOX(box), head); app.cursors_body = gtk_grid_new(); @@ -2939,11 +3846,65 @@ void rebuild_signal_panels(App& app) { app.signal_generation = app.model->generation(); } +/// Where the panel controls live, as their parents say: the three pane toggles are toolbar buttons +/// and Reset is inside the cursors pane. Recorded on the first tick -- the widgets exist and the +/// window is alive, neither of which is true when the dump runs -- so the dump can print a fact +/// instead of asking GTK about a destroyed widget. +void note_panel_controls(App& app) { + if (!app.dbg_panel_controls.empty()) return; + const auto in_bar = [](GtkWidget* w) { + for (GtkWidget* p = w != nullptr ? gtk_widget_get_parent(w) : nullptr; p != nullptr; + p = gtk_widget_get_parent(p)) { + if (GTK_IS_HEADER_BAR(p)) return 1; + } + return 0; + }; + const auto in_cursors_pane = [&app](GtkWidget* w) { + for (GtkWidget* p = w != nullptr ? gtk_widget_get_parent(w) : nullptr; p != nullptr; + p = gtk_widget_get_parent(p)) { + if (p == app.cursors_revealer) return 1; + } + return 0; + }; + // `HeaderBar::pack_end()` parents into an internal box, so the bar is a level up, not the parent. + char text[256]; + std::snprintf(text, sizeof(text), + "cursors=%d measurements=%d math=%d in the toolbar, reset=%d in the cursors pane", + in_bar(app.cursors_toggle), in_bar(app.measures_toggle), in_bar(app.math_toggle), + in_cursors_pane(app.cursors_reset)); + // ... and the order the bar actually shows, which is what "the menu should be last" is about. A + // widget tree walked in child order *is* the layout order, so this is the reading order of the + // controls the user sees -- the only way to check an arrangement without a screenshot. + const std::pair named[] = { + {app.trigger_toggle, "trigger"}, {app.pause_button, "pause"}, + {app.cursors_toggle, "cursors"}, {app.measures_toggle, "measures"}, + {app.math_toggle, "math"}, {app.sidebar_toggle, "sidebar"}, + {app.menu_button, "menu"}, + }; + // The widget tree's child order, which *is* the visual order left to right: a box lays its children + // out in the order it holds them. Since `pack_end()` puts the *first* packed furthest right, this + // reads as the reverse of the pack order -- which is the whole reason the line exists, because the + // pack order alone is not what the user sees. + std::string order; + const std::function walk = [&](GtkWidget* w) { + for (const auto& [widget, name] : named) { + if (w == widget) order += std::string(order.empty() ? "" : " ") + name; + } + for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr; + c = gtk_widget_get_next_sibling(c)) { + walk(c); + } + }; + if (app.toolbar != nullptr) walk(app.toolbar); + app.dbg_panel_controls = std::string(text) + "; toolbar, left to right: " + order; +} + // --- the frame tick --------------------------------------------------------- gboolean tick(gpointer user) { App* app = static_cast(user); if (app->window == nullptr) return G_SOURCE_REMOVE; + note_panel_controls(*app); ScopeModel& model = *app->model; const gint64 tick_start_us = g_get_monotonic_time(); @@ -3746,11 +4707,20 @@ GtkWidget* build_window_control(App& app) { /// [window ▾] [plots ▾] ............... [pause] [trigger] [panel] [menu] GtkWidget* build_toolbar(App& app) { GtkWidget* bar = gtk_header_bar_new(); + app.toolbar = bar; gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), labeled_box_fixed("window", build_window_control(app))); refresh_window_limits(app); + // The end group reads, left to right: cursors, measurements, math, menu -- with the + // sidebar toggle to their left. `pack_end()` lays children out right to left (the first + // packed is the *rightmost*), so they are packed in the reverse of that order, which is + // what puts the menu last on screen. The trigger and the pause are *start*-packed above, + // beside the window control: their event window is read against the span on screen, so + // they belong with the control that sets it. + + // --- the overflow menu ---------------------------------------------------- // Plain buttons in a popover rather than a GMenu: these act on the `RowSet` // directly and two of them are toggles whose state the rest of the UI reads, so @@ -3773,7 +4743,8 @@ GtkWidget* build_toolbar(App& app) { g_signal_connect(app.layout_toggle, "toggled", G_CALLBACK(on_layout_editor_toggled), &app); gtk_box_append(GTK_BOX(menu_box), app.layout_toggle); - app.measures_toggle = gtk_toggle_button_new_with_label("Measurements"); + app.measures_toggle = gtk_toggle_button_new(); + gtk_button_set_icon_name(GTK_BUTTON(app.measures_toggle), "edit-find-symbolic"); gtk_widget_set_tooltip_text(app.measures_toggle, "compute a value over the visible window of a signal, and " "optionally mark it on the plot"); @@ -3787,20 +4758,31 @@ GtkWidget* build_toolbar(App& app) { } }), &app); - gtk_box_append(GTK_BOX(menu_box), app.measures_toggle); + // (packed after math and before cursors below: see the order note at the menu) - app.cursors_toggle = gtk_toggle_button_new_with_label("Cursors"); + app.math_toggle = gtk_toggle_button_new(); + gtk_button_set_icon_name(GTK_BUTTON(app.math_toggle), "edit-find-replace-symbolic"); + gtk_widget_set_tooltip_text(app.math_toggle, + "channels derived from the others by a graph of operations: gain, " + "absolute value, filters, sums and the rest. An enabled one is " + "acquired, plotted and measured exactly like a signal"); + g_signal_connect(app.math_toggle, "toggled", G_CALLBACK(+[](GtkToggleButton* b, + gpointer user) { + App* a = static_cast(user); + if (a->math_revealer != nullptr) { + gtk_revealer_set_reveal_child(GTK_REVEALER(a->math_revealer), + gtk_toggle_button_get_active(b)); + } + }), + &app); + + app.cursors_toggle = gtk_toggle_button_new(); + gtk_button_set_icon_name(GTK_BUTTON(app.cursors_toggle), "find-location-symbolic"); + gtk_widget_set_tooltip_text(app.cursors_toggle, + "the cursor instruments: two times, two levels, or the two read " + "off each trace where they cross"); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.cursors_toggle), false); g_signal_connect(app.cursors_toggle, "toggled", G_CALLBACK(on_cursors_toggled), &app); - gtk_box_append(GTK_BOX(menu_box), app.cursors_toggle); - - app.cursors_reset = gtk_button_new_with_label("Reset cursors"); - gtk_widget_set_tooltip_text(app.cursors_reset, - "re-place the cursors (only shown while they exist)"); - g_signal_connect(app.cursors_reset, "clicked", G_CALLBACK(on_cursors_reset), &app); - gtk_box_append(GTK_BOX(menu_box), app.cursors_reset); - - gtk_box_append(GTK_BOX(menu_box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); GtkWidget* export_btn = gtk_button_new_with_label("Export visible range..."); gtk_widget_set_tooltip_text(export_btn, @@ -3822,7 +4804,9 @@ GtkWidget* build_toolbar(App& app) { gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(app.menu_button), "open-menu-symbolic"); gtk_menu_button_set_popover(GTK_MENU_BUTTON(app.menu_button), popover); gtk_widget_set_tooltip_text(app.menu_button, "view options"); - gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.menu_button); + // `pack_end()` lays its children out right to left -- the first packed is the *rightmost* -- so + // the menu is packed first to sit last on screen, and the pane toggles before it in reverse of the + // order they should read in: cursors, measurements, math, menu. // --- what stays on the bar (packed end: first call is rightmost) ---------- app.sidebar_toggle = gtk_toggle_button_new(); @@ -3830,7 +4814,6 @@ GtkWidget* build_toolbar(App& app) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.sidebar_toggle), true); gtk_widget_set_tooltip_text(app.sidebar_toggle, "show the side panel"); g_signal_connect(app.sidebar_toggle, "toggled", G_CALLBACK(on_sidebar_toggled), &app); - gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.sidebar_toggle); app.trigger_toggle = gtk_toggle_button_new(); gtk_button_set_icon_name(GTK_BUTTON(app.trigger_toggle), "media-record-symbolic"); @@ -3838,14 +4821,29 @@ GtkWidget* build_toolbar(App& app) { "trigger mode: freeze the view and show one captured event " "at a time"); g_signal_connect(app.trigger_toggle, "toggled", G_CALLBACK(on_trigger_toggled), &app); - gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.trigger_toggle); + // Packed with the window control instead of at the far end: the trigger and the pause are read + // against the *span* on screen (a window change moves the trigger's event window with it), so they + // belong next to the control that sets it. + gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), app.trigger_toggle); app.pause_button = gtk_toggle_button_new(); gtk_button_set_icon_name(GTK_BUTTON(app.pause_button), "media-playback-pause-symbolic"); gtk_widget_set_tooltip_text(app.pause_button, "pause acquisition"); g_signal_connect(app.pause_button, "toggled", G_CALLBACK(on_pause_toggled), &app); - gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.pause_button); + gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), app.pause_button); + // Packed here, after every widget above has been created: the *pack* order is what decides the + // layout, not the creation order, and packing before the widget exists passes NULL to the header + // bar (five `gtk_box_prepend: assertion` criticals, and the controls simply absent). + // The **first** packed is the rightmost, and the dump settled it: its tree order is the visual + // order left to right, and it reads the *reverse* of this list. (Both of this comment's earlier + // versions were wrong in turn; the arrangement they produced is what got reported.) Packed in this + // order the row reads, left to right: signal list, cursors, measurements, math, sidebar, menu. + gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.menu_button); + gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.sidebar_toggle); + gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.math_toggle); + gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.measures_toggle); + gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.cursors_toggle); return bar; } @@ -3942,6 +4940,14 @@ void on_activate(GtkApplication* application, gpointer user) { gtk_box_append(GTK_BOX(side), app->measures_revealer); gtk_box_append(GTK_BOX(side), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + app->math_revealer = gtk_revealer_new(); + gtk_revealer_set_transition_type(GTK_REVEALER(app->math_revealer), + GTK_REVEALER_TRANSITION_TYPE_SLIDE_DOWN); + gtk_revealer_set_child(GTK_REVEALER(app->math_revealer), build_math_panel(*app)); + gtk_box_append(GTK_BOX(side), app->math_revealer); + gtk_box_append(GTK_BOX(side), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + rebuild_math_pane(*app); + app->signal_panel = build_signal_panel(*app); app->signal_revealer = gtk_revealer_new(); gtk_revealer_set_transition_type(GTK_REVEALER(app->signal_revealer), @@ -4206,6 +5212,173 @@ gboolean trig_trace_probe(gpointer user) { } +/// Headless self-check: defines math channels the way the math pane will, so the *pipeline* can be +/// checked without driving the editor -- the same graph reaches the producer either way. Two graphs +/// on the first acquired channel: a gain of 2 and a low pass, which the dump then reports beside the +/// source's own extremes, so "twice the amplitude" and "attenuated" are read off real samples. +gboolean math_probe(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + std::string name; + { + std::lock_guard lock(a->model->mutex()); + if (a->model->channels().empty()) return G_SOURCE_REMOVE; + name = a->model->channels().front().name; + } + std::vector channels; + MathChannel gain; + gain.name = "math x2"; + gain.enabled = true; + gain.nodes = {MathNode{MathOp::Source, name}, MathNode{MathOp::Gain}}; + gain.nodes[1].in_a = 0; + gain.nodes[1].gain = 2.0; + gain.output = 1; + channels.push_back(gain); + + MathChannel lp; + lp.name = "math lp"; + lp.enabled = true; + lp.nodes = {MathNode{MathOp::Source, name}, MathNode{MathOp::LowPass}}; + lp.nodes[1].in_a = 0; + lp.nodes[1].fc = 20.0; + lp.output = 1; + channels.push_back(lp); + + // Through the app's list, which is the pane's source of truth: a probe that wrote the model + // directly left the two disagreeing, and the next pane edit then silently dropped its channels. + a->math = std::move(channels); + apply_math_channels(*a); + std::fprintf(stderr, "[math-probe] 2 math channels on '%s'\n", name.c_str()); + // And open the editor on the first one: as much a construction crash test as a state check, + // since a modal window is presented and its canvas, form and gestures are all built here. + // The pane's own `+`, which is the path the user takes: it must source an *acquired* channel. + // From `signals().front()` it sourced the stream's timestamp signal -- no samples, so the graph + // validated and then read NaN for ever, which is exactly the reported "[ch] -> [gain] is all nan". + add_math_channel(*a); + if (!a->math.empty()) { + const std::string src = a->math.back().nodes.empty() ? std::string("(none)") + : a->math.back().nodes[0].source; + std::fprintf(stderr, "[math-probe] pane add: '%s' sources '%s'\n", a->math.back().name.c_str(), + src.c_str()); + } + open_math_dialog(*a, 0); + std::fprintf(stderr, "[math-probe] editor opened\n"); + // The user's own flow, through the app's handlers: the pane's `+` makes a bare source graph, the + // editor adds a gain, the gain is set, Apply saves. The *output* must be the node that was just + // added -- it defaulted to node 0, the source, so every operation was computed and discarded. + { + const int index = static_cast(a->math.size()) - 1; + // Closed first: `open_math_dialog()` on a dialog that is already up just presents it, so this + // block used to edit the *previous* channel and its numbers described the wrong graph. + if (a->math_dialog != nullptr) gtk_window_close(GTK_WINDOW(a->math_dialog)); + open_math_dialog(*a, index); + auto* ed = static_cast( + g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog")); + if (ed != nullptr) { + on_math_node_added(nullptr, ed); + const int output_after_add = ed->output; + // Through the *widgets*, which is what the user touches -- and the read-back below is what + // `on_math_param_changed()` will take them from. + if (ed->gain != nullptr) gtk_spin_button_set_value(GTK_SPIN_BUTTON(ed->gain), 2.0); + if (ed->offset != nullptr) gtk_spin_button_set_value(GTK_SPIN_BUTTON(ed->offset), 0.0); + on_math_param_changed(nullptr, ed); + const std::size_t built = ed->nodes.size(); + const int output_built = ed->output; + const double gain_read = ed->nodes.empty() ? 0.0 : ed->nodes.back().gain; + const double offset_read = ed->nodes.empty() ? 0.0 : ed->nodes.back().offset; + on_math_apply(nullptr, ed); // frees the dialog: nothing below may touch `ed` + const std::size_t saved = a->math.empty() + ? 0u + : a->math[static_cast(index)].nodes.size(); + const int output_saved = + a->math.empty() ? -1 : a->math[static_cast(index)].output; + std::fprintf(stderr, + "[math-probe] dialog build: nodes=%zu, output after add=%d (want %zu); " + "reading back gain=%.2f offset=%.2f; saved nodes=%zu output=%d\n", + built, output_after_add, built - 1, gain_read, offset_read, saved, + output_saved); + } + } + // Closed and reopened: `gtk_window_destroy()` does not emit `close-request`, so a dialog closed + // that way left `app.math_dialog` pointing at a dead widget and every later open was a `present` + // on it -- "I cannot edit a math channel again". A close must clear the pointer, and the reopen + // must build a *new* dialog with its own data. + if (a->math_dialog != nullptr) gtk_window_close(GTK_WINDOW(a->math_dialog)); + std::fprintf(stderr, "[math-probe] after close: dialog=%s\n", + a->math_dialog == nullptr ? "cleared" : "STILL SET (the pointer did not clear)"); + open_math_dialog(*a, 0); + if (a->math_dialog != nullptr) { + auto* again = static_cast( + g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog")); + std::fprintf(stderr, "[math-probe] reopened: dialog=%s, nodes=%s\n", + again != nullptr ? "ok" : "NO DATA", + again != nullptr ? std::to_string(again->nodes.size()).c_str() : "-"); + } else { + std::fprintf(stderr, "[math-probe] reopened: FAILED (no dialog)\n"); + } + // The editor's two reported failures, driven directly. A drag reports the *total* offset from + // where the gesture began, so replaying a real sequence of them must land the box at start+last, + // not at the sum of the deltas; and a numeric field must hold a fraction. + if (a->math_dialog != nullptr) { + auto* d = static_cast( + g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog")); + if (d != nullptr && !d->nodes.empty()) { + const double x0 = d->nodes[0].x; + const double y0 = d->nodes[0].y; + math_canvas_drag_begin(nullptr, x0 + kNodeW * 0.5, y0 + kNodeH * 0.5, d); + std::fprintf(stderr, + "[math-probe] drag-begin at (%.0f,%.0f) over node %d, selected %d, nodes %zu\n", + x0 + kNodeW * 0.5, y0 + kNodeH * 0.5, d->drag_node, d->selected, d->nodes.size()); + math_canvas_drag(nullptr, 20.0, 10.0, d); + math_canvas_drag(nullptr, 40.0, 20.0, d); + math_canvas_drag(nullptr, 60.0, 30.0, d); + std::fprintf(stderr, "[math-probe] drag: (%.0f,%.0f) -> (%.0f,%.0f), want (%.0f,%.0f)\n", x0, + y0, d->nodes[0].x, d->nodes[0].y, x0 + 60.0, y0 + 30.0); + } + if (d != nullptr && !d->nodes.empty()) { + // Only *acquired* channels may be a source: a disabled signal would read as no data for ever, + // and the stream's timestamp signal has no samples at all. + const std::vector acquired = math_source_names(*a); + std::size_t listed = 0; + std::string first; + if (d->source_drop != nullptr) { + GListModel* model = gtk_drop_down_get_model(GTK_DROP_DOWN(d->source_drop)); + listed = model != nullptr ? g_list_model_get_n_items(model) : 0; + if (listed > 0) { + GObject* item = G_OBJECT(g_list_model_get_item(model, 0)); + first = gtk_string_object_get_string(GTK_STRING_OBJECT(item)); + g_object_unref(item); + } + } + std::fprintf(stderr, + "[math-probe] sources: %zu acquired, dropdown lists %zu, first='%s'\n", + acquired.size(), listed, first.c_str()); + // The gain+offset group belongs to one op and must be hidden for the others. + d->selected = 0; + const MathOp was = d->nodes[0].op; + d->nodes[0].op = MathOp::LowPass; + math_dialog_sync(d); + const int for_filter = gtk_widget_get_visible(d->rows[1]) ? 1 : 0; + d->nodes[0].op = MathOp::Gain; + math_dialog_sync(d); + const int for_gain = gtk_widget_get_visible(d->rows[1]) ? 1 : 0; + d->nodes[0].op = was; + math_dialog_sync(d); + std::fprintf(stderr, "[math-probe] rows: gain+offset visible for a filter=%d, for gain=%d " + "(want 0, 1)\n", for_filter, for_gain); + } + if (d != nullptr) { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->fc), 12.5); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->gain), 0.25); + std::fprintf(stderr, "[math-probe] values: fc=%.4f gain=%.4f, want 12.5000 0.2500\n", + gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->fc)), + gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->gain))); + } + } + std::fflush(stderr); + return G_SOURCE_REMOVE; +} + /// Headless self-check: adds one measurement of each statistical type, which is what a user does /// from the pane's `+`. /// @@ -5275,6 +6448,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_TRIG_TRACE_MS")) { g_timeout_add(static_cast(std::atoi(env)), trig_trace_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_MATH_MS")) { + g_timeout_add(static_cast(std::atoi(env)), math_probe, &app); + } if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURE_MS")) { g_timeout_add(static_cast(std::atoi(env)), measure_probe, &app); } @@ -5375,10 +6551,68 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit app.dbg_marker_x, app.dbg_marker_max, app.dbg_trigger_time, app.dbg_trigger_time_valid); std::printf("[udpscope] mode after go-live: %s\n", app.dbg_mode_live.c_str()); + // The derived channels beside the channel they came from. A math channel defined by the pane + // reaches the producer by exactly the path the probe uses, so this is the pipeline's own + // assertion: `x2` must read twice its source's extremes, a low pass must read less. + { + std::vector> wanted; + { + std::lock_guard lock(app.model->mutex()); + for (const MathChannel& m : app.model->math_channels()) { + int idx = -1; + for (std::size_t c = 0; c < app.model->channels().size(); ++c) { + if (app.model->channels()[c].name == m.name) idx = static_cast(c); + } + wanted.emplace_back(m.name, idx); + } + } + for (const auto& [name, idx] : wanted) { + if (idx < 0) { + std::printf("[udpscope] math '%s': no channel (disabled?)\n", name.c_str()); + continue; + } + std::vector t; + std::vector v; + const bool ok = visible_samples(app, static_cast(idx), t, v); + double lo = 0.0; + double hi = 0.0; + for (std::size_t i = 0; ok && i < v.size(); ++i) { + lo = i == 0 ? v[i] : std::min(lo, v[i]); + hi = i == 0 ? v[i] : std::max(hi, v[i]); + } + // The stamps of the window the plot would draw: they must be strictly increasing. A + // duplicate or a backwards step is what a wrap artifact looks like from the data side -- + // the renderer's index window comes from a binary search over these, and a segment drawn + // from the end of the ring to its beginning is a window that spans the wrap. + std::size_t order_breaks = 0; + for (std::size_t i = 1; ok && i < t.size(); ++i) { + if (!(t[i] > t[i - 1])) ++order_breaks; + } + // `produced` beside the visible count: a derived channel is filled once per stored time + // slot, so its ring must hold exactly as many samples as the time ring the plot reads it + // against. The first version pushed twice per slot and this ratio would have said so. + u64 produced = 0; + u64 slot_count = 0; + { + std::lock_guard lock(app.model->mutex()); + produced = app.model->live().values(static_cast(idx)).produced(); + slot_count = app.model->live().time().produced(); + } + std::printf("[udpscope] math '%s': channel %d, %llu samples vs %llu time slots, " + "%zu visible (order breaks %zu), y=[%.4f, %.4f]\n", + name.c_str(), idx, static_cast(produced), + static_cast(slot_count), ok ? v.size() : 0u, order_breaks, + ok ? lo : 0.0, ok ? hi : 0.0); + } + } // Unconditional, unlike the pane's own lines below: this is about the instruments the app // *created*, which exist whether or not any measurement was added. std::printf("[udpscope] readout instruments locked: %d (trigger marker + measurement marks)\n", app.rows->readouts_locked() ? 1 : 0); + // Where the panel controls live. Computed once, when the widgets exist -- the dump runs *after* + // the window is destroyed, where a widget pointer is dangling and asking GTK about it is a + // critical (which is how the first version of this line earned four of them). + std::printf("[udpscope] panel controls: %s\n", app.dbg_panel_controls.c_str()); // The measurements pane, with the numbers the engine computed: a probe adds measurements and // this is where their values are read, so a wrong threshold or a window that moved under a // stale result is visible in the run rather than only on screen.