help: default the contextual layer off, and carry the help into the chooser

The contextual help was on by default, so a first run met a window with a "?"
beside every surface it had not asked to have explained; `contextual_help` now
defaults to false and the layer is asked for from the Help menu, from the
chooser's new help menu, or with Ctrl+H.

The source chooser runs before `App` exists, which makes it the window a user
who does not know how to start is looking at, so it now carries the same three
help affordances the window's Help view does: Documentation (the manual is
another process and needs nothing of the app), Enable contextual help (a row
whose check *is* the setting, written to the file the app reads a moment
later), and Guided tour. The tour is drawn on the app's own window, so the
chooser can only record the wish: it sets `Config::tour_pending` -- the value
`main()` owns and hands to both `choose_source()` and `run_ui()` -- and
`on_activate()` starts it once the window is up and laid out, through its own
callback so the readings can tell a request from the first-run auto-start.
The rows are the window's own `menu_button()`/`menu_toggle()`; `install_css()`
therefore runs from `choose_source()` (it is idempotent now) so the popover
gets the `.udpscope-menu` rules before it is shown.

Opening the manual could also fail with a message that blamed the handler
whatever was missing. `help_installed_path()` now resolves `help:<id>` the way
the specification does and the failure alert says which of the two it is: with
the set installed it names the viewer, without it it names the identifier, the
expected installed path and the install command. `show_help()` gained an
optional reporter, which `UDPSCOPE_PROBE_HELP_MS` uses for a new reading --
`[help] launch attempted= done= ok= error='' installed=` -- since a successful
launch leaves no window of ours to observe.

Tested: clean --clean-first build, three warnings as documented; test-ui 13/13
and test-help pass; no Gtk-CRITICAL in the help, helpui and chooser runs.
This commit is contained in:
Martino Ferrari committed 2026-10-14 17:22:03 +02:00
1 parent e140642ed0
commit d8a79b96ba
15 files changed
+461 -66

No files matched your search

+7
View File
@@ -59,6 +59,13 @@ struct Config {
/// live source. File mode and source mode are exclusive -- there is no acquisition behind a file,
/// and the chooser's Waveforms page sets this rather than the connection fields.
std::string open_waveform;
/// The source chooser's "Guided tour" entry. The tour is drawn on the application's own window,
/// which does not exist while the chooser runs, so the request rides on the one value `main()`
/// owns and hands to both the chooser and the front end (`open_waveform` and the preferences'
/// relative time cross the same way) and the window starts the tour once it is up and laid out.
/// It is *not* a preference: it describes this request and nothing else, so nothing persists it.
bool tour_pending = false;
};
/// Parses argv. On `--help` prints usage to stdout and exits(0); on a bad
+84 -21
View File
@@ -3,7 +3,9 @@
#include <adwaita.h>
#include <functional>
#include <string>
#include <vector>
#include "build_info.hpp"
#include "layout_store.hpp"
@@ -44,27 +46,55 @@ void on_dialog_closed(AdwDialog* dialog, gpointer slot) {
*static_cast<AdwDialog**>(slot) = nullptr;
}
/// The launch is asynchronous and can fail -- no help viewer installed, no handler for the
/// `help:` scheme, or a document no viewer can find -- and it runs in another process, so
/// nothing raises a window of ours. Report the failure here rather than leaving a click that
/// silently did nothing; the detail names the identifier a viewer looks for.
/// What an asynchronous launch needs to survive the call: the window it is attributed to (held,
/// because the call outlives the stack frame that made it) and the caller's reporter, if it asked
/// for one. One small struct rather than two `gpointer`s, because a callback that takes both through
/// one `user_data` still has to get them there.
struct HelpLaunch {
GtkWindow* parent = nullptr;
std::function<void(bool, const std::string&)> done;
};
/// The launch failed. Report it rather than leaving a click that silently did nothing -- and say
/// *which* thing is missing: `gtk_uri_launcher_launch_finish()` reports that the URI could not be
/// handed over, which is the handler's absence, while a document that is not installed is a
/// different failure the launcher cannot tell it from. `help_installed_path()` settles that from the
/// file system, so a missing *document* is not reported as a missing *handler*: with the manual
/// installed the message names the viewer, and without it the message names the document, its
/// expected path and the install command -- and a viewer as well, because that second possibility
/// cannot be ruled out from here.
void on_help_launched(GObject* source, GAsyncResult* result, gpointer user) {
GtkWindow* parent = user != nullptr ? GTK_WINDOW(user) : nullptr;
auto* launch = static_cast<HelpLaunch*>(user);
GError* error = nullptr;
if (!gtk_uri_launcher_launch_finish(GTK_URI_LAUNCHER(source), result, &error)) {
const bool ok = gtk_uri_launcher_launch_finish(GTK_URI_LAUNCHER(source), result, &error);
if (!ok) {
const std::string installed = help_installed_path();
GtkAlertDialog* alert = gtk_alert_dialog_new("Could not open the help manual");
const std::string detail =
std::string(error->message) +
"\n\nThe manual is installed under $XDG_DATA_DIRS/help/C/help:" UDPSCOPE_HELP_NAME
"; a desktop help viewer (for example `yelp`) that handles the `help:` URI is needed "
"to read it.";
std::string detail = error != nullptr ? error->message : std::string("the launch failed");
detail += "\n\n";
if (!installed.empty()) {
detail += "The manual is installed at " + installed +
", so what is missing is a viewer for the `help:` URI it is opened with: install "
"one (for example `yelp`) or make it the desktop's default for that scheme.";
} else {
detail += "The manual does not look installed: nothing named \"help:" UDPSCOPE_HELP_NAME
"\" was found under $XDG_DATA_HOME/help/<lang>/" UDPSCOPE_HELP_NAME "/ or "
"$XDG_DATA_DIRS/help/<lang>/" UDPSCOPE_HELP_NAME "/. Install it with\n\n"
" cmake --install build --prefix ~/.local\n\n"
"which writes ~/.local/share/help/C/" UDPSCOPE_HELP_NAME "/ -- the directory "
"`help:" UDPSCOPE_HELP_NAME "` resolves to. If there is no `help:` viewer either, "
"install one as well (for example `yelp`).";
}
gtk_alert_dialog_set_detail(alert, detail.c_str());
gtk_alert_dialog_show(alert, parent);
gtk_alert_dialog_show(alert, launch->parent);
g_object_unref(alert);
g_error_free(error);
}
// `parent` was referenced for the duration of the async call.
if (parent != nullptr) g_object_unref(parent);
if (launch->done) {
launch->done(ok, ok || error == nullptr ? std::string() : std::string(error->message));
}
if (launch->parent != nullptr) g_object_unref(launch->parent);
delete launch;
if (error != nullptr) g_error_free(error);
}
} // namespace
@@ -108,6 +138,37 @@ const char* about_icon_name() {
return adw_about_dialog_get_application_icon(ADW_ABOUT_DIALOG(g_about));
}
std::string help_installed_path() {
// The freedesktop Help System Specification's own search order: `$XDG_DATA_HOME` first, then every
// `$XDG_DATA_DIRS` entry in order. Yelp resolves `help:<id>` the same way, so this is a reading of
// where the document *would* be found rather than a second, private convention.
std::vector<std::string> roots;
roots.emplace_back(g_get_user_data_dir() != nullptr ? g_get_user_data_dir() : "");
const char* const* dirs = g_get_system_data_dirs();
for (int i = 0; dirs != nullptr && dirs[i] != nullptr; ++i) roots.emplace_back(dirs[i]);
for (const std::string& root : roots) {
if (root.empty()) continue;
const std::string base = root + "/help";
GError* error = nullptr;
GDir* dir = g_dir_open(base.c_str(), 0, &error);
if (dir == nullptr) {
if (error != nullptr) g_error_free(error);
continue;
}
// `<lang>` is a directory of its own (`C`, `en`, `it`, ...), so the document is looked for one
// level below the `help` root rather than straight under it.
while (const char* lang = g_dir_read_name(dir)) {
const std::string candidate = base + "/" + lang + "/" UDPSCOPE_HELP_NAME;
if (g_file_test((candidate + "/index.page").c_str(), G_FILE_TEST_IS_REGULAR)) {
g_dir_close(dir);
return candidate;
}
}
g_dir_close(dir);
}
return std::string();
}
HelpReport help_self_check() {
HelpReport report;
@@ -141,14 +202,16 @@ HelpReport help_self_check() {
return report;
}
void show_help(GtkWindow* parent) {
void show_help(GtkWindow* parent, std::function<void(bool, const std::string&)> done) {
// The freedesktop `help:` URI, handed to whatever the desktop registered for it -- Yelp on
// GNOME. The launch is asynchronous, so the parent is referenced across it and released in
// the callback; `parent` may legitimately be nullptr (the chooser runs before any window
// exists).
// GNOME. The launch is asynchronous, so both the parent and the reporter are carried across it in
// a heap `HelpLaunch` and released in the callback; `parent` may legitimately be nullptr (the
// chooser runs before any window exists).
auto* launch = new HelpLaunch;
launch->parent = parent != nullptr ? GTK_WINDOW(g_object_ref(parent)) : nullptr;
launch->done = std::move(done);
GtkUriLauncher* launcher = gtk_uri_launcher_new("help:" UDPSCOPE_HELP_NAME);
gtk_uri_launcher_launch(launcher, parent, nullptr, on_help_launched,
parent != nullptr ? g_object_ref(parent) : nullptr);
gtk_uri_launcher_launch(launcher, parent, nullptr, on_help_launched, launch);
g_object_unref(launcher);
}
+15 -1
View File
@@ -4,6 +4,7 @@
#include <gtk/gtk.h>
#include <cstddef>
#include <functional>
#include <string>
namespace udpscope {
@@ -23,7 +24,20 @@ const char* about_icon_name();
/// window of our own. `parent` is the window the launch is attributed to (and the one an
/// error is reported on); the viewer is a separate process, so nothing here can be closed
/// with `close_help_windows()`.
void show_help(GtkWindow* parent);
///
/// `done`, when the caller gives one, is called with the *launch*'s outcome: whether the
/// `help:` URI was handed to a viewer, and the error text when it was not. It says nothing
/// about the viewer itself — that process may fail for its own reasons and this side cannot
/// see them — so it is the self-check's reading of the path and not a promise that a window
/// appeared. The failure alert is shown either way; the callback is an extra, not instead.
void show_help(GtkWindow* parent, std::function<void(bool ok, const std::string& error)> done = {});
/// Where `help:<id>` resolves to on this system: the first of `$XDG_DATA_HOME/help/<lang>/<id>/`
/// and `$XDG_DATA_DIRS/help/<lang>/<id>/` that holds an `index.page`, or an empty string when no
/// such directory does. This is the specification's own resolution, and the distinction a launch
/// failure turns on: a missing *document* (install it) and a missing *handler* (install a viewer)
/// are different problems with different fixes, and only the file system can tell them apart.
std::string help_installed_path();
/// What UDPSCOPE_PROBE_HELP_MS reports about the manual *in the tree*. `topics` is the
/// number of `<id>.page` files other than `index.page`, and `index` says whether
+4 -3
View File
@@ -84,9 +84,10 @@ 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.
// The two help keys are the *app's* memory rather than the dialog's: the contextual-help rows --
// the Help menu's and the source chooser's own -- write 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();
+5 -3
View File
@@ -33,9 +33,11 @@ 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;
/// The contextual-help layer: a small "?" beside each surface it can explain. **Off by default** --
/// a first run shows the interface as a user expects it and the layer is asked for, from the Help
/// menu or from the chooser's own help menu, by whoever wants it; on is then a decision that
/// survives a restart.
bool contextual_help = false;
/// 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.
+132 -6
View File
@@ -5,8 +5,13 @@
#include "waveform.hpp"
#include "help_window.hpp"
#include "layout_store.hpp"
#include "settings.hpp"
// The app's shared chrome -- `ui_impl::menu_button()`/`menu_toggle()` and `install_css()` -- so the
// chooser's help menu is the *same* menu the window's Help view is, not a second one that merely
// looks like it. Heavy header, but a row shape copied here would be a row shape that drifts.
#include "ui_app.hpp"
#include "ui_prefs.hpp"
#include <adwaita.h>
@@ -127,6 +132,15 @@ struct Chooser {
/// The preferences, for the button below. Same reasoning as `form` above: a member of the chooser
/// rather than a heap struct, so nothing can outlive the stack frame that owns it.
ui_impl::PrefsForm prefs;
/// The help menu: the `.ui`'s own `GtkMenuButton` and the three rows built onto it. The rows are
/// code for the same reason every other row here is -- each carries a handler, and the toggle
/// carries the value the settings file holds -- while the button that opens them is the file's, so
/// a `.ui` that lost it is caught by the null check below rather than by an empty popover.
GtkWidget* help_button = nullptr;
GtkWidget* help_docs = nullptr;
GtkWidget* help_tour = nullptr;
GtkWidget* help_toggle = nullptr;
GtkWidget* help_popover = nullptr;
};
@@ -596,14 +610,90 @@ void open_chooser_preferences(Chooser& c) {
});
}
/// The chooser's help menu: the same three affordances the window's Help view carries, because a
/// user who has not started yet is exactly the one who does not know how to start. The rows are
/// `ui_impl::menu_button()`/`menu_toggle()` -- the app's own menu-row shape, so the two menus read
/// as one program -- and the popover carries the same `.udpscope-menu` class, which is what trims
/// the rows; `install_css()` therefore runs before this, in `choose_source()`.
///
/// No row shows an accelerator. The window's Help rows show "F1"/"Ctrl+T" because the window handles
/// those keys; this dialog binds none of them, and a shortcut printed beside an action that does not
/// answer to it is the "documented and does nothing" failure the shortcut tables are checked for.
void build_chooser_help(Chooser& c) {
c.help_popover = gtk_popover_new();
gtk_widget_add_css_class(c.help_popover, "udpscope-menu");
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_set_margin_top(box, 6);
gtk_widget_set_margin_bottom(box, 6);
gtk_widget_set_margin_start(box, 6);
gtk_widget_set_margin_end(box, 6);
c.help_docs = ui_impl::menu_button("Documentation", nullptr);
gtk_widget_set_tooltip_text(c.help_docs,
"the udpscope manual, opened in the desktop help viewer");
g_signal_connect(c.help_docs, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
Chooser& ch = *static_cast<Chooser*>(user);
gtk_popover_popdown(GTK_POPOVER(ch.help_popover));
// No window of the app exists yet: the manual is a separate process reached
// through the `help:` URI, so this needs no more of the app than the chooser
// itself. The parent may be null, and this dialog is the right attribution.
show_help(GTK_WINDOW(ch.window));
}),
&c);
gtk_box_append(GTK_BOX(box), c.help_docs);
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");
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");
std::fflush(stderr);
}),
&c);
gtk_box_append(GTK_BOX(box), c.help_tour);
// 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.
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) {
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
// not own are carried through from the file rather than dropped.
if (!save_settings(s)) {
std::fprintf(stderr, "[sources] chooser help: could not write %s\n",
settings_path().c_str());
std::fflush(stderr);
}
}),
&c);
gtk_box_append(GTK_BOX(box), c.help_toggle);
gtk_popover_set_child(GTK_POPOVER(c.help_popover), box);
gtk_menu_button_set_popover(GTK_MENU_BUTTON(c.help_button), c.help_popover);
}
/// `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
/// `UDPSCOPE_PROBE_FIELDS_MS` is in `ui.cpp`.
/// The chooser's frame, read back from the `.ui` it was built from: the two pages by name and title, the
/// switcher wired to the stack, and the four buttons the code resolved. `[help] ui=` does the same against
/// its tables -- the frame is what the file holds, so the frame is what is read, while the rows a user sees
/// are read by the probes that drive them.
/// switcher wired to the stack, the four buttons the code resolved, and the help menu's three rows.
/// `[help] ui=` does the same against its tables -- the frame is what the file holds, so the frame is
/// what is read, while the rows a user sees are read by the probes that drive them.
void report_chooser_ui(const Chooser& c) {
std::fprintf(stderr, "[sources] chooser ui:");
for (GtkWidget* w = gtk_widget_get_first_child(c.page_stack); w != nullptr;
@@ -617,14 +707,21 @@ void report_chooser_ui(const Chooser& c) {
adw_view_stack_page_get_icon_name(page) != nullptr ? "yes" : "NO");
}
std::fprintf(stderr,
" visible='%s' switcher_wired=%d buttons=[prefs=%d new_host=%d open=%d cancel=%d]\n",
" 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",
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)
? 1
: 0,
c.prefs_button != nullptr ? 1 : 0, c.new_host != nullptr ? 1 : 0,
c.waves_open != nullptr ? 1 : 0, c.cancel_button != nullptr ? 1 : 0);
c.waves_open != nullptr ? 1 : 0, c.cancel_button != nullptr ? 1 : 0,
c.help_button != nullptr ? 1 : 0, c.help_docs != nullptr ? 1 : 0,
c.help_tour != nullptr ? 1 : 0, c.help_toggle != nullptr ? 1 : 0,
c.help_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(c.help_toggle))
? 1
: 0);
std::fflush(stderr);
}
@@ -638,6 +735,26 @@ gboolean pick_probe(gpointer user) {
// What the `.ui` contributed, before anything is driven: the pages, the switcher and the buttons.
report_chooser_ui(c);
// `UDPSCOPE_PROBE_CHOOSER_TOUR=1` and `UDPSCOPE_PROBE_CHOOSER_HELP=1` drive the help menu's two
// stateful rows the way a click does, and then fall 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 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.
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::fflush(stderr);
}
if (g_getenv("UDPSCOPE_PROBE_CHOOSER_HELP") != nullptr && c.help_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(c.help_toggle), TRUE);
const Settings back = load_settings();
std::fprintf(stderr, "[sources] chooser help: toggle=%d setting=%d file=%s\n",
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(c.help_toggle)) ? 1 : 0,
back.contextual_help ? 1 : 0, settings_path().c_str());
std::fflush(stderr);
}
// `UDPSCOPE_PROBE_CHOOSER_PREFS=1`: the preferences opened from *here* rather than from the app's
// menu -- the entry point that exists because this dialog runs before `App`. It is driven through
// the same fill-and-apply the app's probe uses, and reports what the run about to start will get,
@@ -906,15 +1023,24 @@ bool choose_source(Config& cfg) {
c.new_host = GTK_WIDGET(gtk_builder_get_object(c.ui, "new_host_button"));
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"));
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.waves_open == nullptr || c.cancel_button == nullptr || c.help_button == nullptr) {
std::fprintf(stderr, "[sources] chooser ui: source-chooser.ui is missing an id this code reads\n");
std::fflush(stderr);
return false;
}
adw_view_switcher_bar_set_stack(ADW_VIEW_SWITCHER_BAR(c.switcher), ADW_VIEW_STACK(c.page_stack));
// The help menu's rows are the window's own `menu_button()`/`menu_toggle()`, so its popover wants
// the same stylesheet the overflow menu is drawn with (`.udpscope-menu`, which trims a row to a
// menu row rather than Adwaita's 34 px button). The app installs it in `on_activate()`, which is
// *after* this dialog, so it has to be installed here first; `install_css()` is idempotent, so
// the later call is the no-op it reads as.
ui_impl::install_css();
build_chooser_help(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);
+9 -2
View File
@@ -5142,8 +5142,15 @@ void on_activate(GtkApplication* application, gpointer user) {
// 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);
// window appeared would be a spotlight on a widget with no allocation yet. The source chooser's
// own "Guided tour" entry arrives as `Config::tour_pending` (the request has to outlive the dialog
// that made it -- the tour is drawn on *this* window) and gets the same delay for the same reason,
// through its own callback so the two are told apart in the readings.
if (app->model->config().tour_pending) {
g_timeout_add(1500, tour_start_requested, app);
} else 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
+11
View File
@@ -33,6 +33,17 @@
<property name="tooltip-text" translatable="yes">the event cache, relative time and the interface's theme — the same dialog as the main window's menu, and the only place relative time can still change this run</property>
</object>
</child>
<!-- The help menu: the same three affordances the window's Help view carries, because a
user who has not started yet is exactly the one who does not know how to start. Only
the button is here — its rows carry a handler and a setting, so they are code, the
same division every other row in this file follows. Beside `_Preferences`, because
both act on the *program* rather than on a source. -->
<child type="start">
<object class="GtkMenuButton" id="help_menu_button">
<property name="icon-name">help-about-symbolic</property>
<property name="tooltip-text" translatable="yes">the manual, a guided tour after start-up, and the contextual help</property>
</object>
</child>
<child type="end">
<object class="GtkButton" id="cancel_button">
<property name="label" translatable="yes">_Cancel</property>
+16
View File
@@ -255,6 +255,18 @@ struct App {
/// 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 chooser's own "Guided tour" request (`Config::tour_pending`), recorded when its timer runs.
/// A separate flag from the auto-start because it is a *different* thing: the user asked for the
/// tour before the window existed, rather than never having seen it.
bool tour_requested = false;
/// The self-check's reading of the manual's launch (`UDPSCOPE_PROBE_HELP_MS`). The launch is
/// asynchronous and the viewer is another process, so the outcome arrives in a callback and the
/// probe prints it from a timer afterwards; `attempted` is set at the call, because a launch that
/// never called back is itself a reading.
bool dbg_help_launch_attempted = false;
bool dbg_help_launch_done = false;
bool dbg_help_launch_ok = false;
std::string dbg_help_launch_error;
/// 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;
@@ -1743,6 +1755,10 @@ 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);
/// The chooser's "Guided tour" request (`Config::tour_pending`): the same delayed start, but it
/// sets `tour_requested` rather than `tour_auto_started`, so the probe and the dump can tell the two
/// apart. Scheduled only when the config carries the request.
gboolean tour_start_requested(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);
+37 -4
View File
@@ -4444,16 +4444,33 @@ UiShortcuts ui_shortcut_rows(const char* path) {
return out;
}
/// Prints the manual launch's outcome, which arrives in a callback (the launch is asynchronous and
/// the viewer is another process) -- so the line is written from a timer a moment later rather than
/// from `help_probe`. `done=0` is a launch that never called back, which is a reading of its own.
gboolean help_launch_report(gpointer user) {
App* a = static_cast<App*>(user);
const std::string installed = help_installed_path();
std::fprintf(stderr, "[help] launch attempted=%d done=%d ok=%d error='%s' installed=%s\n",
a->dbg_help_launch_attempted ? 1 : 0, a->dbg_help_launch_done ? 1 : 0,
a->dbg_help_launch_ok ? 1 : 0, a->dbg_help_launch_error.c_str(),
installed.empty() ? "(not found)" : installed.c_str());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean help_probe(gpointer user) {
App* a = static_cast<App*>(user);
const HelpReport report = help_self_check();
const std::string session =
layout_path(a->model->config().server, a->model->config().port);
// The manual is not opened here: it lives in another process (Yelp, through the `help:` URI)
// and neither it nor this box can draw a window for it, so there is nothing to open, read
// back or close. `help_self_check()` above is the assertion -- the set is on disk where the
// build says it is. The two AdwDialogs below are still built, read and closed for real.
// The manual is *launched* here and read no further: it opens in another process (Yelp, through
// the `help:` URI) and neither it nor this box can draw a window for it, so there is nothing to
// read back or close. `help_self_check()` above is the assertion on the *set* being on disk where
// the build says it is; the launch is the assertion on the *path*, and its outcome is reported by
// `help_launch_report()` once the launch's callback has run. The failure alert the callback shows
// is deliberately left up -- it is what a user sees, and the probe must not report a failure as a
// silence. The two AdwDialogs below are still built, read and closed for real.
show_about(GTK_WINDOW(a->window), session);
show_shortcuts(GTK_WINDOW(a->window));
// Read while it is open: the icon is the point of the dialog carrying one, and
@@ -4461,6 +4478,18 @@ gboolean help_probe(gpointer user) {
const std::string about_icon = about_icon_name();
close_help_windows();
// The manual's path: on a successful launch no window is left behind, so this cannot be observed
// any other way; `show_help()`'s reporter is the only hook that runs on that path.
a->dbg_help_launch_attempted = true;
a->dbg_help_launch_done = false;
a->dbg_help_launch_ok = false;
a->dbg_help_launch_error.clear();
show_help(GTK_WINDOW(a->window), [a](bool ok, const std::string& error) {
a->dbg_help_launch_done = true;
a->dbg_help_launch_ok = ok;
a->dbg_help_launch_error = error;
});
int accel_count = 0;
int gesture_count = 0;
(void)app_accels(&accel_count);
@@ -4479,6 +4508,10 @@ gboolean help_probe(gpointer user) {
build_info::kCommit, build_info::kTimestamp, config_dir().c_str(),
about_icon.c_str());
std::fflush(stderr);
// The launch's outcome needs its callback to have run; a moment is enough for a local
// `g_app_info_launch_default_for_uri()`, and `done=0` in the line is how a launch that never
// called back reads.
g_timeout_add(600, help_launch_report, a);
return G_SOURCE_REMOVE;
}
+13 -2
View File
@@ -342,6 +342,16 @@ gboolean tour_autostart(gpointer user) {
return G_SOURCE_REMOVE;
}
gboolean tour_start_requested(gpointer user) {
App* app = static_cast<App*>(user);
if (app->window == nullptr || app->tour_overlay == nullptr) return G_SOURCE_REMOVE;
// Not `tour_auto_started`: the chooser's entry is the user asking for the tour before the window
// existed, and the reading has to say which of the two started it.
app->tour_requested = true;
start_tour(*app);
return G_SOURCE_REMOVE;
}
// --- the dump's readings ----------------------------------------------------
namespace {
@@ -381,10 +391,11 @@ void capture_help_state(App& app) {
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",
"step=%d auto=%d requested=%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);
tour_step_count(app), tour_step_index(app), app.tour_auto_started ? 1 : 0,
app.tour_requested ? 1 : 0);
app.dbg_context_help = layer;
app.dbg_tour_steps = tour_step_count(app);
app.dbg_tour_step = tour_step_index(app);
+7
View File
@@ -37,6 +37,13 @@ GtkBuilder* ui_load(App& app, const char* resource) {
/// the whole side panel. Scoped to `.udpscope-compact` so it cannot reach any other
/// button in the window.
void install_css() {
// Idempotent, because it is no longer installed from one place: the source chooser runs before the
// application (it draws a menu built from the same `menu_button()` rows) and has to have the
// stylesheet before it shows them, while `on_activate()` still installs it for every run. A second
// provider would be a second copy of the same rules on the display.
static bool installed = false;
if (installed) return;
installed = true;
static const char kCss[] =
"row.udpscope-compact { min-height: 0; padding: 1px 0; }\n"
// A popover menu row: Adwaita gives a button a 34 px minimum height and 8 px of padding,