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:
1 parent
e140642ed0
commit
d8a79b96ba
15 files changed
+461
-66
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user