diff --git a/docs/claude/invariants-plot-and-layout.md b/docs/claude/invariants-plot-and-layout.md index 66905c0..a82221b 100644 --- a/docs/claude/invariants-plot-and-layout.md +++ b/docs/claude/invariants-plot-and-layout.md @@ -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. diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 6af7672..c7158c4 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -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. diff --git a/src/ui.cpp b/src/ui.cpp index af04f34..c2de9da 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -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(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(g_object_get_data(G_OBJECT(button), "udpscope-remove")); diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 7dafeb1..9b4e0cd 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -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); diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 499c66c..c0df2e7 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -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(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(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; } diff --git a/src/ui_signals.cpp b/src/ui_signals.cpp index f4e853d..a34dd4a 100644 --- a/src/ui_signals.cpp +++ b/src/ui_signals.cpp @@ -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( 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& 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); }