diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 659ff6e..bbcf7ec 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -920,16 +920,24 @@ two also catch a row whose accelerator went missing. `settings=` must be a real slide-over page is always kept (the `on_page` hook opens it whatever the dock is doing), a target that is mapped is kept, and a stop in a *collapsed dock* is dropped (`gtk_widget_get_mapped()`; see the surface bullets in [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). - - `[helpui] step=1/6 visible=1 slideover=none spot=[x,y WxH] target=[…] contains=1 card=[…] clear=C` + - `[helpui] step=1/6 visible=1 slideover=none spot=[x,y WxH] raw=[…] target=[…] contains=1 inside=I 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. The `target=` rectangle is the part of the - target that is *on screen* (intersected with the scrolled windows above it, `tour_target_bounds()`): a - form taller than the panel's scroller on a short window is spotlit down to the scroller's edge and no - further. - **`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 + (`none`, or a page id) beside it; see `[toursurface]` below. `raw=` is the target's own rectangle, + unclipped; `target=` is the part of it that is *on screen*, which the probe computes **itself** by + intersecting `raw` with the bounds of every scrolled window above the target (not through + `tour_target_bounds()`, which the spotlight is drawn from -- reading the spotlight against its own helper + would only ever agree with itself). A form taller than the panel's scroller on a short window reads + `raw` taller than `target`, and is spotlit down to the scroller's edge and no further; the trigger stop at + the documented window reads `raw=[279,143 343x474] target=[279,143 343x253]`. + **`contains` must be 1** on every step: the spotlight covers the visible part (the part grown three + pixels), or is empty when none of the target is visible (a target scrolled out of its scroller gets no + hole at all, and not the hole of the frame before). **`inside` must not be 0**: it is the spotlight + staying within the nearest scrolled window above the target (grown by the same three pixels); `-1` is a + target with no scroller above it (the plot, the badge bar, the dock, the status line), `1` the trigger + form and the signal list. `inside=0` is a spotlight that spills out of its scroller over whatever is + under it. `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) - the plot's step is *the whole content*, because at 756 px the split view overlays the side panel + the plot's step is *the whole content*, because at 756 px the split view overlays the dock rather than putting it beside the plot, so `clear=0` there is the geometry and not a placement bug. The badges step reads `clear=1`. - `[helpui] after Next: step=2/6 visible=1`, `[helpui] after Back: step=1/6 visible=1`, @@ -972,10 +980,11 @@ two also catch a row whose accelerator went missing. `settings=` must be a real `slideover=none` although the probe started with the panel open** (a stop with no page closes it), **step 3 reads `slideover=trigger`, and step 4 reads `slideover=none` again**; every `target_mapped` is 1. - - `[toursurface] stop=N/6 visible=1 slideover=… spot=[…] target=[…] contains=1 card=[…] clear=C` -- the + - `[toursurface] stop=N/6 visible=1 slideover=… spot=[…] raw=[…] target=[…] contains=1 inside=I card=[…] clear=C` -- the `tour_geometry_line()` the helpui probe uses, one per stop, read a frame after the stop was entered (the spotlight is withheld until the panel has finished sliding in, `page_settled`): **`contains=1` on - every stop is the assertion** that the spotlight is on the target. `clear=0` is read as geometry, not + every stop is the assertion** that the spotlight is on the target, and `inside` must not be 0 (both as + in the `[helpui]` entry above, where `raw`, `target` and `inside` are explained). `clear=0` is read as geometry, not as a placement bug, on the plot stop (the target is the whole content) and on the trigger stop at the documented 756 px window (the panel is 360 px of it, so the card has no side wide enough and goes flush left over the plot, overlapping the panel's edge by a few pixels). @@ -989,8 +998,8 @@ two also catch a row whose accelerator went missing. `settings=` must be a real is how a probe puts the dock away for good): the dock's two stops are dropped, **and the trigger stop is kept** because the slide-over does not depend on the dock -- four stops remain (plot, badges, trigger, status). -- `[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). +- `[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 | … | esc: opened=1 handled=1 closed=1 idle_handled=0 | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** (`g` returns, so its pair reads `243->0`), **`refused` must be 1**, and the `esc:` reading must be `opened=1 handled=1 closed=1 idle_handled=0`. + 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 `esc:` reading goes through `on_app_key_pressed()` with `GDK_KEY_Escape`**, the handler the window's key controller calls, and not through `slideover_escape()`: the probe opens the `trigger` slide-over page (`opened=1`), sends the key (`handled=1`: it was consumed, and `closed=1`: the page is gone), then sends it again with nothing open (`idle_handled=0`: Escape is left alone when there is nothing for it to close, so a dialog or popover under it still gets it). `handled=0` or `closed=0` is the slide-over's Escape never reaching the panel from the window's key path -- a handler that returns before the slide-over test, or a controller in the wrong phase -- which the `[slideover] esc_closes=1` reading (a direct call to `slideover_escape()`) cannot see. **Limit of the `m`/`g` readings:** the padding is 3000 px at the end of the page, so `reach` is non-zero on any window shorter than that and the key has somewhere to scroll, but it does not push the cursors panel out of the viewport -- on a window whose dock viewport is taller than `cursors_y` (a tall window) the section is already in view at offset 0 and `m` still scrolls to it; `at_cursors=1` is the key landing the section's top at the viewport's top, which is the reading, not "the section was hidden before". 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` @@ -1810,12 +1819,14 @@ and `regained` reads 400 ms later. ## `[side-tab-probe]` ``` -[side-tab-probe] section=measure accums=4 baseline recorded (measure at scroll 243 of 2256) +[side-tab-probe] section=signals accums=4 baseline recorded (measure at scroll 2732 of 2732, outside_at_top=1) [side-tab-probe] section=signals rows=4 accums=4 advanced=4 min_n=18 ``` `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. +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 the probe pads `dock_page` **and the signal list** to 3000 px each (probe only, both undone in the report), and it re-arms once after 400 ms so the first reading has the layout. + +- **`outside_at_top=1` is the precondition**: with the dock at offset 0, the Measure section (the cursors panel, where `dock_show_section("measure")` lands) starts at or below the viewport's bottom edge, so the "away from the section" state really has the measurements out of view and `advanced=4` says something. `outside_at_top=0` means the probe's own set-up failed and the run proves nothing -- before the signal list was padded it read `0` on the documented window, where the section's top is 243 px down and the dock's viewport is taller than that. **The limit is the 3000 px:** the guarantee holds for a dock viewport shorter than the padding (any real window); a viewport taller than the signal list's padding, about 3000 logical px, would read `0` again. - **`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 @@ -2616,6 +2627,54 @@ The same probe then puts five measurements on the same spiked `sine` as **two wi 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. +the wish after `apply_layout()`, and `split_follows=1` the split view following it. **`ok=1` on all three lines is the assertion.** Each leg first cancels any save timer still pending +(`schedule_layout_save()` is an idempotent 400 ms debounce, so a leg would otherwise inherit the previous one's), which is what makes `save_scheduled` a reading +of *that* leg's toggle; with no source port (a recording opened from a file) nothing can be scheduled and the leg expects `save_scheduled=0`. The third line +loads a file as the previous version wrote it (`side_tab = "measure"`) into a `LayoutState` the probe has set to `dock_shown=false`, the opposite of the default: it must load with no error and +leave `dock_shown` at the default (`true`), so `ok=1` requires `dock_shown == default` -- the parser ignores unknown keys, and this is what says the old key still does not break a session. A load that +left the caller's `false` alone would read `dock_shown=0 default=1 ok=0`. The probe writes only to a temp path. + +## `[badges]` + +``` +[badges] table_bad=0 trigger_state=off bar_h=36 bar_h_after=36 fixed=1 open_marked=1 page_open=1 closed_unmarked=1 +[badges] source name=UDPStreamer state=connected pages=0 switch_visible=0 can_target=0 second_page=- second_ok=-1 open_marked=-1 closed_unmarked=1 panel_min_w=0 +``` + +`UDPSCOPE_PROBE_BADGES_MS` reads the badge bar under the plot. **Line 1** is the Trigger badge: `table_bad=0` (the rows of the 16-input +`trigger_badge_state()` table the probe gets wrong -- anything else is a state word that misreports the engine), `fixed=1` (the bar's height is +the same, and at least 36 px, before and after the `trigger` page was opened and closed; `0` means the bar's height depends on content and the plot above it +moves), `open_marked=1` / `page_open=1` / `closed_unmarked=1` (the `udpscope-badge-open` class follows the slide-over's page, both ways; `0` on +any is a badge that does not say its panel is open, or one that stays marked after it closed). + +**Line 2** is the Source badge, and every field has a value it must read: + +- `name=` the source kind's name and `state=` `connected`/`disconnected`, as the badge shows them. `connected` against a live mock; a badge stuck on + `disconnected` is the status not reaching the badge. +- `pages=` the number of slide-over pages the source kind contributes (`source-`): `0` for UDPS, `2` for the E-1608 (`source-ao`, `source-dio`). + `can_target=0` with `pages=0` is the UDPS badge being a read-out rather than a button; `can_target=1` with `pages>0` is the button being live. + `can_target=1` with `pages=0` is a dead button that opens nothing. +- `switch_visible=` the segmented switch being on screen after the first source page was opened: `1` with two or more pages, `0` with one or none. + A switch shown for a single page is a control that cannot change anything; hidden with two, the second panel is unreachable. +- `second_page=` / `second_ok=` what `slideover_page()` read after the switch selected the second page, and whether it is that page. **`second_ok=1` with + two or more pages, `-1` with fewer** (there is no second page to select, so the reading is "not applicable" and not a pass). `second_ok=0` is + the switch changing its own state without the slide-over following it. +- `open_marked=` the Source badge carrying the open mark while its first page is open: `1`; **`-1` with `pages=0`** (nothing to open); + `0` with pages is a badge that does not follow the panel. `closed_unmarked=1` is the mark gone once the panel is closed. +- `panel_min_w=` the widest minimum width of any source panel, measured in the slide-over's page. The page is 360 px less the scroller's own chrome and the + scroller never scrolls horizontally, so a value above that is a panel clipped at its right edge. `0` is no panel. + +## `[slideover]` + +``` +[slideover] open page=probe panel=360x350 at x=270 overlay=630x350 edge_flush=1 full_height=1 outside_hits_plot=1 inside_hits_panel=1 plot_w before=630 open=630 plot_unchanged=1 +[slideover] click_inside_closes=0 still_open=1 click_outside_closes=1 esc_closes=1 closed=1 +``` + +`UDPSCOPE_PROBE_SLIDEOVER_MS` opens a throw-away `probe` page and reads the panel 0.5 s later (add `UDPSCOPE_ENABLE_ALL_MS` so there are traces to hit). +**Line 1:** `panel=360x` (a 360 px panel as tall as the overlay), `edge_flush=1` (its right edge on the overlay's) and `full_height=1`; +`outside_hits_plot=1` / `inside_hits_panel=1` (a pick outside the panel lands in the plot, one inside it in the panel); `plot_unchanged=1` (`before=` equal to +`open=`: the panel is an overlay and does not resize the plot). `outside_hits_plot=0` is a panel allocation wider than 360 or a transparent parent that swallows +the plot's pan, drop and hover; `plot_unchanged=0` is the panel taking room from the plot. **Line 2:** `click_inside_closes=0` with `still_open=1` (a click +inside the panel does not close it), `click_outside_closes=1` (a click on the plot does), `esc_closes=1` (`slideover_escape()` closes it) and `closed=1`. +`esc_closes=1` is the function called directly; the key *through the window's handler* is the `esc:` reading of the `[keys]` line. diff --git a/docs/claude/verification.md b/docs/claude/verification.md index e995b36..01b9a71 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -414,8 +414,8 @@ precedent, not an exception. `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 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` + section out of view) and read again two seconds later. The probe pads the dock page and the signal list to + 3000 px each so the Measure section really starts below the viewport (`outside_at_top=1`) on any real 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 @@ -476,13 +476,13 @@ precedent, not an exception. - **a rejected code:** mock `--code 1234`, app `code=7`: one error line, the retries stop. - `UDPSCOPE_PROBE_E1608_GEN=1500` with `UDPSCOPE_EXIT_AFTER_MS=5500`, run as `--source e1608 --source-opt address=127.0.0.1 --source-opt port=54711 --source-opt ao0_init=0 --source-opt gen_rate=100` against - `tools/mock_e1608.py --port 54711 --log /tmp/e1608-gen100.log` -- the generator's update rate: it starts a Sine on - DAC 0 through the Analog out panel (and opens the `source-ao` slide-over page, since the panel refreshes on its - frame clock, which does not tick for a page that is off screen; the page is closed again at the end), waits 2.5 s, reads the achieved rate and the - panel's "Update rate" row, stops the generator and reads both again 400 ms later. Two lines, in - `probe-readings.md`. Then count the mock log's `AOUT` lines: about 250 with `gen_rate=100` (the generator ran - for about 2.5 s) and about 125 for a run without the option (default 50 Hz). Short and serial, the mock killed - afterwards. It enables nothing else, so it can run alone. + `tools/mock_e1608.py --port 54711 --log /tmp/e1608-gen100.log` -- the generator's update rate: it starts a Sine + on DAC 0 through the Analog out panel (and opens the `source-ao` slide-over page, since the panel refreshes on + its frame clock, which does not tick for a page that is off screen; the page is closed again at the end), + waits 2.5 s, reads the achieved rate and the panel's "Update rate" row, stops the generator and reads both + again 400 ms later. Two lines, in `probe-readings.md`. Then count the mock log's `AOUT` lines: about 250 with + `gen_rate=100` (the generator ran for about 2.5 s) and about 125 for a run without the option (default + 50 Hz). Short and serial, the mock killed afterwards. It enables nothing else, so it can run alone. - `UDPSCOPE_PROBE_E1608_RECONNECT=8000` -- reads the source after the mock dropped the link and 500 ms later checks the frame count moved on. The reading's own entry is in `probe-readings.md`. - `UDPSCOPE_PROBE_E1608_SCAN=1000` with `UDPSCOPE_EXIT_AFTER_MS=6500`, run as `--source e1608 --source-opt diff --git a/src/source_dialog.cpp b/src/source_dialog.cpp index 7889003..70a1c71 100644 --- a/src/source_dialog.cpp +++ b/src/source_dialog.cpp @@ -922,8 +922,8 @@ void chooser_tour_mark_seen() { void chooser_tour_begin(Chooser& c) { if (c.tour.card == nullptr) return; chooser_tour_mark_seen(); - // The chooser's own stack, so a stop can name the page it stands on (the window does the same with - // its sidebar in `start_tour()`). + // The chooser's own stack, so a stop can name the page it stands on (the window has no stack: + // `start_tour()` hooks its slide-over pages in through `on_page`). c.tour.page_stack = c.page_stack; // The whole table, in walk order: `tour_begin()` drops the stops whose surface is not on screen. std::vector stops; diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 2e17cff..9fff5bb 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -2127,7 +2127,8 @@ void tour_update(TourView& view); /// The widget the current stop points at, or nullptr when the walk is not on a stop. GtkWidget* tour_target(TourView& view); /// The part of `target` that is on screen, in `overlay`'s coordinates: its bounds intersected with every -/// scrolled window above it. False when it cannot be measured yet or is scrolled out of sight entirely. +/// scrolled window above it. A target scrolled out of sight entirely is measured and *empty*: true with a +/// zero-size rectangle. False only when it cannot be measured yet (no allocation, not in the overlay). /// The spotlight is drawn from it, and the probe reads the spotlight against it. bool tour_target_bounds(GtkWidget* target, GtkWidget* overlay, graphene_rect_t* out); diff --git a/src/ui_badges.cpp b/src/ui_badges.cpp index fd560f1..024379a 100644 --- a/src/ui_badges.cpp +++ b/src/ui_badges.cpp @@ -151,7 +151,7 @@ gboolean badges_probe(gpointer user) { // The Source badge: its read-out, and the segmented switch of a source with several panels. const std::string first = a->badges.source.page; bool switch_visible = false; - bool second_ok = true; + int second_ok = -1; // -1: there is no second page to select (one source page, or none) std::string second_page; if (!first.empty()) { slideover_open(*a, first); @@ -160,7 +160,7 @@ gboolean badges_probe(gpointer user) { adw_toggle_group_set_active_name(ADW_TOGGLE_GROUP(a->slideover.source_switch), a->slideover.source_pages[1].c_str()); second_page = slideover_page(*a); - second_ok = second_page == a->slideover.source_pages[1]; + second_ok = second_page == a->slideover.source_pages[1] ? 1 : 0; } } const bool marked = gtk_widget_has_css_class(a->badges.source.button, "udpscope-badge-open") != FALSE; @@ -179,7 +179,7 @@ gboolean badges_probe(gpointer user) { gtk_label_get_text(GTK_LABEL(a->badges.source.name)), gtk_label_get_text(GTK_LABEL(a->badges.source.state)), a->slideover.source_pages.size(), switch_visible ? 1 : 0, gtk_widget_get_can_target(a->badges.source.button) ? 1 : 0, - second_page.empty() ? "-" : second_page.c_str(), second_ok ? 1 : 0, + second_page.empty() ? "-" : second_page.c_str(), second_ok, first.empty() ? -1 : marked ? 1 : 0, gtk_widget_has_css_class(a->badges.source.button, "udpscope-badge-open") ? 0 : 1, widest); return G_SOURCE_REMOVE; diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 4930420..5223de3 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -1825,6 +1825,7 @@ gboolean dock_section_probe_report(gpointer user) { } } if (a->dock_page != nullptr) gtk_widget_set_size_request(a->dock_page, -1, -1); + if (a->signal_panel != nullptr) gtk_widget_set_size_request(a->signal_panel, -1, -1); std::fprintf(stderr, "[side-tab-probe] section=%s rows=%zu accums=%zu advanced=%" G_GUINT64_FORMAT " min_n=%" G_GUINT64_FORMAT "\n", @@ -1846,6 +1847,10 @@ gboolean dock_section_probe(gpointer user) { if (!padded && a->dock_page != nullptr) { padded = true; gtk_widget_set_size_request(a->dock_page, -1, 3000); + // ... and the signal list taller than any window too, which is what pushes the Measure section below + // the viewport at offset 0 (padding the page's end only makes it scrollable, and on a window taller than + // the section's y the section would stay in view). Undone in the report. + if (a->signal_panel != nullptr) gtk_widget_set_size_request(a->signal_panel, -1, 3000); g_timeout_add(400, dock_section_probe, a); return G_SOURCE_REMOVE; } @@ -1886,10 +1891,19 @@ gboolean dock_section_probe(gpointer user) { if (a->measures_stats_check != nullptr) { gtk_check_button_set_active(GTK_CHECK_BUTTON(a->measures_stats_check), TRUE); } + GtkAdjustment* dock_adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(a->dock_scroller)); + // From the top, is the measure section (the cursors panel, where `dock_show_section("measure")` lands) + // below the viewport? Only then is scrolling to it a move *into* view and back out a move *away*; a + // section already in view at offset 0 would make the whole probe a reading of nothing. + gtk_adjustment_set_value(dock_adj, 0.0); + graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f); + graphene_point_t measure_at = GRAPHENE_POINT_INIT(-1.f, -1.f); + const bool located = + a->cursors_panel != nullptr && gtk_widget_compute_point(a->cursors_panel, a->dock_page, &zero, &measure_at); + const bool outside_at_top = located && static_cast(measure_at.y) >= gtk_adjustment_get_page_size(dock_adj); 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); @@ -1898,8 +1912,11 @@ gboolean dock_section_probe(gpointer user) { // 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::fprintf(stderr, + "[side-tab-probe] section=%s accums=%zu baseline recorded (measure at scroll %.0f of %.0f, " + "outside_at_top=%d)\n", + dock_section_in_view(*a), g_dock_section_n_before.size(), scrolled, reach, + outside_at_top ? 1 : 0); std::fflush(stderr); // Long enough for several throttled recomputes (one per 100 ms while the window moves). g_timeout_add(2000, dock_section_probe_report, a); @@ -3607,13 +3624,23 @@ gboolean sidebar_probe(gpointer user) { /// 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. +/// `dock_shown` at its default, true (the probe presets it to false, so only a load that resets it passes). +/// Each leg first cancels any pending save timer and expects `save_scheduled=1` only with a source port. +/// 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"; + // `schedule_layout_save()` is an idempotent 400 ms debounce and does nothing without a source port + // (a recording opened from a file has none), so a leg that did not cancel the previous leg's timer + // would read it as its own. Cancel first, and expect a save only where one can be scheduled at all. + const bool can_save = a.source_port != 0; const auto leg = [&](const char* name, bool shown) { + if (a.layout_save_source != 0) { + g_source_remove(a.layout_save_source); + a.layout_save_source = 0; + } 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; @@ -3633,8 +3660,8 @@ gboolean dock_session_probe(gpointer user) { "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) + (wish == shown && collected == shown && saved && loaded && + back.dock_shown == shown && applied && save_pending == can_save) ? 1 : 0); }; leg("hidden", false); @@ -3648,10 +3675,10 @@ gboolean dock_session_probe(gpointer user) { 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; + const 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); + loaded && old_file.dock_shown == fresh.dock_shown ? 1 : 0); } std::remove(path.c_str()); std::fflush(stderr); @@ -6411,6 +6438,19 @@ gboolean keys_probe(gpointer user) { before_after("l: layout", a->layout_toggle, AppAction::LayoutEditor); before_after("s: side panel", a->sidebar_toggle, AppAction::SidePanel); + // Escape through the real key path: with a slide-over page open it must be consumed and close the page + // (the handler runs the slide-over ahead of the editable check), and with none open it must be left alone. + { + const bool opened = slideover_open(*a, "trigger") && slideover_page(*a) == "trigger"; + const gboolean handled = on_app_key_pressed(nullptr, GDK_KEY_Escape, 0, static_cast(0), a); + const bool closed = slideover_page(*a).empty(); + const gboolean idle = on_app_key_pressed(nullptr, GDK_KEY_Escape, 0, static_cast(0), a); + char buf[96]; + std::snprintf(buf, sizeof(buf), " esc: opened=%d handled=%d closed=%d idle_handled=%d |", opened ? 1 : 0, + handled != FALSE ? 1 : 0, closed ? 1 : 0, idle != FALSE ? 1 : 0); + line += buf; + } + // An accel must be refused while text is being typed. Focus the window entry, send 'm' // through the real handler, and require the dock not to scroll. int refused = -1; @@ -6658,19 +6698,46 @@ GtkWidget* helpui_menu_view(App* a) { /// One line: the spotlight, the widget it spotlights, and the card, all in **window coordinates** -- /// the frame a screenshot is cropped in -- with the slide-over's open page beside them (`none` when it -/// is closed), so a stop that had to open a page to find its target is readable as such. `contains` is -/// the spotlight covering its target (it is the target's own rectangle, grown three pixels) and +/// is closed), so a stop that had to open a page to find its target is readable as such. +/// +/// `raw` is the target's own rectangle, unclipped, and `target` the part of it a scrolled window above +/// it leaves on screen -- measured *here*, by intersecting `raw` with each scroller's bounds, and not by +/// `tour_target_bounds()` (which the spotlight is drawn from, so reading the spotlight against it would +/// only agree with itself). `contains` is the spotlight covering that visible part (the part, grown three +/// pixels), or being empty when none of the target is visible; `inside` is the spotlight staying within +/// the nearest scroller above the target (grown by the same three pixels), `-1` when there is none; /// `clear` is the card missing the target. `probe` is the tag it is printed under, so a window with /// more than one tour probe can tell its lines apart. void tour_geometry_line(App& a, const char* probe, const char* what) { graphene_rect_t spot = GRAPHENE_RECT_INIT(0, 0, 0, 0); + graphene_rect_t raw = GRAPHENE_RECT_INIT(0, 0, 0, 0); graphene_rect_t target = GRAPHENE_RECT_INIT(0, 0, 0, 0); + graphene_rect_t scroller = GRAPHENE_RECT_INIT(0, 0, 0, 0); graphene_rect_t card = GRAPHENE_RECT_INIT(0, 0, 0, 0); GtkWidget* t = tour_target(a); const bool have_spot = a.tour.spot != nullptr && gtk_widget_compute_bounds(a.tour.spot, a.window, &spot); - const bool have_target = t != nullptr && gtk_widget_get_mapped(t) && - tour_target_bounds(t, a.window, &target); + const bool have_raw = t != nullptr && gtk_widget_get_mapped(t) && + gtk_widget_compute_bounds(t, a.window, &raw); + target = raw; + bool visible_part = have_raw; + bool have_scroller = false; + for (GtkWidget* p = have_raw ? gtk_widget_get_parent(t) : nullptr; p != nullptr; + p = gtk_widget_get_parent(p)) { + graphene_rect_t clip; + if (!GTK_IS_SCROLLED_WINDOW(p) || !gtk_widget_compute_bounds(p, a.window, &clip)) continue; + if (!have_scroller) { + scroller = clip; + have_scroller = true; + } + graphene_rect_t cut; + if (!graphene_rect_intersection(&target, &clip, &cut)) { + visible_part = false; + target = GRAPHENE_RECT_INIT(0, 0, 0, 0); + break; + } + target = cut; + } const bool have_card = a.tour.card != nullptr && gtk_widget_compute_bounds(a.tour.card, a.window, &card); const auto contains = [](const graphene_rect_t& outer, const graphene_rect_t& inner) { @@ -6682,15 +6749,26 @@ void tour_geometry_line(App& a, const char* probe, const char* what) { return p.origin.x < q.origin.x + q.size.width && q.origin.x < p.origin.x + p.size.width && p.origin.y < q.origin.y + q.size.height && q.origin.y < p.origin.y + p.size.height; }; + // Nothing of the target on screen: the right spotlight is no spotlight at all. + const bool spot_empty = have_spot && spot.size.width <= 0.5f && spot.size.height <= 0.5f; + const bool covers = have_spot && have_raw && (visible_part ? contains(spot, target) : spot_empty); + int inside = -1; + if (have_scroller && have_spot) { + graphene_rect_t grown = GRAPHENE_RECT_INIT(scroller.origin.x - 3.0f, scroller.origin.y - 3.0f, + scroller.size.width + 6.0f, scroller.size.height + 6.0f); + inside = (spot_empty || contains(grown, spot)) ? 1 : 0; + } std::fprintf(stderr, "[%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", + "raw=[%.0f,%.0f %.0fx%.0f] target=[%.0f,%.0f %.0fx%.0f] contains=%d inside=%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, - (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, - have_card && have_target && !overlaps(card, target) ? 1 : 0); + (slideover_page(a).empty() ? "none" : slideover_page(a).c_str()), spot.origin.x, + spot.origin.y, spot.size.width, spot.size.height, raw.origin.x, raw.origin.y, + raw.size.width, raw.size.height, target.origin.x, target.origin.y, target.size.width, + target.size.height, covers ? 1 : 0, inside, card.origin.x, card.origin.y, + card.size.width, card.size.height, + have_card && visible_part && !overlaps(card, target) ? 1 : 0); } gboolean helpui_probe_step(gpointer user) { diff --git a/src/ui_tour.cpp b/src/ui_tour.cpp index 21db85b..21f8a49 100644 --- a/src/ui_tour.cpp +++ b/src/ui_tour.cpp @@ -18,10 +18,10 @@ // // There are *two* tours and one implementation. The main window's walks the plot, the badge bar, the // trigger (in the slide-over), the dock and the like; the source chooser's -- which appears *before* -// `App` exists -- walks the chooser's own widgets and then hands the tour on to the window through `Config::tour_pending`. The -// machinery below is written against a `TourView` and a list of `TourStop`s, so the difference -// between the two is only which widgets they name: the chooser keeps its table in -// `source_dialog.cpp`, where its widgets live. +// `App` exists -- walks the chooser's own widgets and then hands the tour on to the window through +// `Config::tour_pending`. The machinery below is written against a `TourView` and a list of +// `TourStop`s, so the difference between the two is only which widgets they name: the chooser keeps +// its table in `source_dialog.cpp`, where its widgets live. #include "ui_app.hpp" @@ -39,11 +39,11 @@ namespace ui_impl { namespace { /// The card's width. Wide enough for a comfortable line of the paragraph, narrow enough to fit -/// beside the plot on the documented window -- at 756 px the side panel is 320 and a card wider than +/// beside the plot on the documented window -- at 756 px the dock is 320 and a card wider than /// that is a card that has to cover its own target. It is a *floor* on the box, not a cap on the /// paragraph: the wrapped label is capped by `max_width_chars` so the natural width never exceeds it. constexpr int kCardWidth = 280; -/// The gap between the hole and whatever is placed beside it. Eight and not twelve: the side panel +/// The gap between the hole and whatever is placed beside it. Eight and not twelve: the dock /// is 320 px, and it has to hold the card and both gaps. constexpr int kGap = 8; @@ -152,7 +152,12 @@ bool tour_target_bounds(GtkWidget* target, GtkWidget* overlay, graphene_rect_t* graphene_rect_t clip; graphene_rect_t cut; if (!gtk_widget_compute_bounds(parent, overlay, &clip)) continue; - if (!graphene_rect_intersection(out, &clip, &cut)) return false; + if (!graphene_rect_intersection(out, &clip, &cut)) { + // Scrolled out of sight entirely: measured, and nothing of it is on screen. An *empty* rectangle, + // not a failure -- a caller that took "false" for "not measured yet" would keep the previous hole. + *out = GRAPHENE_RECT_INIT(0.0f, 0.0f, 0.0f, 0.0f); + return true; + } *out = cut; } return true; @@ -311,7 +316,12 @@ void tour_update(TourView& view) { !view.page_settled(view.page_user)) { target_present = false; } - if (target_present && tour_target_bounds(target, view.overlay, &bounds)) { + const bool measured = target_present && tour_target_bounds(target, view.overlay, &bounds); + if (measured && (bounds.size.width <= 0.0f || bounds.size.height <= 0.0f)) { + // Measured, but scrolled out of the scroller it stands in: nothing is on screen to point at, and the + // hole from the last frame (the target before it was scrolled away) must not stay standing. + view.hole[0] = view.hole[1] = view.hole[2] = view.hole[3] = 0; + } else if (measured) { // The hole: the target, grown by three pixels so the outline sits *around* the widget rather than // on it, and clamped to the overlay -- a target partly off screen is an ordinary case (a sidebar // dragged wide), and a negative rectangle is not something a margin can express. @@ -328,7 +338,7 @@ void tour_update(TourView& view) { // target went away mid-tour is not a step to be skipped silently. view.hole[0] = view.hole[1] = view.hole[2] = view.hole[3] = 0; } - // ... and when the target *is* there but could not be measured, the previous hole stands: a frame + // ... and when the target *is* there but could not be measured yet, the previous hole stands: a frame // can arrive between two layout passes, and emptying the hole there is the spotlight and the card // jumping to the corner and back. const int hx = view.hole[0]; @@ -344,8 +354,8 @@ void tour_update(TourView& view) { // The card, placed clear of the hole whenever the window has room for it. The strip order is the // order a card is easiest to read in: under the thing it describes, then over it, then in - // whichever column is beside it -- which is, on this window, the side panel for the plot's step and - // the plot for every step in the side panel. + // whichever column is beside it -- which is, on this window, the dock for the plot's stop and the + // plot for the dock's stops. // // The card's size is the *rectangle it was allocated*, read the same way the probe reads it and // the help's figures are drawn from: `gtk_widget_compute_bounds()`. Not `gtk_widget_get_width()`, @@ -402,7 +412,7 @@ void tour_update(TourView& view) { cy = std::clamp(hy, kGap, std::max(kGap, h - ch - kGap)); } else { // No clear side at all -- the target is nearly the whole window, which is the plot's step in a - // window narrow enough for the split view to *overlay* the side panel rather than put it beside + // window narrow enough for the split view to *overlay* the dock rather than put it beside // the plot. The card goes to the bottom-right corner, which is where the least of the target is, // and the two readings the probe prints (`spot=`, `card=`) are what show how little choice there // was rather than the card having been placed badly.