feat: the help is a submenu, with a contextual-help layer and a guided tour
The hamburger menu's help rows were three flat entries and a window. They are a **`Help` submenu** now, which is where every GNOME menu keeps them, with the four things a first run cannot find: - Keyboard shortcuts Ctrl+/ (the existing overview, `show_shortcuts()`) - Documentation F1 (the existing manual, `show_help()`) - Guided tour Ctrl+T (new) - Enable contextual help (new, a stateful row: its check *is* the setting) The submenu *replaces* the menu's rows rather than floating beside them, the same shape and the same reason as the plot's right-click menu: a submenu torn out of the menu it belongs to is a second thing to dismiss, and the `GtkMenuButton` that owned one toggled itself out of sync the moment anything else closed its popover. It is the pages of a `GtkStack`, not a rebuild of the box and not a swap of the popover's child: the main view holds `app.layout_toggle`, which the tick's `sync_layout_editor()` and the `L` accel read, so rebuilding would leave that pointer on a destroyed widget, and a swapped child would leave the view that is not showing without an owner. **The contextual help is one table and a "?" per surface** (`src/ui_context_help.cpp`): the plot, the trigger, the cursors, the measurements, the math channels, the signals, the event history, the time window, the status line, and the math editor's identity row. Each row names the *heading box* its button joins -- a sibling of the title, never an overlay child on a corner, which is what would cover the switch or the [edit] button already there -- so adding a surface is a row and a widget pointer. Off means *gone*, not greyed: the buttons are removed, and they are found by a data key rather than a list of pointers because the math editor's heading is built afresh on every opening and a list would go stale one dialog later. `contextual_help` and `tour_seen` are keys of `settings.toml`, written through the same `Settings`/`load_settings()`/`save_settings()` the Preferences dialog uses, whose own rows start from `load_settings()` and so carry them through untouched. **The guided tour is five steps on a `GtkOverlay`** above the window's content: the plot, the side panel, the trigger, the signals and the status line. There is no popover in it and therefore no pointer grab to leak -- four dim rectangles around the hole are what make it modal -- and the probe asserts that as a reading (`controllers=` against the count taken before it opened) rather than as a claim. Everything is placed from `gtk_widget_compute_bounds()` re-read on every frame, because a widget's allocation is 0 on the frame it is first shown and a window resize moves it under a one-shot measurement; the hole is kept from the last frame that could measure the target, because a frame that lands between two layout passes would otherwise empty it and make the spotlight jump. `Skip`, `Finish` and `Escape` all hide every child and leave nothing behind. A first run gets the tour once, decided by `tour_seen` and not by "the settings file did not exist" (a user who changed the theme has a file and has still never seen it), and the flag is written when the tour *starts* rather than when it finishes: a user who skips it in the first second has still seen it. `UDPSCOPE_PROBE_HELPUI_MS` drives all of it -- the auto-start, the menu's seven rows and the submenu's five, the toggle's effect read as a count of affordances on either side of it, and the tour started, stepped forwards and back and dismissed -- and the exit dump gains `help layer:` and `help figures:`, the latter being the ten surfaces' window-coordinate rectangles that the manual's annotated screenshots crop their boxes from. Two of them are what the D-Bus-free headless run can still not decide, and both are limitations rather than defects: export carries no "?" (it is a menu action with no surface of its own, and a popover inside the menu's popover is the nested-popover trap the invariants already warn about), and the tour card cannot miss its target when the target is the whole content -- which is what the plot is at the window's 756 px minimum, where the split view overlays the side panel instead of putting it beside the plot. The probe prints both rectangles rather than asserting a placement, so that case reads as the geometry it is. Also fixed while it was found: `gtk_widget_measure()` includes the widget's own margins (measured: a 280 px box with `margin-start 453` reports 733), so a measure that is used to *place* the widget is a feedback loop -- that is what made the tour card walk between the corner and the target from frame to frame.
This commit is contained in:
1 parent
c1460a6977
commit
1baa58b8fd
20 files changed
+1558
-30
No files matched your search
+260
-2
@@ -2962,9 +2962,12 @@ gboolean menus_probe_step(gpointer user) {
|
||||
if (a->dbg_menus_step == 1) {
|
||||
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->menu_button));
|
||||
GtkWidget* box = popover != nullptr ? gtk_popover_get_child(popover) : nullptr;
|
||||
static const char* const kExpected[] = {"Fit to data", "Layout editor", "Signal list",
|
||||
// The popover's child is the stack the menu's two views are pages of; the rows are the visible
|
||||
// page's. The Help view is measured by the help probe, not here.
|
||||
if (box != nullptr && GTK_IS_STACK(box)) box = gtk_stack_get_visible_child(GTK_STACK(box));
|
||||
static const char* const kExpected[] = {"Fit to data", "Layout editor",
|
||||
"Export visible range...", "Configurations...",
|
||||
"Keyboard shortcuts", "Help", "About udpscope"};
|
||||
"Help", "Preferences\u2026", "About udpscope"};
|
||||
const int expected = static_cast<int>(sizeof(kExpected) / sizeof(kExpected[0]));
|
||||
int rows = 0;
|
||||
int bad = 0;
|
||||
@@ -4479,5 +4482,260 @@ gboolean help_probe(gpointer user) {
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/// `UDPSCOPE_PROBE_HELPUI_MS`: the help affordance layer, end to end -- the first-run auto-start, the
|
||||
/// four entries of the Help submenu, the contextual-help toggle and what it does to the "?"
|
||||
/// affordances, and the tour started, stepped and dismissed.
|
||||
///
|
||||
/// The steps are 300 ms apart because everything it measures is an *allocation*: a widget's is 0 in
|
||||
/// the tick it is first shown (see the allocation bullets in
|
||||
/// `docs/claude/invariants-trigger-and-pinning.md`), so a step that read its own predecessor's
|
||||
/// geometry would read a rectangle from before the frame that placed it.
|
||||
///
|
||||
/// The tour is started *from here* rather than from the window, so the probe path is exercised with
|
||||
/// whatever history the run has; the auto-start is read first, before this touches anything.
|
||||
void helpui_probe_next(App& a, int step, guint delay_ms) {
|
||||
a.dbg_helpui_step = step;
|
||||
g_timeout_add(delay_ms, helpui_probe_step, &a);
|
||||
}
|
||||
|
||||
/// The window's event controllers, and how many of them are clicks. A tour that grabs input and does
|
||||
/// not give it back shows up here as a count that grew.
|
||||
void helpui_count_controllers(App* a, int* total, int* clicks) {
|
||||
*total = 0;
|
||||
*clicks = 0;
|
||||
if (a->window == nullptr) return;
|
||||
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) {
|
||||
++*total;
|
||||
if (GTK_IS_GESTURE_CLICK(item)) ++*clicks;
|
||||
g_object_unref(item);
|
||||
}
|
||||
}
|
||||
if (controllers != nullptr) g_object_unref(controllers);
|
||||
}
|
||||
|
||||
/// The view the menu is showing: the stack's visible child, which is where the rows are.
|
||||
GtkWidget* helpui_menu_view(App* a) {
|
||||
if (a->menu_stack == nullptr) return nullptr;
|
||||
return gtk_stack_get_visible_child(GTK_STACK(a->menu_stack));
|
||||
}
|
||||
|
||||
/// One line: the spotlight, the widget it spotlights, and the card, all in **window coordinates** --
|
||||
/// the frame a screenshot is cropped in. `contains` is the spotlight covering its target (it is the
|
||||
/// target's own rectangle, grown three pixels) and `clear` is the card missing the target.
|
||||
void helpui_report_geometry(App& a, const char* what) {
|
||||
graphene_rect_t spot = GRAPHENE_RECT_INIT(0, 0, 0, 0);
|
||||
graphene_rect_t target = 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_is_visible(t) &&
|
||||
gtk_widget_compute_bounds(t, a.window, &target);
|
||||
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) {
|
||||
return outer.origin.x <= inner.origin.x + 0.5 && outer.origin.y <= inner.origin.y + 0.5 &&
|
||||
outer.origin.x + outer.size.width >= inner.origin.x + inner.size.width - 0.5 &&
|
||||
outer.origin.y + outer.size.height >= inner.origin.y + inner.size.height - 0.5;
|
||||
};
|
||||
const auto overlaps = [](const graphene_rect_t& p, const graphene_rect_t& q) {
|
||||
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;
|
||||
};
|
||||
std::fprintf(stderr,
|
||||
"[helpui] %s=%d/%d visible=%d spot=[%.0f,%.0f %.0fx%.0f] "
|
||||
"target=[%.0f,%.0f %.0fx%.0f] contains=%d card=[%.0f,%.0f %.0fx%.0f] clear=%d\n",
|
||||
what, tour_step_index(a) + 1, tour_step_count(a), tour_active(a) ? 1 : 0,
|
||||
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);
|
||||
}
|
||||
|
||||
gboolean helpui_probe_step(gpointer user) {
|
||||
App* a = static_cast<App*>(user);
|
||||
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||
const int step = a->dbg_helpui_step;
|
||||
|
||||
if (step == 0) {
|
||||
// The run's own history decides the auto-start (`settings.tour_seen`), and this reads it before
|
||||
// anything else touches the tour: a throwaway config directory is a first run, and a second run
|
||||
// against the same one is not.
|
||||
std::fprintf(stderr,
|
||||
"[helpui] auto_started=%d visible=%d steps=%d step=%d (%s)\n",
|
||||
a->tour_auto_started ? 1 : 0, tour_active(*a) ? 1 : 0, tour_step_count(*a),
|
||||
tour_step_index(*a),
|
||||
a->settings.tour_seen ? "tour_seen already set" : "first run");
|
||||
if (tour_active(*a)) tour_dismiss(*a);
|
||||
std::string ids;
|
||||
const int hints = context_help_count(*a, &ids);
|
||||
std::fprintf(stderr, "[helpui] contextual on: hints=%d ids={%s}\n", hints, ids.c_str());
|
||||
int total = 0;
|
||||
int clicks = 0;
|
||||
helpui_count_controllers(a, &total, &clicks);
|
||||
a->dbg_helpui_controllers = total;
|
||||
// Autohide off *for the probe only*, the same as the menu probe: a popover dismisses itself when
|
||||
// it loses the pointer grab, and under a bare `xvfb-run` there is no window manager to hand it
|
||||
// one, so the rows would be measured before they were ever mapped.
|
||||
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->menu_button));
|
||||
if (popover != nullptr) gtk_popover_set_autohide(popover, FALSE);
|
||||
gtk_menu_button_popup(GTK_MENU_BUTTON(a->menu_button));
|
||||
helpui_probe_next(*a, 1, 300);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
if (step == 1) {
|
||||
// The menu's own view first: the door into the Help view has to be on it.
|
||||
GtkWidget* box = helpui_menu_view(a);
|
||||
int rows = 0;
|
||||
int help_row = 0;
|
||||
for (GtkWidget* row = box != nullptr ? gtk_widget_get_first_child(box) : nullptr;
|
||||
row != nullptr; row = gtk_widget_get_next_sibling(row)) {
|
||||
if (!GTK_IS_BUTTON(row)) continue;
|
||||
GtkWidget* label = nullptr;
|
||||
GtkWidget* accel = nullptr;
|
||||
menu_row_labels(gtk_widget_get_first_child(row), &label, &accel);
|
||||
if (label != nullptr && std::strcmp(gtk_label_get_text(GTK_LABEL(label)), "Help") == 0) {
|
||||
help_row = 1;
|
||||
}
|
||||
++rows;
|
||||
}
|
||||
std::fprintf(stderr, "[helpui] main view: rows=%d help_row=%d\n", rows, help_row);
|
||||
// Into the Help view *through the row itself*: the handler is the thing that can break, so the
|
||||
// probe goes the way a user goes.
|
||||
if (a->menu_help_row != nullptr) g_signal_emit_by_name(a->menu_help_row, "clicked");
|
||||
helpui_probe_next(*a, 2, 300);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
if (step == 2) {
|
||||
static const char* const kExpected[] = {"Back", "Keyboard shortcuts", "Documentation",
|
||||
"Guided tour", "Enable contextual help"};
|
||||
constexpr int kExpectedCount = static_cast<int>(sizeof(kExpected) / sizeof(kExpected[0]));
|
||||
GtkWidget* box = helpui_menu_view(a);
|
||||
int rows = 0;
|
||||
int bad = 0;
|
||||
for (GtkWidget* row = box != nullptr ? gtk_widget_get_first_child(box) : nullptr;
|
||||
row != nullptr; row = gtk_widget_get_next_sibling(row)) {
|
||||
if (!GTK_IS_BUTTON(row)) continue;
|
||||
GtkWidget* label = nullptr;
|
||||
GtkWidget* accel = nullptr;
|
||||
menu_row_labels(gtk_widget_get_first_child(row), &label, &accel);
|
||||
const char* text = label != nullptr ? gtk_label_get_text(GTK_LABEL(label)) : "";
|
||||
const char* key = accel != nullptr ? gtk_label_get_text(GTK_LABEL(accel)) : "";
|
||||
const bool named = rows < kExpectedCount && std::strcmp(text, kExpected[rows]) == 0;
|
||||
if (!named) ++bad;
|
||||
std::fprintf(stderr, "[helpui] entry %d '%s' accel='%s' order=%d\n", rows, text, key,
|
||||
named ? 1 : 0);
|
||||
++rows;
|
||||
}
|
||||
const bool on = a->contextual_toggle != nullptr &&
|
||||
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->contextual_toggle)) != FALSE;
|
||||
std::fprintf(stderr, "[helpui] help view: rows=%d expected=%d rows_bad=%d back=%d toggle=%d "
|
||||
"entries='%s|%s|%s'\n",
|
||||
rows, kExpectedCount, bad, rows > 0 ? 1 : 0, on ? 1 : 0,
|
||||
a->menu_shortcuts_row != nullptr ? "Keyboard shortcuts" : "?",
|
||||
a->menu_docs_row != nullptr ? "Documentation" : "?",
|
||||
a->menu_tour_row != nullptr ? "Guided tour" : "?");
|
||||
// The toggle's *effect*: off means the affordances are gone, not greyed. Both directions are
|
||||
// driven through the row, because the row is what writes the setting.
|
||||
if (a->contextual_toggle != nullptr) {
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->contextual_toggle), FALSE);
|
||||
}
|
||||
std::string off_ids;
|
||||
const int off_hints = context_help_count(*a, &off_ids);
|
||||
if (a->contextual_toggle != nullptr) {
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->contextual_toggle), TRUE);
|
||||
}
|
||||
std::string on_ids;
|
||||
const int on_hints = context_help_count(*a, &on_ids);
|
||||
std::fprintf(stderr, "[helpui] toggle: off -> hints=%d ids={%s}; on -> hints=%d ids={%s}; "
|
||||
"setting=%s\n",
|
||||
off_hints, off_ids.c_str(), on_hints, on_ids.c_str(),
|
||||
a->settings.contextual_help ? "on" : "off");
|
||||
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->menu_button));
|
||||
if (popover != nullptr) gtk_popover_set_autohide(popover, TRUE);
|
||||
gtk_menu_button_popdown(GTK_MENU_BUTTON(a->menu_button));
|
||||
helpui_probe_next(*a, 3, 300);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
if (step == 3) {
|
||||
start_tour(*a);
|
||||
std::fprintf(stderr, "[helpui] tour started from the probe: visible=%d steps=%d step=%d\n",
|
||||
tour_active(*a) ? 1 : 0, tour_step_count(*a), tour_step_index(*a));
|
||||
helpui_probe_next(*a, 4, 400);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
if (step == 4 || step == 5) {
|
||||
// The spotlight against the widget it spotlights, and the card against the same widget: the
|
||||
// spotlight must *contain* the target, and the card must not overlap it when the window has room
|
||||
// for a card beside or above/below it. All of it is an allocation, so it is read one frame after
|
||||
// the step it describes: `update_tour()` places this frame from the allocation the last one left.
|
||||
helpui_report_geometry(*a, "step");
|
||||
if (step == 4) {
|
||||
// Next, which is what a user presses to walk the tour.
|
||||
tour_step(*a, 1);
|
||||
std::fprintf(stderr, "[helpui] after Next: step=%d/%d visible=%d\n", tour_step_index(*a) + 1,
|
||||
tour_step_count(*a), tour_active(*a) ? 1 : 0);
|
||||
helpui_probe_next(*a, 5, 400);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
// Back, then Skip: the two ways out that are not the last step.
|
||||
tour_step(*a, -1);
|
||||
std::fprintf(stderr, "[helpui] after Back: step=%d/%d visible=%d\n", tour_step_index(*a) + 1,
|
||||
tour_step_count(*a), tour_active(*a) ? 1 : 0);
|
||||
tour_dismiss(*a);
|
||||
// Dismissed, and nothing left behind. `controllers=` against the count taken before the tour
|
||||
// opened is the reading for "no lingering input grab": this overlay is ordinary widgets and
|
||||
// takes no grab at all, so a count that grew would be a grab somebody added.
|
||||
int total = 0;
|
||||
int clicks = 0;
|
||||
helpui_count_controllers(a, &total, &clicks);
|
||||
const auto hidden = [](GtkWidget* w) { return w == nullptr || !gtk_widget_is_visible(w); };
|
||||
std::fprintf(stderr,
|
||||
"[helpui] after Skip: visible=%d dims_hidden=%d spot_hidden=%d card_hidden=%d "
|
||||
"controllers=%d (was %d) click_gestures=%d\n",
|
||||
tour_active(*a) ? 1 : 0,
|
||||
hidden(a->tour_dim[0]) && hidden(a->tour_dim[1]) && hidden(a->tour_dim[2]) &&
|
||||
hidden(a->tour_dim[3])
|
||||
? 1
|
||||
: 0,
|
||||
hidden(a->tour_spot) ? 1 : 0, hidden(a->tour_card) ? 1 : 0, total,
|
||||
a->dbg_helpui_controllers, clicks);
|
||||
capture_help_state(*a);
|
||||
std::fprintf(stderr, "[helpui] layer: %s\n", a->dbg_context_help.c_str());
|
||||
helpui_probe_next(*a, 6, 300);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
if (step == 6 || step == 8) {
|
||||
// The trigger pane's summary grid is on screen only in *locked* mode, and one of the help's
|
||||
// figures is that content: the probe reads `help figures:` in both states, so the geometry agent
|
||||
// has the locked pane's numbers without having to guess how to get there.
|
||||
if (a->trigger_edit != nullptr) g_signal_emit_by_name(a->trigger_edit, "clicked");
|
||||
helpui_probe_next(*a, step == 6 ? 7 : 9, 400);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
// Step 7 (locked) and step 9 (back in configuration mode): one capture each, so each line is the
|
||||
// state it names.
|
||||
capture_help_state(*a);
|
||||
std::fprintf(stderr, "[helpui] figures %s: %s\n", step == 7 ? "locked" : "config",
|
||||
a->dbg_help_figures.c_str());
|
||||
if (step == 7) {
|
||||
helpui_probe_next(*a, 8, 400);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
std::fflush(stderr);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
} // namespace ui_impl
|
||||
} // namespace udpscope
|
||||
Reference in new issue
Block a user