ui: one channel list, and the math pane is gone

`rebuild_signal_list()` is handed `model.signals()`, and `append_math_signals_locked()` already
appends one synthetic entry per math channel -- "they go through the ordinary signal path on
purpose: a derived channel is a channel" -- so **every math channel was already a row of the
signal list**, with its tick, chip, name, unit and menu. The math pane listed the same channels a
second time, so this is mostly a deletion: `build_math_panel()`, `rebuild_math_pane()`,
`src/ui/math-panel.ui` and its resource entry, `App::math_list`, and the `math panel:` dump line.

What the one list gains:

- a non-selectable `MATH` divider before the first math row, carrying the `+` that creates a
  channel -- appended even when there are no math channels at all, because a `+` conditional on
  one existing could never create the first one;
- a math row's own menu: *Edit graph…*, *Default colour*, *Delete*. No Information and no
  Calibrate (calibration is applied at ingest, and a derived channel has no stream to describe),
  and no rename, because the node editor owns a channel's name and unit -- a second place to
  change them would be a second answer to the same question. An item dismisses the row's popover
  before opening or destroying anything from it;
- `App::math` mirrored from a math row's tick: `enable_locked()` mirrors it into the model, but
  `App::math` is the copy the session file is written from. The mirror sits *before* the
  `apply_math_channels()` in the `!on` path, which would otherwise push a stale `enabled = true`
  straight back into the model;
- a tick visible in a file view too: a derived channel is computed, and a file view has no
  acquisition to start, which is the other tick's question.

`rebuild_signal_list()` also publishes the list's own shape -- `rows`, `signals`, `math` -- and
the dump prints `divider=1` when `rows == signals + 1 + math`: the reading for a list that lost
its `+`.

Verified: `make test-ui` 14 files (was 15); `signals panel: labels=2 list=1 rows=9 signals=4
math=4 divider=1`; `[math-probe] 3 math channels`, every other line unchanged; no `math panel:`
line, and zero `Gtk-CRITICAL`.
This commit is contained in:
Martino Ferrari committed 2026-10-14 12:56:31 +02:00
1 parent 132041f79e
commit a493737ca1
6 files changed
+186 -190

No files matched your search

+153 -20
View File
@@ -34,6 +34,13 @@ void on_signal_switch(GObject* obj, GParamSpec*, gpointer user) {
// an idle keeps the instance alive until after `set_active` has emitted everything and returned.
g_object_ref(obj);
app->model->set_signal_enabled(index, on);
// `App::math` is the copy the session file is written from, so a math row's tick has to reach it.
// Here and not after the `if (!on)` block below: `apply_math_channels()` down there pushes
// `App::math` into the model, and a stale `enabled = true` would switch the channel straight back on.
{
const int k = math_index_for_signal(*app, name);
if (k >= 0) app->math[static_cast<std::size_t>(k)].enabled = on;
}
if (!on) {
// A trace of a signal nobody acquires draws nothing, and holds the slot a live one could take.
int gone = remove_signal_from_plots(*app, name);
@@ -146,6 +153,91 @@ void on_signal_color_chosen(GObject* source, GAsyncResult* res, gpointer user) {
schedule_layout_save(app);
}
/// `App::math` is indexed by position and `signals()` by name, and the mapping between them is
/// the name: `append_math_signals_locked()` names each synthetic signal after its channel and
/// `enable_locked()` matches it back the same way.
int math_index_for_signal(App& app, const std::string& name) {
for (std::size_t i = 0; i < app.math.size(); ++i) {
if (app.math[i].name == name) return static_cast<int>(i);
}
return -1;
}
/// The `MATH` boundary in the one channel list, carrying the `+` that creates a channel. Not
/// selectable and not activatable: it is a heading, and a heading that highlights under the
/// pointer reads as a row you can plot.
GtkWidget* make_math_divider(App& app) {
GtkWidget* row = gtk_list_box_row_new();
gtk_list_box_row_set_selectable(GTK_LIST_BOX_ROW(row), FALSE);
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), FALSE);
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_margin_top(box, 6);
gtk_widget_set_margin_start(box, 6);
gtk_widget_set_margin_end(box, 6);
GtkWidget* label = gtk_label_new("MATH");
gtk_widget_add_css_class(label, "dim-label");
gtk_widget_add_css_class(label, "caption-heading");
gtk_widget_set_halign(label, GTK_ALIGN_START);
gtk_widget_set_hexpand(label, TRUE);
gtk_box_append(GTK_BOX(box), label);
GtkWidget* add = gtk_button_new_from_icon_name("list-add-symbolic");
gtk_widget_add_css_class(add, "flat");
gtk_widget_set_tooltip_text(add, "add a math channel");
g_signal_connect(add, "clicked", G_CALLBACK(on_math_add_clicked), &app);
gtk_box_append(GTK_BOX(box), add);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box);
return row;
}
/// Create a channel, then open the editor on it: a new channel is a single `Source` node with a
/// default name, which is of no use until it is given a graph.
void on_math_add_clicked(GtkButton*, gpointer user) {
App* a = static_cast<App*>(user);
add_math_channel(*a);
if (a->math.empty()) return;
// `add_math_channel()` appended, so the new channel is the last one; `apply_math_channels()`
// inside it has already rebuilt this list, which is why the index is taken after the call.
open_math_dialog(*a, static_cast<int>(a->math.size()) - 1);
}
/// The row's menu items resolve their identity the one way the list already does: the widget
/// carries its *signal* index, and the name is read off the model under the lock.
static std::string signal_name_at(App& app, const std::size_t index) {
std::lock_guard<std::mutex> lock(app.model->mutex());
const std::vector<SignalInfo>& signals = app.model->signals();
return index < signals.size() ? signals[index].name : std::string();
}
/// Close the row's popover before anything is opened or destroyed from it: a dialog that takes
/// focus over an open popover leaves it hanging over the new window, and `Delete` removes the row
/// the popover belongs to, which cannot be left pointing at it.
static void dismiss_row_menu(GtkButton* button) {
GtkWidget* popover = gtk_widget_get_ancestor(GTK_WIDGET(button), GTK_TYPE_POPOVER);
if (popover != nullptr) gtk_widget_set_visible(popover, FALSE);
}
void on_math_edit_clicked(GtkButton* button, gpointer user) {
App* a = static_cast<App*>(user);
const std::size_t index = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(button), "udpscope-signal")));
const int k = math_index_for_signal(*a, signal_name_at(*a, index));
if (k < 0) return;
dismiss_row_menu(button);
open_math_dialog(*a, k);
}
void on_math_delete_clicked(GtkButton* button, gpointer user) {
App* a = static_cast<App*>(user);
const std::size_t index = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(button), "udpscope-signal")));
const int k = math_index_for_signal(*a, signal_name_at(*a, index));
if (k < 0) return;
dismiss_row_menu(button);
a->math.erase(a->math.begin() + k);
// Rebuilds the model's signal list, the plots and this list, and schedules the save.
apply_math_channels(*a);
}
/// One line per signal: colour chip, checkbox, name, unit, and a menu for the things
/// you touch once (information, calibration). A `GtkSwitch` and two always-visible
/// buttons made every row about twice this tall, which is what turned a four-signal
@@ -165,6 +257,12 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
// that showed both would name one "sine (A)" and the other "sine (B)" as if they were different
// things, and give them different colours. What the row toggles, colours and drags is the signal.
std::vector<std::size_t> rows;
// The `MATH` divider goes in before the first math row, and once only: `append_math_signals_locked()`
// has already put every math channel in `signals()`, so the boundary is the one thing the deleted
// math pane contributed that the list does not already have.
bool math_divider_done = false;
// Streamed signals, counted as they are drawn: the dump reads `rows == signals + 1 + math`.
std::size_t plain = 0;
for (std::size_t i = 0; i < signals.size(); ++i) {
if (ScopeModel::is_time_signal(signals, i)) continue; // the time base is not plottable
const std::string base = layer_base_name(signals[i].name);
@@ -178,6 +276,13 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
for (std::size_t i : rows) {
const SignalInfo& sig = signals[i];
// One list, with a boundary in it: a derived channel *is* a channel, and the divider carries the
// `+` that creates the first one.
if (sig.math && !math_divider_done) {
math_divider_done = true;
gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app));
}
if (!sig.math) ++plain;
GtkWidget* row = gtk_list_box_row_new();
gtk_widget_add_css_class(row, "udpscope-compact");
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
@@ -216,11 +321,13 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
GtkWidget* enable = gtk_check_button_new();
gtk_check_button_set_active(GTK_CHECK_BUTTON(enable), app.model->signal_enabled(i));
gtk_widget_set_tooltip_text(enable, "acquire this signal");
// A derived channel is computed here, not streamed: its tick is its own switch, and it has one
// in every view -- a file view has no acquisition to start, which is the other tick's question.
gtk_widget_set_tooltip_text(enable, sig.math ? "compute this channel" : "acquire this signal");
// A file view has nothing to acquire: an event's or a file's samples are already stored, and
// "acquire this signal" is a stream's question. Hidden rather than disabled -- a greyed tick invites
// the user to look for the reason, and the reason is that this mode has no acquisition at all.
gtk_widget_set_visible(enable, app.waveform_name.empty());
gtk_widget_set_visible(enable, sig.math || app.waveform_name.empty());
g_object_set_data(G_OBJECT(enable), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(enable, "notify::active", G_CALLBACK(on_signal_switch), &app);
gtk_box_append(GTK_BOX(box), enable);
@@ -245,11 +352,6 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
gtk_widget_set_margin_start(menu_box, 4);
gtk_widget_set_margin_end(menu_box, 4);
GtkWidget* info = menu_button("Information", nullptr);
g_object_set_data(G_OBJECT(info), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(info, "clicked", G_CALLBACK(on_info_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), info);
// The way back from a chosen colour. The chooser itself cannot carry a "default" button, and a
// setting with no undo is a trap; it lives here rather than on the chip because a second
// gesture on a one-glyph target is not something a user would find.
@@ -275,20 +377,42 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
schedule_layout_save(*a);
}),
&app);
gtk_box_append(GTK_BOX(menu_box), recolor);
if (sig.math) {
// A math row carries its own menu. Information and Calibrate... are a *streamed*
// signal's: calibration is applied at ingest, and a derived channel has no stream to
// describe. No rename either -- the node editor owns a math channel's name and its unit,
// and a second place to change them would be a second answer to the same question.
GtkWidget* edit = menu_button("Edit graph...", nullptr);
g_object_set_data(G_OBJECT(edit), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(edit, "clicked", G_CALLBACK(on_math_edit_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), edit);
gtk_box_append(GTK_BOX(menu_box), recolor);
GtkWidget* del = menu_button("Delete", nullptr);
gtk_widget_set_tooltip_text(del, "delete this math channel");
g_object_set_data(G_OBJECT(del), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(del, "clicked", G_CALLBACK(on_math_delete_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), del);
} else {
GtkWidget* info = menu_button("Information", nullptr);
g_object_set_data(G_OBJECT(info), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(info, "clicked", G_CALLBACK(on_info_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), info);
GtkWidget* cal = menu_button("Calibrate...", nullptr);
gtk_widget_set_tooltip_text(cal, "gain and offset, applied at ingest");
g_object_set_data(G_OBJECT(cal), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(cal, "clicked",
G_CALLBACK(+[](GtkButton* b, gpointer user) {
App* a = static_cast<App*>(user);
const std::size_t idx = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(b), "udpscope-signal")));
open_calibration(*a, GTK_WINDOW(a->window), idx);
}),
&app);
gtk_box_append(GTK_BOX(menu_box), cal);
gtk_box_append(GTK_BOX(menu_box), recolor);
GtkWidget* cal = menu_button("Calibrate...", nullptr);
gtk_widget_set_tooltip_text(cal, "gain and offset, applied at ingest");
g_object_set_data(G_OBJECT(cal), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(cal, "clicked",
G_CALLBACK(+[](GtkButton* b, gpointer user) {
App* a = static_cast<App*>(user);
const std::size_t idx = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(b), "udpscope-signal")));
open_calibration(*a, GTK_WINDOW(a->window), idx);
}),
&app);
gtk_box_append(GTK_BOX(menu_box), cal);
}
GtkWidget* popover = gtk_popover_new();
gtk_widget_add_css_class(popover, "udpscope-menu");
@@ -312,6 +436,15 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
gtk_list_box_append(GTK_LIST_BOX(list), row);
}
// Appended when there are no math channels at all: the `+` is the only way to create the first
// one, so it cannot be conditional on one already existing.
if (!math_divider_done) gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app));
app.dbg_signal_plain = plain;
app.dbg_signal_rows = 0;
for (GtkWidget* c = gtk_widget_get_first_child(list); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
++app.dbg_signal_rows;
}
app.updating_panels = false;
}