From cc8335c5146ccea5e48ae5013d23c45641ab24d2 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Wed, 14 Oct 2026 14:13:49 +0200 Subject: [PATCH] ui: each pane carries its own switch, the same one the header bar has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 5 ++- docs/claude/invariants-gtk-and-chrome.md | 15 ++++++- docs/claude/probe-readings.md | 25 ++++++++++- src/ui.cpp | 55 +++++++++++++++++++++--- src/ui/cursors-panel.ui | 9 ++++ src/ui/trigger-panel.ui | 12 ++---- src/ui_app.hpp | 26 +++++++++++ src/ui_cursors.cpp | 14 +++++- src/ui_probe.cpp | 32 ++++++++++++-- src/ui_trigger.cpp | 34 +++++++++++++-- 10 files changed, 201 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index f3f190f..545a348 100644 --- a/README.md +++ b/README.md @@ -2397,7 +2397,10 @@ now — they are listed so they are not mistaken for anything else: cursors and the ruler for Measure, a sine crossing its trigger level and the channel list for Signals (the same picture the toolbar's trigger button carries), drawn as a 32x16 pair so both motifs are legible at a glance — with the words kept as each pill's tooltip and - accessible name. The design spec had the switcher showing *titles* and neither page an + accessible name. Each pane's heading also carries its **own switch**, the same control as the + header bar's button — clicking either moves both — because a pane that is *disabled* while its + feature is off is a pane whose controls you would otherwise have to leave to turn it back on. The + design spec had the switcher showing *titles* and neither page an `icon-name`; `narrow` alone does not do it (libadwaita still draws a label beside the icon, measured), so the pages carry an empty title and the pills are named in code. With the math list folded into the signal list under a `MATH` divider whose `+` creates a channel — a derived channel already *is* a channel in diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md index 2904ecd..782ad3b 100644 --- a/docs/claude/invariants-gtk-and-chrome.md +++ b/docs/claude/invariants-gtk-and-chrome.md @@ -166,8 +166,9 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear heights, so one shared scroller would leave a switch scrolled where the new page never asked to be). The reason is the one this trap teaches: five sections competing for one 320 px column left every one of them short of room. A *feature's* on/off is no longer a visibility at all -- the - trigger's and the cursors' toggles set their panel's **sensitivity**, so the panel stays where it - is and the settings you are about to switch back on do not vanish while you look for them. The + trigger's and the cursors' toggles set their pane's **body** insensitive, so the pane stays where it + is and the settings you are about to switch back on do not vanish while you look for them. The *body* + and not the pane, because each pane's heading carries its own switch (see the next bullet). The trap itself does not go away with the widget: a stack's hidden page measures 0x0 too and is still given the leftover width, so **ask about state, not about size** carries over unchanged. Every allocation read in the sidebar is a debug capture, and each reads 0 on the page that is *not* @@ -183,6 +184,16 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear **tooltip and accessible name** from the page's name, then hides the empty label that would otherwise leave the icon off-centre. An icon-only pill is still a named control for a screen reader, and `switcher: … pills=2 labels=0 names=2` is the reading for all three facts at once. +- **A pane's heading switch and the header bar's button are one control in two places.** Each pane's + heading (`build_trigger_panel()` builds the trigger's, `cursors-panel.ui` has the cursors' in its + `head`) carries a `GtkSwitch` that runs the *same action* the toolbar button runs, and + `sync_panel_switches()` re-reads each pair from the toolbar's state after any change — so clicking + either end moves both, and `panel switches: trigger=1/1 cursors=1/1` is the reading. Two traps: + a `GtkSwitch` emits `notify::active` for a *programmatic* write as well, so the sync holds a guard + (`App::panel_switch_sync`) or the program's own write comes back as a click; and the switcher must + stay *out* of what the pane disables, because a control inside an insensitive parent cannot be + clicked — a switch that greyed out with its own pane could never turn it back on. That is why the + sensitive target is the pane's **body** (`trigger_body`, `cursors_body`) and not the pane. - **A list box outlives the children of the pane it sits in, and the teardown order is not yours to choose.** Tearing the window down finalizes the events pane's buttons, then the list box, and the list's own `selected-rows-changed` then reaches a handler holding the freed pointers: four diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 7bfd8a0..280e24f 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -164,7 +164,7 @@ Two lines of the self-check output are assertions in disguise: - `[cursors-probe] mode=.. on=D pane_sensitive=D t0=.. t1=.. dt=.. T=[..] lines=N [..]` (stderr, `UDPSCOPE_PROBE_CURSORS_MS`) — walks `vertical` → `horizontal` → `both` → `mix`, then moves a marker *through the plot* (`Plot::set_cursor_x(..., Free)`, which is - what a drag does) and reports again. `pane_sensitive` is the cursors *panel's* sensitivity, not a + what a drag does) and reports again. `pane_sensitive` is the cursors *panel body's* sensitivity, not a revealer's child: with the cursors off the panel stays exactly where it is and its cells blank to `--`, because a readout whose instruments are off has no sample to quote. **The times must survive every mode change**: they are the `RowSet`'s one pair, and a mode that parks the markers must not read them back @@ -1139,6 +1139,29 @@ library measures and draws the rect in tests of its own. defect this reading exists for, and it was invisible from the frame alone: the file view pinning its axis from the ring each frame is what swallowed the gesture. +## `panel switches:` and the trigger panel's default sensitivity + +``` +[udpscope] trigger panel sensitive before the probe toggled it: 0; signal list items=1 selected=0; column visible after sidebar toggle off=1; pane switches live while their panes are off=1 +[udpscope] panel switches: trigger=1/1 cursors=1/1 drove_toolbar=1 (pane switch / header bar button; `drove_toolbar` is the pane's own switch having moved the button) +``` + +Each pane's heading carries its own switch, and it is the *same* control as the header bar's button: +both run the same action and both are re-read from the toolbar's state afterwards, so they cannot +disagree. The two lines are the claim, in both directions: + +- **`pane switches live while their panes are off=1`** — read before anything turns either on, because + it is the whole point of putting the switches in the headings: a control inside an insensitive parent + cannot be clicked, so a switch that went insensitive *with* its own pane could never turn it back on. + **`0` is that defect**: the switch is drawn, greyed, and useless. +- **`trigger=` and `cursors=` are `pane switch / header bar button`**, and the pairs must agree. The + trigger's is moved from the *toolbar* end (these steps toggle that button to arm the trigger) and the + cursors' from the *pane* end, so one line carries both directions of the sync. **`drove_toolbar=1`** + is the second direction's assertion: the pane's own switch turned the cursors on and the header bar's + button followed. +- `trigger panel sensitive …=0` is the *panel body*, not the panel: the heading row, with its switch, + stays live. `1` here while trigger mode is off is the pane not being disabled at all. + ## `[sidebar-probe]` ``` diff --git a/src/ui.cpp b/src/ui.cpp index 1e06005..d43a7c2 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -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(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 diff --git a/src/ui/cursors-panel.ui b/src/ui/cursors-panel.ui index 145c0d2..3038cce 100644 --- a/src/ui/cursors-panel.ui +++ b/src/ui/cursors-panel.ui @@ -38,6 +38,15 @@ flat + + + + center + + diff --git a/src/ui/trigger-panel.ui b/src/ui/trigger-panel.ui index 8429fc4..749c1ff 100644 --- a/src/ui/trigger-panel.ui +++ b/src/ui/trigger-panel.ui @@ -2,8 +2,10 @@