ui: each pane carries its own switch, the same one the header bar has

The trigger pane and the cursors pane now have a `GtkSwitch` in their heading, and it is *one control in
two places* with the header bar's button: both run the same action (`run_app_action()`), and
`sync_panel_switches()` re-reads each pair from the toolbar's state after any change, so clicking either
end moves both. A pane whose feature is off is disabled, which is exactly the state you need the switch
in, so it belongs *in* the pane rather than only in the toolbar.

Three things it needed:

- **The sensitive target moved to the pane's body.** A control inside an insensitive parent cannot be
  clicked, so a switch that greyed out with its own pane could never turn it back on. `on_trigger_toggled`
  and `on_cursors_toggled` now disable `trigger_body` / `cursors_body`, and so does the startup default;
  the headings, with their switches, stay live.
- **The trigger's heading moved out of `trigger-panel.ui`** and is built in `build_trigger_panel()`,
  which returns the heading row and the panel together. It has to be a sibling of the body for the switch
  to stay usable, and keeping it in the file would have meant the file owning a widget the code must
  arrange. The file's own panel is unchanged otherwise, so the `[trigger panel:]` counts still read.
- **A guard on the sync.** Setting a `GtkSwitch` emits `notify::active` for a programmatic write just as
  it does for a click, so without `App::panel_switch_sync` the program's own write would come back as a
  click and flip the feature again.

Readings, both directions in one line:

    trigger panel sensitive before the probe toggled it: 0; … ; pane switches live while their panes are off=1
    panel switches: trigger=1/1 cursors=1/1 drove_toolbar=1  (pane switch / header bar button)

`pane switches live …=1` is the design's point and is read before anything turns either pane on; `0` would
be a switch drawn, greyed and useless. The trigger pair is moved from the *toolbar* end and the cursors
pair from the *pane* end, so the line carries both halves of the sync, and `drove_toolbar=1` is the pane's
own switch having turned the cursors on with the button following. `trigger panel sensitive …=0` is the
body, not the panel.

Verified: the two lines above; `[side-tab-probe] … advanced=4`; `make test-ui` 14 files; zero
`Gtk-CRITICAL` and no missing icon. Docs: the new reading in `probe-readings.md` with what a `0` means,
the invariants bullet for the one-control-in-two-places rule and its two traps, the sidebar bullet's
sensitive target, and the README note on why the switch is in the pane.
This commit is contained in:
Martino Ferrari committed 2026-10-14 14:13:49 +02:00
1 parent d9287ea826
commit cc8335c514
10 files changed
+201 -26

No files matched your search

+49 -6
View File
@@ -4355,6 +4355,39 @@ void on_window_popover_press(GtkGestureClick*, int, double x, double y, gpointer
/// gtk_toggle_button_set_active(), which emits ::toggled and so runs exactly the handler a
/// click would; the non-toggles call their click handlers with a null button, which they
/// already ignore. Split out of the key handler so the probe can fire a row directly.
/// The pane's switches and the header bar's buttons are one control with two places to click, so a
/// change in either re-reads the other. `panel_switch_sync` is the guard: setting a `GtkSwitch` emits
/// its own `notify::active`, and without it the program's write would come back as a click.
void sync_panel_switches(App& app) {
if (app.panel_switch_sync) return;
app.panel_switch_sync = true;
const auto mirror = [](GtkWidget* panel_switch, GtkWidget* toolbar_toggle) {
if (panel_switch == nullptr || toolbar_toggle == nullptr) return;
gtk_switch_set_active(GTK_SWITCH(panel_switch),
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(toolbar_toggle)) == TRUE);
};
mirror(app.trigger_switch, app.trigger_toggle);
mirror(app.cursors_switch, app.cursors_toggle);
app.panel_switch_sync = false;
}
/// A pane's own switch drives the *same action* as the header bar's button, so either place reaches
/// the feature down one path, and the sync above is what keeps the two showing the same thing.
void on_panel_switch_toggled(GObject* obj, GParamSpec*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->panel_switch_sync) return;
const bool on = gtk_switch_get_active(GTK_SWITCH(obj)) != FALSE;
const bool is_trigger = app->trigger_switch != nullptr && obj == G_OBJECT(app->trigger_switch);
GtkWidget* toggle = is_trigger ? app->trigger_toggle : app->cursors_toggle;
if (toggle == nullptr) return;
// The action toggles the toolbar button, so it is run only when the two actually disagree -- which
// is what makes this a *set* rather than a second toggle of something already in that state.
if ((gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(toggle)) != FALSE) != on) {
run_app_action(*app, is_trigger ? AppAction::TriggerPanel : AppAction::CursorsPane);
}
sync_panel_switches(*app);
}
void run_app_action(App& app, AppAction action) {
const auto toggle = [](GtkWidget* button) {
if (button == nullptr) return;
@@ -4771,10 +4804,12 @@ void on_activate(GtkApplication* application, gpointer user) {
app->signal_panel = build_signal_panel(*app);
gtk_box_append(GTK_BOX(signals_page), app->signal_panel);
// Both instruments start off, so both panels start insensitive -- stated here by the one place
// that knows the default, rather than left to whichever widget happened to be built first.
gtk_widget_set_sensitive(app->trigger_panel, FALSE);
gtk_widget_set_sensitive(app->cursors_panel, FALSE);
// Both instruments start off, so both *bodies* start insensitive -- stated here by the one place
// that knows the default, rather than left to whichever widget happened to be built first. The
// bodies and not the panels: each panel's heading carries the switch that turns it on, and a
// control inside an insensitive parent cannot be clicked.
gtk_widget_set_sensitive(app->trigger_body, FALSE);
gtk_widget_set_sensitive(app->cursors_body, FALSE);
gtk_widget_set_sensitive(app->cursors_reset, FALSE);
if (app->side_stack != nullptr) {
@@ -5685,6 +5720,12 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
app.dbg_trigger_sensitive, app.dbg_switcher_visible,
rows.cursors_visible() ? 1 : 0, rows.time_window(),
app.window_limit_s);
std::printf("[udpscope] panel switches: trigger=%d/%d cursors=%d/%d drove_toolbar=%d "
"(pane switch / header bar button; `drove_toolbar` is the pane's own switch having "
"moved the button)\n",
app.dbg_panel_switch_trigger, app.dbg_toolbar_switch_trigger,
app.dbg_panel_switch_cursors, app.dbg_toolbar_switch_cursors,
app.dbg_panel_drove_toolbar);
std::printf("[udpscope] switcher: policy=%s pills=%d labels=%d names=%d icon=%dx%d "
"size=%dx%d\n",
app.dbg_switcher_policy == 0 ? "narrow" : (app.dbg_switcher_policy == 1 ? "wide" : "?"),
@@ -5855,9 +5896,11 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
app.dbg_badge_frames[0], app.dbg_badge_frames[1], app.dbg_badge_frames[2],
app.dbg_badge_frames[3]);
std::printf("[udpscope] trigger panel sensitive before the probe toggled it: %d; "
"signal list items=%d selected=%d; column visible after sidebar toggle off=%d\n",
"signal list items=%d selected=%d; column visible after sidebar toggle off=%d; "
"pane switches live while their panes are off=%d\n",
app.dbg_trigger_sensitive_default, app.dbg_trigger_items,
app.dbg_trigger_selected, app.dbg_side_column_after_hide);
app.dbg_trigger_selected, app.dbg_side_column_after_hide,
app.dbg_panel_switch_live);
{
// The panel's *frame* read back: the row boxes the `.ui` holds, and that every control the code built
// ended up inside the panel rather than in a row that is not there. A control is created in code and