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
+115
@@ -200,6 +200,73 @@ struct App {
|
||||
bool search_jump_empty = false;
|
||||
GtkWidget* menu_button = nullptr;
|
||||
GtkWidget* cursors_toggle = nullptr;
|
||||
/// --- the help affordance layer -------------------------------------------
|
||||
///
|
||||
/// The overflow menu's two views. The popover's child is a `GtkStack` holding both, and the
|
||||
/// *visible child* is the view: swapping the stack rather than rebuilding the box is what keeps
|
||||
/// `layout_toggle` — which points into the main view and is read by the tick — a live widget.
|
||||
GtkWidget* menu_stack = nullptr;
|
||||
GtkWidget* menu_main_view = nullptr;
|
||||
GtkWidget* menu_help_view = nullptr;
|
||||
/// The Help view's own rows, so the probe can read them and drive them without walking the popover:
|
||||
/// the two existing dialogs' entry points and the two new actions.
|
||||
GtkWidget* menu_shortcuts_row = nullptr;
|
||||
GtkWidget* menu_docs_row = nullptr;
|
||||
GtkWidget* menu_tour_row = nullptr;
|
||||
/// The two navigation rows: the main view's door into the Help view, and the Back row out of it.
|
||||
GtkWidget* menu_help_row = nullptr;
|
||||
GtkWidget* menu_back_row = nullptr;
|
||||
/// "Enable contextual help": a stateful menu row whose check *is* the setting.
|
||||
GtkWidget* contextual_toggle = nullptr;
|
||||
/// The "?" each surface carries, one per row of the table in `ui_context_help.cpp`. These are the
|
||||
/// heading rows the buttons join — not the surfaces themselves — so a hint is a sibling of a
|
||||
/// title rather than an overlay on top of a control. `math_head` only exists while the math
|
||||
/// editor is open, which is why the row is skipped when it is null.
|
||||
GtkWidget* trigger_head = nullptr;
|
||||
GtkWidget* cursors_head = nullptr;
|
||||
GtkWidget* measures_head = nullptr;
|
||||
GtkWidget* signals_head = nullptr;
|
||||
GtkWidget* events_head = nullptr;
|
||||
GtkWidget* math_head = nullptr;
|
||||
/// The status line's row, and the header bar's slot for the window control: the two surfaces that
|
||||
/// are not a panel with a heading.
|
||||
GtkWidget* status_row = nullptr;
|
||||
GtkWidget* window_control_slot = nullptr;
|
||||
/// The guided tour. `tour_overlay` is a `GtkOverlay` inserted above the window's content, and the
|
||||
/// four dim rectangles, the spotlight and the card are its overlay children. `tour_plan` is the
|
||||
/// steps whose target was on screen when the tour started, so a surface that is not there is not
|
||||
/// walked over. `tour_hole` is the spotlight's rectangle in the overlay's own coordinates, kept
|
||||
/// from the last frame that could measure the target: a frame can come between two layout passes,
|
||||
/// where `gtk_widget_compute_bounds()` answers nothing, and a hole that emptied there would make
|
||||
/// the spotlight and the card jump for that frame.
|
||||
GtkWidget* tour_overlay = nullptr;
|
||||
GtkWidget* tour_dim[4] = {};
|
||||
GtkWidget* tour_spot = nullptr;
|
||||
GtkWidget* tour_card = nullptr;
|
||||
GtkWidget* tour_title = nullptr;
|
||||
GtkWidget* tour_body = nullptr;
|
||||
GtkWidget* tour_counter = nullptr;
|
||||
GtkWidget* tour_back = nullptr;
|
||||
GtkWidget* tour_next = nullptr;
|
||||
int tour_hole[4] = {0, 0, 0, 0};
|
||||
bool tour_running = false;
|
||||
int tour_index = 0;
|
||||
std::vector<int> tour_plan;
|
||||
/// The first-run auto-start: set by the timer that fires it, and read by the probe and the dump.
|
||||
/// The decision itself is `settings.tour_seen`, which is the whole of "this has happened before".
|
||||
bool tour_auto_started = false;
|
||||
/// The help layer's own dump readings, captured on the tick while the window is alive (nothing
|
||||
/// here may be read in the dump -- see `docs/claude/verification.md`).
|
||||
std::string dbg_context_help;
|
||||
std::string dbg_help_figures;
|
||||
int dbg_tour_steps = -1;
|
||||
int dbg_tour_step = -1;
|
||||
int dbg_tour_visible = -1;
|
||||
/// Which step of the help-affordance probe runs next (`UDPSCOPE_PROBE_HELPUI_MS`), and how many
|
||||
/// event controllers the window carried before the tour opened -- the reading that says the tour
|
||||
/// left nothing behind.
|
||||
int dbg_helpui_step = 0;
|
||||
int dbg_helpui_controllers = -1;
|
||||
/// Where the panel controls ended up, decided once at build time: the dump runs after the window
|
||||
/// is destroyed and cannot ask GTK anything about a widget.
|
||||
std::string dbg_panel_controls;
|
||||
@@ -1631,6 +1698,54 @@ void collect_spins(GtkWidget* w, std::vector<double>* out);
|
||||
gboolean defaults_probe(gpointer user);
|
||||
gboolean keys_probe(gpointer user);
|
||||
gboolean help_probe(gpointer user);
|
||||
void helpui_probe_next(App& a, int step, guint delay_ms);
|
||||
gboolean helpui_probe_step(gpointer user);
|
||||
|
||||
// --- the help affordance layer (ui_context_help.cpp, ui_tour.cpp) ----------
|
||||
//
|
||||
// Two things live here and they are not the same thing: the *contextual help* is a "?" on every
|
||||
// surface the app can explain, and the *guided tour* is one walk over five of them. Both read the
|
||||
// one setting the Help menu's toggle writes.
|
||||
|
||||
/// Adds or removes the "?" affordances so they match `settings.contextual_help`, and does nothing
|
||||
/// else. Idempotent, so it can be called after the window is built, on every change of the setting
|
||||
/// and once more when a dialog that carries one of the hosts is opened.
|
||||
void context_help_sync(App& app);
|
||||
/// How many "?" affordances are on screen, writing their ids space-separated into `ids`. Found by
|
||||
/// the data key rather than kept in a list, so a host that died with a dialog cannot leave a stale
|
||||
/// pointer behind.
|
||||
int context_help_count(App& app, std::string* ids);
|
||||
/// The menu row's handler: writes the setting, saves the file and re-states the layer.
|
||||
void on_contextual_help_toggled(GtkToggleButton* button, gpointer user);
|
||||
/// The "?" for one surface by id, for a host that only exists while a dialog is open (the math
|
||||
/// editor is rebuilt on every open, so it cannot be a row the window's own build visits).
|
||||
void context_help_add(App& app, const char* id, GtkWidget* host);
|
||||
|
||||
/// Builds the tour's overlay children (the four dim rectangles, the spotlight outline and the card)
|
||||
/// inside `app.tour_overlay`. Called once, as the overlay is inserted.
|
||||
void build_tour(App& app);
|
||||
/// Starts the tour at its first step and writes `tour_seen`. Safe to call while it already runs --
|
||||
/// it restarts at step 0.
|
||||
void start_tour(App& app);
|
||||
/// Moves `delta` steps, ending the tour on the way past the last one.
|
||||
void tour_step(App& app, int delta);
|
||||
/// Ends the tour: every child of the overlay is hidden and nothing of the tour is left on screen.
|
||||
void tour_dismiss(App& app);
|
||||
/// Called from the tick while the tour runs: re-measures the target each frame, so a window resize
|
||||
/// keeps the spotlight on the widget, and places the card where it does not cover it.
|
||||
void update_tour(App& app);
|
||||
bool tour_active(const App& app);
|
||||
/// The widget the current tour step points at, or nullptr when the tour is not on a step. The probe
|
||||
/// reads the spotlight against it.
|
||||
GtkWidget* tour_target(App& app);
|
||||
/// The steps this run actually walks (targets that were on screen when it started), and where it is.
|
||||
int tour_step_count(const App& app);
|
||||
int tour_step_index(const App& app);
|
||||
/// The first-run auto-start's callback, scheduled only when `settings.tour_seen` is false.
|
||||
gboolean tour_autostart(gpointer user);
|
||||
/// Captures the layer's own readings and the bounds of the surfaces the help's figures label, while
|
||||
/// the window is alive -- the dump runs after it is destroyed and may not ask GTK anything.
|
||||
void capture_help_state(App& app);
|
||||
|
||||
int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq,
|
||||
RowSet& rows);
|
||||
|
||||
Reference in new issue
Block a user