feat: switching a signal off takes its traces off every plot

The switch in the signal list means "do not acquire this", and until now a trace of it
stayed on its plot drawing nothing: a slot that holds one of the four a plot has, reads
`--`, and blocks the drop that would fill it with something live.

`on_signal_switch()` now clears every slot that holds that signal, by the **signal name**
the slot remembers -- never the channel index, which is what the enable change renumbers --
and says what it did: `'sine' is off: 1 trace left the plots`. The follow-ups (a relayout,
the panels' chips, the layout save) happen once rather than per slot.

Re-enabling deliberately does not put the traces back: the arrangement is the user's, and
guessing where they were is worse than leaving them off. Say the word if you want that
direction too.

[enable-all-probe] reads it end to end -- it enables every signal, assigns the channels
across the rows, then switches one off *through its own acquire tick*:

    [enable-all-probe] switched 'sine' off: traces_left=0 (want 0) notice=''sine' is off: 1 trace left the plots'

which is also why the notice shares the status line's warning styling from the previous
commit: the trace leaving the plots is something the user should see happen.
This commit is contained in:
Martino Ferrari committed 2026-10-12 22:12:24 +02:00
1 parent aaf544be1e
commit 8b3240cacb
6 files changed
+102 -1

No files matched your search

@@ -173,3 +173,12 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
because `relayout_areas()` is the one place that builds a figure and re-points the area at it.
`UDPSCOPE_PROBE_COLOR_MS` reads the result back out of `plot::Plot::trace()` and out of the
chip's own markup, since asking the resolver twice would assert nothing.
- **Switching a signal off takes its traces off every plot.** The switch means "do not acquire this", and a
trace left behind draws nothing: it holds one of the four slots a plot has, reads `--`, and blocks the drop
that would fill it with something live. `on_signal_switch()` clears every slot by the **signal name** the
slot remembers -- never the channel index, which is exactly what the enable change renumbers -- and says what
it did in a notice, because a trace that vanishes silently is its own kind of confusing. The follow-ups (a
relayout, the panels' chips, the layout save) are done once by the caller rather than per slot. Re-enabling
does **not** put the traces back: the arrangement is the user's, and guessing where they were is worse than
leaving them off.
+12
View File
@@ -1243,3 +1243,15 @@ second is that it *said so* -- the message and the stock `.warning` class the st
notice is fresh; the third is the composed line the user reads, which is why the label is only checked here
and not in the second: the status line folds the notice into itself on its own cadence, so a beat after the
refusal it may still hold the previous text.
## `[enable-all-probe]` and switching a signal off
```
[enable-all-probe] 4 channel(s) enabled and assigned to plots
[enable-all-probe] switched 'sine' off: traces_left=0 (want 0) notice=''sine' is off: 1 trace left the plots'
```
`UDPSCOPE_ENABLE_ALL_MS` enables every signal and assigns the acquired channels across the rows, then
switches one off **through its own acquire tick in the signal list** -- the tick is a box inside the row, which
is the shape `signal_tick_visible()` walks -- and reads the plots back. `traces_left=0` is the rule: a disabled
signal leaves every plot rather than staying as a slot that draws nothing, and the notice is what says so.
+21
View File
@@ -2534,6 +2534,27 @@ void remove_trace(App& app, int row, int slot) {
app.rows->assign(row, slot, std::string(), std::string(), 0);
}
/// Takes a signal off every plot, and says how many traces went.
///
/// Switching a signal off means "do not acquire this", so a trace of it left on a plot draws nothing: a slot
/// that reads `--`, takes the place a live signal could have had, and blocks the drop that would fill it. The
/// slot is what is cleared, and the caller does the follow-ups once (a relayout, the panels, the layout save)
/// rather than per slot.
int remove_signal_from_plots(App& app, const std::string& name) {
if (name.empty()) return 0;
int removed = 0;
for (int row = 0; row < app.rows->row_count(); ++row) {
for (int slot = 0; slot < RowSet::kSlotsPerRow; ++slot) {
// The slot remembers the *signal* (and its element), never the channel index -- the channel list is
// renumbered on every enable change, which is what this is reacting to.
if (app.rows->row(row).signal_name[static_cast<std::size_t>(slot)] != name) continue;
remove_trace(app, row, slot);
++removed;
}
}
return removed;
}
void on_remove_clicked(GtkButton* button, gpointer user) {
(void)user;
auto* target = static_cast<RemoveTarget*>(g_object_get_data(G_OBJECT(button), "udpscope-remove"));
+1
View File
@@ -1250,6 +1250,7 @@ void on_y_auto_clicked(GtkButton* button, gpointer user);
void on_fixed_range_apply(GtkButton*, gpointer user);
void on_y_fixed_clicked(GtkButton* button, gpointer user);
void remove_trace(App& app, int row, int slot);
int remove_signal_from_plots(App& app, const std::string& name);
void on_remove_clicked(GtkButton* button, gpointer user);
GtkWidget* menu_icon_button(const char* icon, const char* label);
void on_row_menu_show_remove(GtkButton*, gpointer user);
+40
View File
@@ -1864,6 +1864,46 @@ gboolean enable_all_probe(gpointer user) {
std::fprintf(stderr, "[enable-all-probe] %zu channel(s) enabled and assigned to plots\n",
a->model->channel_count());
std::fflush(stderr);
// Then switch one off through its own switch in the signal list: a disabled signal must leave the plots,
// not sit there drawing nothing in a slot a live one could take.
g_timeout_add(250, +[](gpointer u) -> gboolean {
App* app = static_cast<App*>(u);
if (app->model->channels().empty() || app->signal_list == nullptr) return G_SOURCE_REMOVE;
const std::size_t signal = app->model->channels().front().signal;
const std::string name = app->model->channels().front().name;
GtkWidget* tick = nullptr;
for (GtkWidget* row_widget = gtk_widget_get_first_child(app->signal_list);
row_widget != nullptr && tick == nullptr;
row_widget = gtk_widget_get_next_sibling(row_widget)) {
// The tick sits a box inside the row, which is the shape `signal_tick_visible()` walks too.
for (GtkWidget* box = gtk_widget_get_first_child(row_widget); box != nullptr && tick == nullptr;
box = gtk_widget_get_next_sibling(box)) {
for (GtkWidget* w = gtk_widget_get_first_child(box); w != nullptr;
w = gtk_widget_get_next_sibling(w)) {
if (!GTK_IS_CHECK_BUTTON(w)) continue;
if (GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(w), "udpscope-signal")) != signal) continue;
tick = w;
break;
}
}
}
if (tick == nullptr) {
std::fprintf(stderr, "[enable-all-probe] no acquire tick found for signal %zu\n", signal);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gtk_check_button_set_active(GTK_CHECK_BUTTON(tick), FALSE);
int left = 0;
for (int r = 0; r < app->rows->row_count(); ++r) {
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
if (app->rows->row(r).signal_name[static_cast<std::size_t>(s)] == name) ++left;
}
}
std::fprintf(stderr, "[enable-all-probe] switched '%s' off: traces_left=%d (want 0) notice='%s'\n",
name.c_str(), left, app->notice.c_str());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}, a);
return G_SOURCE_REMOVE;
}
+19 -1
View File
@@ -17,7 +17,25 @@ void on_signal_switch(GObject* obj, GParamSpec*, gpointer user) {
if (app->updating_panels) return;
const std::size_t index = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(obj, "udpscope-signal")));
app->model->set_signal_enabled(index, gtk_check_button_get_active(GTK_CHECK_BUTTON(obj)));
const bool on = gtk_check_button_get_active(GTK_CHECK_BUTTON(obj)) != FALSE;
// The name first: the switch may be the last reference to a signal the model is about to drop.
std::string name;
{
const std::vector<SignalInfo>& signals = app->model->signals();
if (index < signals.size()) name = signals[index].name;
}
app->model->set_signal_enabled(index, on);
if (!on) {
// A trace of a signal nobody acquires draws nothing, and holds the slot a live one could take.
const int gone = remove_signal_from_plots(*app, name);
if (gone > 0) {
app->relayout_pending = true;
rebuild_signal_panels(*app); // the chips of the signals that are still plotted
set_notice(*app, "'" + name + "' is off: " + std::to_string(gone) +
(gone == 1 ? " trace left the plots" : " traces left the plots"),
4);
}
}
// The enable state is part of the session (a signal can be on only as a math channel's source).
schedule_layout_save(*app);
}