diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md index 61d74cf..1dd842d 100644 --- a/docs/claude/invariants-gtk-and-chrome.md +++ b/docs/claude/invariants-gtk-and-chrome.md @@ -346,3 +346,13 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear (`in_dialog=1`) or asks whether it was ever given an allocation (`mapped=1`, `canvas=420x836`). When a frame moves into a file, the probe that proves it has to read the frame's *structure*, not only the numbers it holds. + +- **A popover's autohide is only as good as its grab.** `GtkPopover` dismisses itself when it loses the + pointer grab -- and the grab is the thing that can be missing: a window manager moving it, a nested popover + (a `GtkDropDown`'s menu, which the time-window row has) taking it, and *every* headless run, where there is + no manager to hand one out and the probes have to turn autohide off before a popover will stay up to be + measured. The symptom is "sometimes it is awkward to close": a click anywhere else leaves the bubble up. + `on_window_popover_press()` states the rule outright instead -- any press that reaches the window's own + surface closes the window popover. That needs no hit test beyond the button, because a popover *is* its own + surface and a click inside it never arrives; the button is left to its own click, whose toggle is what + closes it. `[windowpop]` reads both halves, with autohide off so it is testing the rule and not the grab. diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 81dd08a..f1d9ba6 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -1214,3 +1214,18 @@ builds, plus `nested` — the list being *inside* that revealer, which is the on (a list parented elsewhere reads 1 everywhere else). `[events]` is the other half, and it drives the pane end to end: recording off, recording on, the trigger, the history button that reveals it, a row click and the replay, then Compare and Accumulate — so the file's buttons are read by being used. + +## `[windowpop]` + +``` +[windowpop] opened=1 visible=1 click_gestures=2 +[windowpop] after a press elsewhere: active=0 (want 0) +[windowpop] after a press on the button: active=1 (want 1) +``` + +`UDPSCOPE_PROBE_WINDOWPOP_MS`: the time-window popover and how it closes. Autohide is turned *off* for the +probe, exactly as the menu probe does it -- a popover dismisses itself when GTK holds its *grab*, and a bare +`xvfb-run` has no window manager to hand one out -- so what is measured is the state a user meets when the +grab goes missing, where the press has to close the bubble anyway. `click_gestures` reads that the controller +is *connected* as well as that its handler works; the last press is on the button itself, which must be left +to its own toggle (`want 1` rather than `0`). diff --git a/src/ui.cpp b/src/ui.cpp index 654212c..dcb0a98 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -4114,6 +4114,32 @@ GtkWidget* build_window_control(App& app) { return app.window_button; } +/// Any press in the window closes the time-window popover. +/// +/// `GtkPopover`'s autohide does this by itself for as long as GTK holds the popover's grab -- and the grab +/// is the thing that can go missing (a window manager moving it, a nested popover taking it for the unit +/// menu, and every headless run, where there is no manager to hand one out at all), after which a click +/// anywhere else in the window leaves the bubble up. That is the "sometimes it is awkward to close". +/// +/// A press that reaches the window's own surface was *not* inside the popover: a popover is its own +/// surface, so a click within it never arrives here. That is what makes this the rule stated outright +/// rather than a grab away from working -- and it needs no hit test beyond leaving the button alone, +/// whose own click is what toggles the bubble shut. +void on_window_popover_press(GtkGestureClick*, int, double x, double y, gpointer user) { + App* app = static_cast(user); + if (app->window_button == nullptr || + !gtk_menu_button_get_active(GTK_MENU_BUTTON(app->window_button))) { + return; + } + // The button itself is left to its own click, which toggles the bubble shut -- closing it here would + // only have the toggle open it again. Anything else on the window's surface closes it. + for (GtkWidget* w = gtk_widget_pick(app->window, x, y, GTK_PICK_DEFAULT); + w != nullptr && w != app->window; w = gtk_widget_get_parent(w)) { + if (w == app->window_button) return; + } + gtk_menu_button_popdown(GTK_MENU_BUTTON(app->window_button)); +} + /// The one place a key turns into an action. Toggles go through /// gtk_toggle_button_set_active(), which emits ::toggled and so runs exactly the handler a /// click would; the non-toggles call their click handlers with a null button, which they @@ -4659,6 +4685,13 @@ void on_activate(GtkApplication* application, gpointer user) { g_signal_connect(keys, "key-pressed", G_CALLBACK(on_app_key_pressed), app); gtk_widget_add_controller(app->window, keys); + // Capture phase, unlike the keys above: a press belongs to whatever widget is under it (the plot's + // gestures among them), and the point here is to see every one of them, not to win none of them. + GtkGesture* press = gtk_gesture_click_new(); + gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(press), GTK_PHASE_CAPTURE); + g_signal_connect(press, "pressed", G_CALLBACK(on_window_popover_press), app); + gtk_widget_add_controller(app->window, GTK_EVENT_CONTROLLER(press)); + gtk_window_present(GTK_WINDOW(app->window)); } @@ -5279,6 +5312,12 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_PROBE_KEYS_MS")) { g_timeout_add(static_cast(std::atoi(env)), keys_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_WINDOWPOP_MS")) { + g_timeout_add(static_cast(std::atoi(env)), windowpop_probe_step, &app); + } + if (const char* env = g_getenv("UDPSCOPE_PROBE_KEYS_MS")) { + g_timeout_add(static_cast(std::atoi(env)), keys_probe, &app); + } if (const char* env = g_getenv("UDPSCOPE_PROBE_COLOR_MS")) { g_timeout_add(static_cast(std::atoi(env)), color_probe, &app); } diff --git a/src/ui_app.hpp b/src/ui_app.hpp index e9a89c8..7dafeb1 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -1349,6 +1349,11 @@ gboolean zoomkeys_probe(gpointer user); void menus_probe_next(App& a, int step, guint delay_ms); void menu_row_labels(GtkWidget* box, GtkWidget** label, GtkWidget** accel); gboolean menus_probe_step(gpointer user); +gboolean windowpop_probe_step(gpointer user); + +/// Closes the time-window popover when a press lands elsewhere in the window (see ui.cpp). Declared for +/// the probe, which drives it and presses the button to check the one case that must *not* close it. +void on_window_popover_press(GtkGestureClick* gesture, int n_press, double x, double y, gpointer user); gboolean color_probe(gpointer user); gboolean reseq_probe(gpointer user); void capture_probe_next(App& a, int step, guint delay_ms); diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 2da9e38..7d5a162 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -2500,6 +2500,63 @@ void menus_probe_next(App& a, int step, guint delay_ms) { g_timeout_add(delay_ms, menus_probe_step, &a); } +/// `UDPSCOPE_PROBE_WINDOWPOP_MS`: the time-window popover, closed by a press elsewhere in the window. +/// +/// Autohide is turned off for the probe, exactly as the menu probe does it: a popover dismisses itself +/// when GTK holds its *grab*, and a bare `xvfb-run` has no window manager to hand one out -- so with +/// autohide on the bubble would not stay up to be tested. Autohide off is the state a user meets when +/// the grab goes missing, which is the report this guards: the press has to close it anyway. The second +/// half presses the button itself, which must *not* close it (its own click toggles). +gboolean windowpop_probe_step(gpointer user) { + static int step = 0; + App* a = static_cast(user); + if (a->window == nullptr || a->window_button == nullptr) return G_SOURCE_REMOVE; + const auto active = [a] { + return gtk_menu_button_get_active(GTK_MENU_BUTTON(a->window_button)) ? 1 : 0; + }; + if (step == 0) { + GtkPopover* pop = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->window_button)); + if (pop != nullptr) gtk_popover_set_autohide(pop, FALSE); + gtk_menu_button_popup(GTK_MENU_BUTTON(a->window_button)); + step = 1; + g_timeout_add(300, windowpop_probe_step, a); + return G_SOURCE_REMOVE; + } + // How many click gestures the window carries, so that "the handler works" and "the handler is + // connected" are read as two facts rather than one. + int clicks = 0; + { + GListModel* controllers = gtk_widget_observe_controllers(a->window); + const guint n = controllers != nullptr ? g_list_model_get_n_items(controllers) : 0; + for (guint i = 0; i < n; ++i) { + GObject* item = G_OBJECT(g_list_model_get_item(controllers, i)); + if (item != nullptr) { + if (GTK_IS_GESTURE_CLICK(item)) ++clicks; + g_object_unref(item); + } + } + if (controllers != nullptr) g_object_unref(controllers); + } + std::fprintf(stderr, "[windowpop] opened=%d visible=%d click_gestures=%d\n", active(), + gtk_widget_is_visible(GTK_WIDGET(gtk_menu_button_get_popover( + GTK_MENU_BUTTON(a->window_button)))) + ? 1 + : 0, + clicks); + on_window_popover_press(nullptr, 0, 20.0, 200.0, a); + std::fprintf(stderr, "[windowpop] after a press elsewhere: active=%d (want 0)\n", active()); + gtk_menu_button_popup(GTK_MENU_BUTTON(a->window_button)); + graphene_rect_t r = GRAPHENE_RECT_INIT(0, 0, 0, 0); + if (gtk_widget_compute_bounds(a->window_button, a->window, &r)) { + on_window_popover_press(nullptr, 0, r.origin.x + r.size.width / 2.0, + r.origin.y + r.size.height / 2.0, a); + } + std::fprintf(stderr, "[windowpop] after a press on the button: active=%d (want 1)\n", active()); + gtk_menu_button_popdown(GTK_MENU_BUTTON(a->window_button)); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} + gboolean menus_probe_step(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE;