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
@@ -66,6 +66,10 @@ Settings load_settings() {
|
||||
s.relative_time = parse_bool(value, s.relative_time);
|
||||
} else if (key == "theme") {
|
||||
if (value == "auto" || value == "light" || value == "dark") s.theme = value;
|
||||
} else if (key == "contextual_help") {
|
||||
s.contextual_help = parse_bool(value, s.contextual_help);
|
||||
} else if (key == "tour_seen") {
|
||||
s.tour_seen = parse_bool(value, s.tour_seen);
|
||||
}
|
||||
}
|
||||
return s;
|
||||
@@ -80,6 +84,11 @@ bool save_settings(const Settings& settings) {
|
||||
out << "event_cache_max = " << settings.event_cache_max << "\n";
|
||||
out << "relative_time = " << (settings.relative_time ? "true" : "false") << "\n";
|
||||
out << "theme = \"" << settings.theme << "\"\n";
|
||||
// The two help keys are the *app's* memory rather than the dialog's: the contextual-help toggle in
|
||||
// the Help menu writes the first, and the first-run tour writes the second. `read_rows()` in the
|
||||
// preferences starts from `load_settings()`, so a dialog save carries them through untouched.
|
||||
out << "contextual_help = " << (settings.contextual_help ? "true" : "false") << "\n";
|
||||
out << "tour_seen = " << (settings.tour_seen ? "true" : "false") << "\n";
|
||||
out.flush();
|
||||
return out.good();
|
||||
}
|
||||
|
||||
@@ -33,6 +33,13 @@ struct Settings {
|
||||
/// "auto", "light" or "dark": what the interface should look like. Kept as the three names libadwaita
|
||||
/// uses, so the file says what the setting is rather than what an enum happens to be numbered.
|
||||
std::string theme = "auto";
|
||||
/// The contextual-help layer: a small "?" beside each surface it can explain. On by default -- an
|
||||
/// affordance nobody can find is not help -- and off is a decision that survives a restart.
|
||||
bool contextual_help = true;
|
||||
/// Whether the guided tour has been offered. A first run has no file carrying this key, so the tour
|
||||
/// starts itself once; the flag is written when it *starts*, not when it finishes, because a user who
|
||||
/// skips it has still seen it and a second launch with the same tour would be a nuisance.
|
||||
bool tour_seen = false;
|
||||
};
|
||||
|
||||
/// The defaults, as a value rather than as a comment: what the dialog's *Defaults* button writes and
|
||||
|
||||
+5
-1
@@ -22,9 +22,13 @@ constexpr Accel kAccels[] = {
|
||||
"Panes and editors"},
|
||||
{GDK_KEY_e, GDK_CONTROL_MASK, AppAction::Export, "<Control>e",
|
||||
"Export the visible range", "Files and windows"},
|
||||
{GDK_KEY_F1, 0, AppAction::Help, "F1", "Open the help window", "Files and windows"},
|
||||
{GDK_KEY_F1, 0, AppAction::Help, "F1", "Open the documentation", "Files and windows"},
|
||||
{GDK_KEY_slash, GDK_CONTROL_MASK, AppAction::Shortcuts, "<Control>slash",
|
||||
"Open this shortcut overview", "Files and windows"},
|
||||
{GDK_KEY_t, GDK_CONTROL_MASK, AppAction::GuidedTour, "<Control>t",
|
||||
"Start the guided tour", "Files and windows"},
|
||||
{GDK_KEY_h, GDK_CONTROL_MASK, AppAction::ContextualHelp, "<Control>h",
|
||||
"Turn the contextual help on or off", "Files and windows"},
|
||||
{GDK_KEY_q, GDK_CONTROL_MASK, AppAction::Quit, "<Control>q", "Quit udpscope",
|
||||
"Files and windows"},
|
||||
};
|
||||
|
||||
@@ -17,6 +17,8 @@ enum class AppAction {
|
||||
Export,
|
||||
Help,
|
||||
Shortcuts,
|
||||
GuidedTour,
|
||||
ContextualHelp,
|
||||
Quit,
|
||||
};
|
||||
|
||||
|
||||
+206
-24
@@ -2002,6 +2002,11 @@ void open_math_dialog(App& app, int edit) {
|
||||
}
|
||||
GtkWidget* window = d->window;
|
||||
gtk_window_set_title(GTK_WINDOW(window), edit >= 0 ? "Edit math channel" : "New math channel");
|
||||
// The contextual-help "?" for this editor. This is the one surface whose heading only exists while a
|
||||
// dialog is open -- the file is rebuilt on every opening -- so it cannot be a row the window's own
|
||||
// build visits: the row is still the table's, and this states its host and asks for the hint.
|
||||
app.math_head = head;
|
||||
context_help_add(app, "math", head);
|
||||
// The dialog's own state, freed with the window: a plain local here would leak the graph and every drag
|
||||
// it is halfway through on each opening.
|
||||
g_object_set_data_full(G_OBJECT(window), "udpscope-math-dialog", d,
|
||||
@@ -2010,7 +2015,13 @@ void open_math_dialog(App& app, int edit) {
|
||||
GtkWidget* dd = GTK_WIDGET(w);
|
||||
MathDialog* md = static_cast<MathDialog*>(
|
||||
g_object_get_data(G_OBJECT(dd), "udpscope-math-dialog"));
|
||||
if (md != nullptr && md->app != nullptr) md->app->math_dialog = nullptr;
|
||||
if (md != nullptr && md->app != nullptr) {
|
||||
// The host goes with the window, and the builder that owns it is dropped on the
|
||||
// next opening: clear the pointer here rather than leaving it aimed at widgets
|
||||
// that are about to be finalized.
|
||||
md->app->math_head = nullptr;
|
||||
md->app->math_dialog = nullptr;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
nullptr);
|
||||
@@ -2311,6 +2322,9 @@ GtkWidget* build_measures_panel(App& app) {
|
||||
update_measures(*a, /*force=*/true, /*accumulate=*/true);
|
||||
}),
|
||||
&app);
|
||||
// The heading row -- the .ui's anonymous first child, carrying the title and the `+`. It is where
|
||||
// the contextual help's "?" joins (see `ui_context_help.cpp`).
|
||||
app.measures_head = gtk_widget_get_first_child(box);
|
||||
return box;
|
||||
}
|
||||
|
||||
@@ -2430,32 +2444,70 @@ void on_export_clicked(GtkButton*, gpointer user) {
|
||||
g_object_unref(dialog);
|
||||
}
|
||||
|
||||
/// Dismisses the overflow menu. Every entry that opens something *else* -- a dialog, a window, the
|
||||
/// tour -- does this first, or the menu is still up behind what it opened.
|
||||
void close_overflow_menu(App& app) {
|
||||
if (app.menu_button == nullptr) return;
|
||||
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app.menu_button));
|
||||
if (popover != nullptr) gtk_popover_popdown(popover);
|
||||
}
|
||||
|
||||
void on_about_clicked(GtkButton*, gpointer user) {
|
||||
App* app = static_cast<App*>(user);
|
||||
if (app->menu_button != nullptr) {
|
||||
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
|
||||
if (popover != nullptr) gtk_popover_popdown(popover);
|
||||
}
|
||||
close_overflow_menu(*app);
|
||||
show_about(GTK_WINDOW(app->window),
|
||||
layout_path(app->model->config().server, app->model->config().port));
|
||||
}
|
||||
|
||||
void on_help_clicked(GtkButton*, gpointer user) {
|
||||
App* app = static_cast<App*>(user);
|
||||
if (app->menu_button != nullptr) {
|
||||
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
|
||||
if (popover != nullptr) gtk_popover_popdown(popover);
|
||||
}
|
||||
close_overflow_menu(*app);
|
||||
show_help(GTK_WINDOW(app->window));
|
||||
}
|
||||
|
||||
void on_shortcuts_clicked(GtkButton*, gpointer user) {
|
||||
App* app = static_cast<App*>(user);
|
||||
close_overflow_menu(*app);
|
||||
show_shortcuts(GTK_WINDOW(app->window));
|
||||
}
|
||||
|
||||
/// The Help submenu's two rows beyond the dialogs. The view swap is the plot menu's own shape: the
|
||||
/// second view *replaces* the first, with a Back row at its head, because a submenu torn out of the
|
||||
/// menu it belongs to is a second thing to dismiss and a `GtkMenuButton` that owned one toggled
|
||||
/// itself out of sync the moment anything else closed its popover.
|
||||
///
|
||||
/// What it does *not* do is rebuild the rows. The main view holds `app.layout_toggle`, which the tick
|
||||
/// reads (`sync_layout_editor()`) and which the `L` accel moves, so a rebuild would leave that pointer
|
||||
/// aimed at a destroyed widget; the two views are pages of a `GtkStack` instead, and a view is swapped
|
||||
/// by naming the page.
|
||||
void on_menu_show_help(GtkButton*, gpointer user) {
|
||||
App* app = static_cast<App*>(user);
|
||||
if (app->menu_stack == nullptr) return;
|
||||
gtk_stack_set_visible_child_name(GTK_STACK(app->menu_stack), "help");
|
||||
// The popover's content changed size, which is what `present()` re-allocates and re-positions: a
|
||||
// popover that is already up does not re-measure itself.
|
||||
if (app->menu_button != nullptr) {
|
||||
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
|
||||
if (popover != nullptr) gtk_popover_popdown(popover);
|
||||
if (popover != nullptr) gtk_popover_present(popover);
|
||||
}
|
||||
show_shortcuts(GTK_WINDOW(app->window));
|
||||
}
|
||||
|
||||
void on_menu_back(GtkButton*, gpointer user) {
|
||||
App* app = static_cast<App*>(user);
|
||||
if (app->menu_stack == nullptr) return;
|
||||
gtk_stack_set_visible_child_name(GTK_STACK(app->menu_stack), "main");
|
||||
if (app->menu_button != nullptr) {
|
||||
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
|
||||
if (popover != nullptr) gtk_popover_present(popover);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Guided tour": the menu hands over to the tour module, and the tour is startable at any time --
|
||||
/// from here, from `<Control>t`, and by itself on a first run.
|
||||
void on_tour_clicked(GtkButton*, gpointer user) {
|
||||
App* app = static_cast<App*>(user);
|
||||
close_overflow_menu(*app);
|
||||
start_tour(*app);
|
||||
}
|
||||
|
||||
// --- right-click menu -------------------------------------------------------
|
||||
@@ -3263,6 +3315,11 @@ gboolean tick(gpointer user) {
|
||||
// no text and no name either.
|
||||
name_side_tabs(*app);
|
||||
note_panel_controls(*app);
|
||||
// The tour, re-measured every frame: the target's allocation is 0 on the frame it is first shown
|
||||
// (see the allocation bullets in `docs/claude/invariants-trigger-and-pinning.md`) and the widget
|
||||
// moves under the spotlight on a window resize, so a one-shot measurement is wrong both ways.
|
||||
// Cheap when the tour is not running: one flag.
|
||||
update_tour(*app);
|
||||
ScopeModel& model = *app->model;
|
||||
const gint64 tick_start_us = g_get_monotonic_time();
|
||||
|
||||
@@ -3311,6 +3368,17 @@ gboolean tick(gpointer user) {
|
||||
std::fflush(stderr);
|
||||
}
|
||||
}
|
||||
// The help layer's own readings and the bounds the help's annotated figures crop, on the same
|
||||
// once-a-second cadence and for the same reason as the line above: the dump runs after the window
|
||||
// is destroyed and `verification.md` forbids a `gtk_widget_*` call there.
|
||||
{
|
||||
static gint64 next_help_log = 0;
|
||||
const gint64 now = g_get_monotonic_time();
|
||||
if (now >= next_help_log) {
|
||||
next_help_log = now + G_TIME_SPAN_SECOND;
|
||||
capture_help_state(*app);
|
||||
}
|
||||
}
|
||||
// Captured every frame so the dump has the last live value: see `dbg_measures_pane_w`.
|
||||
if (app->measures_list != nullptr) {
|
||||
app->dbg_measures_pane_w = gtk_widget_get_width(app->measures_list);
|
||||
@@ -4516,6 +4584,14 @@ void run_app_action(App& app, AppAction action) {
|
||||
case AppAction::Shortcuts:
|
||||
show_shortcuts(GTK_WINDOW(app.window));
|
||||
break;
|
||||
case AppAction::GuidedTour:
|
||||
start_tour(app);
|
||||
break;
|
||||
case AppAction::ContextualHelp:
|
||||
// Through the menu's own row rather than around it: the check *is* the setting, and the row's
|
||||
// handler is the one place that writes it.
|
||||
toggle(app.contextual_toggle);
|
||||
break;
|
||||
case AppAction::Quit:
|
||||
if (app.window != nullptr) gtk_window_close(GTK_WINDOW(app.window));
|
||||
break;
|
||||
@@ -4528,6 +4604,12 @@ gboolean on_app_key_pressed(GtkEventControllerKey*, guint keyval, guint, GdkModi
|
||||
gpointer user) {
|
||||
App* app = static_cast<App*>(user);
|
||||
if (app->window == nullptr) return FALSE;
|
||||
// Escape ends the tour, and it does so ahead of the editable check: a tour that cannot be dismissed
|
||||
// with the key every one of them teaches is a tour with a trap in it.
|
||||
if (keyval == GDK_KEY_Escape && tour_active(*app)) {
|
||||
tour_dismiss(*app);
|
||||
return TRUE;
|
||||
}
|
||||
GtkWidget* focus = gtk_window_get_focus(GTK_WINDOW(app->window));
|
||||
if (focus != nullptr && GTK_IS_EDITABLE(focus)) return FALSE;
|
||||
|
||||
@@ -4589,6 +4671,9 @@ GtkWidget* build_toolbar(App& app) {
|
||||
// has already packed, so a control added here would land right of the whole group.
|
||||
if (GtkWidget* slot = GTK_WIDGET(gtk_builder_get_object(ui, "window_control_slot"))) {
|
||||
gtk_box_append(GTK_BOX(slot), build_window_control(app));
|
||||
// Where the contextual help's "?" for the span control joins: the slot, not the bar, because a
|
||||
// control added to a header bar lands right of everything the file packed.
|
||||
app.window_control_slot = slot;
|
||||
}
|
||||
refresh_window_limits(app);
|
||||
|
||||
@@ -4608,8 +4693,9 @@ GtkWidget* build_toolbar(App& app) {
|
||||
// because these act on the `RowSet` directly and two of them are toggles whose state the
|
||||
// rest of the UI reads, so an action model would only add a second copy of that state.
|
||||
//
|
||||
// The order is a GNOME menu's: what you do to the *view*, then the data, then the help
|
||||
// trio. Nothing here needs a separator of its own -- the groups are the separators.
|
||||
// The order is a GNOME menu's: what you do to the *view*, then the data, then Help, then the
|
||||
// two about-the-program entries. Nothing here needs a separator of its own -- the groups are
|
||||
// the separators.
|
||||
GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
gtk_widget_set_margin_top(menu_box, 6);
|
||||
gtk_widget_set_margin_bottom(menu_box, 6);
|
||||
@@ -4649,16 +4735,19 @@ GtkWidget* build_toolbar(App& app) {
|
||||
|
||||
gtk_box_append(GTK_BOX(menu_box), menu_separator());
|
||||
|
||||
GtkWidget* shortcuts_item = menu_button("Keyboard shortcuts", "Ctrl+/");
|
||||
gtk_widget_set_tooltip_text(shortcuts_item,
|
||||
"every key and mouse gesture, including the plot's own");
|
||||
g_signal_connect(shortcuts_item, "clicked", G_CALLBACK(on_shortcuts_clicked), &app);
|
||||
gtk_box_append(GTK_BOX(menu_box), shortcuts_item);
|
||||
|
||||
GtkWidget* help_item = menu_button("Help", "F1");
|
||||
gtk_widget_set_tooltip_text(help_item, "the paged guide to every feature");
|
||||
g_signal_connect(help_item, "clicked", G_CALLBACK(on_help_clicked), &app);
|
||||
gtk_box_append(GTK_BOX(menu_box), help_item);
|
||||
// The Help submenu's door. A row and not a `GtkMenuButton`: the submenu is the *next view of this
|
||||
// menu*, and the chevron is what says so -- the same shape, and the same reason, as the plot menu's
|
||||
// Remove-signal row.
|
||||
GtkWidget* help_row = menu_button("Help", nullptr);
|
||||
gtk_widget_set_tooltip_text(help_row,
|
||||
"the shortcut list, the documentation, a guided tour and the "
|
||||
"contextual help");
|
||||
g_signal_connect(help_row, "clicked", G_CALLBACK(on_menu_show_help), &app);
|
||||
if (GtkWidget* inner = gtk_button_get_child(GTK_BUTTON(help_row))) {
|
||||
gtk_box_append(GTK_BOX(inner), gtk_image_new_from_icon_name("pan-end-symbolic"));
|
||||
}
|
||||
app.menu_help_row = help_row;
|
||||
gtk_box_append(GTK_BOX(menu_box), help_row);
|
||||
|
||||
GtkWidget* prefs_item = menu_button("Preferences\u2026", nullptr);
|
||||
g_signal_connect(prefs_item, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
@@ -4672,6 +4761,61 @@ GtkWidget* build_toolbar(App& app) {
|
||||
g_signal_connect(about_item, "clicked", G_CALLBACK(on_about_clicked), &app);
|
||||
gtk_box_append(GTK_BOX(menu_box), about_item);
|
||||
|
||||
// --- the Help view, the menu's second page --------------------------------
|
||||
// Exactly the four entries the menu promises: the two dialogs that already existed, the tour, and
|
||||
// the switch for the "?" layer. The two dialogs are the *same* entry points the keys reach --
|
||||
// `show_shortcuts()` and `show_help()` -- so the menu and the accelerator cannot drift apart.
|
||||
GtkWidget* help_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
gtk_widget_set_margin_top(help_box, 6);
|
||||
gtk_widget_set_margin_bottom(help_box, 6);
|
||||
gtk_widget_set_margin_start(help_box, 6);
|
||||
gtk_widget_set_margin_end(help_box, 6);
|
||||
|
||||
GtkWidget* back_row = menu_icon_button("go-previous-symbolic", "Back");
|
||||
g_signal_connect(back_row, "clicked", G_CALLBACK(on_menu_back), &app);
|
||||
app.menu_back_row = back_row;
|
||||
gtk_box_append(GTK_BOX(help_box), back_row);
|
||||
gtk_box_append(GTK_BOX(help_box), menu_separator());
|
||||
|
||||
app.menu_shortcuts_row = menu_button("Keyboard shortcuts", "Ctrl+/");
|
||||
gtk_widget_set_tooltip_text(app.menu_shortcuts_row,
|
||||
"every key and mouse gesture, including the plot's own");
|
||||
g_signal_connect(app.menu_shortcuts_row, "clicked", G_CALLBACK(on_shortcuts_clicked), &app);
|
||||
gtk_box_append(GTK_BOX(help_box), app.menu_shortcuts_row);
|
||||
|
||||
app.menu_docs_row = menu_button("Documentation", "F1");
|
||||
gtk_widget_set_tooltip_text(app.menu_docs_row,
|
||||
"the udpscope manual, opened in the desktop help viewer");
|
||||
g_signal_connect(app.menu_docs_row, "clicked", G_CALLBACK(on_help_clicked), &app);
|
||||
gtk_box_append(GTK_BOX(help_box), app.menu_docs_row);
|
||||
|
||||
app.menu_tour_row = menu_button("Guided tour", "Ctrl+T");
|
||||
gtk_widget_set_tooltip_text(app.menu_tour_row,
|
||||
"walk over the plot, the side panel, the trigger, the signals and "
|
||||
"the status line, one step at a time");
|
||||
g_signal_connect(app.menu_tour_row, "clicked", G_CALLBACK(on_tour_clicked), &app);
|
||||
gtk_box_append(GTK_BOX(help_box), app.menu_tour_row);
|
||||
|
||||
// The check is the setting: `menu_toggle()` states it before connecting, so the row that starts
|
||||
// checked does not fire the handler while the surfaces it acts on are still being built.
|
||||
app.contextual_toggle = menu_toggle("Enable contextual help", nullptr,
|
||||
app.settings.contextual_help);
|
||||
gtk_widget_set_tooltip_text(app.contextual_toggle,
|
||||
"a small \u201c?\u201d beside each surface, saying what that surface "
|
||||
"is for");
|
||||
g_signal_connect(app.contextual_toggle, "toggled", G_CALLBACK(on_contextual_help_toggled), &app);
|
||||
gtk_box_append(GTK_BOX(help_box), app.contextual_toggle);
|
||||
|
||||
// The two views, as the pages of a stack: the popover's child is the stack and the visible child is
|
||||
// the view. Swapping the *page* rather than rebuilding the box is what keeps `app.layout_toggle` --
|
||||
// which points into the main view and which the tick reads -- a live widget.
|
||||
app.menu_main_view = menu_box;
|
||||
app.menu_help_view = help_box;
|
||||
app.menu_stack = gtk_stack_new();
|
||||
gtk_stack_set_transition_type(GTK_STACK(app.menu_stack), GTK_STACK_TRANSITION_TYPE_NONE);
|
||||
gtk_stack_add_named(GTK_STACK(app.menu_stack), menu_box, "main");
|
||||
gtk_stack_add_named(GTK_STACK(app.menu_stack), help_box, "help");
|
||||
|
||||
// --- the pane toggles, which live on the bar rather than in the menu ------
|
||||
// The three pane toggles, and the rest of the bar, are the file's now; their handlers are wired where
|
||||
// the bar's ids are resolved, below. The popover above still shares this function's styling helpers
|
||||
@@ -4679,7 +4823,7 @@ GtkWidget* build_toolbar(App& app) {
|
||||
|
||||
GtkWidget* popover = gtk_popover_new();
|
||||
gtk_widget_add_css_class(popover, "udpscope-menu");
|
||||
gtk_popover_set_child(GTK_POPOVER(popover), menu_box);
|
||||
gtk_popover_set_child(GTK_POPOVER(popover), app.menu_stack);
|
||||
app.menu_button = GTK_WIDGET(gtk_builder_get_object(ui, "menu_button"));
|
||||
app.sidebar_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "sidebar_toggle"));
|
||||
app.trigger_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_toggle"));
|
||||
@@ -4734,6 +4878,9 @@ void on_activate(GtkApplication* application, gpointer user) {
|
||||
app->status_badge = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_badge"));
|
||||
app->status_label = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_label"));
|
||||
app->status_info_button = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_info"));
|
||||
// The status line's row, which is where the status line's own "?" joins and one of the surfaces the
|
||||
// help's annotated figures label.
|
||||
app->status_row = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_row"));
|
||||
GtkWidget* side = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_column"));
|
||||
GtkWidget* side_stack = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_stack"));
|
||||
GtkWidget* side_switcher = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_switcher"));
|
||||
@@ -4744,6 +4891,7 @@ void on_activate(GtkApplication* application, gpointer user) {
|
||||
if (app->window == nullptr || side_view == nullptr || events_divider == nullptr ||
|
||||
app->plot_box == nullptr || app->plot_split == nullptr || app->status_badge == nullptr ||
|
||||
app->status_label == nullptr || app->status_info_button == nullptr ||
|
||||
app->status_row == nullptr ||
|
||||
side == nullptr || side_stack == nullptr || side_switcher == nullptr ||
|
||||
measure_page == nullptr || signals_page == nullptr) {
|
||||
return;
|
||||
@@ -4769,6 +4917,19 @@ void on_activate(GtkApplication* application, gpointer user) {
|
||||
// shows are the window's, and those are in the file.
|
||||
gtk_window_set_titlebar(GTK_WINDOW(app->window), build_toolbar(*app));
|
||||
|
||||
// The guided tour's overlay goes *above* the window's content, between the window and the split
|
||||
// view: its children -- the four dim rectangles, the spotlight's outline and the card -- have to
|
||||
// be able to cover the whole content, and a widget placed anywhere inside the split view could
|
||||
// only ever cover its own half of it. The split view keeps its reference from the builder, so
|
||||
// handing the window a new child and then adopting the old one costs no reference bookkeeping.
|
||||
{
|
||||
GtkWidget* overlay = gtk_overlay_new();
|
||||
gtk_window_set_child(GTK_WINDOW(app->window), overlay);
|
||||
gtk_overlay_set_child(GTK_OVERLAY(overlay), side_view);
|
||||
app->tour_overlay = overlay;
|
||||
build_tour(*app);
|
||||
}
|
||||
|
||||
// The size the window opens at, stated in code as well as in the `.ui`: `default-width` there is the
|
||||
// size it *asks* for, and a window whose content has a larger minimum opens at that minimum instead --
|
||||
// 756 px here, the plot column's own minimum plus the sidebar's 320. The old code set it explicitly;
|
||||
@@ -4974,6 +5135,16 @@ void on_activate(GtkApplication* application, gpointer user) {
|
||||
rebuild_signal_panels(*app);
|
||||
g_timeout_add(kTickMs, tick, app);
|
||||
|
||||
// The contextual-help layer, once every surface it names exists. It reads the setting, so a user
|
||||
// who turned it off meets a window with no "?" on it and a menu whose row is unchecked.
|
||||
context_help_sync(*app);
|
||||
|
||||
// A first run is the run whose settings file does not say the tour has been offered -- see
|
||||
// `tour_autostart()`. Delayed rather than fired on the first tick: the window has to be up and
|
||||
// laid out for the tour to have anything to point at, and a tour that opened on the frame the
|
||||
// window appeared would be a spotlight on a widget with no allocation yet.
|
||||
if (!app->settings.tour_seen) g_timeout_add(1500, tour_autostart, app);
|
||||
|
||||
// Bubble phase on purpose: a focused GtkEditable or GtkButton wins its own keys, so typing
|
||||
// 'm' into the window entry does not toggle the measurements pane and Space does not
|
||||
// double-fire on a focused button.
|
||||
@@ -5601,6 +5772,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_HELP_MS")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), help_probe, &app);
|
||||
}
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_HELPUI_MS")) {
|
||||
helpui_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
|
||||
}
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_CONFIGS_MS")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), configs_probe, &app);
|
||||
}
|
||||
@@ -5855,6 +6029,14 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
app.dbg_switcher_pills, app.dbg_switcher_labels, app.dbg_switcher_names,
|
||||
app.dbg_switcher_icon_w, app.dbg_switcher_icon_h, app.dbg_switcher_w,
|
||||
app.dbg_switcher_h);
|
||||
// The help affordance layer: what the contextual-help toggle left on the surfaces, and where the
|
||||
// guided tour stood when the last capture ran. Both are captured on the tick (`capture_help_state`),
|
||||
// so neither asks a destroyed widget anything.
|
||||
std::printf("[udpscope] help layer: %s\n", app.dbg_context_help.c_str());
|
||||
// The bounds the help's annotated figures draw their crop boxes from: window coordinates, or
|
||||
// -1,-1,0,0 for a widget that is not on screen (`is_visible()`, not `get_visible()` -- a widget
|
||||
// under a hidden parent keeps its own flag true). Measured on the tick, after a frame.
|
||||
std::printf("[udpscope] help figures: %s\n", app.dbg_help_figures.c_str());
|
||||
std::printf("[udpscope] mode with trigger on: %s\n", app.dbg_mode_triggered.c_str());
|
||||
std::printf("[udpscope] trigger marker x=%.3f, view x_max=%.3f; model trigger_time=%.3f "
|
||||
"valid=%d\n",
|
||||
|
||||
+15
-1
@@ -121,7 +121,7 @@
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">F1</property>
|
||||
<property name="title" translatable="yes">Open the help window</property>
|
||||
<property name="title" translatable="yes">Open the documentation</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
@@ -131,6 +131,20 @@
|
||||
<property name="title" translatable="yes">Open this shortcut overview</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator"><Control>t</property>
|
||||
<property name="title" translatable="yes">Start the guided tour</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator"><Control>h</property>
|
||||
<property name="title" translatable="yes">Turn the contextual help on or off</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
|
||||
+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);
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
//
|
||||
// The contextual-help layer: one small "?" beside each surface the application can explain, whose
|
||||
// bubble says what that surface is for, in a title, a sentence or two, and the key that reaches it
|
||||
// when there is one.
|
||||
//
|
||||
// The surfaces are the table below -- a surface is a row -- and a hint *joins the surface's own
|
||||
// heading row* rather than being overlaid on it: a sibling of the title can never cover a control,
|
||||
// which an overlay child in a corner can and does. The one exception is the plot, which has no
|
||||
// heading: its "?" is an overlay child of the overlay the layout editor already uses, so it costs
|
||||
// nothing and sits over the plot's top-right corner.
|
||||
//
|
||||
// Off means *gone*, not greyed: the toggle removes the buttons instead of disabling them. Which is
|
||||
// also why the buttons are found by a data key rather than kept in a list here -- the math editor's
|
||||
// heading dies with its builder on every reopen, and a list of pointers into it would go stale.
|
||||
|
||||
#include "ui_app.hpp"
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
namespace udpscope {
|
||||
namespace ui_impl {
|
||||
namespace {
|
||||
|
||||
/// The "?" glyph. `help-about-symbolic` is already the status bar's information button, so a second
|
||||
/// thing meaning "help" needs a name of its own: this is Adwaita's question mark, and the dump reads
|
||||
/// it back through `gtk_icon_theme_has_icon()` -- a hint button whose icon does not resolve is a
|
||||
/// blank control, the failure the pane [edit] buttons already paid for once.
|
||||
constexpr const char* kHintIcon = "dialog-question-symbolic";
|
||||
/// The data key on a hint button, carrying the surface's id as the value. One key is both the mark
|
||||
/// that says "this is a hint" and the name the probe and the dump report it by.
|
||||
constexpr const char* kHintKey = "udpscope-help-hint";
|
||||
|
||||
} // namespace
|
||||
|
||||
/// One surface the contextual-help layer explains.
|
||||
///
|
||||
/// `host` is the box the "?" is appended to -- the surface's *heading row* for a panel, the
|
||||
/// status line's row, the header bar's window-control slot -- or, for the plot, the `GtkOverlay` it
|
||||
/// becomes an overlay child of. A null host is a surface whose host only exists while a dialog is
|
||||
/// open: the math editor's, which `open_math_dialog()` states and `context_help_add()` uses.
|
||||
struct HelpHint {
|
||||
const char* id; ///< what the probe and the dump call this affordance
|
||||
const char* title; ///< the bubble's bold first line
|
||||
const char* body; ///< one short paragraph
|
||||
const char* shortcut; ///< the key that reaches the surface, as it is printed, or ""
|
||||
GtkWidget* App::* host;
|
||||
};
|
||||
|
||||
/// The table, which is the whole of the curation: a new surface is a row, and the heading row it
|
||||
/// names must already exist in `App` (that is what `ui_app.hpp`'s help-layer fields are for).
|
||||
constexpr HelpHint kHelpHints[] = {
|
||||
{"plot", "The plot",
|
||||
"Every signal you have switched on is drawn here, one row per group of traces, with all rows "
|
||||
"sharing this one time axis. The wheel zooms about the pointer, a drag pans, and a right-click "
|
||||
"on a row opens that plot's own menu.",
|
||||
"", &App::area_overlay},
|
||||
{"trigger", "The trigger",
|
||||
"Freezes the view on an event you describe: a signal, a condition (an edge, a pulse width, a "
|
||||
"window, a dropout), a level and the event window around it. Arm it and the view holds the "
|
||||
"first event that matches, so a pulse that happens once a minute is readable at any window.",
|
||||
"A", &App::trigger_head},
|
||||
{"cursors", "The cursors",
|
||||
"Two movable instruments read against the window on screen: two verticals for time, two "
|
||||
"horizontals for the value axis, or the two crossings of each trace. What every plotted row "
|
||||
"reads at the cursor is listed in this pane.",
|
||||
"C", &App::cursors_head},
|
||||
{"measures", "The measurements",
|
||||
"Readings over the visible window: average, RMS, top, peak to peak, amplitude, frequency, a "
|
||||
"threshold count and more. Statistics under a reading track how it has varied since the last "
|
||||
"reset, without the plot having to be still.",
|
||||
"", &App::measures_head},
|
||||
{"math", "Math channels",
|
||||
"A channel computed from the streamed ones in a small node graph: sources, gains, filters and "
|
||||
"arithmetic, wired together and named here. It is a channel like any other, so it can be "
|
||||
"plotted, measured, searched and exported.",
|
||||
"", &App::math_head},
|
||||
{"signals", "The signals",
|
||||
"Every signal the source declares, each with the switch that acquires it. Nothing is buffered "
|
||||
"until it is switched on, and only acquired signals appear in the drop targets and the "
|
||||
"measurement editor. A signal that is dragged onto a plot is drawn there.",
|
||||
"G", &App::signals_head},
|
||||
{"events", "The event history",
|
||||
"Every triggered acquisition the cache keeps, with a picture of each. Open one to read it as a "
|
||||
"file, or select two or four and have them compared in two plots or overlaid in one.",
|
||||
"", &App::events_head},
|
||||
{"window", "The time window",
|
||||
"How much time is on screen, counted back from the newest sample. Type a span, change its unit, "
|
||||
"or step by decades; the trigger's event window is framed out of the same span. No gesture on "
|
||||
"the plot changes it -- only this control does.",
|
||||
"", &App::window_control_slot},
|
||||
{"status", "The status line",
|
||||
"The stream's own report, on its own once-a-second cadence: the display mode, the rate actually "
|
||||
"arriving, the window on screen and the buffer holding it. The information button beside it "
|
||||
"opens the counters behind those words.",
|
||||
"", &App::status_row},
|
||||
};
|
||||
constexpr int kHelpHintCount = static_cast<int>(sizeof(kHelpHints) / sizeof(kHelpHints[0]));
|
||||
|
||||
namespace {
|
||||
|
||||
/// The bubble: a bold title, the paragraph, and the key when there is one. Wrapped at a stated
|
||||
/// width rather than left to the popover's own measure, or a long sentence is one very wide line.
|
||||
GtkWidget* hint_popover(const HelpHint& hint) {
|
||||
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
|
||||
gtk_widget_set_margin_start(box, 12);
|
||||
gtk_widget_set_margin_end(box, 12);
|
||||
gtk_widget_set_margin_top(box, 10);
|
||||
gtk_widget_set_margin_bottom(box, 10);
|
||||
|
||||
GtkWidget* title = gtk_label_new(nullptr);
|
||||
char markup[128];
|
||||
std::snprintf(markup, sizeof(markup), "<b>%s</b>", hint.title);
|
||||
gtk_label_set_markup(GTK_LABEL(title), markup);
|
||||
gtk_label_set_xalign(GTK_LABEL(title), 0.0f);
|
||||
gtk_box_append(GTK_BOX(box), title);
|
||||
|
||||
GtkWidget* body = gtk_label_new(hint.body);
|
||||
gtk_label_set_wrap(GTK_LABEL(body), TRUE);
|
||||
gtk_label_set_max_width_chars(GTK_LABEL(body), 42);
|
||||
gtk_label_set_xalign(GTK_LABEL(body), 0.0f);
|
||||
gtk_box_append(GTK_BOX(box), body);
|
||||
|
||||
if (hint.shortcut != nullptr && hint.shortcut[0] != '\0') {
|
||||
const std::string key = std::string("Shortcut: ") + hint.shortcut;
|
||||
GtkWidget* line = gtk_label_new(key.c_str());
|
||||
gtk_widget_add_css_class(line, "dim-label");
|
||||
gtk_label_set_xalign(GTK_LABEL(line), 0.0f);
|
||||
gtk_box_append(GTK_BOX(box), line);
|
||||
}
|
||||
|
||||
GtkWidget* popover = gtk_popover_new();
|
||||
gtk_popover_set_child(GTK_POPOVER(popover), box);
|
||||
return popover;
|
||||
}
|
||||
|
||||
/// One "?" button, marked with its surface's id.
|
||||
GtkWidget* hint_button(const HelpHint& hint) {
|
||||
GtkWidget* button = gtk_menu_button_new();
|
||||
gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(button), kHintIcon);
|
||||
gtk_menu_button_set_popover(GTK_MENU_BUTTON(button), hint_popover(hint));
|
||||
gtk_widget_add_css_class(button, "flat");
|
||||
gtk_widget_set_valign(button, GTK_ALIGN_CENTER);
|
||||
gtk_widget_set_tooltip_text(button, "What is this?");
|
||||
g_object_set_data_full(G_OBJECT(button), kHintKey, g_strdup(hint.id), g_free);
|
||||
return button;
|
||||
}
|
||||
|
||||
const HelpHint* hint_by_id(const char* id) {
|
||||
for (int i = 0; i < kHelpHintCount; ++i) {
|
||||
if (std::strcmp(kHelpHints[i].id, id) == 0) return &kHelpHints[i];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
/// Takes the hint off `host`, if it carries one. Dismissed first, so a popover that is still up is
|
||||
/// not destroyed underneath GTK's own popdown path (a `GtkMenuButton` owns its popover, so the
|
||||
/// button cannot be dropped while the bubble is showing).
|
||||
void remove_hint(GtkWidget* host) {
|
||||
if (host == nullptr) return;
|
||||
for (GtkWidget* child = gtk_widget_get_first_child(host); child != nullptr;) {
|
||||
GtkWidget* next = gtk_widget_get_next_sibling(child);
|
||||
if (g_object_get_data(G_OBJECT(child), kHintKey) != nullptr) {
|
||||
if (GTK_IS_MENU_BUTTON(child)) {
|
||||
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(child));
|
||||
if (popover != nullptr && gtk_widget_is_visible(GTK_WIDGET(popover))) {
|
||||
gtk_popover_popdown(popover);
|
||||
}
|
||||
}
|
||||
// `gtk_widget_unparent()` rather than `gtk_box_remove()`: the host may be a box or the plot's
|
||||
// overlay, and this is the one call that means the same thing for both.
|
||||
gtk_widget_unparent(child);
|
||||
}
|
||||
child = next;
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts the hint for one table row on `host`, or takes it off when the layer is off.
|
||||
void sync_one(App& app, int row, GtkWidget* host) {
|
||||
if (row < 0 || row >= kHelpHintCount || host == nullptr) return;
|
||||
const HelpHint& hint = kHelpHints[row];
|
||||
remove_hint(host);
|
||||
if (!app.settings.contextual_help) return;
|
||||
GtkWidget* button = hint_button(hint);
|
||||
if (GTK_IS_BOX(host)) {
|
||||
gtk_box_append(GTK_BOX(host), button);
|
||||
} else if (GTK_IS_OVERLAY(host)) {
|
||||
// The plot's own case: an overlay child in the corner, where the layout editor's buttons are not
|
||||
// (they follow the rows, and the Done button is the *other* corner).
|
||||
gtk_widget_set_halign(button, GTK_ALIGN_END);
|
||||
gtk_widget_set_valign(button, GTK_ALIGN_START);
|
||||
gtk_widget_set_margin_end(button, 8);
|
||||
gtk_widget_set_margin_top(button, 8);
|
||||
gtk_overlay_add_overlay(GTK_OVERLAY(host), button);
|
||||
} else {
|
||||
gtk_widget_set_parent(button, host);
|
||||
}
|
||||
}
|
||||
|
||||
/// Walks up from `widget` counting the hint buttons in the subtree, and collecting their ids.
|
||||
int count_hints(GtkWidget* widget, std::string* ids) {
|
||||
if (widget == nullptr) return 0;
|
||||
int n = 0;
|
||||
if (const char* id = static_cast<const char*>(g_object_get_data(G_OBJECT(widget), kHintKey))) {
|
||||
if (ids != nullptr) {
|
||||
if (!ids->empty()) ids->push_back(' ');
|
||||
ids->append(id);
|
||||
}
|
||||
++n;
|
||||
}
|
||||
for (GtkWidget* child = gtk_widget_get_first_child(widget); child != nullptr;
|
||||
child = gtk_widget_get_next_sibling(child)) {
|
||||
n += count_hints(child, ids);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void context_help_sync(App& app) {
|
||||
for (int i = 0; i < kHelpHintCount; ++i) sync_one(app, i, app.*(kHelpHints[i].host));
|
||||
}
|
||||
|
||||
void context_help_add(App& app, const char* id, GtkWidget* host) {
|
||||
const HelpHint* hint = hint_by_id(id);
|
||||
if (hint == nullptr) return;
|
||||
sync_one(app, static_cast<int>(hint - kHelpHints), host);
|
||||
}
|
||||
|
||||
int context_help_count(App& app, std::string* ids) {
|
||||
if (ids != nullptr) ids->clear();
|
||||
if (app.window == nullptr) return 0;
|
||||
return count_hints(app.window, ids);
|
||||
}
|
||||
|
||||
void on_contextual_help_toggled(GtkToggleButton* button, gpointer user) {
|
||||
App* app = static_cast<App*>(user);
|
||||
const bool on = gtk_toggle_button_get_active(button) != FALSE;
|
||||
if (on == app->settings.contextual_help) return;
|
||||
app->settings.contextual_help = on;
|
||||
// Through the one mechanism the preferences already use: the settings file, written in full so the
|
||||
// keys this toggle does not own are carried through from the value the app is running on.
|
||||
if (!save_settings(app->settings)) {
|
||||
std::fprintf(stderr, "[help] could not write %s: the toggle will not survive a restart\n",
|
||||
settings_path().c_str());
|
||||
}
|
||||
context_help_sync(*app);
|
||||
}
|
||||
|
||||
} // namespace ui_impl
|
||||
} // namespace udpscope
|
||||
@@ -109,6 +109,10 @@ GtkWidget* build_cursors_panel(App& app) {
|
||||
return box != nullptr ? box : gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
}
|
||||
|
||||
// The panel's heading row -- its first child, which carries the title, the mode dropdown, Reset and
|
||||
// the pane's switch. It is where the contextual help's "?" joins (see `ui_context_help.cpp`).
|
||||
app.cursors_head = gtk_widget_get_first_child(box);
|
||||
|
||||
// The mode names are the dropdown's model, and they are a C array, so they are set here.
|
||||
const char* names[5] = {kCursorModeNames[0], kCursorModeNames[1], kCursorModeNames[2],
|
||||
kCursorModeNames[3], nullptr};
|
||||
|
||||
@@ -986,6 +986,8 @@ GtkWidget* build_events_pane(App& app) {
|
||||
app.events_accumulate = GTK_WIDGET(gtk_builder_get_object(ui, "events_accumulate"));
|
||||
GtkWidget* live = GTK_WIDGET(gtk_builder_get_object(ui, "events_live"));
|
||||
app.events_list = GTK_WIDGET(gtk_builder_get_object(ui, "events_list"));
|
||||
// The heading row, where the contextual help's "?" joins (see `ui_context_help.cpp`).
|
||||
app.events_head = GTK_WIDGET(gtk_builder_get_object(ui, "events_head"));
|
||||
if (app.events_revealer == nullptr || app.events_compare == nullptr ||
|
||||
app.events_accumulate == nullptr || live == nullptr || app.events_list == nullptr) {
|
||||
// A missing id is a mistake in the `.ui`: the pane comes up empty rather than taking the window with it,
|
||||
|
||||
+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
|
||||
@@ -592,6 +592,8 @@ GtkWidget* build_signal_panel(App& app) {
|
||||
GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/signals-panel.ui");
|
||||
GtkWidget* box = GTK_WIDGET(gtk_builder_get_object(ui, "signal_panel"));
|
||||
app.signal_list = GTK_WIDGET(gtk_builder_get_object(ui, "signal_list"));
|
||||
// The heading row, where the contextual help's "?" joins (see `ui_context_help.cpp`).
|
||||
app.signals_head = GTK_WIDGET(gtk_builder_get_object(ui, "signals_head"));
|
||||
app.signals_edit = GTK_WIDGET(gtk_builder_get_object(ui, "signals_edit"));
|
||||
if (app.signals_edit != nullptr) {
|
||||
g_signal_connect(app.signals_edit, "clicked", G_CALLBACK(on_pane_edit_clicked), &app);
|
||||
|
||||
+417
@@ -0,0 +1,417 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
//
|
||||
// The guided tour: a walk over the surfaces a first run has no way of knowing about, drawn on a
|
||||
// `GtkOverlay` inserted above the window's content.
|
||||
//
|
||||
// Why an overlay and not a popover per step: a popover takes a pointer grab, and a run of them takes
|
||||
// one per step. The overlay takes none at all -- what makes the tour modal is the four dim
|
||||
// rectangles, which are ordinary widgets that swallow the clicks that land on them, and dismissing
|
||||
// the tour is hiding them. There is no grab to leak and nothing to unparent.
|
||||
//
|
||||
// The spotlight is *four* rectangles and not a drawing: the top, bottom, left and right strips
|
||||
// around the target, positioned by margins each frame from `gtk_widget_compute_bounds()`. That is
|
||||
// the whole of "dims everything except the target", it needs no cairo and no paint function, and it
|
||||
// is re-measured on every frame -- the target's own allocation is 0 on the frame it is first shown
|
||||
// (see the allocation bullets in `docs/claude/invariants-trigger-and-pinning.md`), and a window
|
||||
// resize moves it under a spotlight that a one-shot measurement would leave behind.
|
||||
|
||||
#include "ui_app.hpp"
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
namespace udpscope {
|
||||
namespace ui_impl {
|
||||
namespace {
|
||||
|
||||
/// One step. The target is measured, never named by a stored rectangle: the point of re-measuring
|
||||
/// every frame is that nothing here knows where the widget is.
|
||||
struct TourStep {
|
||||
const char* id;
|
||||
const char* title;
|
||||
const char* body;
|
||||
GtkWidget* App::* target;
|
||||
};
|
||||
|
||||
constexpr TourStep kTourSteps[] = {
|
||||
{"plot", "The plot",
|
||||
"Every signal you have switched on is drawn here, all rows sharing one time axis. The wheel "
|
||||
"zooms, a drag pans, and a right-click on a row opens that plot's own menu.",
|
||||
&App::area_overlay},
|
||||
{"side", "The side panel",
|
||||
"The working surface beside the plot. Measure holds the cursors and the measurements; Signals "
|
||||
"holds the trigger and the list of signals. The buttons in the bar above show and hide it.",
|
||||
&App::side_box},
|
||||
{"trigger", "The trigger",
|
||||
"Describe an event -- a signal, a condition, a level and the window around it -- and arm it: "
|
||||
"the view then freezes on the first event that matches, however rare it is.",
|
||||
&App::trigger_body},
|
||||
{"signals", "The signals",
|
||||
"Every signal the source declares, each with the switch that acquires it. A signal has to be "
|
||||
"switched on before it can be plotted, measured or searched.",
|
||||
&App::signal_list},
|
||||
{"status", "The status line",
|
||||
"The stream's own report, once a second: the display mode, the rate arriving, the window on "
|
||||
"screen and the buffer holding it. The information button opens the counters behind it.",
|
||||
&App::status_row},
|
||||
};
|
||||
constexpr int kTourStepCount = static_cast<int>(sizeof(kTourSteps) / sizeof(kTourSteps[0]));
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
/// is 320 px, and it has to hold the card and both gaps.
|
||||
constexpr int kGap = 8;
|
||||
|
||||
/// Places an overlay child of the tour overlay at an exact rectangle. `GtkOverlay` positions an
|
||||
/// overlay child by its alignment and its margins and gives it its measured size, so a fixed
|
||||
/// rectangle is a size request plus two margins.
|
||||
void place(GtkWidget* widget, int x, int y, int width, int height) {
|
||||
gtk_widget_set_halign(widget, GTK_ALIGN_START);
|
||||
gtk_widget_set_valign(widget, GTK_ALIGN_START);
|
||||
gtk_widget_set_margin_start(widget, std::max(0, x));
|
||||
gtk_widget_set_margin_top(widget, std::max(0, y));
|
||||
gtk_widget_set_size_request(widget, std::max(0, width), std::max(0, height));
|
||||
}
|
||||
|
||||
/// Places an overlay child by its *natural* size at a point. The card sizes itself -- its paragraph
|
||||
/// wraps at a stated width -- and only its position is ours. Giving it the width we measured back as
|
||||
/// a size request is a feedback loop: a request is a floor on the widget's own measure, so the width
|
||||
/// it was placed at becomes the width it reports next frame, and a starting value that is one pixel
|
||||
/// too large grows by that pixel for ever.
|
||||
void place_natural(GtkWidget* widget, int x, int y) {
|
||||
gtk_widget_set_halign(widget, GTK_ALIGN_START);
|
||||
gtk_widget_set_valign(widget, GTK_ALIGN_START);
|
||||
gtk_widget_set_margin_start(widget, std::max(0, x));
|
||||
gtk_widget_set_margin_top(widget, std::max(0, y));
|
||||
}
|
||||
|
||||
/// The step currently walked, or nullptr when the tour is not on a step.
|
||||
const TourStep* current_step(const App& app) {
|
||||
if (app.tour_index < 0 || app.tour_index >= static_cast<int>(app.tour_plan.size())) return nullptr;
|
||||
return &kTourSteps[app.tour_plan[static_cast<std::size_t>(app.tour_index)]];
|
||||
}
|
||||
|
||||
/// Restates the card's text for the current step. Called on a change of step, not per frame.
|
||||
void show_step(App& app) {
|
||||
const TourStep* step = current_step(app);
|
||||
if (step == nullptr || app.tour_card == nullptr) return;
|
||||
// The hole belongs to the step: reusing the last one for a frame would put the spotlight on the
|
||||
// surface the tour has just left, and the next frame fills this one in.
|
||||
app.tour_hole[0] = app.tour_hole[1] = app.tour_hole[2] = app.tour_hole[3] = 0;
|
||||
char markup[128];
|
||||
std::snprintf(markup, sizeof(markup), "<b>%s</b>", step->title);
|
||||
gtk_label_set_markup(GTK_LABEL(app.tour_title), markup);
|
||||
gtk_label_set_text(GTK_LABEL(app.tour_body), step->body);
|
||||
char counter[64];
|
||||
std::snprintf(counter, sizeof(counter), "%d of %d",
|
||||
app.tour_index + 1, static_cast<int>(app.tour_plan.size()));
|
||||
gtk_label_set_text(GTK_LABEL(app.tour_counter), counter);
|
||||
gtk_widget_set_visible(app.tour_back, app.tour_index > 0);
|
||||
gtk_button_set_label(GTK_BUTTON(app.tour_next),
|
||||
app.tour_index + 1 >= static_cast<int>(app.tour_plan.size()) ? "Finish"
|
||||
: "Next");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
GtkWidget* tour_target(App& app) {
|
||||
const TourStep* step = current_step(app);
|
||||
return step != nullptr ? app.*(step->target) : nullptr;
|
||||
}
|
||||
|
||||
void build_tour(App& app) {
|
||||
if (app.tour_overlay == nullptr) return;
|
||||
for (GtkWidget*& dim : app.tour_dim) {
|
||||
dim = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
gtk_widget_add_css_class(dim, "udpscope-tour-dim");
|
||||
gtk_overlay_add_overlay(GTK_OVERLAY(app.tour_overlay), dim);
|
||||
}
|
||||
app.tour_spot = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
gtk_widget_add_css_class(app.tour_spot, "udpscope-tour-spot");
|
||||
// The outline is a frame, not a shield: a click on the *target* is a click on the target.
|
||||
gtk_widget_set_can_target(app.tour_spot, FALSE);
|
||||
gtk_overlay_add_overlay(GTK_OVERLAY(app.tour_overlay), app.tour_spot);
|
||||
|
||||
// The card, last: overlay children are painted in the order they were added, and the card has to
|
||||
// be above the dim strips that cover the rest of the window.
|
||||
app.tour_card = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
|
||||
gtk_widget_add_css_class(app.tour_card, "udpscope-tour-card");
|
||||
gtk_widget_set_size_request(app.tour_card, kCardWidth, -1);
|
||||
|
||||
app.tour_title = gtk_label_new(nullptr);
|
||||
gtk_label_set_xalign(GTK_LABEL(app.tour_title), 0.0f);
|
||||
gtk_box_append(GTK_BOX(app.tour_card), app.tour_title);
|
||||
|
||||
app.tour_body = gtk_label_new(nullptr);
|
||||
gtk_label_set_wrap(GTK_LABEL(app.tour_body), TRUE);
|
||||
// The cap that keeps the card narrow. `max_width_chars` is what bounds a wrapped label's *natural*
|
||||
// width; without it the paragraph's natural width is the whole sentence on one line and the card
|
||||
// is that wide however it is placed.
|
||||
gtk_label_set_max_width_chars(GTK_LABEL(app.tour_body), 30);
|
||||
gtk_label_set_xalign(GTK_LABEL(app.tour_body), 0.0f);
|
||||
gtk_box_append(GTK_BOX(app.tour_card), app.tour_body);
|
||||
|
||||
GtkWidget* footer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||||
app.tour_counter = gtk_label_new(nullptr);
|
||||
gtk_widget_add_css_class(app.tour_counter, "dim-label");
|
||||
gtk_label_set_xalign(GTK_LABEL(app.tour_counter), 0.0f);
|
||||
gtk_widget_set_hexpand(app.tour_counter, TRUE);
|
||||
gtk_box_append(GTK_BOX(footer), app.tour_counter);
|
||||
app.tour_back = gtk_button_new_with_label("Back");
|
||||
g_signal_connect(app.tour_back, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
tour_step(*static_cast<App*>(user), -1);
|
||||
}),
|
||||
&app);
|
||||
gtk_box_append(GTK_BOX(footer), app.tour_back);
|
||||
GtkWidget* skip = gtk_button_new_with_label("Skip");
|
||||
g_signal_connect(skip, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
tour_dismiss(*static_cast<App*>(user));
|
||||
}),
|
||||
&app);
|
||||
gtk_box_append(GTK_BOX(footer), skip);
|
||||
app.tour_next = gtk_button_new_with_label("Next");
|
||||
gtk_widget_add_css_class(app.tour_next, "suggested-action");
|
||||
g_signal_connect(app.tour_next, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
tour_step(*static_cast<App*>(user), 1);
|
||||
}),
|
||||
&app);
|
||||
gtk_box_append(GTK_BOX(footer), app.tour_next);
|
||||
gtk_box_append(GTK_BOX(app.tour_card), footer);
|
||||
gtk_overlay_add_overlay(GTK_OVERLAY(app.tour_overlay), app.tour_card);
|
||||
|
||||
// Everything starts hidden: the tour is not on screen until it is started.
|
||||
for (GtkWidget* dim : app.tour_dim) gtk_widget_set_visible(dim, FALSE);
|
||||
gtk_widget_set_visible(app.tour_spot, FALSE);
|
||||
gtk_widget_set_visible(app.tour_card, FALSE);
|
||||
}
|
||||
|
||||
void start_tour(App& app) {
|
||||
if (app.tour_overlay == nullptr || app.tour_card == nullptr) return;
|
||||
// "The tour has run" is written when it *starts*: a user who skips it has still seen it, and the
|
||||
// alternative -- marking it on the last step -- would offer the whole tour again to everyone who
|
||||
// decided in the first second that they did not want it.
|
||||
if (!app.settings.tour_seen) {
|
||||
app.settings.tour_seen = true;
|
||||
(void)save_settings(app.settings);
|
||||
}
|
||||
// The steps whose target is on screen, in table order. A surface that is not there is not walked
|
||||
// over -- the point of asking `is_visible()` rather than `get_visible()` is that a widget under a
|
||||
// hidden parent still reports its own flag as true.
|
||||
app.tour_plan.clear();
|
||||
for (int i = 0; i < kTourStepCount; ++i) {
|
||||
GtkWidget* target = app.*(kTourSteps[i].target);
|
||||
if (target != nullptr && gtk_widget_is_visible(target)) app.tour_plan.push_back(i);
|
||||
}
|
||||
if (app.tour_plan.empty()) return;
|
||||
app.tour_index = 0;
|
||||
app.tour_running = true;
|
||||
for (GtkWidget* dim : app.tour_dim) gtk_widget_set_visible(dim, TRUE);
|
||||
gtk_widget_set_visible(app.tour_spot, TRUE);
|
||||
gtk_widget_set_visible(app.tour_card, TRUE);
|
||||
show_step(app);
|
||||
update_tour(app);
|
||||
}
|
||||
|
||||
void tour_step(App& app, int delta) {
|
||||
if (!app.tour_running) return;
|
||||
const int next = app.tour_index + delta;
|
||||
if (next < 0 || next >= static_cast<int>(app.tour_plan.size())) {
|
||||
tour_dismiss(app);
|
||||
return;
|
||||
}
|
||||
app.tour_index = next;
|
||||
show_step(app);
|
||||
update_tour(app);
|
||||
}
|
||||
|
||||
void tour_dismiss(App& app) {
|
||||
app.tour_running = false;
|
||||
app.tour_index = 0;
|
||||
for (GtkWidget* dim : app.tour_dim) {
|
||||
if (dim != nullptr) gtk_widget_set_visible(dim, FALSE);
|
||||
}
|
||||
if (app.tour_spot != nullptr) gtk_widget_set_visible(app.tour_spot, FALSE);
|
||||
if (app.tour_card != nullptr) gtk_widget_set_visible(app.tour_card, FALSE);
|
||||
}
|
||||
|
||||
bool tour_active(const App& app) { return app.tour_running; }
|
||||
|
||||
int tour_step_count(const App& app) { return static_cast<int>(app.tour_plan.size()); }
|
||||
|
||||
int tour_step_index(const App& app) { return app.tour_index; }
|
||||
|
||||
void update_tour(App& app) {
|
||||
if (!app.tour_running || app.tour_overlay == nullptr) return;
|
||||
const int w = gtk_widget_get_width(app.tour_overlay);
|
||||
const int h = gtk_widget_get_height(app.tour_overlay);
|
||||
if (w <= 0 || h <= 0) return; // no allocation yet: nothing to place anything against
|
||||
|
||||
// The target, re-measured every frame. Its allocation is 0 on the frame it is first shown, so a
|
||||
// one-shot measurement would leave the hole in the corner.
|
||||
GtkWidget* target = tour_target(app);
|
||||
graphene_rect_t bounds = GRAPHENE_RECT_INIT(0.0f, 0.0f, 0.0f, 0.0f);
|
||||
const bool target_present = target != nullptr && gtk_widget_is_visible(target);
|
||||
if (target_present && gtk_widget_compute_bounds(target, app.tour_overlay, &bounds)) {
|
||||
// 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.
|
||||
const int x0 = static_cast<int>(std::floor(bounds.origin.x)) - 3;
|
||||
const int y0 = static_cast<int>(std::floor(bounds.origin.y)) - 3;
|
||||
const int x1 = static_cast<int>(std::ceil(bounds.origin.x + bounds.size.width)) + 3;
|
||||
const int y1 = static_cast<int>(std::ceil(bounds.origin.y + bounds.size.height)) + 3;
|
||||
app.tour_hole[0] = std::clamp(x0, 0, w);
|
||||
app.tour_hole[1] = std::clamp(y0, 0, h);
|
||||
app.tour_hole[2] = std::clamp(x1, 0, w) - app.tour_hole[0];
|
||||
app.tour_hole[3] = std::clamp(y1, 0, h) - app.tour_hole[1];
|
||||
} else if (!target_present) {
|
||||
// Nothing on screen to point at: the whole surface dims and the card takes a corner. A step whose
|
||||
// target went away mid-tour is not a step to be skipped silently.
|
||||
app.tour_hole[0] = app.tour_hole[1] = app.tour_hole[2] = app.tour_hole[3] = 0;
|
||||
}
|
||||
// ... and when the target *is* there but could not be measured, 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 = app.tour_hole[0];
|
||||
const int hy = app.tour_hole[1];
|
||||
const int hw = app.tour_hole[2];
|
||||
const int hh = app.tour_hole[3];
|
||||
|
||||
place(app.tour_dim[0], 0, 0, w, hy); // above the hole
|
||||
place(app.tour_dim[1], 0, hy + hh, w, h - hy - hh); // below it
|
||||
place(app.tour_dim[2], 0, hy, hx, hh); // to its left
|
||||
place(app.tour_dim[3], hx + hw, hy, w - hx - hw, hh); // to its right
|
||||
place(app.tour_spot, hx, hy, hw, hh);
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Its size is measured, never requested -- but `gtk_widget_measure()` reports the size *including
|
||||
// the widget's own margins* (measured: a 280 px box with `margin-start` 453 measures 733). Ours are
|
||||
// the card's position, so reading the measure without taking them off is a feedback loop: the width
|
||||
// it was placed at becomes the width it reports, and the card walks to the far corner and back
|
||||
// between frames.
|
||||
int cw = 0;
|
||||
int ch = 0;
|
||||
gtk_widget_measure(app.tour_card, GTK_ORIENTATION_HORIZONTAL, -1, nullptr, &cw, nullptr, nullptr);
|
||||
cw = std::max(kCardWidth, cw - gtk_widget_get_margin_start(app.tour_card) -
|
||||
gtk_widget_get_margin_end(app.tour_card));
|
||||
gtk_widget_measure(app.tour_card, GTK_ORIENTATION_VERTICAL, cw, nullptr, &ch, nullptr, nullptr);
|
||||
ch = std::max(0, ch - gtk_widget_get_margin_top(app.tour_card) -
|
||||
gtk_widget_get_margin_bottom(app.tour_card));
|
||||
int cx = 0, cy = 0;
|
||||
if (h - (hy + hh) >= ch + 2 * kGap) {
|
||||
cx = std::clamp(hx, kGap, std::max(kGap, w - cw - kGap));
|
||||
cy = hy + hh + kGap;
|
||||
} else if (hy >= ch + 2 * kGap) {
|
||||
cx = std::clamp(hx, kGap, std::max(kGap, w - cw - kGap));
|
||||
cy = hy - ch - kGap;
|
||||
} else if (w - (hx + hw) >= cw + 2 * kGap) {
|
||||
cx = hx + hw + kGap;
|
||||
cy = std::clamp(hy, kGap, std::max(kGap, h - ch - kGap));
|
||||
} else if (hx >= cw + 2 * kGap) {
|
||||
cx = hx - cw - kGap;
|
||||
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
|
||||
// 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.
|
||||
cx = std::max(kGap, w - cw - kGap);
|
||||
cy = std::max(kGap, h - ch - kGap);
|
||||
}
|
||||
place_natural(app.tour_card, cx, cy);
|
||||
}
|
||||
|
||||
gboolean tour_autostart(gpointer user) {
|
||||
App* app = static_cast<App*>(user);
|
||||
if (app->window == nullptr || app->tour_overlay == nullptr) return G_SOURCE_REMOVE;
|
||||
app->tour_auto_started = true;
|
||||
start_tour(*app);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
// --- the dump's readings ----------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
/// The surfaces the help's annotated figures label. Their geometry is what a figure's crop box is
|
||||
/// drawn from, and it has to be *measured* -- at 1280x800 the panels are not where a plausible guess
|
||||
/// puts them, and a box drawn from a guess is a box over the wrong thing.
|
||||
struct FigureSurface {
|
||||
const char* name;
|
||||
GtkWidget* App::* widget;
|
||||
};
|
||||
|
||||
constexpr FigureSurface kFigureSurfaces[] = {
|
||||
{"trigger_head", &App::trigger_head},
|
||||
{"trigger_summary", &App::trigger_summary},
|
||||
{"trigger_panel", &App::trigger_panel},
|
||||
{"signal_list", &App::signal_list},
|
||||
{"side_switcher", &App::side_switcher},
|
||||
{"side_stack", &App::side_stack},
|
||||
{"window_button", &App::window_button},
|
||||
{"status_label", &App::status_label},
|
||||
{"header_bar", &App::toolbar},
|
||||
{"plot", &App::area_overlay},
|
||||
};
|
||||
constexpr int kFigureSurfaceCount =
|
||||
static_cast<int>(sizeof(kFigureSurfaces) / sizeof(kFigureSurfaces[0]));
|
||||
|
||||
} // namespace
|
||||
|
||||
void capture_help_state(App& app) {
|
||||
std::string ids;
|
||||
const int hints = context_help_count(app, &ids);
|
||||
// The icon is read back through the theme for the reason the pane [edit] buttons are: a name
|
||||
// Adwaita does not have is a *blank* control that works and reads as nothing at all.
|
||||
GtkIconTheme* theme = gtk_icon_theme_get_for_display(gdk_display_get_default());
|
||||
const bool icon_ok = theme != nullptr && gtk_icon_theme_has_icon(theme, "dialog-question-symbolic");
|
||||
char layer[512];
|
||||
std::snprintf(layer, sizeof(layer),
|
||||
"contextual=%s hints=%d ids={%s} icon=dialog-question-symbolic(%s); tour=%s steps=%d "
|
||||
"step=%d auto=%d",
|
||||
app.settings.contextual_help ? "on" : "off", hints, ids.empty() ? "" : ids.c_str(),
|
||||
icon_ok ? "ok" : "MISSING", app.tour_running ? "running" : "idle",
|
||||
tour_step_count(app), tour_step_index(app), app.tour_auto_started ? 1 : 0);
|
||||
app.dbg_context_help = layer;
|
||||
app.dbg_tour_steps = tour_step_count(app);
|
||||
app.dbg_tour_step = tour_step_index(app);
|
||||
app.dbg_tour_visible = app.tour_running ? 1 : 0;
|
||||
|
||||
std::string figures;
|
||||
for (int i = 0; i < kFigureSurfaceCount; ++i) {
|
||||
const FigureSurface& surface = kFigureSurfaces[i];
|
||||
GtkWidget* widget = app.*(surface.widget);
|
||||
graphene_rect_t r = GRAPHENE_RECT_INIT(0.0f, 0.0f, 0.0f, 0.0f);
|
||||
int x = -1, y = -1, width = 0, height = 0;
|
||||
// `is_visible()`, not `get_visible()`: a widget under a hidden parent -- the trigger summary
|
||||
// while the pane is in configuration mode, the Measure page while the Signals tab is showing --
|
||||
// keeps its own flag true and reads 0x0, which a crop box would take for a real rectangle.
|
||||
if (widget != nullptr && gtk_widget_is_visible(widget) &&
|
||||
gtk_widget_compute_bounds(widget, app.window, &r)) {
|
||||
x = static_cast<int>(std::lround(r.origin.x));
|
||||
y = static_cast<int>(std::lround(r.origin.y));
|
||||
width = static_cast<int>(std::lround(r.size.width));
|
||||
height = static_cast<int>(std::lround(r.size.height));
|
||||
}
|
||||
if (!figures.empty()) figures.push_back(' ');
|
||||
figures += std::string(surface.name) + "=" + std::to_string(x) + "," + std::to_string(y) + "," +
|
||||
std::to_string(width) + "," + std::to_string(height);
|
||||
}
|
||||
app.dbg_help_figures = figures;
|
||||
}
|
||||
|
||||
} // namespace ui_impl
|
||||
} // namespace udpscope
|
||||
@@ -671,6 +671,10 @@ GtkWidget* build_trigger_panel(App& app) {
|
||||
gtk_widget_set_margin_top(head, 8);
|
||||
gtk_widget_set_margin_start(head, 8);
|
||||
gtk_widget_set_margin_end(head, 8);
|
||||
// The row the contextual help's "?" for this pane joins (see `ui_context_help.cpp`): a sibling of
|
||||
// the title and the controls, so a help button can never cover one of them. It is also one of the
|
||||
// surfaces the help's annotated figures label, which is why the pointer outlives this function.
|
||||
app.trigger_head = head;
|
||||
GtkWidget* title = gtk_label_new("Trigger");
|
||||
gtk_widget_set_halign(title, GTK_ALIGN_START);
|
||||
gtk_widget_set_hexpand(title, TRUE);
|
||||
|
||||
+9
-1
@@ -53,7 +53,15 @@ void install_css() {
|
||||
".udpscope-event-title { font-weight: bold; }\n"
|
||||
".udpscope-event-thumb { border: 1px solid alpha(currentColor, 0.15); border-radius: 4px; }\n"
|
||||
"row.udpscope-compact button { min-height: 20px; min-width: 20px; padding: 0 3px; }\n"
|
||||
"row.udpscope-compact checkbutton { min-height: 20px; }\n";
|
||||
"row.udpscope-compact checkbutton { min-height: 20px; }\n"
|
||||
// The guided tour. The dim is a half-transparent black rather than a theme colour: its whole
|
||||
// job is to darken whatever is underneath, on a light theme and a dark one alike. The card
|
||||
// needs its own background, or a box in a popover-less overlay is transparent and the text
|
||||
// under it shows through.
|
||||
".udpscope-tour-dim { background-color: rgba(0, 0, 0, 0.45); }\n"
|
||||
".udpscope-tour-spot { border: 2px solid @accent_bg_color; border-radius: 8px; }\n"
|
||||
".udpscope-tour-card { background-color: @window_bg_color; border: 1px solid @borders;"
|
||||
" border-radius: 12px; padding: 12px; box-shadow: 0 3px 12px rgba(0, 0, 0, 0.45); }\n";
|
||||
GtkCssProvider* p = gtk_css_provider_new();
|
||||
gtk_css_provider_load_from_string(p, kCss);
|
||||
gtk_style_context_add_provider_for_display(gdk_display_get_default(),
|
||||
|
||||
Reference in new issue
Block a user