help: a tinier (i), hints beside their controls, and the tour starting in the chooser

The contextual help becomes the affordance the ask names: a plain (i)
(`dialog-information-symbolic`) at **16x16**, and one that sits beside the
control it explains rather than only in a pane's heading.

- The mark: `GtkMenuButton` ignores `min-width` on the widget and a size
  request, so the size is made in `install_css()` on all three nodes (outer
  `menubutton`, its `button` subnode, the glyph). Measured 16x16 against the
  default 34x34; the dump prints it as `hint=WxH`, measured with
  `gtk_widget_compute_bounds()` on the first *visible* hint.
- More sites: `events_record` (the "record events" switch in the trigger
  panel), `trigger_advanced` (the pre-trigger share row), `signals_config`
  (the signals pane's hint line, wrapped in a box so a hint can join it).
  Hints join as siblings, so none can cover a switch, pencil or dropdown.
- The source chooser gets its own layer -- `chooser_sources` (a new heading
  above the saved-source list) and `chooser_waveform` (the waveforms action
  row) -- built with the one `context_help_button()` both windows use and
  re-synced by its Help menu's toggle. The form's `AdwEntryRow`s cannot host a
  sibling hint, so none is invented for the host/port fields.
- The tour is one implementation over two windows: `ui_impl::TourView` plus a
  list of `TourStop`s; each window keeps its own table. A walk that passes its
  last stop calls `on_done`, and the chooser's sets `Config::tour_pending`, so
  a first run is one tour that crosses from the chooser into the app. The
  chooser's tour auto-starts from `settings.tour_seen` exactly as the window's
  does, and its "Guided tour" row now starts *it* instead of only requesting
  the window's.
- Probes: `UDPSCOPE_PROBE_CHOOSER_TOUR=1` / `UDPSCOPE_PROBE_CHOOSER_TOUR_MS`,
  the hint size in the `help layer:`/`[helpui] contextual on:` lines, and the
  chooser's hints in its `chooser ui:` line. Docs updated in
  invariants-gtk-and-chrome.md, probe-readings.md and verification.md.
This commit is contained in:
Martino Ferrari committed 2026-10-14 22:42:38 +02:00
1 parent 395c9caec3
commit 829176248a
14 files changed
+911 -303

No files matched your search

+241 -16
View File
@@ -141,11 +141,40 @@ struct Chooser {
GtkWidget* help_tour = nullptr;
GtkWidget* help_toggle = nullptr;
GtkWidget* help_popover = nullptr;
/// The contextual help's "(i)" affordances, one per surface *this* window explains. They are not
/// the window's table -- `ui_context_help.cpp` names `App` fields, and there is no `App` here yet
/// -- so this window holds its own two rows and joins them with `context_help_button()`, which
/// draws the *one* "(i)" button both windows use.
GtkWidget* sources_head = nullptr;
GtkWidget* waves_actions = nullptr;
/// The guided tour of *this* window. It walks the chooser's own widgets and, when the walk
/// finishes, hands it on to the application through `Config::tour_pending`. The machinery is the
/// shared `ui_impl::TourView`; only the stops are this window's, because they name its widgets.
ui_impl::TourView tour;
/// The timer that auto-starts this tour on a first run, kept so a chooser that closes first can
/// cancel it (`&c` is a stack frame, and a timer that outlives it is the defect this project has
/// already paid for once).
guint tour_source = 0;
/// `UDPSCOPE_PROBE_CHOOSER_TOUR_MS`'s own timer, cancelled the same way.
guint tour_probe_source = 0;
};
void chooser_finish(Chooser& c, bool chosen) {
c.chosen = chosen;
// The two timers point into this stack frame (`&c`), so they go before the frame does. Both are
// cleared first: `g_source_remove()` on an id whose source already returned is a GLib critical.
if (c.tour_source != 0) {
g_source_remove(c.tour_source);
c.tour_source = 0;
}
if (c.tour_probe_source != 0) {
g_source_remove(c.tour_probe_source);
c.tour_probe_source = 0;
}
// The tour's own widgets go with the window; ending the walk first means the spotlight and the
// card are hidden rather than finalized by the destroy while still visible.
ui_impl::tour_end(c.tour);
// Destroy the form first: any path that reaches here while the form is up -- a row click, the
// Escape key -- would otherwise leave a window whose four handlers are bound to a dead stack
// frame. `gtk_window_destroy()` rather than `gtk_window_close()`: the pointer is already
@@ -176,6 +205,10 @@ void chooser_populate_waves(Chooser& c);
void report_pick(const Chooser& c);
/// Defined below; the row buttons reach it, and the row builder sits above it.
void open_form(Chooser& c, int edit_index);
/// The chooser's own help surfaces and its own tour, defined below: the help menu's rows and the
/// first-run timer call them, and both need the window's widgets resolved first.
void chooser_help_sync(Chooser& c);
void chooser_tour_begin(Chooser& c);
void on_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
@@ -644,16 +677,18 @@ void build_chooser_help(Chooser& c) {
c.help_tour = ui_impl::menu_button("Guided tour", nullptr);
gtk_widget_set_tooltip_text(c.help_tour,
"walk over the plot, the side panel, the trigger, the signals and "
"the status line, once the window is up");
"a walk over this dialog's own widgets, then over the plot, the side "
"panel, the trigger, the signals and the status line once a source "
"has been opened");
g_signal_connect(c.help_tour, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
Chooser& ch = *static_cast<Chooser*>(user);
gtk_popover_popdown(GTK_POPOVER(ch.help_popover));
// The tour lives on the window the app builds, which does not exist yet, so the
// request rides on the `Config` this call was handed and the app starts it once
// the window is up -- see `Config::tour_pending`.
ch.cfg->tour_pending = true;
std::fprintf(stderr, "[sources] chooser help: tour requested\n");
// The tour *starts here*, on this window: the chooser is what a first run meets
// first and what it has no way of knowing about. When this walk finishes it sets
// `Config::tour_pending`, so the application continues it once a source has been
// picked -- see `chooser_tour_begin()` and `chooser_tour_done()`.
chooser_tour_begin(ch);
std::fprintf(stderr, "[sources] chooser help: tour started in the chooser\n");
std::fflush(stderr);
}),
&c);
@@ -661,14 +696,14 @@ void build_chooser_help(Chooser& c) {
// The check *is* the setting, exactly as in the window's menu: the row is stated before the handler
// is connected, so a row that opens checked does not write the file while the dialog is still being
// built. The chooser cannot re-sync anything (there is no "?" layer here), so the handler only has
// to leave the setting where the app will read it.
// built. The chooser re-syncs its own "(i)" layer here, the way the window re-syncs the window's.
c.help_toggle = ui_impl::menu_toggle("Enable contextual help", nullptr,
load_settings().contextual_help);
gtk_widget_set_tooltip_text(c.help_toggle,
"a small \u201c?\u201d beside each surface of the main window, saying "
"what that surface is for");
g_signal_connect(c.help_toggle, "toggled", G_CALLBACK(+[](GtkToggleButton* button, gpointer) {
"a small \u201c(i)\u201d beside each surface of this dialog and of the "
"main window, saying what that surface is for");
g_signal_connect(c.help_toggle, "toggled", G_CALLBACK(+[](GtkToggleButton* button, gpointer user) {
Chooser& ch = *static_cast<Chooser*>(user);
Settings s = load_settings();
s.contextual_help = gtk_toggle_button_get_active(button) != FALSE;
// Written in full, the way the window's toggle writes it: the keys this row does
@@ -678,6 +713,7 @@ void build_chooser_help(Chooser& c) {
settings_path().c_str());
std::fflush(stderr);
}
chooser_help_sync(ch);
}),
&c);
gtk_box_append(GTK_BOX(box), c.help_toggle);
@@ -686,6 +722,159 @@ void build_chooser_help(Chooser& c) {
gtk_menu_button_set_popover(GTK_MENU_BUTTON(c.help_button), c.help_popover);
}
/// The chooser's contextual help: the same "(i)" the window draws, on the two surfaces *this*
/// window has a row for -- the list of saved sources and the waveforms page's action row. It is
/// called once after the window is built and again by the Help menu's toggle, which is the one place
/// the setting can change while the dialog is up.
///
/// The form's rows are deliberately not a host: an `AdwEntryRow` is not a box and its editing widget
/// is internal, so a hint could not join it as a sibling -- it would have to be appended to the whole
/// `AdwPreferencesGroup` as a row of its own, which is not a hint *beside* the field but a row under
/// it. The connection fields are therefore explained by the group's own `description`, which is
/// where libadwaita puts a hint that must survive being typed over.
void chooser_help_sync(Chooser& c) {
const bool on = load_settings().contextual_help;
ui_impl::context_help_button(
c.sources_head, "chooser_sources", "The sources you have opened",
"One row per saved configuration. Clicking one opens it: it becomes the active configuration "
"and the stream starts. The pencil edits a row's name, note, buffer size and clock; the cross "
"forgets it and deletes its saved session.",
"", on);
ui_impl::context_help_button(
c.waves_actions, "chooser_waveform", "Opening a recording",
"A `.npy` an earlier run exported is opened for viewing and analysis instead of a live source: "
"there is no acquisition behind it, so the trigger and the rate counters are not there -- only "
"the samples, which can be measured, searched and re-exported.",
"", on);
}
/// One stop of the *chooser's* tour. Its targets are this window's widgets, which exist before `App`
/// does -- which is the whole reason the stops cannot live in the window's table.
struct ChooserStop {
GtkWidget* Chooser::* target;
const char* title;
const char* body;
};
constexpr ChooserStop kChooserStops[] = {
{&Chooser::list, "Choosing a source",
"Every source you have saved, newest first. Clicking one opens it -- that is the choice this "
"dialog exists to make -- and the arrows on a row edit or forget its configuration."},
{&Chooser::switcher, "Two kinds of input",
"A *source* is a stream to receive; a *waveform* is a recording an earlier run exported. They "
"are the two answers to \u201cwhat should be on the screen\u201d, and these are the tabs "
"between them."},
{&Chooser::new_host, "A source that is not listed",
"Opens the form for an address that has no configuration yet: host, port, the settings a source "
"can only be given before it is opened, and the name its session will be saved under."},
{&Chooser::help_button, "The manual, and this tour",
"Documentation opens the manual in the desktop help viewer. This tour walks this window and "
"then the application's own window. Contextual help is the switch below it: on, every surface "
"carries a small \u201c(i)\u201d saying what it is for."},
{&Chooser::prefs_button, "Preferences",
"The event cache, relative time and the interface theme. This is the last moment relative time "
"can still change this run -- the buffers are planned as soon as a source is picked -- which is "
"why the button is here and not only in the window's menu."},
};
constexpr int kChooserStopCount = static_cast<int>(sizeof(kChooserStops) / sizeof(kChooserStops[0]));
/// The walk is over: the chooser hands the tour on to the application. This is the second half of the
/// two-window tour -- `tour_pending` is the value `main()` owns and hands to both `choose_source()`
/// and `run_ui()`, and the window starts its own tour from it once a source has been picked.
void chooser_tour_done(void* user) {
Chooser& c = *static_cast<Chooser*>(user);
c.cfg->tour_pending = true;
std::fprintf(stderr,
"[sources] chooser tour: finished %d stops -- the window continues it "
"(tour_pending=1)\n",
kChooserStopCount);
std::fflush(stderr);
}
/// "The tour has run" is the same `settings.tour_seen` the window's auto-start reads, written when
/// the walk *starts*: a user who skips it has still seen it.
void chooser_tour_mark_seen() {
Settings s = load_settings();
if (s.tour_seen) return;
s.tour_seen = true;
if (!save_settings(s)) {
std::fprintf(stderr, "[sources] tour: could not write %s: it will be offered again\n",
settings_path().c_str());
}
}
void chooser_tour_begin(Chooser& c) {
if (c.tour.card == nullptr) return;
chooser_tour_mark_seen();
// The stops whose target is on screen, in table order, the same filter the window's tour uses: a
// surface that is not there is not walked over.
std::vector<ui_impl::TourStop> stops;
for (int i = 0; i < kChooserStopCount; ++i) {
GtkWidget* target = c.*(kChooserStops[i].target);
if (target != nullptr && gtk_widget_is_visible(target)) {
stops.push_back({target, kChooserStops[i].title, kChooserStops[i].body});
}
}
if (stops.empty()) return;
ui_impl::tour_begin(c.tour, std::move(stops), chooser_tour_done, &c);
}
/// The tour is drawn over the *whole* window, header bar included: two of its stops name the header's
/// own buttons, and an overlay placed inside the content would give them negative coordinates and
/// clamp its hole to the top edge. `AdwWindow`'s content takes the overlay and the toolbar view
/// becomes its child -- the builder still owns the tree, so there is no reference bookkeeping.
void build_chooser_tour(Chooser& c) {
GtkWidget* content = adw_window_get_content(ADW_WINDOW(c.window));
if (content == nullptr) return;
// The overlay has to *become* the window's content before the toolbar view can be adopted by it:
// `gtk_overlay_set_child()` refuses a child that still has a parent, so the two calls are in this
// order -- handing the window the overlay first is what unparents the toolbar view. The view is
// held across the swap rather than left to whatever the builder's object list keeps: it has no id
// for the builder to name it by, and the window's content was its only owner.
GtkWidget* overlay = gtk_overlay_new();
g_object_ref(content);
adw_window_set_content(ADW_WINDOW(c.window), overlay);
gtk_overlay_set_child(GTK_OVERLAY(overlay), content);
g_object_unref(content);
ui_impl::tour_build(c.tour, overlay);
}
/// A first run is the run whose settings file does not say the tour has been offered -- the same key
/// the window's own auto-start reads. Delayed 1.5 s for the reason the window's is: the window has to
/// be up and laid out before anything points at it.
gboolean chooser_tour_autostart(gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
c.tour_source = 0;
std::fprintf(stderr, "[sources] tour: first run -- auto_started=1 in the chooser\n");
std::fflush(stderr);
chooser_tour_begin(c);
return G_SOURCE_REMOVE;
}
/// `UDPSCOPE_PROBE_CHOOSER_TOUR_MS`: the chooser's tour, read and walked the way the window's is --
/// where it stands, then every stop, then the handoff. It reports the walk by *stepping* it (the
/// button the user presses), so what it reads is what a first run sees, not the state the probe set.
gboolean chooser_tour_probe(gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
c.tour_probe_source = 0;
std::fprintf(stderr, "[sources] chooser tour: visible=%d steps=%d step=%d\n",
c.tour.running ? 1 : 0, static_cast<int>(c.tour.plan.size()), c.tour.index);
while (c.tour.running) {
const int before = c.tour.index;
ui_impl::tour_advance(c.tour, 1);
if (c.tour.running) {
std::fprintf(stderr, "[sources] chooser tour: next -> step=%d/%d\n", c.tour.index,
static_cast<int>(c.tour.plan.size()));
} else {
std::fprintf(stderr, "[sources] chooser tour: Next past stop %d finished the walk\n", before + 1);
}
}
std::fprintf(stderr, "[sources] chooser tour: after finish visible=%d tour_pending=%d\n",
c.tour.running ? 1 : 0, c.cfg->tour_pending ? 1 : 0);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// `UDPSCOPE_PROBE_PICK_MS`: activate the first row, the way a click does.
/// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row. The crash surface of a
/// dialog built, filled and destroyed is as much the point as the values, the same way
@@ -706,9 +895,14 @@ void report_chooser_ui(const Chooser& c) {
adw_view_stack_page_get_title(page),
adw_view_stack_page_get_icon_name(page) != nullptr ? "yes" : "NO");
}
std::string hint_ids;
const int hints = ui_impl::context_help_count_in(c.window, &hint_ids);
int hint_w = 0;
int hint_h = 0;
ui_impl::context_help_size_in(c.window, &hint_w, &hint_h);
std::fprintf(stderr,
" visible='%s' switcher_wired=%d buttons=[prefs=%d new_host=%d open=%d cancel=%d] "
"help=[button=%d docs=%d tour=%d toggle=%d on=%d]\n",
"help=[button=%d docs=%d tour=%d toggle=%d on=%d] hints=[%d {%s}] hint=%dx%d\n",
adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(c.page_stack)),
adw_view_switcher_bar_get_stack(ADW_VIEW_SWITCHER_BAR(c.switcher)) ==
ADW_VIEW_STACK(c.page_stack)
@@ -721,7 +915,8 @@ void report_chooser_ui(const Chooser& c) {
c.help_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(c.help_toggle))
? 1
: 0);
: 0,
hints, hint_ids.c_str(), hint_w, hint_h);
std::fflush(stderr);
}
@@ -741,9 +936,18 @@ gboolean pick_probe(gpointer user) {
// would assert nothing. The tour is read back out of the `Config` the run will be handed, which is
// the carrier; the toggle is read back out of the settings file it writes, since the app is built
// from that file and not from the dialog.
// `UDPSCOPE_PROBE_CHOOSER_TOUR=1` drives the help menu's Guided tour row the way a click does --
// which now starts a tour *in this dialog*, not merely a request for the window's -- then walks it
// to its last stop and past it, so the handoff is exercised, and then falls through to the pick
// below. The point of the tour entry is that it crosses into the run this chooser starts, so a
// probe that stopped here would assert nothing; the carrier is `Config::tour_pending`.
if (g_getenv("UDPSCOPE_PROBE_CHOOSER_TOUR") != nullptr && c.help_tour != nullptr) {
g_signal_emit_by_name(c.help_tour, "clicked");
std::fprintf(stderr, "[sources] chooser help: tour_pending=%d\n", c.cfg->tour_pending ? 1 : 0);
std::fprintf(stderr, "[sources] chooser tour: visible=%d steps=%d step=%d\n",
c.tour.running ? 1 : 0, static_cast<int>(c.tour.plan.size()), c.tour.index);
while (c.tour.running) ui_impl::tour_advance(c.tour, 1);
std::fprintf(stderr, "[sources] chooser tour: after finish visible=%d tour_pending=%d\n",
c.tour.running ? 1 : 0, c.cfg->tour_pending ? 1 : 0);
std::fflush(stderr);
}
if (g_getenv("UDPSCOPE_PROBE_CHOOSER_HELP") != nullptr && c.help_toggle != nullptr) {
@@ -1024,9 +1228,12 @@ bool choose_source(Config& cfg) {
c.waves_open = GTK_WIDGET(gtk_builder_get_object(c.ui, "waves_open"));
c.cancel_button = GTK_WIDGET(gtk_builder_get_object(c.ui, "cancel_button"));
c.help_button = GTK_WIDGET(gtk_builder_get_object(c.ui, "help_menu_button"));
c.sources_head = GTK_WIDGET(gtk_builder_get_object(c.ui, "sources_head"));
c.waves_actions = GTK_WIDGET(gtk_builder_get_object(c.ui, "waves_actions"));
if (c.window == nullptr || c.page_stack == nullptr || c.switcher == nullptr || c.list == nullptr ||
c.wave_list == nullptr || c.prefs_button == nullptr || c.new_host == nullptr ||
c.waves_open == nullptr || c.cancel_button == nullptr || c.help_button == nullptr) {
c.waves_open == nullptr || c.cancel_button == nullptr || c.help_button == nullptr ||
c.sources_head == nullptr || c.waves_actions == nullptr) {
std::fprintf(stderr, "[sources] chooser ui: source-chooser.ui is missing an id this code reads\n");
std::fflush(stderr);
return false;
@@ -1041,6 +1248,11 @@ bool choose_source(Config& cfg) {
ui_impl::install_css();
build_chooser_help(c);
// The chooser's own "(i)" layer and its own tour, both over the widgets the block above resolved.
// The tour's overlay wraps the whole window, so it is built before the window is presented.
chooser_help_sync(c);
build_chooser_tour(c);
g_signal_connect(c.list, "row-activated", G_CALLBACK(on_row_activated), &c);
g_signal_connect(c.wave_list, "row-activated", G_CALLBACK(on_wave_activated), &c);
g_signal_connect(c.waves_open, "clicked", G_CALLBACK(on_wave_open_clicked), &c);
@@ -1078,6 +1290,19 @@ bool choose_source(Config& cfg) {
if (const char* env = g_getenv("UDPSCOPE_PROBE_PICK_MS")) {
c.pick_source = g_timeout_add(static_cast<guint>(std::atoi(env)), pick_probe, &c);
}
// A first run gets the tour here, on this window, before a source is picked: the chooser is the
// surface a first run meets first, and the window's own auto-start is unreachable until this call
// has returned with a source. 1.5 s is the window's own delay and for its reason -- the window has
// to be up and laid out before anything points at it -- and the walk's own finish sets
// `tour_pending`, which is how it continues into the application's window.
if (!load_settings().tour_seen) {
c.tour_source = g_timeout_add(1500, chooser_tour_autostart, &c);
}
// `UDPSCOPE_PROBE_CHOOSER_TOUR_MS`: schedule the tour probe *after* the auto-start above, so the
// reading is of a tour the run itself began rather than one the probe started.
if (const char* env = g_getenv("UDPSCOPE_PROBE_CHOOSER_TOUR_MS")) {
c.tour_probe_source = g_timeout_add(static_cast<guint>(std::atoi(env)), chooser_tour_probe, &c);
}
c.loop = g_main_loop_new(nullptr, FALSE);
g_main_loop_run(c.loop);