From 4b65197f56ecbca93e5e476c2c08e0826eef3971 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 2 Nov 2026 20:37:55 +0100 Subject: [PATCH] ui: the sidebar's tabs become one dock; Measure and Signals by keyboard section; session keeps the dock's state Co-Authored-By: Claude Opus 4.6 --- README.md | 6 +- docs/claude/architecture.md | 2 +- docs/claude/invariants-gtk-and-chrome.md | 46 ++-- docs/claude/invariants-plot-and-layout.md | 5 +- docs/claude/invariants-session-and-export.md | 5 +- docs/claude/probe-readings.md | 116 ++++----- docs/claude/verification.md | 23 +- src/layout_store.cpp | 6 +- src/layout_store.hpp | 7 +- src/shortcuts.cpp | 4 +- src/shortcuts.hpp | 4 +- src/ui.cpp | 188 ++++++--------- src/ui/main-window.ui | 69 +----- src/ui/shortcuts.ui | 4 +- src/ui_app.hpp | 56 ++--- src/ui_layout.cpp | 7 +- src/ui_probe.cpp | 239 ++++++++++++++----- src/ui_source.cpp | 42 ---- src/ui_tour.cpp | 11 +- 19 files changed, 421 insertions(+), 419 deletions(-) diff --git a/README.md b/README.md index ded6c36..bf3e487 100644 --- a/README.md +++ b/README.md @@ -2915,9 +2915,11 @@ now — they are listed so they are not mistaken for anything else: unlocked, because configuring the host is what a first run is for. 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 - switcher's policy is `wide` and each page has a title and no icon (the switcher's own placeholder icon is hidden). With the math + sidebar is one **dock** now, with no tab switcher: the signal list, the cursors and the measurements + in one scroller, shown from the start and insensitive until the stream has signals; `m` and `g` + scroll it to the cursors and back to the signal list. 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 - the model, so the two lists were the same list drawn twice. A tab is one-of-*N*, so no + the model, so the two lists were the same list drawn twice. No toggle hides a pane any more: cursors-off **disables** its readout and leaves it in place, and trigger-off leaves the trigger panel fully editable — a trigger is configured *before* it is switched on, and the locked view says `stream (trigger off)` instead of the settings — and the measurements' and math panes' toolbar toggles are gone with the panes. diff --git a/docs/claude/architecture.md b/docs/claude/architecture.md index b295015..40c8568 100644 --- a/docs/claude/architecture.md +++ b/docs/claude/architecture.md @@ -36,7 +36,7 @@ What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*. `src/source/udps_*.cpp` (`grep -rn "udps_" src` outside those files is a leak of the old coupling). - The frame is **single-timebase**. A multi-timebase source (ABScope) will change the interface and the data path together; do not bend one frame to carry two clocks. -- A kind's capability panels are pages of `side_stack`, named `source-`. A disconnected source +- A kind's capability panels are pages of the slide-over (`slideover_add_page`), named `source-`. A disconnected source leaves them **insensitive, not hidden** (`apply_source_panels` in `src/ui_source.cpp`). - Session file naming for a non-UDPS kind: the pseudo-host is `-` (characters outside `[A-Za-z0-9._-]` become `_`) on port **1** (0 is rejected by the session-file parser), the kind is diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md index c594499..cc7d9fc 100644 --- a/docs/claude/invariants-gtk-and-chrome.md +++ b/docs/claude/invariants-gtk-and-chrome.md @@ -50,7 +50,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear runs after the window is destroyed and asking GTK about a widget then is four criticals, which is how the first version of that line earned them. What stays in the menu is what acts on the *data* (Fit, Export, Configurations) and the one editor that is not a pane (the layout editor): the signal list - is a *tab* of the sidebar, so there is nothing left to reveal or hide about it. The Arm/Disarm button in the trigger panel still has text. + is a section of the sidebar's dock, so there is nothing left to reveal or hide about it. The Arm/Disarm button in the trigger panel still has text. - **Five of this app's icons are its own, and they are a `GResource` compiled into the binary.** No icon theme has a glyph for an oscilloscope trigger, a pair of cursors or a measurement ruler, and the stock names those buttons used to carry said something else @@ -61,9 +61,9 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear an icon with a live user is not an orphan, whatever the toggle that introduced it did -- and it is the Preferences window's icon. - The sidebar's **tabs have no icons any more**: two glyphs (`udpscope-tab-measure-symbolic`, + The sidebar has **no tabs any more** (it is one dock): two tab glyphs (`udpscope-tab-measure-symbolic`, `udpscope-tab-signals-symbolic`, each a 32x16 pair) were drawn for them and judged not understandable - enough, so the tabs are text and both SVGs were deleted. The *trigger's* own icon was redrawn to the + enough, so the tabs were text, and both SVGs were deleted. The *trigger's* own icon was redrawn to the Signals glyph's picture at that time and kept it. - **A shortcut table row's `accel` is GTK accelerator syntax, and a pointer gesture has none.** `src/shortcuts.cpp`'s strings are handed to `AdwShortcutsItem`, which parses them @@ -229,11 +229,13 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear instead of across. `gtk_widget_set_visible()` is what a layout honours, so anything that must not take room has to be hidden and not merely unrevealed; the revealer keeps the slide while the pane *is* open. The allocated width is how this was found -- the revealer's own measure said 0. - **The sidebar is a tab stack now, and has no `GtkRevealer` left in it**: an `AdwViewSwitcher` - above an `AdwViewStack`, each page a `GtkScrolledWindow` of its own (the two pages are different - 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 + **The sidebar is one dock now, and has no `GtkRevealer` left in it**: a single `GtkScrolledWindow` + (`dock_scroller`) holding the signal list, the cursors and the measurements one under another. It + replaced an `AdwViewSwitcher` over an `AdwViewStack`, whose four text tabs did not fit 320 px (and a source + can add more); a source kind's own panels are slide-over pages, not dock sections. The reason is the one + this trap teaches: sections competing for one 320 px column each left every one of them short of room, and + a scroller gives each its natural height. `m` and `g` scroll the dock to the cursors and to the signal list + (`dock_show_section()`). A *feature's* on/off is no longer a visibility at all -- the **cursors'** toggle sets its pane's **body** insensitive -- the readout has nothing to show with the instruments off -- and the *body* and not the pane, because the pane's heading carries its own switch (see the next bullet). The **trigger's body is deliberately never disabled**: a trigger is configured @@ -241,23 +243,15 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear ordinary way to work. It used to go insensitive with the mode, which left the settings unreachable exactly when a user wanted to change them; `trigger panel sensitive …=1` is the regression guard, and a `0` there means the configuration has been locked out again. 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* - current -- a change in what the reading means, not a defect, and no functional code may depend on - those numbers. -- **The sidebar's tabs are text: `AdwViewSwitcherPolicy.wide`, a title on every page, no icon.** The - built-in pages get their titles in the `.ui` and a source kind's pages from `SourcePanel::title` - (`adw_view_stack_add_titled`); the page's *name* still identifies it to `select_side_tab()` and the - session file. An icon-only version existed (policy `narrow`, empty titles, the pills named in code by - `name_side_tabs()`, because `narrow` still draws a label beside an icon); it was reversed because the - glyphs were not understandable. A page added with an empty title is a blank pill, and - `switcher: … pills=N labels=N images=0 pages=N` reads it as `labels` below `pills`. **No icon at all** - is not what "no `icon-name`" gives you: `AdwViewSwitcherButton` always builds a `GtkImage` and falls - back to `image-missing` (the `[x]` placeholder, which also squeezed the title to "Meas..."), and no - property turns it off. `keep_tabs_icon_free()` (`src/ui_source.cpp`) hides every image in the switcher, - re-hides one that is shown again, and re-runs on the stack's `items-changed` (connected *after* the - switcher's own, so the new button exists) and on the switcher's `map`; `images=0` is the reading. + trap itself does not go away with the widget: a section scrolled out of view is still laid out, but + **ask about state, not about size** still holds for anything a tick reads. +- **The dock is shown but insensitive until the stream has signals ("disable, do not hide").** + `update_side_visibility()` and the tick's per-frame block set the column's visibility from the user's wish + alone (`sidebar_wanted`) and the dock's *sensitivity* from `have_signals`; the toolbar's Dock button stays + sensitive throughout. The wish is persisted (`dock = true|false` in the session file) and a change of it + schedules a save. There is no tab icon to keep clear any more: the `keep_tabs_icon_free()` workaround for + `AdwViewSwitcherButton`'s placeholder `[x]` image went with the switcher, and `dock:` in the dump + replaces the `switcher:` line. - **An *overlaid* sidebar covers the plot, so a drag out of it has nowhere to land.** `collapsed` in an `AdwOverlaySplitView` means the sidebar sits *on* the plot rather than beside it: the pointer is over the list, never over a drop target, and a signal can be dragged but not dropped. `row_drag_sidebar()` hides @@ -278,7 +272,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear `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 + (`App::panel_switch_sync`) or the program's own write comes back as a click; and the switch 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. diff --git a/docs/claude/invariants-plot-and-layout.md b/docs/claude/invariants-plot-and-layout.md index a82221b..897d782 100644 --- a/docs/claude/invariants-plot-and-layout.md +++ b/docs/claude/invariants-plot-and-layout.md @@ -23,10 +23,11 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear toggle is the same mistake in one line, and it cannot be told from a real change by a "is this our own write" flag either, since the toggle's signal may fire after the write returns. Instead `App::sidebar_wanted` holds the wish (written only by `on_sidebar_toggled`, guarded by `App::sidebar_syncing` around our own - `set_active`), `update_side_visibility()` is the single place that turns that wish plus "are there signals" - into the panel's *and* the button's state together, and `tick()` re-asserts them when the widget disagrees — + `set_active`), `update_side_visibility()` is the single place that turns that wish into the panel's visibility + and "are there signals" into the dock's *sensitivity* (the button stays sensitive), and `tick()` re-asserts both when the widget disagrees — which is also why a hide *gesture* does not stick: the panel stays where the button says it is. The no-signals case must leave the wish alone, or a source connecting later finds a sidebar the user never closed. + The wish is the session's `dock` key: a change of it (not a program's own write, and not a restore) schedules a save. - **`AdwOverlaySplitView:sidebar-width-fraction` is a 0..1 fraction of the window, whatever `sidebar-width-unit` says.** Setting a pixel count there (320) is a `GObject` critical, and it silently falls back to 0.25 — which reads as "the sidebar is mysteriously 560 px wide" on a wide window with no diff --git a/docs/claude/invariants-session-and-export.md b/docs/claude/invariants-session-and-export.md index 550e0ea..169ebdb 100644 --- a/docs/claude/invariants-session-and-export.md +++ b/docs/claude/invariants-session-and-export.md @@ -30,7 +30,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear not freeze a colour that is meant to follow the signal list), **the math channels with their graphs**, **the measurements pane**, and **`measure_stats`** — the measurements statistics toggle; written as a bare top-level boolean (`measure_stats = true/false`), defaults to `false` - when absent. Signal identity + when absent, and **`dock`** -- the Dock button's state (the user's last word on it, `sidebar_wanted`), + a bare top-level boolean written where the sidebar's `side_tab` used to be, default `true`. The parser + ignores unknown keys, so a file written before the tabs became a dock (`side_tab = "measure"`) still loads + and simply opens with the dock shown; nothing needs to read the old key. Signal identity is the *name* throughout (a CONFIG renumbers channels), 'enabled' is read off the channels the model built -- an enabled signal is exactly one that produced a channel -- and `apply_layout()` installs the graphs **before** assigning the rows, since a row may hold a math channel's name and diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 36faa0d..48d9243 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -479,22 +479,18 @@ Two lines of the self-check output are assertions in disguise: substitutes a missing-image placeholder, the button still works, and every other line of the dump still passes. Changing an icon name is a one-line change in `build_toolbar()` and this is the only check that it names something that exists. -- `switcher: policy=wide pills=4 labels=4 images=0 pages=4 size=308x34` (in the exit dump, beside `panels:`) — the - sidebar tabs as the switcher actually draws them, and they are **words**: `labels` counts *mapped labels - with text*, so `pills=labels=pages` says every page has a visible title. **`images` must be `0`**: it - counts *mapped* `GtkImage`s inside the switcher. `AdwViewSwitcherButton` builds an icon in every tab, and - a page with a title and no `icon-name` gets `image-missing` -- the `[x]` placeholder; the tabs hide it - (`keep_tabs_icon_free()`, which also covers pages `build_source_panels()` adds). A nonzero `images` - names a tab still drawing an icon or its placeholder (`images=pages` is the unfixed state), and - `pills`/`labels` never saw it. Read it on a source with extra pages (`--source probe` under - `UDPSCOPE_PROBE_SOURCE_KINDS`) as well as on UDPS. `pages` is read from the stack - (2 for UDPS, 3 for the probe source, 4 for the E-1608 with its Analog out and Digital I/O tabs). A - `labels` below `pills` is a page added with an empty title; `pills=0` means nothing is mapped, which - makes the rest of the line meaningless rather than a pass. `policy=wide` is read rather than assumed. - `size=` is the bar's own size: a text tab costs about 34 px of height, and a width of 308 shows the - four tabs fit the 320 px column. +- `dock: shown=1 sensitive=1 w=320 scroller_pages=1` (in the exit dump, beside `panels:`) -- the sidebar's dock + as the last tick found it. It replaces the old `switcher:` line: there is no tab switcher, the sidebar is + one scroller (`dock_scroller`) holding the signal list, the cursors and the measurements. **`shown=1` + always** (the dock is on screen from the start; `shown=0` only when the user hid it with the Dock button, + which a probe does deliberately); **`sensitive=0` only before the stream has signals** ("disable, do not + hide") -- a run against a port with no sender reads `shown=1 sensitive=0`, a normal run `sensitive=1`; + `w=` is the sidebar's own width (320); **`scroller_pages=1`** counts the scrollers directly under the dock + column and says there is one scroller and no stack. These are captured on the tick or at the exit probe: + the dump runs after the window is destroyed. A source with extra panels adds *no* dock section: its panels + are slide-over pages (`source-`), so the line reads the same on the E-1608 and the probe kind. - `help layer: contextual=on hints=12 ids={events plot status cursors measures trigger trigger_advanced events_record trigger_search signals signals_config window} icon=dialog-information-symbolic(ok) hint=12x12; tour=idle steps=5 step=0 auto=1 requested=0` (in the exit - dump, beside `switcher:`) — the help affordance layer as the last tick found it. **`hints=` must equal + dump, beside `dock:`) — the help affordance layer as the last tick found it. **`hints=` must equal the number of ids**, and the ids are the surfaces the contextual help is on: eleven of the window itself, and `math` only while the math editor is open (its heading lives in a builder that is rebuilt on every opening, so it is not one of the window's own). Four of the eleven are not a pane's *heading* @@ -538,7 +534,7 @@ Two lines of the self-check output are assertions in disguise: [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). The writer chains to `g_log_writer_default()`, so every message still reaches stderr exactly as before and the documented baselines are read the same way. -- `help figures: trigger_head=X,Y,W,H trigger_summary=… trigger_panel=… signal_list=… side_switcher=… side_stack=… window_button=… status_label=… header_bar=… plot=…` (in the exit +- `help figures: trigger_head=X,Y,W,H trigger_summary=… trigger_panel=… signal_list=… side_stack=… window_button=… status_label=… header_bar=… plot=…` (in the exit dump, beside `help layer:`) — the **window-coordinate** rectangle of each surface the help's annotated figures label, `x,y,width,height`, or `-1,-1,0,0` for a surface that is **not on screen**. It is measured on the tick while the window is alive (nothing in the dump may call `gtk_widget_*` — see @@ -919,9 +915,9 @@ two also catch a row whose accelerator went missing. `settings=` must be a real selectable into it (`gtk_widget_get_mapped()` -- a wrong *page* is kept, because selecting its page reveals it, while a *collapsed pane* is still dropped; see the surface bullets in [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). - - `[helpui] step=1/5 visible=1 side_tab=signals spot=[x,y WxH] target=[…] contains=1 card=[…] clear=C` - — **in window coordinates**, the frame a screenshot is cropped in, with the sidebar's page beside - it (a stop the walk had to change the page for is readable as such; see `[toursurface]` below). + - `[helpui] step=1/5 visible=1 slideover=none spot=[x,y WxH] target=[…] contains=1 card=[…] clear=C` + — **in window coordinates**, the frame a screenshot is cropped in, with the slide-over's open page + (`none`, or a page id) beside it; see `[toursurface]` below. **`contains` must be 1** on every step: the spotlight is the target's own rectangle grown three pixels. `clear` is the card missing the target, and it is 1 wherever the window has room for the card beside or above/below the hole — at the documented run's window (see `help figures:` below) @@ -949,34 +945,30 @@ two also catch a row whose accelerator went missing. `settings=` must be a real read `mapped=0` (and `0x0`), which is exactly why the header box exists (`search_head` in `src/ui/trigger-panel.ui`) and why the hint joins it as its last child. `mapped_header=` is the header box beside it, and `hint=WxH` is the same 12x12 the layer's own line reads. -- `[toursurface] …` (stderr, `UDPSCOPE_PROBE_TOUR_SURFACE_MS`) — a stop that stands on a page it is - not on, entered from the page it is not on. It puts the sidebar on **Measure**, starts the window's - tour, and walks it a stop per tick (400 ms), because a stack allocates the new page's widgets on - the frame *after* the switch — so each reading is of the page the step left standing, not of the - one the switch has just left behind. The lines: +- `[toursurface] …` (stderr, `UDPSCOPE_PROBE_TOUR_SURFACE_MS`) -- a stop that stands on a surface it is + not on, entered from the state it is not in. Since the sidebar's tabs became the dock the probe opens the + `trigger` slide-over page instead of switching a tab, starts the window's tour and walks it a stop per tick + (400 ms). **The tour's own rework for the slide-over is Task 5's**: until its page hook exists the tour + has no page stack, so a stop whose target is on a slide-over page is dropped, and the lines below read the + dock only. The lines: - - `[toursurface] before: side_tab=signals signal_list_mapped=1 running=1` — the state a first run - starts in (a fresh config's sidebar is on Signals), then the auto-started walk is dismissed. - - `[toursurface] on measure: side_tab=measure signal_list_mapped=0 side_stack_mapped=1` — a frame - after the switch: **`signal_list_mapped=0` beside `side_stack_mapped=1`** is the whole premise, - the list is genuinely off screen while the stack that holds it is on screen. - - `[toursurface] tour: visible=1 steps=5 step=0 signals_stop=1` — **`signals_stop=1` is the filter's - reading**: a stop whose target is *not mapped* is still in the plan because it names a page the - stack can select. `steps=5` is the full table. - - `[toursurface] step=N/5 side_tab= target_mapped=D[ target=signal_list]` — one per stop. The - page moves from `measure` to `signals` at the `trigger` stop (the first stop whose target is on - the Signals page) and stays there; **the `signals` stop reads `target_mapped=1`**. - - `[toursurface] signals stop=4/5 visible=1 side_tab=signals spot=[…] target=[…] contains=1 - card=[…] clear=1` — the `tour_geometry_line()` the helpui probe uses, read on the frame *after* - the switch: **`side_tab=signals` with `contains=1` is the assertion** that the spotlight and the - card are on the list, not on the page the walk came from. - - `[toursurface] collapsed: side_stack_mapped=0 steps=2 signals_stop=0 side_stop=0` — the other half - of the filter, read: with `sidebar_wanted` off (`update_side_visibility()` re-asserts it, so this - is how a probe puts the sidebar away for good) the split view unmaps the column, the stack with - it, and the two stops that need the sidebar are dropped while the page-less ones stay. This is - what says a *wrong page* and a *collapsed pane* are not the same case. -- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | c: cursors: 0->1 | m: tab signals->measure | g: tab measure->signals | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. - The `m`/`g` pairs are the *tab name* before and after, which is what says those two keys select a tab rather than toggling a pane; `t`/`c` read the toolbar toggle's own state, because those two enable a feature and the button is where that state is visible. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `: : ->` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values). + - `[toursurface] before: slideover=none signal_list_mapped=1 running=1` -- the state a first run starts in + (the dock is on screen), then the auto-started walk is dismissed. + - `[toursurface] on trigger: slideover=trigger signal_list_mapped=1 side_box_mapped=1` -- a frame after the + page was opened: the dock is still on screen under the slide-over (they are not alternatives). + - `[toursurface] tour: visible=1 steps=N step=0 signals_stop=1` -- **`signals_stop=1` is the filter's + reading**: the signal list is in the dock, which is mapped, so its stop is in the plan. + - `[toursurface] step=N/M slideover= target_mapped=D[ target=signal_list]` -- one per stop; + **the `signals` stop reads `target_mapped=1`**. + - `[toursurface] signals stop=N/M visible=1 slideover=… spot=[…] target=[…] contains=1 card=[…] clear=1` -- + the `tour_geometry_line()` the helpui probe uses: **`contains=1` is the assertion** that the spotlight + and the card are on the list. + - `[toursurface] collapsed: side_box_mapped=0 steps=2 signals_stop=0 side_stop=0` -- the other half of + the filter, read: with `sidebar_wanted` off (`update_side_visibility()` re-asserts it, so this is how a + probe puts the sidebar away for good) the split view unmaps the column and the stops that need the + dock are dropped while the page-less ones stay. This is what says a *collapsed pane* is dropped. +- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | c: cursors: 0->1 | m: dock 0->243 (cursors_y=243 reach=2256 at_cursors=1) | g: dock 243->0 at_top=1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** (`g` returns, so its pair reads `243->0`) and **`refused` must be 1**. + The `m`/`g` readings are the dock scroller's vertical adjustment before and after (the probe pads the dock to 3000 px so there is something to scroll, and undoes it at the end): **`m` must land on `cursors_y`, the cursors panel's y in the dock (`at_cursors=1`), and `g` must return to 0 (`at_top=1`)**, which is what says those two keys scroll to a section rather than toggling a pane; a `reach` of 0 would mean the dock fit its viewport and the probe's padding was lost; `t`/`c` read the toolbar toggle's own state, because those two enable a feature and the button is where that state is visible. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `: : ->` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values). - `[color] '' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1)`, then `[color] override: trace=#123456 chip_agrees=1`, `[color] session: entries=1 rgb=#123456` and `[color] default: trace=#RRGGBB chip_agrees=1` (stderr, `UDPSCOPE_PROBE_COLOR_MS`) — **all three `chip_agrees` must be 1**, the override line's `trace` must be `#123456`, the session line must read `entries=1 rgb=#123456`, and the default line's `trace` must be back to the first line's. Run it with `UDPSCOPE_ENABLE_ALL_MS` ahead of it, or there is no bound trace and the probe prints `no bound trace to recolour` instead of asserting anything; on the standard mock the first line reads `'sine' at row 0 slot 0: trace=#4FA3FF`. Two things make this an assertion rather than a tautology. The colours are read out of **`plot::Plot::trace().color`** — what the renderer will stroke, reached through `Row::plot` *after* `relayout_areas()`, since a new colour is a new figure — and out of the chip's own `` markup, not by asking `RowSet::signal_color()` twice, which would pass against a chip that never reached the trace. And the override is `#123456`, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips `collect_layout()` → `save_layout()` → `load_layout()` on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit. - `[reseq-probe] reseq=N late=N stale=N overflows=N holes=N hole_slots=N backfilled=N gaps_refused=N open_holes=N bands=N bands_repaired=N nan_slots=N scanned=N trigger_in_hole=D` @@ -1796,18 +1788,16 @@ and `regained` reads 400 ms later. ## `[side-tab-probe]` ``` -[side-tab-probe] page=signals accums=4 baseline recorded -[side-tab-probe] page=signals rows=4 accums=4 advanced=4 min_n=18 +[side-tab-probe] section=measure accums=4 baseline recorded (measure at scroll 243 of 2256) +[side-tab-probe] section=signals rows=4 accums=4 advanced=4 min_n=18 ``` -`UDPSCOPE_PROBE_SIDE_TAB_MS` is the one reading for the rule that **the statistics accumulator keeps running -on the hidden page**. It switches *to* the Measure tab, forces one pass so there is a baseline, then switches -away to Signals and reads again two seconds later -- with the measurements' page not current, so its widgets -are unrealized and measure 0x0. +`UDPSCOPE_PROBE_SIDE_TAB_MS` (the variable and the tag keep their pre-dock names; the function is `dock_section_probe`) is the one reading for the rule that **the statistics accumulator keeps running +while the Measure section is out of view**. It scrolls the dock *to* the Measure section (`dock_show_section("measure")`), forces one pass so there is a baseline, then scrolls back to Signals and reads again two seconds later. To make the scroll observable on a short window the probe pads `dock_page` to 3000 px (probe only), and it re-arms once after 400 ms so the first reading has the layout. -- **`advanced=4` is the assertion**: every accumulator gained passes while its page was off screen, and +- **`advanced=4` is the assertion**: every accumulator gained passes while its section was out of view, and `min_n` (the smallest `n` seen) must be non-zero too. **`advanced=0` is the defect**: something gated the - accumulation on the visible tab, so the readout would gain a gap in its min/max/avg for exactly as long as + accumulation on the visible section, so the readout would gain a gap in its min/max/avg for exactly as long as the trigger was on screen. A `Gtk-CRITICAL` here is the other half of the same rule -- a label or an allocation read on the unrealized page. - The probe enables a signal **and assigns it to a plot** itself, both deliberately. A signal starts disabled @@ -1849,7 +1839,7 @@ kind, so a run without it never shows that kind. The `form` line appears once pe `drained=0` is a queue that `poll()` does not drain on some path; `after` greater than `before + 1` is a command run twice. -The UDPS run has no `source-` page in `side_stack`, because the UDPS kind has no panels. +The UDPS run has no `source-` slide-over page, because the UDPS kind has no panels. ## `[e1608]` @@ -2591,3 +2581,19 @@ The same probe then puts five measurements on the same spiked `sine` as **two wi - **`measure windows=2`** -- the five legs ask for corners `0, 50, 0, 0, 50` on one channel, which is two distinct windows. **`windows=1` means the corner is not part of the key**: the five collapsed onto one walk and two of them read a signal they did not ask for. + +## `[dock-session]` + +``` +[dock-session] hidden: wish=0 save_scheduled=1 collected=0 saved=1 loaded=1 read=0 applied=1 split_follows=1 ok=1 +[dock-session] shown: wish=1 save_scheduled=1 collected=1 saved=1 loaded=1 read=1 applied=1 split_follows=1 ok=1 +[dock-session] old file (side_tab only): loaded=1 dock_shown=1 default=1 ok=1 +``` + +`UDPSCOPE_PROBE_DOCK_SESSION_MS` reads the one thing the dock persists, the Dock button's state, as `dock = true|false` in the +session file (it replaced `side_tab`). Each of the first two lines drives the toggle, then follows the wish through: `save_scheduled=1` is +`on_sidebar_toggled` having scheduled a save (a user's wish that was not saved is lost at the next start), `collected` is +`collect_layout()`'s `dock_shown`, `saved`/`loaded` the file round trip on a temp path, `read` the loaded value, `applied` +the wish after `apply_layout()`, and `split_follows=1` the split view following it. **`ok=1` on all three lines is the assertion.** The third line +loads a file as the previous version wrote it (`side_tab = "measure"`): it must load with no error and leave `dock_shown` at its default +(`true`) -- the parser ignores unknown keys, and this is what says the old key still does not break a session. The probe writes only to a temp path. diff --git a/docs/claude/verification.md b/docs/claude/verification.md index d5b3dd6..090b15d 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -412,12 +412,19 @@ precedent, not an exception. pauses, reports again with `dn=` per measurement, then resets and reports a third time. Three phases: `phase=0`, `phase=1` (after pause) and `phase=2` (after Reset). Needs `UDPSCOPE_ENABLE_ALL_MS` ahead of it and `UDPSCOPE_PROBE_MEASURE_MS` to add measurements first. -- `UDPSCOPE_PROBE_SIDE_TAB_MS=3000` with `UDPSCOPE_EXIT_AFTER_MS=11000` — the measurements on the - Measure tab, a forced pass, then the tab switched away and read again two seconds later. **`advanced=4` - is the assertion** (every accumulator gained passes while its page was off screen); `advanced=0` is the - defect where the accumulation is gated on the visible tab, which would leave a gap in min/max/avg for +- `UDPSCOPE_PROBE_SIDE_TAB_MS=3000` with `UDPSCOPE_EXIT_AFTER_MS=11000` — the measurements in the + dock's Measure section, a forced pass, then the dock scrolled back to the signal list (the Measure + section out of view) and read again two seconds later. The probe pads the dock to 3000 px so a scroll is + observable on a short window. **`advanced=4` + is the assertion** (every accumulator gained passes while its section was out of view); `advanced=0` is the + defect where the accumulation is gated on the visible section, which would leave a gap in min/max/avg for exactly as long as the trigger was on screen. It enables *and plots* its own signal, so it needs no other - probe ahead of it. The reading's own entry is in `probe-readings.md`. + probe ahead of it. The reading's own entry is in `probe-readings.md`. (Probe name `dock_section_probe`; the + variable and the `[side-tab-probe]` tag keep their old names so existing scripts keep working.) +- `UDPSCOPE_PROBE_DOCK_SESSION_MS=2500` with `UDPSCOPE_EXIT_AFTER_MS=8000` -- the session file's `dock` key, + end to end: the dock hidden, saved and reloaded; shown again; and a file from the previous version + (`side_tab = "measure"`, no `dock`) loaded without an error. Three `[dock-session]` lines, each `ok=1`; + entries in `probe-readings.md`. - `UDPSCOPE_PROBE_SLIDEOVER_MS=1500` with `UDPSCOPE_EXIT_AFTER_MS=5000` (and `UDPSCOPE_ENABLE_ALL_MS=500`) -- the slide-over panel over the plot's right edge, opened on a throw-away `probe` page, read 0.5 s later. Two `[slideover]` lines. Line 1 must read `panel=360x ... edge_flush=1 full_height=1 @@ -631,12 +638,12 @@ as distinct from the fault injection below, which exercises ingest: bullet) — so a probe run against a fresh config directory will have it on screen for the first second and a half, and that start *writes* `tour_seen` to whichever config directory the run is using, which is why the second run against the same one reads `auto=0`. -- `UDPSCOPE_PROBE_TOUR_SURFACE_MS` — the tour's *surface* switch: it puts the sidebar on **Measure**, +- `UDPSCOPE_PROBE_TOUR_SURFACE_MS` — the tour's *surface* switch: it opens a slide-over page (`trigger`), starts the window's tour, and walks it a stop per tick to the signals stop, reading the sidebar's page and the spotlight/card where the walk arrives. It then puts the sidebar *away* and starts the tour again, which is the same filter's other case (a collapsed pane drops the stops a page switch - could have revealed). `[toursurface] signals stop=4/5 … side_tab=signals … contains=1` and - `[toursurface] collapsed: side_stack_mapped=0 steps=2 signals_stop=0` are the two assertions; its + could have revealed). `[toursurface] signals stop=4/5 … slideover=none … contains=1` and + `[toursurface] collapsed: side_box_mapped=0 steps=2 signals_stop=0` are the two assertions; its lines are in [probe-readings.md](probe-readings.md). Run it alone with `UDPSCOPE_EXIT_AFTER_MS=9000 UDPSCOPE_EXIT_DUMP=1` against a live source. - `UDPSCOPE_PROBE_DEFAULTS_MS` — opens **Calibrate** and reads every spin button in it and in the diff --git a/src/layout_store.cpp b/src/layout_store.cpp index d6454b8..502f9aa 100644 --- a/src/layout_store.cpp +++ b/src/layout_store.cpp @@ -568,8 +568,8 @@ bool load_layout(const std::string& path, LayoutState& out) { } } else if (key == "measure_stats") { st.measure_stats = value == "true"; - } else if (key == "side_tab") { - st.side_tab = strip_quote(value); + } else if (key == "dock") { + st.dock_shown = value == "true"; } else if (key.rfind("trigger_", 0) == 0) { trigger_key(st.trigger, key.substr(8), strip_quote(value)); } else if (key == "multicast") { @@ -740,7 +740,7 @@ bool save_layout(const std::string& path, const LayoutState& state) { o << "]\n"; } o << "measure_stats = " << (state.measure_stats ? "true" : "false") << "\n"; - o << "side_tab = \"" << state.side_tab << "\"\n"; + o << "dock = " << (state.dock_shown ? "true" : "false") << "\n"; // The trigger, settings and state both. Written even when it is off: that is the point of it. o << "trigger_enabled = " << (state.trigger.enabled ? "true" : "false") << "\n"; if (!state.trigger.channel.empty()) { diff --git a/src/layout_store.hpp b/src/layout_store.hpp index eb7db18..38264d7 100644 --- a/src/layout_store.hpp +++ b/src/layout_store.hpp @@ -73,9 +73,10 @@ struct LayoutState { /// stream and mean nothing restored against another one. bool measure_stats = false; - /// Which sidebar tab was showing: "measure" or "signals". The one thing the panel persists -- - /// no revealer states remain, and the 320 px width is the `.ui`'s. - std::string side_tab = "signals"; + /// The dock's shown state (the user's last word on the toolbar's Dock button). The one thing the dock + /// persists -- its 320 px width is the `.ui`'s. An older file's `side_tab` key is not read: the parser + /// ignores keys it does not know. + bool dock_shown = true; /// The measurements pane, by signal *name* and with the type and options a row was built with. std::vector measures; diff --git a/src/shortcuts.cpp b/src/shortcuts.cpp index 504f7f6..1471949 100644 --- a/src/shortcuts.cpp +++ b/src/shortcuts.cpp @@ -14,8 +14,8 @@ constexpr Accel kAccels[] = { {GDK_KEY_a, 0, AppAction::TriggerArm, "a", "Arm or disarm the trigger", "Acquisition"}, {GDK_KEY_t, 0, AppAction::TriggerPanel, "t", "Enable or disable trigger mode", "Acquisition"}, {GDK_KEY_c, 0, AppAction::CursorsPane, "c", "Enable or disable the cursors", "Panes and editors"}, - {GDK_KEY_m, 0, AppAction::SideTabMeasure, "m", "Show the Measure tab", "Panes and editors"}, - {GDK_KEY_g, 0, AppAction::SideTabSignals, "g", "Show the Signals tab", "Panes and editors"}, + {GDK_KEY_m, 0, AppAction::ShowMeasure, "m", "Go to Measure", "Panes and editors"}, + {GDK_KEY_g, 0, AppAction::ShowSignals, "g", "Go to Signals", "Panes and editors"}, {GDK_KEY_l, 0, AppAction::LayoutEditor, "l", "Show or hide the layout editor", "Panes and editors"}, {GDK_KEY_s, 0, AppAction::SidePanel, "s", "Show or hide the side panel", diff --git a/src/shortcuts.hpp b/src/shortcuts.hpp index d9c16dd..3cc6def 100644 --- a/src/shortcuts.hpp +++ b/src/shortcuts.hpp @@ -10,8 +10,8 @@ enum class AppAction { TriggerArm, TriggerPanel, CursorsPane, - SideTabMeasure, - SideTabSignals, + ShowMeasure, + ShowSignals, LayoutEditor, SidePanel, Export, diff --git a/src/ui.cpp b/src/ui.cpp index b38a491..095f553 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -2864,35 +2864,38 @@ void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer open_row_menu(*app, row, x, y, widget); } -void on_side_tab_changed(GObject* o, GParamSpec*, gpointer user) { - App* app = static_cast(user); - const char* name = adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(o)); - app->side_tab = name != nullptr ? name : "signals"; - // Not while the layout is being applied: the restore would schedule a save of what it just read. - if (!app->updating_panels) schedule_layout_save(*app); +/// Scrolls the dock to a section. The dock is one scroller (`App::dock_scroller`) holding the signal list, +/// the cursors and the measurements top to bottom, so "measure" is the cursors panel's offset in the +/// page, and "signals" the top. Nothing is hidden or removed: both stay in the page. An unknown name is a +/// no-op, so a key bound to a section a later version drops cannot blank anything. +void dock_show_section(App& app, const char* section) { + if (app.dock_scroller == nullptr || app.dock_page == nullptr || section == nullptr) return; + GtkAdjustment* adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(app.dock_scroller)); + if (std::strcmp(section, "signals") == 0) { + // The list is the first section: its own offset in the page is the page's margin, and "go to Signals" + // means the top of the dock. + gtk_adjustment_set_value(adj, 0.0); + return; + } + if (std::strcmp(section, "measure") != 0 || app.cursors_panel == nullptr) return; + graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f); + graphene_point_t at; + if (!gtk_widget_compute_point(app.cursors_panel, app.dock_page, &zero, &at)) return; + gtk_adjustment_set_value(adj, at.y); } -void select_side_tab(App& app, const char* name) { - if (app.side_stack == nullptr || name == nullptr) return; - // An unknown name leaves the stack where it is -- a session file from a later version must not - // be able to blank the sidebar. - if (adw_view_stack_get_child_by_name(ADW_VIEW_STACK(app.side_stack), name) == nullptr) return; - adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(app.side_stack), name); - app.side_tab = name; -} - -/// The side column is on when it has something to show *and* the user's sidebar -/// button is in; the two panel buttons appear as soon as a CONFIG describes any -/// signal at all. +/// The dock is on when the user's sidebar button is in -- from the start, with or without signals; the +/// two panel buttons appear as soon as a CONFIG describes any signal at all. void update_side_visibility(App& app) { - if (app.side_stack == nullptr) return; + if (app.side_box == nullptr) return; const bool have_signals = !app.model->signals().empty(); - // `have_signals` decides whether the sidebar *can* be on; `sidebar_wanted` is the user's last word, - // which is why it is kept apart from the button's own state -- that state follows the panel now, and - // the panel is off while there is nothing to show. A swipe that hides the sidebar fires the button's - // handler (the binding is bidirectional), so a gesture is a choice too. - gtk_widget_set_sensitive(app.sidebar_toggle, have_signals); - const bool want_column = have_signals && app.sidebar_wanted; + // `sidebar_wanted` is the user's last word, which is why it is kept apart from the button's own state: + // a swipe that hides the sidebar fires the button's handler (the binding is bidirectional), so a gesture + // is a choice too. "Disable, do not hide": the dock is on screen from the start and does nothing until + // the stream has signals, so the signals decide its *sensitivity* and never whether it is there. + gtk_widget_set_sensitive(app.side_box, have_signals); + gtk_widget_set_sensitive(app.sidebar_toggle, TRUE); + const bool want_column = app.sidebar_wanted; // The split view owns the sidebar's visibility: it animates it, and when the window is too narrow it // shows it as an overlay rather than letting the column be squeezed to nothing. if (app.side_view != nullptr) { @@ -2908,7 +2911,12 @@ void on_sidebar_toggled(GtkToggleButton* button, gpointer user) { App* app = static_cast(user); // Only a state we did not write is a wish: `update_side_visibility` sets this button (and the panel) // from `sidebar_wanted`, so an unguarded change here is the user's. - if (!app->sidebar_syncing) app->sidebar_wanted = gtk_toggle_button_get_active(button) != FALSE; + if (!app->sidebar_syncing) { + app->sidebar_wanted = gtk_toggle_button_get_active(button) != FALSE; + // The wish is persisted (`SessionState::dock_shown`), so a change of it is a change of the session -- + // but not one made while a layout is being applied, which would save what it just read. + if (!app->updating_panels) schedule_layout_save(*app); + } update_side_visibility(*app); } @@ -2994,8 +3002,8 @@ void note_panel_controls(App& app) { // `HeaderBar::pack_end()` parents into an internal box, so the bar is a level up, not the parent. char text[256]; std::snprintf(text, sizeof(text), - "cursors=%d in the toolbar, switcher=%d, reset=%d in the cursors pane", - in_bar(app.cursors_toggle), app.side_switcher != nullptr ? 1 : 0, + "cursors=%d in the toolbar, dock=%d, reset=%d in the cursors pane", + in_bar(app.cursors_toggle), app.dock_page != nullptr ? 1 : 0, in_cursors_pane(app.cursors_reset)); // ... and the order the bar actually shows, which is what "the menu should be last" is about. A // widget tree walked in child order *is* the layout order, so this is the reading order of the @@ -3051,42 +3059,6 @@ void note_panel_controls(App& app) { " " + icon_report(theme, "org.udpscope.app-symbolic", "/org/udpscope/icons/scalable/apps/org.udpscope.app-symbolic.svg"); - - // What the switcher is drawing: the sidebar's tabs are words (`wide` policy, a title per page, no - // icon). The pills are `AdwViewSwitcher`'s own buttons, built when the stack is mapped, so this - // reads on the first tick. `mapped`, not `visible`: visibility is per widget, so a label whose - // *parent* is hidden still reads visible; `mapped` requires every ancestor, which is what "on - // screen" means -- and it is why the pill count is read the same way: if nothing is mapped the - // pill count reads 0 and the label count means nothing, rather than passing vacuously. - int pills = 0; - int drawn_labels = 0; - int drawn_images = 0; - const std::function count_drawn = [&](GtkWidget* w) { - if (GTK_IS_TOGGLE_BUTTON(w) && gtk_widget_get_mapped(w)) ++pills; - // A *mapped* image is an icon (or its missing-icon placeholder) on a tab: tabs are words only. - if (GTK_IS_IMAGE(w) && gtk_widget_get_mapped(w)) ++drawn_images; - // A *mapped* label with text: an empty label would be counted by "mapped" alone. - if (GTK_IS_LABEL(w) && gtk_widget_get_mapped(w) && gtk_label_get_text(GTK_LABEL(w))[0] != '\0') { - ++drawn_labels; - } - for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr; - c = gtk_widget_get_next_sibling(c)) { - count_drawn(c); - } - }; - if (app.side_switcher != nullptr) count_drawn(app.side_switcher); - app.dbg_switcher_pills = pills; - app.dbg_switcher_labels = drawn_labels; - app.dbg_switcher_images = drawn_images; - // Walked through `get_pages()`, not the widget tree: an `AdwViewStackPage` is a plain `GObject`. - app.dbg_switcher_pages = - app.side_stack != nullptr - ? static_cast(g_list_model_get_n_items( - G_LIST_MODEL(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack))))) - : 0; - if (app.side_switcher != nullptr) { - g_object_get(G_OBJECT(app.side_switcher), "policy", &app.dbg_switcher_policy, nullptr); - } } /// The event-zoom sweep's two halves: a gesture zoom, then the same walk through the window @@ -4022,7 +3994,7 @@ gboolean tick(gpointer user) { // follow a resize and the gutter settling without being told. if (app->layout_editing) sync_layout_editor(*app); - // The one thing that has to be decided *per frame* about the sidebar: whether there is room for it + // The one thing that has to be decided *per frame* about the dock: whether there is room for it // beside the plot. Collapsed, the split view puts it *over* the plot rather than giving it a share of // a width that is not there -- so a small window shows fewer pixels of plot, never a panel squeezed // to nothing. `kSidebarRoom` is the sidebar's 320 plus the width below which the plot stops being @@ -4030,8 +4002,12 @@ gboolean tick(gpointer user) { if (app->side_view != nullptr && app->window != nullptr) { // The split view writes `show-sidebar` itself (at startup and when it collapses), so the wish is // re-asserted here -- but only when they disagree, so a settled frame touches nothing. This is also - // why a hide *gesture* does not stick: the panel is where the button says it is. - const bool want = !app->model->signals().empty() && app->sidebar_wanted; + // why a hide *gesture* does not stick: the panel is where the button says it is. The wish alone decides + // whether the dock is there; having signals decides only whether it does anything ("disable, do not + // hide"): a dock that is on screen from the start does not jump in when the first CONFIG arrives. + const bool want = app->sidebar_wanted; + gtk_widget_set_sensitive(app->side_box, !app->model->signals().empty()); + gtk_widget_set_sensitive(app->sidebar_toggle, TRUE); if (adw_overlay_split_view_get_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(app->side_view)) != want) { adw_overlay_split_view_set_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(app->side_view), want); if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app->sidebar_toggle)) != want) { @@ -4702,8 +4678,8 @@ void run_app_action(App& app, AppAction action) { case AppAction::CursorsPane: toggle(app.cursors_toggle); break; - case AppAction::SideTabMeasure: select_side_tab(app, "measure"); break; - case AppAction::SideTabSignals: select_side_tab(app, "signals"); break; + case AppAction::ShowMeasure: dock_show_section(app, "measure"); break; + case AppAction::ShowSignals: dock_show_section(app, "signals"); break; case AppAction::LayoutEditor: toggle(app.layout_toggle); break; @@ -4745,6 +4721,9 @@ gboolean on_app_key_pressed(GtkEventControllerKey*, guint keyval, guint, GdkModi tour_dismiss(*app); return TRUE; } + // ... and then the slide-over: Escape closes it, ahead of the editable check for the same reason -- the + // key that dismisses a panel must work from inside one of its own spin buttons. + if (keyval == GDK_KEY_Escape && slideover_escape(*app)) return TRUE; GtkWidget* focus = gtk_window_get_focus(GTK_WINDOW(app->window)); if (focus != nullptr && GTK_IS_EDITABLE(focus)) return FALSE; @@ -4998,8 +4977,8 @@ void on_activate(GtkApplication* application, gpointer user) { install_css(); // The window's skeleton is a `.ui` file: the window, its two panes, the plot column with the status - // line under it, and the sidebar's tab switcher and two-page stack. Everything that depends on the - // stream -- the events pane, the two plot areas, the sidebar's panels -- is built below and put + // line under it, and the dock's scroller. Everything that depends on the + // stream -- the events pane, the two plot areas, the dock's panels -- is built below and put // into the slots by id. g_clear_object(&app->window_ui); app->window_ui = gtk_builder_new_from_resource("/org/udpscope/ui/main-window.ui"); @@ -5018,10 +4997,8 @@ void on_activate(GtkApplication* application, gpointer user) { // help's annotated figures label. app->status_row = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_row")); GtkWidget* side = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_column")); - GtkWidget* side_stack = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_stack")); - GtkWidget* side_switcher = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_switcher")); - GtkWidget* measure_page = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "measure_page")); - GtkWidget* signals_page = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "signals_page")); + app->dock_scroller = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "dock_scroller")); + app->dock_page = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "dock_page")); app->badge_bar = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "badge_bar")); app->badge_signals = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "badge_signals")); app->badge_system = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "badge_system")); @@ -5033,8 +5010,7 @@ void on_activate(GtkApplication* application, gpointer user) { app->status_label == nullptr || app->status_info_button == nullptr || app->status_row == nullptr || app->badge_bar == nullptr || app->badge_signals == nullptr || app->badge_system == nullptr || - side == nullptr || side_stack == nullptr || side_switcher == nullptr || - measure_page == nullptr || signals_page == nullptr) { + side == nullptr || app->dock_scroller == nullptr || app->dock_page == nullptr) { return; } // The window's actions live on the application, so it has to know which one it belongs to. @@ -5177,28 +5153,28 @@ void on_activate(GtkApplication* application, gpointer user) { gtk_popover_set_has_arrow(GTK_POPOVER(app->status_popover), TRUE); } - // The right-hand side is two tabs, not five stacked panels: one tab is on screen at a time, so - // no section competes with four others for height. Each page scrolls on its own -- the two are - // different heights, and one shared scroller would leave a switch scrolled where the new page - // never asked to be. - // `side` and `side_stack`/`side_switcher`/`measure_page`/`signals_page` are resolved above with - // the rest of the skeleton and checked by the null guard; assign the members here. + // The right-hand side is the dock: one scroller, the signal list first (it is what is dragged onto the + // plot), then the cursors and the measurements. Every section is always there -- "go to Measure" scrolls + // to it instead of switching a tab, so the measurements are as live as the list above them. + // `side` and `dock_page`/`dock_scroller` are resolved above with the rest of the skeleton and checked + // by the null guard; assign the member here. app->side_box = side; - app->side_stack = side_stack; - app->side_switcher = side_switcher; - // Measure: what the cursors bracket, and the figures derived from it. + // Built in the order the panels have always been (cursors, measurements, trigger, signals) and placed in + // the dock's: the trigger form goes to the slide-over (reached by the Trigger badge). app->cursors_panel = build_cursors_panel(*app); - gtk_box_append(GTK_BOX(measure_page), app->cursors_panel); - gtk_box_append(GTK_BOX(measure_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); - gtk_box_append(GTK_BOX(measure_page), build_measures_panel(*app)); - - // The trigger form lives in the slide-over (reached by the Trigger badge); the signal list stays in the - // sidebar until the dock replaces it. + GtkWidget* measures_panel = build_measures_panel(*app); app->trigger_panel = build_trigger_panel(*app); + // Top-aligned: the page's scroller gives the child the page's height, and a form that stretched to it + // would spread its rows (or draw its frame) over empty space under the last one. + gtk_widget_set_valign(app->trigger_panel, GTK_ALIGN_START); slideover_add_page(*app, "trigger", "Trigger", app->trigger_panel); app->signal_panel = build_signal_panel(*app); - gtk_box_append(GTK_BOX(signals_page), app->signal_panel); + gtk_box_append(GTK_BOX(app->dock_page), app->signal_panel); + gtk_box_append(GTK_BOX(app->dock_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + gtk_box_append(GTK_BOX(app->dock_page), app->cursors_panel); + gtk_box_append(GTK_BOX(app->dock_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + gtk_box_append(GTK_BOX(app->dock_page), measures_panel); // The cursors start off, so their readout starts insensitive -- stated here by the one place that // knows the default, and the *body* and not the panel, because the panel's heading carries the @@ -5210,15 +5186,8 @@ void on_activate(GtkApplication* application, gpointer user) { sync_arm_buttons(*app); gtk_widget_set_sensitive(app->cursors_reset, FALSE); - if (app->side_stack != nullptr) { - adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(app->side_stack), "signals"); - g_signal_connect(app->side_stack, "notify::visible-child-name", - G_CALLBACK(on_side_tab_changed), app); - } - // The source kind's own tabs (a generator, digital I/O), added before the first tick so the - // pages-versus-pills reading below counts them too. The tabs are words, so the switcher's icons - // are hidden first and kept hidden for the pages this call adds. - keep_tabs_icon_free(*app); + // The source kind's own panels (a generator, digital I/O), pages of the slide-over, added before the + // first tick. build_source_panels(*app); // The badge bar reads `trigger_toggle` (made by `build_toolbar()` above) and the source's pages, which // `build_source_panels()` has just registered: the Source badge opens the first of them. @@ -5915,7 +5884,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit g_timeout_add(static_cast(std::atoi(env)), stats_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_SIDE_TAB_MS")) { - g_timeout_add(static_cast(std::atoi(env)), side_tab_probe, &app); + g_timeout_add(static_cast(std::atoi(env)), dock_section_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_SLIDEOVER_MS")) { g_timeout_add(static_cast(std::atoi(env)), slideover_probe, &app); @@ -6028,6 +5997,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_PROBE_MENUS_MS")) { menus_probe_next(app, 0, static_cast(std::atoi(env))); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_DOCK_SESSION_MS")) { + g_timeout_add(static_cast(std::atoi(env)), dock_session_probe, &app); + } if (const char* env = g_getenv("UDPSCOPE_PROBE_KEYS_MS")) { g_timeout_add(static_cast(std::atoi(env)), keys_probe, &app); } @@ -6237,12 +6209,12 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit inside(app.events_list, app.events_revealer)); } std::printf("[udpscope] panels: side=%dx%d (visible=%d) signals=%dx%d row_h=%d " - "(page_current=%d) trigger=%dx%d (sensitive=%d) switcher=%d " + "(page_current=%d) trigger=%dx%d (sensitive=%d) " "cursors=%d window=%.3fs window_cap=%.1fs\n", app.dbg_side_w, app.dbg_side_h, app.dbg_side_column_after_hide, app.dbg_signal_w, app.dbg_signal_h, app.dbg_signal_row_h, app.dbg_signal_page_current, app.dbg_trigger_w, app.dbg_trigger_h, - app.dbg_trigger_sensitive, app.dbg_switcher_visible, + app.dbg_trigger_sensitive, rows.cursors_visible() ? 1 : 0, rows.time_window(), app.window_limit_s); { @@ -6267,10 +6239,8 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit 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 images=%d pages=%d size=%dx%d\n", - app.dbg_switcher_policy == 0 ? "narrow" : (app.dbg_switcher_policy == 1 ? "wide" : "?"), - app.dbg_switcher_pills, app.dbg_switcher_labels, app.dbg_switcher_images, - app.dbg_switcher_pages, app.dbg_switcher_w, app.dbg_switcher_h); + std::printf("[udpscope] dock: shown=%d sensitive=%d w=%d scroller_pages=%d\n", app.dbg_dock_shown, + app.dbg_dock_sensitive, app.dbg_dock_w, app.dbg_dock_scrollers); // The help affordance layer: what the contextual-help toggle left on the surfaces, and where the // guided tour stood when the last capture ran. Both are captured on the tick (`capture_help_state`), // so neither asks a destroyed widget anything. diff --git a/src/ui/main-window.ui b/src/ui/main-window.ui index a2d16b1..1ecc6aa 100644 --- a/src/ui/main-window.ui +++ b/src/ui/main-window.ui @@ -4,7 +4,7 @@ + vertical 300 - - side_stack - wide - 4 - 4 - 6 - 6 - - - - + 1 - - - measure - Measure - - - never - automatic - - - vertical - - - - + never + automatic + + + vertical - - - - signals - Signals - - - never - automatic - - - vertical - - - - - - + diff --git a/src/ui/shortcuts.ui b/src/ui/shortcuts.ui index dd6e334..bab4fe6 100644 --- a/src/ui/shortcuts.ui +++ b/src/ui/shortcuts.ui @@ -73,14 +73,14 @@ 1 m - Show the Measure tab + Go to Measure 1 g - Show the Signals tab + Go to Signals diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 11d0a75..5949134 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -241,13 +241,11 @@ struct App { GtkWidget* trigger_panel = nullptr; GtkWidget* signal_panel = nullptr; GtkWidget* side_box = nullptr; - /// The sidebar's tab stack and its switcher. The stack is what chooses which panels are on - /// screen; a feature's own on/off never does (it sets its panel's sensitivity instead). - GtkWidget* side_stack = nullptr; - GtkWidget* side_switcher = nullptr; - /// The page name the stack is showing, and the one value the session file persists about the - /// sidebar. "signals" on a first run. - std::string side_tab = "signals"; + /// The dock: the sidebar's one scroller and the box inside it, which holds the signal list, the + /// cursors and the measurements (`dock_show_section()` scrolls it). A feature's own on/off never + /// removes a section (it sets its panel's sensitivity instead). + GtkWidget* dock_page = nullptr; + GtkWidget* dock_scroller = nullptr; GtkWidget* arm_button = nullptr; /// The Force button, beside the Arm/Disarm one: one acquisition now, with the configured pre/post /// window. It carries no state of its own -- `sync_arm_buttons()` states the one it has @@ -477,18 +475,6 @@ struct App { /// works, and every other line of the dump still passes. std::string dbg_toolbar_icons; std::string dbg_app_icons; - /// What the switcher is: how many pills it holds, and how many labels it is *drawing*. The tabs - /// are words (`wide` policy, a title per page), so the label count must equal the page count. - int dbg_switcher_pills = -1; - int dbg_switcher_labels = -1; - /// How many `GtkImage`s the switcher has *mapped*: a tab is its title and nothing else, so this - /// must be 0 -- an icon, or the missing-icon placeholder GTK draws for a page with no `icon-name`. - int dbg_switcher_images = -1; - /// How many pages the sidebar stack holds: `pills` and `labels` must both equal it. - int dbg_switcher_pages = -1; - /// The switcher's own size, beside it: what the tab bar costs the column. - int dbg_switcher_w = -1; - int dbg_switcher_h = -1; /// The panes' own switches and the header bar's buttons, read together by the exit probe: the two /// must agree, and `dbg_panel_drove_toolbar` is the pane's switch having moved the button. int dbg_panel_switch_trigger = -1; @@ -504,7 +490,6 @@ struct App { /// The Arm button, read with the trigger off: it is the pane's one runtime control, so it is the one /// thing in the body that *is* disabled with the mode. int dbg_arm_sensitive = -1; - int dbg_switcher_policy = -1; /// The header bar, so the dump can report the order the controls are in: a header bar is a /// *titlebar*, not a child of the window, so walking down from the window does not reach it. GtkWidget* toolbar = nullptr; @@ -881,7 +866,7 @@ struct App { std::string source_kind = "udps"; SourceConfig source_opts; std::string source_title; - /// The panels the source kind added to `side_stack`, in tab order. Empty for UDPS and in file mode. + /// The panels the source kind added to the slide-over, in page order. Empty for UDPS and in file mode. std::vector source_panels; bool source_has_datagrams = true; ///< the kind reports raw datagrams; false leaves the capture button disabled guint layout_save_source = 0; @@ -1139,7 +1124,14 @@ struct App { int dbg_signal_row_h = 0; int dbg_trigger_w = 0; int dbg_trigger_h = 0; + /// The signal list has rows (the dock has no tab to be "current" on any more). int dbg_signal_page_current = 0; + /// The dock as the exit probe found it: the split view showing the sidebar, the column sensitive (it is + /// not until the stream has signals), its width, and how many `GtkScrolledWindow`s it holds directly. + int dbg_dock_shown = -1; + int dbg_dock_sensitive = -1; + int dbg_dock_w = -1; + int dbg_dock_scrollers = -1; int dbg_trigger_sensitive = 0; int dbg_trigger_items = 0; int dbg_trigger_selected = -1; @@ -1161,7 +1153,6 @@ struct App { double dbg_trigger_time = 0.0; int dbg_trigger_time_valid = -1; std::string dbg_mode_live; - int dbg_switcher_visible = 0; int dbg_trigger_sensitive_default = 0; /// The detail string the popover and the exit dump read. The dump used to read the label, @@ -1572,9 +1563,9 @@ constexpr int kEventThumbH = 46; constexpr int kEventThumbMaxH = 104; inline int g_stats_phase = 0; inline std::vector g_stats_n_before; -/// The same for the tab-switch probe: the accumulator counts recorded while the Measure page was -/// still current, so the report can say whether any of them advanced afterwards. -inline std::vector g_side_tab_n_before; +/// The same for the dock-section probe: the accumulator counts recorded while the Measure section was +/// still scrolled into view, so the report can say whether any of them advanced after it scrolled out. +inline std::vector g_dock_section_n_before; inline double g_trigrenum_edge = 0.0; inline std::uint64_t g_trigrenum_latches = 0; inline ConditionProbe g_condition; @@ -1600,11 +1591,6 @@ void config_dialog_refresh(ConfigDialog& d); void open_configurations(App& app); void save_layout_now(App& app); -/// The sidebar's tabs are words: hide the icon every `AdwViewSwitcherButton` draws (the -/// missing-icon placeholder, for a page with a title and no `icon-name`), for the buttons that exist -/// and for any page added later. Call once, before `build_source_panels()`. -void keep_tabs_icon_free(App& app); - /// Capability panels (`src/ui_source.cpp`). `build_source_panels` adds the source kind's panels as /// pages of the slide-over (`source-`), once, before the first tick. `apply_source_panels` sets every panel's /// sensitivity from `connected` -- disabled, never hidden. @@ -1803,8 +1789,9 @@ void row_menu_fill(RowMenuState& st); GtkWidget* open_row_menu(App& app, int row, double x, double y, GtkWidget* parent, bool autohide = true); void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer user); gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, gpointer user); -void on_side_tab_changed(GObject*, GParamSpec*, gpointer user); -void select_side_tab(App& app, const char* name); +/// Scrolls the dock to a section: "signals" (the top) or "measure" (the cursors panel, which the +/// measurements follow). An unknown name is a no-op. +void dock_show_section(App& app, const char* section); /// Reads each pane's own switch back off the header bar's button, and the other way round: the two are /// one control in two places, and `panel_switch_sync` keeps the write from looking like a click. void sync_panel_switches(App& app); @@ -1907,8 +1894,9 @@ gboolean measurepin_live_wait(gpointer user); gboolean measure_trace(gpointer user); gboolean stats_probe_report(gpointer user); gboolean stats_probe(gpointer user); -gboolean side_tab_probe_report(gpointer user); -gboolean side_tab_probe(gpointer user); +gboolean dock_section_probe_report(gpointer user); +gboolean dock_section_probe(gpointer user); +gboolean dock_session_probe(gpointer user); // UDPSCOPE_PROBE_DOCK_SESSION_MS /// The slide-over panel (`src/ui_slideover.cpp`): one panel at a time over the plot's right edge, a page /// per setup surface, each page in a scroller of its own. void build_slideover(App& app); // once, after the plot_overlay exists diff --git a/src/ui_layout.cpp b/src/ui_layout.cpp index 329dc57..db631af 100644 --- a/src/ui_layout.cpp +++ b/src/ui_layout.cpp @@ -64,7 +64,7 @@ LayoutState collect_layout(App& app) { st.math = app.math; st.measures = app.measures; st.measure_stats = app.measure_stats_on; - st.side_tab = app.side_tab; + st.dock_shown = app.sidebar_wanted; // The trigger, its settings *and* whether it was on. The channel goes by name, like every identity // in this file: every rebuild renumbers the channels. { @@ -427,7 +427,10 @@ void apply_layout(App& app, const LayoutState& st) { app.measures = st.measures; app.measure_stats_on = st.measure_stats; app.updating_panels = true; - select_side_tab(app, st.side_tab.c_str()); + // The dock's shown state is the wish `update_side_visibility()` re-asserts every frame; setting the + // toggle goes through `sidebar_syncing`, so the restore does not schedule a save of what it just read. + app.sidebar_wanted = st.dock_shown; + update_side_visibility(app); if (app.measures_stats_check != nullptr) { gtk_check_button_set_active(GTK_CHECK_BUTTON(app.measures_stats_check), app.measure_stats_on); diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 14cf6c7..bd6af31 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -104,12 +104,6 @@ gboolean exit_probe_step3(gpointer user) { a->dbg_signal_h = gtk_widget_get_height(a->signal_panel); a->dbg_trigger_w = gtk_widget_get_width(a->trigger_panel); a->dbg_trigger_h = gtk_widget_get_height(a->trigger_panel); - // The switcher's size as allocated: read here and not on the first tick, where the window has not - // been allocated yet and every widget still measures 0x0. - if (a->side_switcher != nullptr) { - a->dbg_switcher_w = gtk_widget_get_width(a->side_switcher); - a->dbg_switcher_h = gtk_widget_get_height(a->side_switcher); - } // How many rows of the signal list carry a drag source: dragging a signal onto a plot is how a plot is // filled, so it has to survive both pane modes -- the locked list drops the tick, the menu and the `+`, // and this is the reading that says it did not drop the drag with them. One row is the `MATH` divider, @@ -187,14 +181,9 @@ gboolean exit_probe_step3(gpointer user) { } if (a->area != nullptr) a->dbg_frames_painted = static_cast(a->area->frames_painted()); a->dbg_reset_sensitive = a->cursors_reset != nullptr && gtk_widget_get_sensitive(a->cursors_reset) ? 1 : 0; - // A page, not a revealer: a stack's hidden page measures 0x0 the way an unrevealed revealer - // did, so the reading asks about *state* -- is this page the current one -- and never about size. - const char* page = a->side_stack != nullptr - ? adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(a->side_stack)) - : nullptr; - const int on_signals = page != nullptr && std::strcmp(page, "signals") == 0 ? 1 : 0; - a->dbg_signal_page_current = - on_signals && gtk_widget_get_first_child(a->signal_list) != nullptr ? 1 : 0; + // The signal list has rows: a reading about *state* (the dock has no page to be current on), never + // about size. + a->dbg_signal_page_current = gtk_widget_get_first_child(a->signal_list) != nullptr ? 1 : 0; // The trigger form is no longer on a sidebar tab, so the reading is the body's own sensitivity. a->dbg_trigger_sensitive = gtk_widget_get_sensitive(a->trigger_body) ? 1 : 0; // `gtk_widget_get_visible` on the sidebar's GtkBox is always 1: AdwOverlaySplitView hides its @@ -203,8 +192,17 @@ gboolean exit_probe_step3(gpointer user) { const int sidebar_shown = a->side_view != nullptr && adw_overlay_split_view_get_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(a->side_view)) ? 1 : 0; a->dbg_side_column_after_hide = sidebar_shown; - a->dbg_switcher_visible = - a->side_switcher != nullptr && gtk_widget_get_visible(a->side_switcher) ? 1 : 0; + // The dock, read here with the other sizes (the dump runs after the window is gone). `sensitive` is the + // availability: the column is on screen whether or not the stream has signals, and does nothing until + // it does. + a->dbg_dock_shown = sidebar_shown; + a->dbg_dock_sensitive = gtk_widget_get_sensitive(a->side_box) ? 1 : 0; + a->dbg_dock_w = a->dbg_side_w; + a->dbg_dock_scrollers = 0; + for (GtkWidget* c = gtk_widget_get_first_child(a->side_box); c != nullptr; + c = gtk_widget_get_next_sibling(c)) { + if (GTK_IS_SCROLLED_WINDOW(c)) ++a->dbg_dock_scrollers; + } if (a->trigger_signal_drop != nullptr) { GListModel* model = gtk_drop_down_get_model(GTK_DROP_DOWN(a->trigger_signal_drop)); a->dbg_trigger_items = model != nullptr ? static_cast(g_list_model_get_n_items(model)) : 0; @@ -1795,11 +1793,24 @@ gboolean stats_probe(gpointer user) { return G_SOURCE_REMOVE; } -/// Phase two of the tab-switch probe: the hidden page's accumulator must have advanced. A statistics -/// readout is *history*, so min/max/avg would gain a gap for exactly as long as the trigger was on -/// screen -- which is the one defect the design's "what runs on a hidden page" rule is there to -/// prevent, and the reason no frame-rate code may be gated on which tab is current. -gboolean side_tab_probe_report(gpointer user) { +/// Which section the dock's scroller is showing at its top: "measure" once it has been scrolled to the +/// cursors panel or past it, "signals" above that. Read from the adjustment, so it is the state the user +/// would see and not the last call made. +static const char* dock_section_in_view(App& a) { + if (a.dock_scroller == nullptr || a.cursors_panel == nullptr || a.dock_page == nullptr) return "none"; + graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f); + graphene_point_t at; + if (!gtk_widget_compute_point(a.cursors_panel, a.dock_page, &zero, &at)) return "none"; + GtkAdjustment* adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(a.dock_scroller)); + return gtk_adjustment_get_value(adj) > 0.5 * at.y ? "measure" : "signals"; +} + +/// Phase two of the dock-section probe: the accumulators must have advanced while the Measure section was +/// scrolled out of view. A statistics readout is *history*, so min/max/avg would gain a gap for exactly as +/// long as the section was off screen -- which is the one defect the design's "what runs on a hidden +/// page" rule is there to prevent, and the reason no frame-rate code may be gated on which section is in +/// view. (It was a tab once; the dock is one scroller, and the rule outlived the tabs.) +gboolean dock_section_probe_report(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; std::uint64_t advanced = 0; @@ -1807,25 +1818,37 @@ gboolean side_tab_probe_report(gpointer user) { bool first = true; for (std::size_t i = 0; i < a->measure_stats.size(); ++i) { const std::uint64_t n = a->measure_stats[i].n; - if (i < g_side_tab_n_before.size() && n > g_side_tab_n_before[i]) ++advanced; + if (i < g_dock_section_n_before.size() && n > g_dock_section_n_before[i]) ++advanced; if (first || n < min_n) { min_n = n; first = false; } } + if (a->dock_page != nullptr) gtk_widget_set_size_request(a->dock_page, -1, -1); std::fprintf(stderr, - "[side-tab-probe] page=%s rows=%zu accums=%zu advanced=%" G_GUINT64_FORMAT + "[side-tab-probe] section=%s rows=%zu accums=%zu advanced=%" G_GUINT64_FORMAT " min_n=%" G_GUINT64_FORMAT "\n", - a->side_tab.c_str(), a->measures.size(), a->measure_stats.size(), advanced, min_n); + dock_section_in_view(*a), a->measures.size(), a->measure_stats.size(), advanced, min_n); std::fflush(stderr); return G_SOURCE_REMOVE; } -/// Phase one: four measurements on the first channel, statistics on, then switch *away* from the -/// Measure tab and come back in two seconds to read what ran while it was not on screen. -gboolean side_tab_probe(gpointer user) { +/// Phase one: four measurements on the first channel, statistics on, then scroll the dock *away* from the +/// Measure section and come back in two seconds to read what ran while it was not in view. (The probe +/// keeps its old environment variable, `UDPSCOPE_PROBE_SIDE_TAB_MS`, and its `[side-tab-probe]` tag.) +gboolean dock_section_probe(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; + // A dock whose content fits has nothing to scroll away from, and a tall window does that. The first + // pass makes the page taller than any window (a probe-only minimum height, undone in the report) and + // comes back once it has been laid out, so "scrolled out of view" is a state this run really reaches. + static bool padded = false; + if (!padded && a->dock_page != nullptr) { + padded = true; + gtk_widget_set_size_request(a->dock_page, -1, 3000); + g_timeout_add(400, dock_section_probe, a); + return G_SOURCE_REMOVE; + } // One signal on, and on a plot, the way `enable_all_probe` does it and a restored session arrives. // Both halves matter here: a signal starts disabled (enabling is what starts acquisition), and a // channel with no trace leaves the live window *still* -- and a still window is exactly the state @@ -1863,18 +1886,23 @@ gboolean side_tab_probe(gpointer user) { if (a->measures_stats_check != nullptr) { gtk_check_button_set_active(GTK_CHECK_BUTTON(a->measures_stats_check), TRUE); } - select_side_tab(*a, "measure"); + dock_show_section(*a, "measure"); + // Where the dock went: `scrolled` is its offset and `reach` the most it can scroll. A reach of 0 would + // mean the content fits and "away from the section" is not a state this run can reach. + GtkAdjustment* dock_adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(a->dock_scroller)); + const double scrolled = gtk_adjustment_get_value(dock_adj); + const double reach = gtk_adjustment_get_upper(dock_adj) - gtk_adjustment_get_page_size(dock_adj); update_measures(*a, /*force=*/true, /*accumulate=*/true); - g_side_tab_n_before.clear(); - for (const MeasureStatsAccum& s : a->measure_stats) g_side_tab_n_before.push_back(s.n); - // Away from the page the measurements are on: its widgets are now unrealized and measure 0x0, and - // the tick keeps calling `update_measures()` regardless. - select_side_tab(*a, "signals"); - std::fprintf(stderr, "[side-tab-probe] page=%s accums=%zu baseline recorded\n", - a->side_tab.c_str(), g_side_tab_n_before.size()); + g_dock_section_n_before.clear(); + for (const MeasureStatsAccum& s : a->measure_stats) g_dock_section_n_before.push_back(s.n); + // Away from the section the measurements are in: they are scrolled out of the viewport (the widgets + // stay mapped, only clipped), and the tick keeps calling `update_measures()` regardless. + dock_show_section(*a, "signals"); + std::fprintf(stderr, "[side-tab-probe] section=%s accums=%zu baseline recorded (measure at scroll %.0f of %.0f)\n", + dock_section_in_view(*a), g_dock_section_n_before.size(), scrolled, reach); std::fflush(stderr); // Long enough for several throttled recomputes (one per 100 ms while the window moves). - g_timeout_add(2000, side_tab_probe_report, a); + g_timeout_add(2000, dock_section_probe_report, a); return G_SOURCE_REMOVE; } @@ -3573,6 +3601,63 @@ gboolean sidebar_probe(gpointer user) { return G_SOURCE_REMOVE; } +/// `UDPSCOPE_PROBE_DOCK_SESSION_MS`: the dock's shown state through the session file, both ways. +/// +/// The Dock button's state is the one thing the dock persists (`dock = true|false`, replacing `side_tab`). +/// Leg 1 hides the dock through the button (a wish, so it must schedule a save), collects the session and +/// writes it, reads the file back and applies it; leg 2 does the same shown. Leg 3 loads a file written by +/// the previous version (`side_tab = "measure"`, no `dock`): it must load without a complaint and leave +/// `dock_shown` at its default, true. Read as `ok=1` on every line. +gboolean dock_session_probe(gpointer user) { + App& a = *static_cast(user); + if (a.window == nullptr || a.sidebar_toggle == nullptr) return G_SOURCE_REMOVE; + const std::string dir = g_get_tmp_dir(); + const std::string path = dir + "/udpscope-dock-session-probe.toml"; + const auto leg = [&](const char* name, bool shown) { + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a.sidebar_toggle), shown); + const bool wish = a.sidebar_wanted; + const bool save_pending = a.layout_save_source != 0; + LayoutState st = collect_layout(a); + const bool collected = st.dock_shown; + const bool saved = save_layout(path, st); + LayoutState back; + const bool loaded = load_layout(path, back); + // Apply the *opposite* first so the restore has something to undo, then the loaded state. + a.sidebar_wanted = !shown; + apply_layout(a, back); + const bool applied = a.sidebar_wanted == shown; + const bool split_follows = + (adw_overlay_split_view_get_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(a.side_view)) != FALSE) == shown; + std::fprintf(stderr, + "[dock-session] %s: wish=%d save_scheduled=%d collected=%d saved=%d loaded=%d read=%d " + "applied=%d split_follows=%d ok=%d\n", + name, wish ? 1 : 0, save_pending ? 1 : 0, collected ? 1 : 0, saved ? 1 : 0, + loaded ? 1 : 0, back.dock_shown ? 1 : 0, applied ? 1 : 0, split_follows ? 1 : 0, + (wish == shown && save_pending && collected == shown && saved && loaded && + back.dock_shown == shown && applied) + ? 1 : 0); + }; + leg("hidden", false); + leg("shown", true); + { + std::FILE* f = std::fopen(path.c_str(), "w"); + if (f != nullptr) { + std::fputs("side_tab = \"measure\"\nmeasure_stats = false\n", f); + std::fclose(f); + } + LayoutState old_file; + old_file.dock_shown = false; // anything but the default, so "left at the default" is a reading + const bool loaded = load_layout(path, old_file); + LayoutState fresh; + std::fprintf(stderr, "[dock-session] old file (side_tab only): loaded=%d dock_shown=%d default=%d ok=%d\n", + loaded ? 1 : 0, old_file.dock_shown ? 1 : 0, fresh.dock_shown ? 1 : 0, + loaded && fresh.dock_shown ? 1 : 0); + } + std::remove(path.c_str()); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} + gboolean prefs_probe(gpointer user) { App& a = *static_cast(user); open_preferences(a); @@ -4524,7 +4609,7 @@ gboolean measures_probe_step(gpointer user) { m.options.show_on_plot = measure_can_show_on_plot(m.type); a->measures.push_back(std::move(m)); } - select_side_tab(*a, "measure"); + dock_show_section(*a, "measure"); rebuild_measures_pane(*a); std::fprintf(stderr, "[measures-probe] added %zu measurements on '%s'\n", a->measures.size(), signal.c_str()); @@ -4683,7 +4768,7 @@ gboolean bandavg_probe_step(gpointer user) { m.options.show_on_plot = measure_can_show_on_plot(m.type); a->measures.push_back(std::move(m)); } - select_side_tab(*a, "measure"); + dock_show_section(*a, "measure"); rebuild_measures_pane(*a); g_timeout_add(250, measure_trace, a); bandavg_probe_next(*a, 1, 1500); @@ -6247,6 +6332,16 @@ gboolean defaults_probe(gpointer user) { gboolean keys_probe(gpointer user) { App* a = static_cast(user); + // `m` scrolls the dock, and a dock whose content fits has nothing to scroll: a run on a tall window + // would read 0->0 and prove nothing. So the first pass only makes the page taller than any window + // (a probe-only minimum height, undone below) and comes back once it has been laid out. + static bool padded = false; + if (!padded && a->dock_page != nullptr) { + padded = true; + gtk_widget_set_size_request(a->dock_page, -1, 3000); + g_timeout_add(400, keys_probe, a); + return G_SOURCE_REMOVE; + } std::string line = "[keys]"; const auto before_after = [&](const char* name, GtkWidget* button, AppAction action) { @@ -6282,38 +6377,56 @@ gboolean keys_probe(gpointer user) { before_after("t: trigger panel", a->trigger_toggle, AppAction::TriggerPanel); before_after("c: cursors", a->cursors_toggle, AppAction::CursorsPane); + // `m` and `g` scroll the dock: the reading is the scroller's offset before and after, and `m` must land + // on the cursors panel's own y (clamped to what the page can scroll, which is why the reachable maximum + // is printed beside it), `g` back on 0. + const auto dock_adjustment = [a]() { + return gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(a->dock_scroller)); + }; + const auto dock_value = [&]() { return gtk_adjustment_get_value(dock_adjustment()); }; { - const std::string before_tab = a->side_tab; - run_app_action(*a, AppAction::SideTabMeasure); - const std::string after_tab = a->side_tab; - char buf[96]; - std::snprintf(buf, sizeof(buf), " m: tab %s->%s |", before_tab.c_str(), after_tab.c_str()); + const double before = dock_value(); + run_app_action(*a, AppAction::ShowMeasure); + const double after = dock_value(); + graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f); + graphene_point_t at = GRAPHENE_POINT_INIT(-1.f, -1.f); + if (!gtk_widget_compute_point(a->cursors_panel, a->dock_page, &zero, &at)) at.y = -1.f; + GtkAdjustment* adj = dock_adjustment(); + const double reach = gtk_adjustment_get_upper(adj) - gtk_adjustment_get_page_size(adj); + char buf[160]; + std::snprintf(buf, sizeof(buf), " m: dock %.0f->%.0f (cursors_y=%.0f reach=%.0f at_cursors=%d) |", before, + after, static_cast(at.y), reach, + std::fabs(after - std::min(static_cast(at.y), reach)) < 1.0 ? 1 : 0); line += buf; } { - const std::string before_tab = a->side_tab; - run_app_action(*a, AppAction::SideTabSignals); - const std::string after_tab = a->side_tab; + const double before = dock_value(); + run_app_action(*a, AppAction::ShowSignals); + const double after = dock_value(); char buf[96]; - std::snprintf(buf, sizeof(buf), " g: tab %s->%s |", before_tab.c_str(), after_tab.c_str()); + std::snprintf(buf, sizeof(buf), " g: dock %.0f->%.0f at_top=%d |", before, after, + std::fabs(after) < 1.0 ? 1 : 0); line += buf; } before_after("l: layout", a->layout_toggle, AppAction::LayoutEditor); before_after("s: side panel", a->sidebar_toggle, AppAction::SidePanel); // An accel must be refused while text is being typed. Focus the window entry, send 'm' - // through the real handler, and require the side tab not to change. + // through the real handler, and require the dock not to scroll. int refused = -1; if (a->window_entry != nullptr) { gtk_menu_button_popup(GTK_MENU_BUTTON(a->window_button)); gtk_widget_grab_focus(a->window_entry); - const std::string before_tab = a->side_tab; + // The dock has to be somewhere other than where `m` would take it, or "did not scroll" proves nothing. + gtk_adjustment_set_value(dock_adjustment(), 0.0); + const double before_dock = dock_value(); const gboolean handled = on_app_key_pressed(nullptr, GDK_KEY_m, 0, static_cast(0), a); - const std::string after_tab = a->side_tab; - refused = (handled == FALSE && before_tab == after_tab) ? 1 : 0; + const double after_dock = dock_value(); + refused = (handled == FALSE && before_dock == after_dock) ? 1 : 0; gtk_menu_button_popdown(GTK_MENU_BUTTON(a->window_button)); } + if (a->dock_page != nullptr) gtk_widget_set_size_request(a->dock_page, -1, -1); char tail[64]; std::snprintf(tail, sizeof(tail), " editable focused: 'm' refused=%d\n", refused); line += tail; @@ -6570,10 +6683,10 @@ void tour_geometry_line(App& a, const char* probe, const char* what) { p.origin.y < q.origin.y + q.size.height && q.origin.y < p.origin.y + p.size.height; }; std::fprintf(stderr, - "[%s] %s=%d/%d visible=%d side_tab=%s spot=[%.0f,%.0f %.0fx%.0f] " + "[%s] %s=%d/%d visible=%d slideover=%s spot=[%.0f,%.0f %.0fx%.0f] " "target=[%.0f,%.0f %.0fx%.0f] contains=%d card=[%.0f,%.0f %.0fx%.0f] clear=%d\n", probe, what, tour_step_index(a) + 1, tour_step_count(a), tour_active(a) ? 1 : 0, - a.side_tab.c_str(), spot.origin.x, spot.origin.y, spot.size.width, spot.size.height, + (slideover_page(a).empty() ? "none" : slideover_page(a).c_str()), spot.origin.x, spot.origin.y, spot.size.width, spot.size.height, target.origin.x, target.origin.y, target.size.width, target.size.height, have_spot && have_target && contains(spot, target) ? 1 : 0, card.origin.x, card.origin.y, card.size.width, card.size.height, @@ -6818,10 +6931,10 @@ gboolean tour_surface_probe_step(gpointer user) { case 0: { // Where the run starts: a fresh config's sidebar is on Signals, so this is a *first* run's // state. The auto-start's walk (if one began) would only confuse the reading; it goes first. - std::fprintf(stderr, "[toursurface] before: side_tab=%s signal_list_mapped=%d running=%d\n", - a->side_tab.c_str(), mapped(a->signal_list), tour_active(*a) ? 1 : 0); + std::fprintf(stderr, "[toursurface] before: slideover=%s signal_list_mapped=%d running=%d\n", + slideover_page(*a).empty() ? "none" : slideover_page(*a).c_str(), mapped(a->signal_list), tour_active(*a) ? 1 : 0); tour_dismiss(*a); - select_side_tab(*a, "measure"); + slideover_open(*a, "trigger"); a->dbg_tour_surface_step = 1; tour_surface_probe_next(*a, 500); return G_SOURCE_REMOVE; @@ -6830,8 +6943,9 @@ gboolean tour_surface_probe_step(gpointer user) { // A frame after the switch, so the Measure page has been laid out and the list really is off // screen. The tour is started *here*, from this state. std::fprintf(stderr, - "[toursurface] on measure: side_tab=%s signal_list_mapped=%d side_stack_mapped=%d\n", - a->side_tab.c_str(), mapped(a->signal_list), mapped(a->side_stack)); + "[toursurface] on trigger: slideover=%s signal_list_mapped=%d side_box_mapped=%d\n", + slideover_page(*a).empty() ? "none" : slideover_page(*a).c_str(), + mapped(a->signal_list), mapped(a->side_box)); start_tour(*a); std::fprintf(stderr, "[toursurface] tour: visible=%d steps=%d step=%d signals_stop=%d\n", tour_active(*a) ? 1 : 0, tour_step_count(*a), tour_step_index(*a), @@ -6848,8 +6962,9 @@ gboolean tour_surface_probe_step(gpointer user) { } GtkWidget* target = tour_target(*a); const bool is_list = target != nullptr && target == a->signal_list; - std::fprintf(stderr, "[toursurface] step=%d/%d side_tab=%s target_mapped=%d%s\n", - tour_step_index(*a) + 1, tour_step_count(*a), a->side_tab.c_str(), mapped(target), + std::fprintf(stderr, "[toursurface] step=%d/%d slideover=%s target_mapped=%d%s\n", + tour_step_index(*a) + 1, tour_step_count(*a), + slideover_page(*a).empty() ? "none" : slideover_page(*a).c_str(), mapped(target), is_list ? " target=signal_list" : ""); if (is_list) { // The frame after the switch: the page has been laid out and `update_tour()` has placed the @@ -6876,9 +6991,9 @@ gboolean tour_surface_probe_step(gpointer user) { default: { start_tour(*a); std::fprintf(stderr, - "[toursurface] collapsed: side_stack_mapped=%d steps=%d signals_stop=%d " + "[toursurface] collapsed: side_box_mapped=%d steps=%d signals_stop=%d " "side_stop=%d\n", - mapped(a->side_stack), tour_step_count(*a), + mapped(a->side_box), tour_step_count(*a), tour_plan_holds(*a, a->signal_list) ? 1 : 0, tour_plan_holds(*a, a->side_box) ? 1 : 0); tour_dismiss(*a); diff --git a/src/ui_source.cpp b/src/ui_source.cpp index 1337e7c..b9be3d0 100644 --- a/src/ui_source.cpp +++ b/src/ui_source.cpp @@ -11,48 +11,6 @@ namespace udpscope { namespace ui_impl { -namespace { - -/// Hide every `GtkImage` inside `w`, and keep it hidden. `AdwViewSwitcherButton` always builds an -/// icon -- in both of its layouts -- and a page with no `icon-name` gets `image-missing`, which is -/// the placeholder the sidebar's text tabs used to draw. There is no property or policy that turns -/// the icon off, so the image is hidden (a hidden child takes no space: the label gets the pill) and -/// a `notify::visible` handler puts it back to hidden if the button ever shows it again. -void hide_tab_icons(GtkWidget* w) { - if (GTK_IS_IMAGE(w)) { - if (g_object_get_data(G_OBJECT(w), "udpscope-icon-hidden") == nullptr) { - g_object_set_data(G_OBJECT(w), "udpscope-icon-hidden", GINT_TO_POINTER(1)); - g_signal_connect(w, "notify::visible", G_CALLBACK(+[](GObject* o, GParamSpec*, gpointer) { - GtkWidget* img = GTK_WIDGET(o); - if (gtk_widget_get_visible(img)) gtk_widget_set_visible(img, FALSE); - }), - nullptr); - } - gtk_widget_set_visible(w, FALSE); - return; - } - for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr; c = gtk_widget_get_next_sibling(c)) { - hide_tab_icons(c); - } -} - -void on_tab_pages_changed(GListModel*, guint, guint, guint, gpointer sw) { - hide_tab_icons(GTK_WIDGET(sw)); -} - -} // namespace - -void keep_tabs_icon_free(App& app) { - if (app.side_switcher == nullptr || app.side_stack == nullptr) return; - hide_tab_icons(app.side_switcher); - // A page added later gets a button when the model reports it; this handler is connected *after* - // the switcher's own, so the button already exists when it runs. - g_signal_connect_after(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack)), "items-changed", - G_CALLBACK(on_tab_pages_changed), app.side_switcher); - // And once more when the switcher is (re)mapped, which also covers a rebuild of the buttons. - g_signal_connect_swapped(app.side_switcher, "map", G_CALLBACK(hide_tab_icons), app.side_switcher); -} - void build_source_panels(App& app) { if (app.slideover.stack == nullptr || app.acq == nullptr || app.acq->source() == nullptr) return; SourceKind* kind = find_kind(app.source_kind); diff --git a/src/ui_tour.cpp b/src/ui_tour.cpp index 43a1816..4aea78b 100644 --- a/src/ui_tour.cpp +++ b/src/ui_tour.cpp @@ -422,11 +422,9 @@ void start_tour(App& app) { app.settings.tour_seen = true; (void)save_settings(app.settings); } - // The sidebar's own stack, so a stop can name the page it stands on. Set here and not in - // `build_tour()`: the overlay is inserted above the window's content *before* `App::side_stack` is - // assigned, so the member does not exist yet at build time -- and the walk cannot start before the - // window is up anyway. - app.tour.page_stack = app.side_stack; + // The main window has no page stack any more (the dock is one scroller): `page_stack` stays null, so + // a stop's `page` selects nothing and a stop whose target is unmapped is dropped. The slide-over hook + // that lets a stop open the trigger page is the next step's. // The whole table, in walk order: `tour_begin()` drops the stops whose surface is not on screen. std::vector stops; stops.reserve(kTourStepCount); @@ -486,8 +484,7 @@ constexpr FigureSurface kFigureSurfaces[] = { {"trigger_summary", &App::trigger_summary}, {"trigger_panel", &App::trigger_panel}, {"signal_list", &App::signal_list}, - {"side_switcher", &App::side_switcher}, - {"side_stack", &App::side_stack}, + {"side_stack", &App::side_box}, {"window_button", &App::window_button}, {"status_label", &App::status_label}, {"header_bar", &App::toolbar},