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
+360 -135

No files matched your search

+14 -1
View File
@@ -439,6 +439,7 @@ bool load_layout(const std::string& path, LayoutState& out) {
st.window_s = 0.0;
st.rows.clear();
st.calibration.clear();
st.units.clear();
st.colors.clear();
st.enabled.clear();
st.math.clear();
@@ -446,7 +447,7 @@ bool load_layout(const std::string& path, LayoutState& out) {
std::string line;
// 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;
bool plot_section_seen = false;
while (std::getline(in, line)) {
@@ -460,6 +461,9 @@ bool load_layout(const std::string& path, LayoutState& out) {
} else if (t == "[[calibration]]") {
section = Section::Calibration;
st.calibration.emplace_back();
} else if (t == "[[unit]]") {
section = Section::Unit;
st.units.emplace_back();
} else if (t == "[[color]]") {
section = Section::Color;
st.colors.emplace_back();
@@ -686,6 +690,12 @@ bool load_layout(const std::string& path, LayoutState& out) {
} 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()) {
if (key == "name") {
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
<< "\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) {
char rgb[16];
std::snprintf(rgb, sizeof(rgb), "\"#%06X\"", color.rgb & 0x00FFFFFFu);
+7
View File
@@ -36,6 +36,13 @@ struct LayoutState {
};
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
/// 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.
+39
View File
@@ -232,6 +232,13 @@ void ScopeModel::set_signals(std::vector<SignalInfo> signals) {
signals.erase(std::remove_if(signals.begin(), signals.end(),
[](const SignalInfo& s) { return s.math; }),
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_)) {
// 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
@@ -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 {
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.
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(); }
const Partition& partition() const noexcept { return part_; }
@@ -868,6 +879,9 @@ class ScopeModel {
std::atomic<bool> paused_{false};
/// Per-signal-name calibration (see `set_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<double> display_epoch_{0.0};
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) {
DragContext* ctx = static_cast<DragContext*>(user);
// 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.
enum class CalibField { Both, Gain, Offset };
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);
int signal_color_index(const std::vector<SignalInfo>& signals, std::size_t signal);
std::string color_hex(plot::Color c);
+2 -2
View File
@@ -123,8 +123,8 @@ constexpr HelpHint kHelpHints[] = {
"every row.",
"", &App::trigger_search},
{"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 "
"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 "
"A signal's own page, opened by its badge or by its row here, shows its unit, gain and offset (a pencil edits each) "
"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.",
"", &App::signals_note},
{"window", "The time window",
+9
View File
@@ -42,6 +42,7 @@ LayoutState collect_layout(App& app) {
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
// 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,
@@ -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
// signal list cannot show is one `collect_layout()` would write back every session for ever.
for (const auto& color : st.colors) {
+182 -120
View File
@@ -2,7 +2,7 @@
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// 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.
#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")));
}
/// 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) {
for (GtkWidget* c = gtk_widget_get_first_child(page); c != nullptr;) {
GtkWidget* next = gtk_widget_get_next_sibling(c);
@@ -100,11 +192,15 @@ void fill(App& app, const std::string& name, GtkWidget* page) {
gtk_widget_set_halign(title, GTK_ALIGN_START);
gtk_label_set_ellipsize(GTK_LABEL(title), PANGO_ELLIPSIZE_END);
gtk_box_append(GTK_BOX(page), title);
if (!sig.unit.empty()) {
GtkWidget* unit = gtk_label_new(sig.unit.c_str());
gtk_widget_add_css_class(unit, "dim-label");
gtk_widget_set_halign(unit, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(page), unit);
if (sig.math) {
if (!sig.unit.empty()) {
GtkWidget* unit = gtk_label_new(sig.unit.c_str());
gtk_widget_add_css_class(unit, "dim-label");
gtk_widget_set_halign(unit, GTK_ALIGN_START);
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.
@@ -193,13 +289,13 @@ void fill(App& app, const std::string& name, GtkWidget* page) {
}),
&app);
// Information (a streamed signal's), then its gain and offset below, and a math channel's editor.
GtkWidget* more_head = gtk_label_new(sig.math ? "Math channel" : "Signal");
gtk_widget_add_css_class(more_head, "heading");
gtk_widget_set_halign(more_head, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(page), more_head);
GtkWidget* more = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
// A math channel's editor. (A streamed signal's details are the grid under its name.)
if (sig.math) {
GtkWidget* more_head = gtk_label_new("Math channel");
gtk_widget_add_css_class(more_head, "heading");
gtk_widget_set_halign(more_head, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(page), more_head);
GtkWidget* more = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget* edit = text_button("Edit graph...", more);
tag(edit, index);
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");
tag(del, index);
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);
// 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);
gtk_box_append(GTK_BOX(page), more);
}
}
@@ -506,8 +541,9 @@ gboolean signal_page_probe(gpointer user) {
}
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::string declared0 = a.model->declared_unit(index);
const auto label_text = [&](const char* id) -> std::string {
auto it = a.signal_pages.find(name);
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));
};
const auto pencil_n = [&](int n) -> GtkWidget* {
auto it = a.signal_pages.find(name);
GtkWidget* grid = it == a.signal_pages.end() ? nullptr : find_descendant(it->second, [](GtkWidget* w) {
return std::string(gtk_widget_get_name(w)) == "udpscope-calibration";
});
int seen = 0;
for (GtkWidget* c = grid == nullptr ? nullptr : gtk_widget_get_first_child(grid); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
if (GTK_IS_BUTTON(c) && gtk_button_get_icon_name(GTK_BUTTON(c)) != nullptr && seen++ == n) return c;
}
return nullptr;
};
// Click pencil `n`, find the dialog titled `title`, set its `Spin`/`Entry`, press Apply. Returns the number
// 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();
for (guint i = 0; i < g_list_model_get_n_items(windows); ++i) {
GtkWindow* w = GTK_WINDOW(g_list_model_get_item(windows, i));
const char* t = gtk_window_get_title(w);
if (t != nullptr && std::string(t) == title) {
GtkWidget* body = gtk_window_get_child(w);
fields = 0;
find_descendant(body, [&](GtkWidget* x) {
if (GTK_IS_SPIN_BUTTON(x) || GTK_IS_ENTRY(x)) ++fields;
return false;
});
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"); });
if (apply != nullptr && (spin_w != nullptr || entry_w != nullptr)) {
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");
} else {
gtk_window_close(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;
int pencil_edits_one = -1;
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;
{
auto it = a.signal_pages.find(name);
GtkWidget* grid = it == a.signal_pages.end() ? nullptr : find_descendant(it->second, [](GtkWidget* w) {
return std::string(gtk_widget_get_name(w)) == "udpscope-calibration";
});
GtkWidget* gain_pencil = nullptr;
if (grid != nullptr) {
for (GtkWidget* c = gtk_widget_get_first_child(grid); c != nullptr; c = gtk_widget_get_next_sibling(c)) {
if (GTK_IS_BUTTON(c) && gtk_button_get_icon_name(GTK_BUTTON(c)) != nullptr) {
if (pencils++ == 0) gain_pencil = c;
}
}
}
if (gain_pencil != nullptr) {
g_signal_emit_by_name(gain_pencil, "clicked");
GListModel* windows = gtk_window_get_toplevels();
for (guint i = 0; i < g_list_model_get_n_items(windows); ++i) {
GtkWindow* w = GTK_WINDOW(g_list_model_get_item(windows, i));
const char* title = gtk_window_get_title(w);
if (title != nullptr && std::string(title) == "Gain") {
GtkWidget* body = gtk_window_get_child(w);
int spins = 0;
find_descendant(body, [&](GtkWidget* x) {
if (GTK_IS_SPIN_BUTTON(x)) ++spins;
return false;
});
GtkWidget* spin_w = find_descendant(body, [](GtkWidget* x) { return GTK_IS_SPIN_BUTTON(x); });
GtkWidget* apply = find_descendant(body, [](GtkWidget* x) { return is_button_labelled(x, "Apply"); });
if (spin_w != nullptr && apply != nullptr) {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(spin_w), 4.0);
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 {
gtk_window_close(w);
}
}
g_object_unref(w);
}
}
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);
signal_pages_sync(a);
std::fprintf(stderr, "[signalpage] calibration identity=%s set=%s pencils=%d pencil_edits_one=%d\n",
cal_identity.c_str(), cal_set.c_str(), pencils, pencil_edits_one);
std::fprintf(stderr,
"[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,
"[signalpage] name=%s open=%d pages=%d enable_visible=%d plot_buttons=%d plot_on_row0=%d "
"plot_on_row0_disabled=%d "