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 @@