ui: a signal's details sit directly under its name; its unit is user-changeable and saved with the session

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-11-03 11:42:30 +01:00
1 parent bbef7faf3d
commit 87f61ea364
14 files changed
+335 -110

No files matched your search

+1 -1
View File
@@ -132,7 +132,7 @@ divider's `+` for a new math channel; when every signal is acquired a dim "every
stands in for the list. While the bar has overflowed into the "N signals" badge the page lists the acquired stands in for the list. While the bar has overflowed into the "N signals" badge the page lists the acquired
signals too, since their rows are then the only way to each signal's page. There is no signal lock: a signals too, since their rows are then the only way to each signal's page. There is no signal lock: a
signal's page and the list always allow acquire, colour, calibration, the math editor and delete. signal's page and the list always allow acquire, colour, calibration, the math editor and delete.
A signal badge opens that signal's own page in the slide-over -- colour, the gain and offset (two aligned numbers, a pencil beside each editing just that one) and the *Plot on N* A signal badge opens that signal's own page in the slide-over -- the unit, gain and offset and the CONFIG's details directly under the name (aligned rows; a pencil beside the unit, gain and offset edits just that one -- a unit rename is stored per signal name, saved as `[[unit]]` and re-applied to every CONFIG), the colour and the *Plot on N*
buttons -- and is also the drag handle: drag it onto a plot to draw the signal there, or onto another buttons -- and is also the drag handle: drag it onto a plot to draw the signal there, or onto another
badge to reorder them. Past **six** signal badges the bar shows a single **"N signals"** badge in their badge to reorder them. Past **six** signal badges the bar shows a single **"N signals"** badge in their
place (it opens the list, and `+` is hidden): the E-1608's 8 analog inputs and counter would not fit place (it opens the list, and `+` is hidden): the E-1608's 8 analog inputs and counter would not fit
+5 -3
View File
@@ -65,9 +65,11 @@ the badge bar. Drag the badge onto a plot row to add a trace; drag it again to a
the same signal twice, or onto another badge to reorder the badges. The colour is per *signal*, so the same signal twice, or onto another badge to reorder the badges. The colour is per *signal*, so
the same signal is the same colour wherever it appears. the same signal is the same colour wherever it appears.
Clicking a signal's badge opens its own page: the colour, **Plot on** buttons for the rows, and Clicking a signal's badge opens its own page. Directly under the name are its **Unit**, **Gain** and
**Gain** and **Offset**, shown as two aligned numbers with a pencil button each that edits that one value. **Offset** — each with a pencil button that edits that one value — and then what the stream declared about
They are applied at ingest, so the measurements and the export carry the same numbers the plot shows. it (type, size, range, sample rate, timing). Below come the colour and the **Plot on** buttons for the
rows. Gain and offset are applied at ingest, so the measurements and the export carry the same numbers the
plot shows; a renamed unit is used there too and is saved with the session.
## 4. The window and navigation ## 4. The window and navigation
+4 -1
View File
@@ -23,7 +23,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
not follow the lock). `[sigbadges] drag_sources` is the reading, and `drag_sources` on the `signals panel:` not follow the lock). `[sigbadges] drag_sources` is the reading, and `drag_sources` on the `signals panel:`
line must be 0. line must be 0.
- **The session is the layout.** `LayoutState` persists what makes a reopened scope look like the - **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 one that was closed: the window, the rows and their slot signals, the calibration, **the unit renames**
(`[[unit]]`, `name` + `unit`, only the overrides; `ScopeModel::set_signal_unit` re-applies them to every CONFIG,
and `set_signals()` takes them off the incoming units before the "same stream" comparison so a reconnect still
keeps its history), **the enabled
signals**, **the chosen trace colours** (`[[color]]`, `name` + `rgb = "#RRGGBB"` — only the signals**, **the chosen trace colours** (`[[color]]`, `name` + `rgb = "#RRGGBB"` — only the
*overrides*, so a stream's new signals still take the positional palette and a saved session does *overrides*, so a stream's new signals still take the positional palette and a saved session does
not freeze a colour that is meant to follow the signal list), **the math channels with their not freeze a colour that is meant to follow the signal list), **the math channels with their
+3 -3
View File
@@ -2761,11 +2761,11 @@ the slide-over closed and the page was dropped by `signal_pages_sync()`. `pruned
channel removed) and the slide-over was still on `trigger`, both by `slideover_page()` and by the stack's visible child; `0` is `signal_pages_sync()` parking the stack on the channel removed) and the slide-over was still on `trigger`, both by `slideover_page()` and by the stack's visible child; `0` is `signal_pages_sync()` parking the stack on the
signals list while the title and the badge marker still name `trigger`; `-1` when there is no `trigger` page or no math channel could be added. signals list while the title and the badge marker still name `trigger`; `-1` when there is no `trigger` page or no math channel could be added.
A second line, `[signalpage] calibration identity=1/0 set=2.5/-1.25 pencils=2 pencil_edits_one=1`, reads the page's gain and offset rows A second line, `[signalpage] calibration identity=1/0 set=2.5/-1.25 pencils=3 pencil_edits_one=1 unit_edit=1 unit='V'`, reads the page's details grid (unit, gain, offset, then the CONFIG's read-only details) rows
(`udpscope-gain` / `udpscope-offset` labels, one string rule: `format_calibration()`, up to six decimals with trailing zeros dropped): `identity` is what an (`udpscope-gain` / `udpscope-offset` labels, one string rule: `format_calibration()`, up to six decimals with trailing zeros dropped): `identity` is what an
uncalibrated signal shows, `set` what the page shows after `set_calibration(2.5, -1.25)` and `signal_pages_sync()` (the Apply path), `pencils=2` the two edit uncalibrated signal shows, `set` what the page shows after `set_calibration(2.5, -1.25)` and `signal_pages_sync()` (the Apply path), `pencils=3` the unit, gain and offset edit
buttons, and `pencil_edits_one=1` that the gain pencil opens a dialog titled "Gain" with exactly one spin button and that Apply changed the gain only buttons, and `pencil_edits_one=1` that the gain pencil opens a dialog titled "Gain" with exactly one spin button and that Apply changed the gain only
(offset stayed -1.25) and refilled the label. `-1` is no pencil / no dialog; `0` is a dialog that edits both or a label that did not follow the model. (offset stayed -1.25) and refilled the label. `unit_edit=1`: the unit pencil opens a dialog titled "Unit" with one entry, "kV" reached the model's signal and the page's `udpscope-unit` label, and an empty entry restored the declared unit (`unit=` prints it; it must be the mock's own). A rename bumps `generation()`, so the panels re-read the list and the measurement statistics restart. `-1` is no pencil / no dialog; `0` is a dialog that edits both or a label that did not follow the model.
## `[sigbadges]` ## `[sigbadges]`
+7 -4
View File
@@ -30,9 +30,10 @@
</item> </item>
<item> <item>
<p>Once a signal is acquired its badge appears. Click it to open that signal's page: its <p>Once a signal is acquired its badge appears. Click it to open that signal's page: its
colour, <gui>Plot on</gui> buttons for the rows, <gui>Information</gui> for the signal's details directly under its name — <gui>Unit</gui>, <gui>Gain</gui> and <gui>Offset</gui>,
details, and its <gui>Gain</gui> and <gui>Offset</gui>, each with a pencil button that each with a pencil button that edits that one value, then the type, size, range, sample
edits that one number.</p> rate and timing the stream declared — its colour and <gui>Plot on</gui> buttons for the
rows.</p>
</item> </item>
</steps> </steps>
</section> </section>
@@ -47,7 +48,9 @@
through, so a trace already scaled by a gain of 2 reads 2. The pencil beside a number opens through, so a trace already scaled by a gain of 2 reads 2. The pencil beside a number opens
an editor for that number only, at its current value — an editor for that number only, at its current value —
pressing <gui>Apply</gui> confirms it rather than resetting it. A signal nobody pressing <gui>Apply</gui> confirms it rather than resetting it. A signal nobody
calibrated shows the identity, 1 and 0.</p></item> calibrated shows the identity, 1 and 0. The unit can be renamed the same way (an empty
entry restores the one the stream declares); the new name is used in the readouts, the
measurements and the export, and is remembered with the session.</p></item>
<item><p>Calibration is applied <em>at ingest</em>, so changing it re-plans the buffer <item><p>Calibration is applied <em>at ingest</em>, so changing it re-plans the buffer
and drops the history. So does enabling or disabling a signal, and so does a new and drops the history. So does enabling or disabling a signal, and so does a new
CONFIG. An empty plot for a second after one of those is the ring refilling, not a CONFIG. An empty plot for a second after one of those is the ring refilling, not a
+14 -1
View File
@@ -439,6 +439,7 @@ bool load_layout(const std::string& path, LayoutState& out) {
st.window_s = 0.0; st.window_s = 0.0;
st.rows.clear(); st.rows.clear();
st.calibration.clear(); st.calibration.clear();
st.units.clear();
st.colors.clear(); st.colors.clear();
st.enabled.clear(); st.enabled.clear();
st.math.clear(); st.math.clear();
@@ -446,7 +447,7 @@ bool load_layout(const std::string& path, LayoutState& out) {
std::string line; std::string line;
// What the last `[[...]]` header opened, so keys land in the right record. // What the last `[[...]]` header opened, so keys land in the right record.
enum class Section { Top, Plot, Calibration, Color, Math, Measure } section = Section::Top; enum class Section { Top, Plot, Calibration, Unit, Color, Math, Measure } section = Section::Top;
int plot_count = 0; int plot_count = 0;
bool plot_section_seen = false; bool plot_section_seen = false;
while (std::getline(in, line)) { while (std::getline(in, line)) {
@@ -460,6 +461,9 @@ bool load_layout(const std::string& path, LayoutState& out) {
} else if (t == "[[calibration]]") { } else if (t == "[[calibration]]") {
section = Section::Calibration; section = Section::Calibration;
st.calibration.emplace_back(); st.calibration.emplace_back();
} else if (t == "[[unit]]") {
section = Section::Unit;
st.units.emplace_back();
} else if (t == "[[color]]") { } else if (t == "[[color]]") {
section = Section::Color; section = Section::Color;
st.colors.emplace_back(); st.colors.emplace_back();
@@ -686,6 +690,12 @@ bool load_layout(const std::string& path, LayoutState& out) {
} catch (...) { } catch (...) {
} }
} }
} else if (section == Section::Unit && !st.units.empty()) {
if (key == "name") {
st.units.back().name = strip_quote(value);
} else if (key == "unit") {
st.units.back().unit = strip_quote(value);
}
} else if (section == Section::Color && !st.colors.empty()) { } else if (section == Section::Color && !st.colors.empty()) {
if (key == "name") { if (key == "name") {
st.colors.back().name = strip_quote(value); st.colors.back().name = strip_quote(value);
@@ -785,6 +795,9 @@ bool save_layout(const std::string& path, const LayoutState& state) {
o << "\n[[calibration]]\nname = " << toml_escape(cal.name) << "\ngain = " << cal.gain o << "\n[[calibration]]\nname = " << toml_escape(cal.name) << "\ngain = " << cal.gain
<< "\noffset = " << cal.offset << "\n"; << "\noffset = " << cal.offset << "\n";
} }
for (const auto& u : state.units) {
o << "\n[[unit]]\nname = " << toml_escape(u.name) << "\nunit = " << toml_escape(u.unit) << "\n";
}
for (const auto& color : state.colors) { for (const auto& color : state.colors) {
char rgb[16]; char rgb[16];
std::snprintf(rgb, sizeof(rgb), "\"#%06X\"", color.rgb & 0x00FFFFFFu); std::snprintf(rgb, sizeof(rgb), "\"#%06X\"", color.rgb & 0x00FFFFFFu);
+7
View File
@@ -36,6 +36,13 @@ struct LayoutState {
}; };
std::vector<Calibration> calibration; std::vector<Calibration> calibration;
/// The unit names the user gave streamed signals, by signal name. Only the overrides.
struct Unit {
std::string name;
std::string unit;
};
std::vector<Unit> units;
/// The trace colours the user chose, `0xRRGGBB`. Only the *overrides*: a signal with no entry /// The trace colours the user chose, `0xRRGGBB`. Only the *overrides*: a signal with no entry
/// takes the positional palette entry, which is what keeps a stream's new signals coloured /// takes the positional palette entry, which is what keeps a stream's new signals coloured
/// without a session having to name them. Saved by name like every identity here. /// without a session having to name them. Saved by name like every identity here.
+39
View File
@@ -232,6 +232,13 @@ void ScopeModel::set_signals(std::vector<SignalInfo> signals) {
signals.erase(std::remove_if(signals.begin(), signals.end(), signals.erase(std::remove_if(signals.begin(), signals.end(),
[](const SignalInfo& s) { return s.math; }), [](const SignalInfo& s) { return s.math; }),
signals.end()); signals.end());
// The renames come off the stream's own units first, so that an identical CONFIG still compares
// equal against `signals_` (which holds the renamed ones) and a reconnect keeps its history.
declared_unit_.clear();
for (SignalInfo& s : signals) {
declared_unit_[s.name] = s.unit;
if (const auto it = unit_override_.find(s.name); it != unit_override_.end()) s.unit = it->second;
}
if (!signals_.empty() && same_stream_signals(signals, signals_)) { if (!signals_.empty() && same_stream_signals(signals, signals_)) {
// A reconnect's identical CONFIG: the honest recovery is *nothing* -- the ring keeps // A reconnect's identical CONFIG: the honest recovery is *nothing* -- the ring keeps
// its history, the trigger keeps its state and its held event, and the outage shows // its history, the trigger keeps its state and its held event, and the outage shows
@@ -770,6 +777,38 @@ void ScopeModel::set_calibration(std::size_t signal, double gain, double offset)
} }
} }
void ScopeModel::set_signal_unit(std::size_t signal, const std::string& unit) {
std::lock_guard<std::mutex> lock(mutex_);
if (signal >= signals_.size() || signals_[signal].math) return;
const std::string name = signals_[signal].name;
std::string shown = unit;
if (unit.empty()) {
unit_override_.erase(name);
const auto it = declared_unit_.find(name);
shown = it != declared_unit_.end() ? it->second : std::string();
} else {
unit_override_[name] = unit;
}
if (signals_[signal].unit == shown) return;
signals_[signal].unit = shown;
for (ChannelInfo& c : channels_) {
if (c.signal == signal) c.unit = shown;
}
generation_.fetch_add(1, std::memory_order_release);
}
std::string ScopeModel::declared_unit(std::size_t signal) {
std::lock_guard<std::mutex> lock(mutex_);
if (signal >= signals_.size()) return std::string();
const auto it = declared_unit_.find(signals_[signal].name);
return it != declared_unit_.end() ? it->second : signals_[signal].unit;
}
std::vector<std::pair<std::string, std::string>> ScopeModel::unit_overrides() {
std::lock_guard<std::mutex> lock(mutex_);
return {unit_override_.begin(), unit_override_.end()};
}
double ScopeModel::channel_dt(std::size_t channel) const { double ScopeModel::channel_dt(std::size_t channel) const {
return channel < channels_.size() ? channels_[channel].dt : 0.0; return channel < channels_.size() ? channels_[channel].dt : 0.0;
} }
+14
View File
@@ -465,6 +465,17 @@ class ScopeModel {
/// no channel to read it from, and the record is what a dialog must show. /// no channel to read it from, and the record is what a dialog must show.
std::pair<double, double> calibration(std::size_t signal); std::pair<double, double> calibration(std::size_t signal);
/// Renames the unit a streamed signal is shown, read and exported in. Stored by the signal's *name*
/// like the calibration, and re-applied to every later CONFIG, so a rename survives a reconnect.
/// An empty `unit` removes the override and restores the unit the stream declared. A math channel's
/// unit belongs to its graph and is ignored here. Bumps `generation()` when it changed something,
/// which is what makes the panels re-read the signal list.
void set_signal_unit(std::size_t signal, const std::string& unit);
/// The unit the stream declared for that signal, whatever the user renamed it to.
std::string declared_unit(std::size_t signal);
/// The renames in force, `{name, unit}`, for the session file.
std::vector<std::pair<std::string, std::string>> unit_overrides();
std::size_t channel_count() const noexcept { return channels_.size(); } std::size_t channel_count() const noexcept { return channels_.size(); }
const Partition& partition() const noexcept { return part_; } const Partition& partition() const noexcept { return part_; }
@@ -868,6 +879,9 @@ class ScopeModel {
std::atomic<bool> paused_{false}; std::atomic<bool> paused_{false};
/// Per-signal-name calibration (see `set_calibration`). /// Per-signal-name calibration (see `set_calibration`).
std::unordered_map<std::string, std::pair<double, double>> calibration_; std::unordered_map<std::string, std::pair<double, double>> calibration_;
/// Per-signal-name unit renames (see `set_signal_unit`) and the units the stream declared.
std::unordered_map<std::string, std::string> unit_override_;
std::unordered_map<std::string, std::string> declared_unit_;
std::atomic<std::uint64_t> event_seq_{0}; std::atomic<std::uint64_t> event_seq_{0};
std::atomic<double> display_epoch_{0.0}; std::atomic<double> display_epoch_{0.0};
std::atomic<bool> trigger_armed_{false}; std::atomic<bool> trigger_armed_{false};
+71
View File
@@ -918,6 +918,77 @@ void open_calibration(App& app, GtkWindow* parent, std::size_t signal, CalibFiel
} }
/// The unit editor: one entry, seeded with the unit now shown, the stream's own unit as its placeholder.
/// An empty entry restores the declared unit (see `ScopeModel::set_signal_unit`).
void open_unit_editor(App& app, GtkWindow* parent, std::size_t signal) {
std::string name;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
if (signal >= app.model->signals().size()) return;
name = app.model->signals()[signal].name;
}
const std::string declared = app.model->declared_unit(signal);
GtkWidget* window = dialog_window(GTK_WIDGET(parent), "Unit");
GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window));
GtkWidget* title = gtk_label_new(name.c_str());
gtk_widget_set_halign(title, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(box), title);
GtkWidget* entry = gtk_entry_new();
{
std::lock_guard<std::mutex> lock(app.model->mutex());
if (signal < app.model->signals().size()) {
gtk_editable_set_text(GTK_EDITABLE(entry), app.model->signals()[signal].unit.c_str());
}
}
gtk_entry_set_placeholder_text(GTK_ENTRY(entry), declared.empty() ? "(none declared)" : declared.c_str());
gtk_entry_set_activates_default(GTK_ENTRY(entry), true);
gtk_box_append(GTK_BOX(box), labeled_box("unit", entry));
GtkWidget* hint = gtk_label_new("Empty restores the unit the stream declares.");
gtk_widget_add_css_class(hint, "dim-label");
gtk_widget_set_halign(hint, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(box), hint);
struct UnitContext {
App* app;
std::string name;
GtkWidget* entry;
GtkWidget* window;
};
auto* ctx = new UnitContext{&app, name, entry, window};
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
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<UnitContext*>(p); });
g_signal_connect(apply, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
UnitContext* c = static_cast<UnitContext*>(user);
// Resolved by name at Apply: a CONFIG may have renumbered the signals while the dialog was open.
std::size_t index = std::string::npos;
{
std::lock_guard<std::mutex> lock(c->app->model->mutex());
const std::vector<SignalInfo>& s = c->app->model->signals();
for (std::size_t i = 0; i < s.size(); ++i) {
if (s[i].name == c->name) index = i;
}
}
if (index != std::string::npos) {
std::string text = gtk_editable_get_text(GTK_EDITABLE(c->entry));
const auto b = text.find_first_not_of(" \t");
const auto e = text.find_last_not_of(" \t");
text = b == std::string::npos ? std::string() : text.substr(b, e - b + 1);
c->app->model->set_signal_unit(index, text);
schedule_layout_save(*c->app);
signal_pages_sync(*c->app);
}
gtk_window_destroy(GTK_WINDOW(c->window));
}),
ctx);
dialog_add_button(window, cancel);
dialog_add_button(window, apply);
gtk_window_present(GTK_WINDOW(window));
}
GdkContentProvider* on_drag_prepare(GtkDragSource*, double, double, gpointer user) { GdkContentProvider* on_drag_prepare(GtkDragSource*, double, double, gpointer user) {
DragContext* ctx = static_cast<DragContext*>(user); DragContext* ctx = static_cast<DragContext*>(user);
// The payload is the signal index; the channel is resolved on drop. // The payload is the signal index; the channel is resolved on drop.
+2
View File
@@ -1714,6 +1714,8 @@ void on_calibrate_apply(GtkButton*, gpointer user);
/// Which of the calibration's two numbers a dialog edits; `Both` is the full dialog. /// Which of the calibration's two numbers a dialog edits; `Both` is the full dialog.
enum class CalibField { Both, Gain, Offset }; enum class CalibField { Both, Gain, Offset };
void open_calibration(App& app, GtkWindow* parent, std::size_t signal, CalibField field = CalibField::Both); void open_calibration(App& app, GtkWindow* parent, std::size_t signal, CalibField field = CalibField::Both);
/// The one-entry editor for a streamed signal's unit (an empty entry restores the declared one).
void open_unit_editor(App& app, GtkWindow* parent, std::size_t signal);
GdkContentProvider* on_drag_prepare(GtkDragSource*, double, double, gpointer user); GdkContentProvider* on_drag_prepare(GtkDragSource*, double, double, gpointer user);
int signal_color_index(const std::vector<SignalInfo>& signals, std::size_t signal); int signal_color_index(const std::vector<SignalInfo>& signals, std::size_t signal);
std::string color_hex(plot::Color c); std::string color_hex(plot::Color c);
+2 -2
View File
@@ -123,8 +123,8 @@ constexpr HelpHint kHelpHints[] = {
"every row.", "every row.",
"", &App::trigger_search}, "", &App::trigger_search},
{"signals_config", "Configuring signals", {"signals_config", "Configuring signals",
"A signal's own page, opened by its badge or by its row here, shows the gain and offset that map " "A signal's own page, opened by its badge or by its row here, shows its unit, gain and offset (a pencil edits each) "
"raw counts to a unit (a pencil edits each), the colour a trace is drawn in, and which plot a signal is drawn on. A " "above what the stream declared about it, the colour a trace is drawn in, and which plot a signal is drawn on. A "
"signal nothing measures can be switched off there to stop acquiring it.", "signal nothing measures can be switched off there to stop acquiring it.",
"", &App::signals_note}, "", &App::signals_note},
{"window", "The time window", {"window", "The time window",
+9
View File
@@ -42,6 +42,7 @@ LayoutState collect_layout(App& app) {
break; break;
} }
} }
for (const auto& [name, unit] : model.unit_overrides()) st.units.push_back({name, unit});
// The *enable* state, not just what is plotted: a signal can be enabled only as a math channel's // 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 // 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, // read off the channels the model built -- it has no per-signal accessor and does not need one,
@@ -492,6 +493,14 @@ void apply_layout(App& app, const LayoutState& st) {
} }
} }
for (const auto& u : st.units) {
for (std::size_t si = 0; si < signals.size(); ++si) {
if (signals[si].name != u.name) continue;
model.set_signal_unit(si, u.unit);
break;
}
}
// Skipped for a name the stream no longer carries, like the calibration above: an override the // Skipped for a name the stream no longer carries, like the calibration above: an override the
// signal list cannot show is one `collect_layout()` would write back every session for ever. // signal list cannot show is one `collect_layout()` would write back every session for ever.
for (const auto& color : st.colors) { for (const auto& color : st.colors) {
+157 -95
View File
@@ -2,7 +2,7 @@
// SPDX-FileCopyrightText: 2026 Martino Ferrari // SPDX-FileCopyrightText: 2026 Martino Ferrari
// //
// The per-signal slide-over pages (`signal:<name>`): one signal's acquire switch, colour, the plot it is on, // The per-signal slide-over pages (`signal:<name>`): one signal's acquire switch, colour, the plot it is on,
// its information, gain and offset (a math channel's editor instead). Opened from a row of the signal list; // its unit, gain, offset and details under its name (a math channel's editor instead). Opened from a row of the signal list;
// kept in step with the model by `signal_pages_sync()`, which `rebuild_signal_panels()` ends with. // kept in step with the model by `signal_pages_sync()`, which `rebuild_signal_panels()` ends with.
#include "ui_app.hpp" #include "ui_app.hpp"
@@ -66,6 +66,98 @@ std::size_t tagged(GtkButton* b) {
return static_cast<std::size_t>(GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(b), "udpscope-signal"))); return static_cast<std::size_t>(GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(b), "udpscope-signal")));
} }
/// One row of the details grid: the name left, the value right-aligned in tabular figures, the value's
/// unit in a column of its own (so the digits line up whatever the unit is), and, for a value the user can
/// change, a pencil that edits just that one.
struct DetailRow {
const char* label;
std::string value;
std::string unit;
const char* id; // widget name of the value label, which the probe reads; null for none
const char* tip;
int edit; // which editor the pencil opens: 0 unit, 1 gain, 2 offset; -1 for none
};
/// Everything the page knows about a streamed signal, directly under its name: the unit, gain and offset
/// (each with a pencil), then what the CONFIG declared about it.
GtkWidget* build_info_grid(App& app, std::size_t index, const SignalInfo& sig,
const std::vector<SignalInfo>& all) {
const std::pair<double, double> cal = app.model->calibration(index);
const char* time_modes[] = {"per packet", "full array", "first sample", "last sample"};
const std::string time_mode =
sig.time_mode < 4 ? time_modes[sig.time_mode] : std::to_string(int(sig.time_mode));
const std::string time_signal =
sig.time_signal_idx < all.size() ? all[sig.time_signal_idx].name : std::string("none");
char size[48];
std::snprintf(size, sizeof(size), "%u x %u", sig.rows, sig.cols);
const std::vector<DetailRow> rows = {
{"Unit:", sig.unit.empty() ? "--" : sig.unit, "", "udpscope-unit",
"edit the unit this signal is shown and exported in",
0},
{"Gain:", format_calibration(cal.first), "", "udpscope-gain", "edit the gain, applied at ingest",
1},
{"Offset:", format_calibration(cal.second), sig.unit, "udpscope-offset",
"edit the offset, applied at ingest",
2},
{"Type:", std::to_string(int(sig.type_code)), "", nullptr, nullptr, -1},
{"Quantisation:", std::to_string(int(sig.quant_type)), "", nullptr, nullptr, -1},
{"Size:", size, "", nullptr, nullptr, -1},
{"Range:", format_calibration(sig.range_min) + " .. " + format_calibration(sig.range_max), sig.unit,
nullptr, nullptr, -1},
{"Sample rate:", format_calibration(sig.sampling_rate), "Hz", nullptr, nullptr, -1},
{"Time mode:", time_mode, "", nullptr, nullptr, -1},
{"Time signal:", time_signal, "", nullptr, nullptr, -1},
};
GtkWidget* grid = gtk_grid_new();
gtk_widget_set_name(grid, "udpscope-calibration"); // the probe reads its labels and pencils
gtk_grid_set_column_spacing(GTK_GRID(grid), 8);
gtk_grid_set_row_spacing(GTK_GRID(grid), 4);
int r = 0;
for (const DetailRow& row : rows) {
GtkWidget* name_label = gtk_label_new(row.label);
gtk_widget_set_halign(name_label, GTK_ALIGN_START);
gtk_grid_attach(GTK_GRID(grid), name_label, 0, r, 1, 1);
GtkWidget* value = gtk_label_new(row.value.c_str());
gtk_widget_add_css_class(value, "numeric");
if (row.id != nullptr) gtk_widget_set_name(value, row.id);
gtk_label_set_xalign(GTK_LABEL(value), 1.0f);
gtk_label_set_width_chars(GTK_LABEL(value), 14);
gtk_widget_set_hexpand(value, true);
gtk_widget_set_halign(value, GTK_ALIGN_END);
gtk_grid_attach(GTK_GRID(grid), value, 1, r, 1, 1);
GtkWidget* unit = gtk_label_new(row.unit.c_str());
gtk_widget_add_css_class(unit, "dim-label");
gtk_label_set_xalign(GTK_LABEL(unit), 0.0f);
gtk_label_set_width_chars(GTK_LABEL(unit), 4);
gtk_label_set_ellipsize(GTK_LABEL(unit), PANGO_ELLIPSIZE_END);
gtk_grid_attach(GTK_GRID(grid), unit, 2, r, 1, 1);
if (row.edit >= 0) {
GtkWidget* edit = gtk_button_new_from_icon_name("document-edit-symbolic");
gtk_widget_add_css_class(edit, "flat");
gtk_widget_set_tooltip_text(edit, row.tip);
gtk_accessible_update_property(GTK_ACCESSIBLE(edit), GTK_ACCESSIBLE_PROPERTY_LABEL, row.tip, -1);
tag(edit, index);
g_object_set_data(G_OBJECT(edit), "udpscope-edit", GINT_TO_POINTER(row.edit));
g_signal_connect(edit, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
App* a = static_cast<App*>(user);
const int which = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), "udpscope-edit"));
if (which == 0) {
open_unit_editor(*a, GTK_WINDOW(a->window), tagged(b));
} else {
open_calibration(*a, GTK_WINDOW(a->window), tagged(b),
which == 1 ? CalibField::Gain : CalibField::Offset);
}
}),
&app);
gtk_grid_attach(GTK_GRID(grid), edit, 3, r, 1, 1);
}
++r;
}
return grid;
}
void fill(App& app, const std::string& name, GtkWidget* page) { void fill(App& app, const std::string& name, GtkWidget* page) {
for (GtkWidget* c = gtk_widget_get_first_child(page); c != nullptr;) { for (GtkWidget* c = gtk_widget_get_first_child(page); c != nullptr;) {
GtkWidget* next = gtk_widget_get_next_sibling(c); GtkWidget* next = gtk_widget_get_next_sibling(c);
@@ -100,12 +192,16 @@ void fill(App& app, const std::string& name, GtkWidget* page) {
gtk_widget_set_halign(title, GTK_ALIGN_START); gtk_widget_set_halign(title, GTK_ALIGN_START);
gtk_label_set_ellipsize(GTK_LABEL(title), PANGO_ELLIPSIZE_END); gtk_label_set_ellipsize(GTK_LABEL(title), PANGO_ELLIPSIZE_END);
gtk_box_append(GTK_BOX(page), title); gtk_box_append(GTK_BOX(page), title);
if (sig.math) {
if (!sig.unit.empty()) { if (!sig.unit.empty()) {
GtkWidget* unit = gtk_label_new(sig.unit.c_str()); GtkWidget* unit = gtk_label_new(sig.unit.c_str());
gtk_widget_add_css_class(unit, "dim-label"); gtk_widget_add_css_class(unit, "dim-label");
gtk_widget_set_halign(unit, GTK_ALIGN_START); gtk_widget_set_halign(unit, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(page), unit); gtk_box_append(GTK_BOX(page), unit);
} }
} else {
gtk_box_append(GTK_BOX(page), build_info_grid(app, index, sig, all));
}
// Acquire / compute. The same handler as the list's tick, which reads the signal index off the widget. // Acquire / compute. The same handler as the list's tick, which reads the signal index off the widget.
GtkWidget* enable = gtk_check_button_new_with_label(sig.math ? "Compute this channel" : "Acquire this signal"); GtkWidget* enable = gtk_check_button_new_with_label(sig.math ? "Compute this channel" : "Acquire this signal");
@@ -193,13 +289,13 @@ void fill(App& app, const std::string& name, GtkWidget* page) {
}), }),
&app); &app);
// Information (a streamed signal's), then its gain and offset below, and a math channel's editor. // A math channel's editor. (A streamed signal's details are the grid under its name.)
GtkWidget* more_head = gtk_label_new(sig.math ? "Math channel" : "Signal"); if (sig.math) {
GtkWidget* more_head = gtk_label_new("Math channel");
gtk_widget_add_css_class(more_head, "heading"); gtk_widget_add_css_class(more_head, "heading");
gtk_widget_set_halign(more_head, GTK_ALIGN_START); gtk_widget_set_halign(more_head, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(page), more_head); gtk_box_append(GTK_BOX(page), more_head);
GtkWidget* more = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget* more = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
if (sig.math) {
GtkWidget* edit = text_button("Edit graph...", more); GtkWidget* edit = text_button("Edit graph...", more);
tag(edit, index); tag(edit, index);
g_signal_connect(edit, "clicked", G_CALLBACK(on_math_edit_clicked), &app); g_signal_connect(edit, "clicked", G_CALLBACK(on_math_edit_clicked), &app);
@@ -207,68 +303,7 @@ void fill(App& app, const std::string& name, GtkWidget* page) {
gtk_widget_add_css_class(del, "destructive-action"); gtk_widget_add_css_class(del, "destructive-action");
tag(del, index); tag(del, index);
g_signal_connect(del, "clicked", G_CALLBACK(on_math_delete_clicked), &app); g_signal_connect(del, "clicked", G_CALLBACK(on_math_delete_clicked), &app);
} else {
GtkWidget* info = text_button("Information", more);
tag(info, index);
g_signal_connect(info, "clicked", G_CALLBACK(on_info_clicked), &app);
}
gtk_box_append(GTK_BOX(page), more); gtk_box_append(GTK_BOX(page), more);
// Gain and offset, applied at ingest: two rows, labels left, numbers right-aligned in tabular figures, the
// offset's unit in its own column so the digits line up, and a pencil that edits that one value.
if (!sig.math) {
const std::pair<double, double> cal = app.model->calibration(index);
GtkWidget* grid = gtk_grid_new();
gtk_widget_set_name(grid, "udpscope-calibration"); // the probe reads its labels
gtk_grid_set_column_spacing(GTK_GRID(grid), 8);
gtk_grid_set_row_spacing(GTK_GRID(grid), 4);
const struct {
const char* label;
double value;
const std::string unit;
CalibField field;
const char* tip;
} rows[] = {
{"Gain:", cal.first, "", CalibField::Gain, "edit the gain, applied at ingest"},
{"Offset:", cal.second, sig.unit, CalibField::Offset, "edit the offset, applied at ingest"},
};
int r = 0;
for (const auto& row : rows) {
GtkWidget* name_label = gtk_label_new(row.label);
gtk_widget_set_halign(name_label, GTK_ALIGN_START);
gtk_grid_attach(GTK_GRID(grid), name_label, 0, r, 1, 1);
GtkWidget* value = gtk_label_new(format_calibration(row.value).c_str());
gtk_widget_add_css_class(value, "numeric");
gtk_widget_set_name(value, row.field == CalibField::Gain ? "udpscope-gain" : "udpscope-offset");
gtk_label_set_xalign(GTK_LABEL(value), 1.0f);
gtk_label_set_width_chars(GTK_LABEL(value), 12);
gtk_label_set_ellipsize(GTK_LABEL(value), PANGO_ELLIPSIZE_START);
gtk_widget_set_hexpand(value, true);
gtk_widget_set_halign(value, GTK_ALIGN_END);
gtk_grid_attach(GTK_GRID(grid), value, 1, r, 1, 1);
GtkWidget* unit = gtk_label_new(row.unit.c_str());
gtk_widget_add_css_class(unit, "dim-label");
gtk_label_set_xalign(GTK_LABEL(unit), 0.0f);
gtk_label_set_width_chars(GTK_LABEL(unit), 4);
gtk_label_set_ellipsize(GTK_LABEL(unit), PANGO_ELLIPSIZE_END);
gtk_grid_attach(GTK_GRID(grid), unit, 2, r, 1, 1);
GtkWidget* edit = gtk_button_new_from_icon_name("document-edit-symbolic");
gtk_widget_add_css_class(edit, "flat");
gtk_widget_set_tooltip_text(edit, row.tip);
gtk_accessible_update_property(GTK_ACCESSIBLE(edit), GTK_ACCESSIBLE_PROPERTY_LABEL, row.tip, -1);
tag(edit, index);
g_object_set_data(G_OBJECT(edit), "udpscope-field", GINT_TO_POINTER(static_cast<int>(row.field)));
g_signal_connect(edit, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
App* a = static_cast<App*>(user);
open_calibration(*a, GTK_WINDOW(a->window), tagged(b),
static_cast<CalibField>(GPOINTER_TO_INT(
g_object_get_data(G_OBJECT(b), "udpscope-field"))));
}),
&app);
gtk_grid_attach(GTK_GRID(grid), edit, 3, r, 1, 1);
++r;
}
gtk_box_append(GTK_BOX(page), grid);
} }
} }
@@ -506,8 +541,9 @@ gboolean signal_page_probe(gpointer user) {
} }
slideover_close(a); slideover_close(a);
} }
// Gain and offset on the page: the labels follow the model, and a pencil edits just its own number. // The details under the name: the labels follow the model, and each pencil edits just its own value.
const std::pair<double, double> cal0 = a.model->calibration(index); const std::pair<double, double> cal0 = a.model->calibration(index);
const std::string declared0 = a.model->declared_unit(index);
const auto label_text = [&](const char* id) -> std::string { const auto label_text = [&](const char* id) -> std::string {
auto it = a.signal_pages.find(name); auto it = a.signal_pages.find(name);
if (it == a.signal_pages.end()) return "?"; if (it == a.signal_pages.end()) return "?";
@@ -516,60 +552,86 @@ gboolean signal_page_probe(gpointer user) {
}); });
return l == nullptr ? "?" : gtk_label_get_text(GTK_LABEL(l)); return l == nullptr ? "?" : gtk_label_get_text(GTK_LABEL(l));
}; };
const std::string cal_identity = label_text("udpscope-gain") + "/" + label_text("udpscope-offset"); const auto pencil_n = [&](int n) -> GtkWidget* {
a.model->set_calibration(index, 2.5, -1.25);
signal_pages_sync(a);
const std::string cal_set = label_text("udpscope-gain") + "/" + label_text("udpscope-offset");
int pencils = 0;
int pencil_edits_one = -1;
{
auto it = a.signal_pages.find(name); auto it = a.signal_pages.find(name);
GtkWidget* grid = it == a.signal_pages.end() ? nullptr : find_descendant(it->second, [](GtkWidget* w) { GtkWidget* grid = it == a.signal_pages.end() ? nullptr : find_descendant(it->second, [](GtkWidget* w) {
return std::string(gtk_widget_get_name(w)) == "udpscope-calibration"; return std::string(gtk_widget_get_name(w)) == "udpscope-calibration";
}); });
GtkWidget* gain_pencil = nullptr; int seen = 0;
if (grid != nullptr) { for (GtkWidget* c = grid == nullptr ? nullptr : gtk_widget_get_first_child(grid); c != nullptr;
for (GtkWidget* c = gtk_widget_get_first_child(grid); c != nullptr; c = gtk_widget_get_next_sibling(c)) { c = gtk_widget_get_next_sibling(c)) {
if (GTK_IS_BUTTON(c) && gtk_button_get_icon_name(GTK_BUTTON(c)) != nullptr) { if (GTK_IS_BUTTON(c) && gtk_button_get_icon_name(GTK_BUTTON(c)) != nullptr && seen++ == n) return c;
if (pencils++ == 0) gain_pencil = c;
} }
} return nullptr;
} };
if (gain_pencil != nullptr) { // Click pencil `n`, find the dialog titled `title`, set its `Spin`/`Entry`, press Apply. Returns the number
g_signal_emit_by_name(gain_pencil, "clicked"); // of editing widgets the dialog had, or -1 when there was no dialog.
const auto drive = [&](int n, const char* title, double number, const char* text) -> int {
GtkWidget* pencil = pencil_n(n);
if (pencil == nullptr) return -1;
g_signal_emit_by_name(pencil, "clicked");
int fields = -1;
GListModel* windows = gtk_window_get_toplevels(); GListModel* windows = gtk_window_get_toplevels();
for (guint i = 0; i < g_list_model_get_n_items(windows); ++i) { for (guint i = 0; i < g_list_model_get_n_items(windows); ++i) {
GtkWindow* w = GTK_WINDOW(g_list_model_get_item(windows, i)); GtkWindow* w = GTK_WINDOW(g_list_model_get_item(windows, i));
const char* title = gtk_window_get_title(w); const char* t = gtk_window_get_title(w);
if (title != nullptr && std::string(title) == "Gain") { if (t != nullptr && std::string(t) == title) {
GtkWidget* body = gtk_window_get_child(w); GtkWidget* body = gtk_window_get_child(w);
int spins = 0; fields = 0;
find_descendant(body, [&](GtkWidget* x) { find_descendant(body, [&](GtkWidget* x) {
if (GTK_IS_SPIN_BUTTON(x)) ++spins; if (GTK_IS_SPIN_BUTTON(x) || GTK_IS_ENTRY(x)) ++fields;
return false; return false;
}); });
GtkWidget* spin_w = find_descendant(body, [](GtkWidget* x) { return GTK_IS_SPIN_BUTTON(x); }); GtkWidget* spin_w = find_descendant(body, [](GtkWidget* x) { return GTK_IS_SPIN_BUTTON(x); });
GtkWidget* entry_w = find_descendant(body, [](GtkWidget* x) { return GTK_IS_ENTRY(x) && !GTK_IS_SPIN_BUTTON(x); });
GtkWidget* apply = find_descendant(body, [](GtkWidget* x) { return is_button_labelled(x, "Apply"); }); GtkWidget* apply = find_descendant(body, [](GtkWidget* x) { return is_button_labelled(x, "Apply"); });
if (spin_w != nullptr && apply != nullptr) { if (apply != nullptr && (spin_w != nullptr || entry_w != nullptr)) {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(spin_w), 4.0); if (text != nullptr && entry_w != nullptr) gtk_editable_set_text(GTK_EDITABLE(entry_w), text);
if (text == nullptr && spin_w != nullptr) gtk_spin_button_set_value(GTK_SPIN_BUTTON(spin_w), number);
g_signal_emit_by_name(apply, "clicked"); g_signal_emit_by_name(apply, "clicked");
const std::pair<double, double> after = a.model->calibration(index);
pencil_edits_one = spins == 1 && after.first == 4.0 && after.second == -1.25 &&
label_text("udpscope-gain") == "4"
? 1
: 0;
} else { } else {
gtk_window_close(w); gtk_window_close(w);
} }
} }
g_object_unref(w); g_object_unref(w);
} }
return fields;
};
const std::string cal_identity = label_text("udpscope-gain") + "/" + label_text("udpscope-offset");
a.model->set_calibration(index, 2.5, -1.25);
signal_pages_sync(a);
const std::string cal_set = label_text("udpscope-gain") + "/" + label_text("udpscope-offset");
int pencils = 0;
while (pencil_n(pencils) != nullptr) ++pencils;
const int gain_fields = drive(1, "Gain", 4.0, nullptr);
const std::pair<double, double> after = a.model->calibration(index);
const int pencil_edits_one =
gain_fields == 1 && after.first == 4.0 && after.second == -1.25 && label_text("udpscope-gain") == "4" ? 1
: 0;
// The unit: a pencil renames it, the rename reaches the model's signal and the page, and an empty entry
// restores what the stream declared.
const int unit_fields = drive(0, "Unit", 0.0, "kV");
std::string model_unit;
{
std::lock_guard<std::mutex> lock(a.model->mutex());
model_unit = a.model->signals()[index].unit;
} }
const std::string page_unit = label_text("udpscope-unit");
drive(0, "Unit", 0.0, "");
std::string restored;
{
std::lock_guard<std::mutex> lock(a.model->mutex());
restored = a.model->signals()[index].unit;
} }
const int unit_edit = unit_fields == 1 && model_unit == "kV" && page_unit == "kV" && restored == declared0 ? 1 : 0;
a.model->set_calibration(index, cal0.first, cal0.second); a.model->set_calibration(index, cal0.first, cal0.second);
signal_pages_sync(a); signal_pages_sync(a);
std::fprintf(stderr, "[signalpage] calibration identity=%s set=%s pencils=%d pencil_edits_one=%d\n", std::fprintf(stderr,
cal_identity.c_str(), cal_set.c_str(), pencils, pencil_edits_one); "[signalpage] calibration identity=%s set=%s pencils=%d pencil_edits_one=%d unit_edit=%d "
"unit='%s'\n",
cal_identity.c_str(), cal_set.c_str(), pencils, pencil_edits_one, unit_edit, restored.c_str());
std::fprintf(stderr, std::fprintf(stderr,
"[signalpage] name=%s open=%d pages=%d enable_visible=%d plot_buttons=%d plot_on_row0=%d " "[signalpage] name=%s open=%d pages=%d enable_visible=%d plot_buttons=%d plot_on_row0=%d "
"plot_on_row0_disabled=%d " "plot_on_row0_disabled=%d "