ui: tighten the badge/dock/tour probes after review and fix the tour's stale hole

The session old-file probe and save_scheduled now discriminate, Esc is read through the real key path, the dock-section probe proves the Measure section starts out of view, and the tour's spotlight is measured independently of the helper it is drawn from. A target scrolled out of its scroller no longer leaves the previous hole standing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-11-02 20:56:09 +01:00
1 parent 2826042156
commit a1672acc70
7 files changed
+210 -62

No files matched your search

+2 -2
View File
@@ -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<ui_impl::TourStop> stops;
+2 -1
View File
@@ -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);
+3 -3
View File
@@ -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;
+96 -18
View File
@@ -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<double>(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<App*>(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<GdkModifierType>(0), a);
const bool closed = slideover_page(*a).empty();
const gboolean idle = on_app_key_pressed(nullptr, GDK_KEY_Escape, 0, static_cast<GdkModifierType>(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) {
+22 -12
View File
@@ -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.