diff --git a/README.md b/README.md index 4b101f9..4756915 100644 --- a/README.md +++ b/README.md @@ -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 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. -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 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 diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index e5cb80a..9981515 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -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 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 -**Gain** and **Offset**, shown as two aligned numbers with a pencil button each that edits that one value. -They are applied at ingest, so the measurements and the export carry the same numbers the plot shows. +Clicking a signal's badge opens its own page. Directly under the name are its **Unit**, **Gain** and +**Offset** — each with a pencil button that edits that one value — and then what the stream declared about +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 diff --git a/docs/claude/invariants-session-and-export.md b/docs/claude/invariants-session-and-export.md index bb0b23a..beee2b7 100644 --- a/docs/claude/invariants-session-and-export.md +++ b/docs/claude/invariants-session-and-export.md @@ -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:` line must be 0. - **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 *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 diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index b17d2bb..8b51516 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -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 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 -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 -(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]` diff --git a/help/C/org.udpscope.app/signals.page b/help/C/org.udpscope.app/signals.page index 3e6f0bb..a642169 100644 --- a/help/C/org.udpscope.app/signals.page +++ b/help/C/org.udpscope.app/signals.page @@ -30,9 +30,10 @@

Once a signal is acquired its badge appears. Click it to open that signal's page: its - colour, Plot on buttons for the rows, Information for the signal's - details, and its Gain and Offset, each with a pencil button that - edits that one number.

+ details directly under its name — Unit, Gain and Offset, + each with a pencil button that edits that one value, then the type, size, range, sample + rate and timing the stream declared — its colour and Plot on buttons for the + rows.

@@ -47,7 +48,9 @@ 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 — pressing Apply confirms it rather than resetting it. A signal nobody - calibrated shows the identity, 1 and 0.

+ 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.

Calibration is applied at ingest, so changing it re-plans the buffer 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 diff --git a/src/layout_store.cpp b/src/layout_store.cpp index c6987f9..480936f 100644 --- a/src/layout_store.cpp +++ b/src/layout_store.cpp @@ -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); diff --git a/src/layout_store.hpp b/src/layout_store.hpp index fff8a93..ad6320e 100644 --- a/src/layout_store.hpp +++ b/src/layout_store.hpp @@ -36,6 +36,13 @@ struct LayoutState { }; std::vector 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 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. diff --git a/src/model.cpp b/src/model.cpp index 7535a9d..b47f4d0 100644 --- a/src/model.cpp +++ b/src/model.cpp @@ -232,6 +232,13 @@ void ScopeModel::set_signals(std::vector 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 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 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> ScopeModel::unit_overrides() { + std::lock_guard 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; } diff --git a/src/model.hpp b/src/model.hpp index 23daf4c..fcf7760 100644 --- a/src/model.hpp +++ b/src/model.hpp @@ -465,6 +465,17 @@ class ScopeModel { /// no channel to read it from, and the record is what a dialog must show. std::pair 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> 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 paused_{false}; /// Per-signal-name calibration (see `set_calibration`). std::unordered_map> calibration_; + /// Per-signal-name unit renames (see `set_signal_unit`) and the units the stream declared. + std::unordered_map unit_override_; + std::unordered_map declared_unit_; std::atomic event_seq_{0}; std::atomic display_epoch_{0.0}; std::atomic trigger_armed_{false}; diff --git a/src/ui.cpp b/src/ui.cpp index 0ed0d0c..e5b9c79 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -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 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 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(p); }); + g_signal_connect(apply, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { + UnitContext* c = static_cast(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 lock(c->app->model->mutex()); + const std::vector& 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(user); // The payload is the signal index; the channel is resolved on drop. diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 7140b6c..b08d21c 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -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& signals, std::size_t signal); std::string color_hex(plot::Color c); diff --git a/src/ui_context_help.cpp b/src/ui_context_help.cpp index 86e9603..23d0658 100644 --- a/src/ui_context_help.cpp +++ b/src/ui_context_help.cpp @@ -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", diff --git a/src/ui_layout.cpp b/src/ui_layout.cpp index 4ba5e04..3b8b665 100644 --- a/src/ui_layout.cpp +++ b/src/ui_layout.cpp @@ -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) { diff --git a/src/ui_signal_page.cpp b/src/ui_signal_page.cpp index a6632b9..529cd0e 100644 --- a/src/ui_signal_page.cpp +++ b/src/ui_signal_page.cpp @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: 2026 Martino Ferrari // // The per-signal slide-over pages (`signal:`): 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(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& all) { + const std::pair 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 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(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 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(row.field))); - g_signal_connect(edit, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { - App* a = static_cast(user); - open_calibration(*a, GTK_WINDOW(a->window), tagged(b), - static_cast(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 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 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 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 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 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 "