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:
1 parent
395c9caec3
commit
829176248a
14 files changed
+911
-303
No files matched your search
+241
-16
@@ -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);
|
||||
|
||||
+1
-1
@@ -4926,7 +4926,7 @@ void on_activate(GtkApplication* application, gpointer user) {
|
||||
GtkWidget* overlay = gtk_overlay_new();
|
||||
gtk_window_set_child(GTK_WINDOW(app->window), overlay);
|
||||
gtk_overlay_set_child(GTK_OVERLAY(overlay), side_view);
|
||||
app->tour_overlay = overlay;
|
||||
app->tour.overlay = overlay;
|
||||
build_tour(*app);
|
||||
}
|
||||
|
||||
|
||||
+14
-4
@@ -45,10 +45,20 @@
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel" id="signals_hint">
|
||||
<property name="label" translatable="yes">drag a signal onto a plot</property>
|
||||
<property name="halign">start</property>
|
||||
<property name="css-classes">dim-label</property>
|
||||
<!-- The hint line, wrapped in a box of its own so the contextual help's "(i)" can join it: a
|
||||
`GtkLabel` cannot host a sibling, and a hint overlaid in a corner could cover the text it
|
||||
is meant to sit beside. The label keeps the slack, so the mark sits at the row's right. -->
|
||||
<object class="GtkBox" id="signals_note">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="signals_hint">
|
||||
<property name="label" translatable="yes">drag a signal onto a plot</property>
|
||||
<property name="halign">start</property>
|
||||
<property name="hexpand">1</property>
|
||||
<property name="css-classes">dim-label</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
|
||||
@@ -66,13 +66,41 @@
|
||||
<property name="title" translatable="yes">Sources</property>
|
||||
<property name="icon-name">network-transmit-receive-symbolic</property>
|
||||
<property name="child">
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="hscrollbar-policy">never</property>
|
||||
<property name="child">
|
||||
<object class="GtkListBox" id="sources_list">
|
||||
<property name="activate-on-single-click">1</property>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<!-- The heading row above the list. It exists mainly so the contextual help's
|
||||
"(i)" has a row of its own to join: a hint appended to the list would be a
|
||||
row among the sources, and one overlaid on the scroller could cover a row
|
||||
it is meant to explain. The name is dim, because the switcher's pill above
|
||||
already says which page this is. -->
|
||||
<object class="GtkBox" id="sources_head">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<property name="margin-start">12</property>
|
||||
<property name="margin-end">6</property>
|
||||
<property name="margin-top">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="sources_heading">
|
||||
<property name="label" translatable="yes">Saved sources</property>
|
||||
<property name="halign">start</property>
|
||||
<property name="hexpand">1</property>
|
||||
<property name="css-classes">dim-label</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="vexpand">1</property>
|
||||
<property name="hscrollbar-policy">never</property>
|
||||
<property name="child">
|
||||
<object class="GtkListBox" id="sources_list">
|
||||
<property name="activate-on-single-click">1</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
@@ -97,7 +125,7 @@
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<object class="GtkBox" id="waves_actions">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<property name="halign">end</property>
|
||||
|
||||
+89
-25
@@ -57,6 +57,48 @@ enum class EventGroupKind {
|
||||
Compare, ///< two events in two plots side by side, at one scale, with synchronised axes
|
||||
Accumulate, ///< up to four events overlaid in the same traces, translucent, so agreement is brightness
|
||||
};
|
||||
|
||||
// --- the guided tour, shared by both windows (ui_tour.cpp) -------------------
|
||||
//
|
||||
// The tour is *two* tours now -- the main window's and the source chooser's -- because the chooser is
|
||||
// the window a first run meets first and the surfaces it must explain are its own. The machinery is
|
||||
// one implementation: a `TourView` is the overlay children (the four dim rectangles, the spotlight
|
||||
// outline and the card) plus where the walk stands in it, and either window holds one. What differs
|
||||
// is only the *stops*: a stop's target is a widget the owner resolves, so each window keeps its own
|
||||
// table (the chooser runs before `App` exists and could not name one of `App`'s fields).
|
||||
|
||||
/// One stop of a guided tour: the widget to spotlight, and the two paragraphs of text beside it.
|
||||
/// The target is resolved when the tour starts, so the table may name widgets that do not exist yet.
|
||||
struct TourStop {
|
||||
GtkWidget* target;
|
||||
const char* title;
|
||||
const char* body;
|
||||
};
|
||||
|
||||
/// A tour's widgets and its place in the walk. `hole` is the spotlight's rectangle in the overlay's
|
||||
/// own coordinates, kept from the last frame that could measure the target: a frame can come between
|
||||
/// two layout passes, where `gtk_widget_compute_bounds()` answers nothing, and a hole that emptied
|
||||
/// there would make the spotlight and the card jump.
|
||||
struct TourView {
|
||||
GtkWidget* overlay = nullptr;
|
||||
GtkWidget* dim[4] = {};
|
||||
GtkWidget* spot = nullptr;
|
||||
GtkWidget* card = nullptr;
|
||||
GtkWidget* title = nullptr;
|
||||
GtkWidget* body = nullptr;
|
||||
GtkWidget* counter = nullptr;
|
||||
GtkWidget* back = nullptr;
|
||||
GtkWidget* next = nullptr;
|
||||
int hole[4] = {0, 0, 0, 0};
|
||||
bool running = false;
|
||||
int index = 0;
|
||||
std::vector<TourStop> plan;
|
||||
/// Runs when the walk passes its last stop (Next/Finish, never Skip): the chooser's hands the tour
|
||||
/// on to the application through `Config::tour_pending`.
|
||||
void (*on_done)(void* user) = nullptr;
|
||||
void* done_user = nullptr;
|
||||
};
|
||||
|
||||
struct App {
|
||||
ScopeModel* model = nullptr;
|
||||
Acquisition* acq = nullptr;
|
||||
@@ -232,26 +274,17 @@ struct App {
|
||||
/// are not a panel with a heading.
|
||||
GtkWidget* status_row = nullptr;
|
||||
GtkWidget* window_control_slot = nullptr;
|
||||
/// The guided tour. `tour_overlay` is a `GtkOverlay` inserted above the window's content, and the
|
||||
/// four dim rectangles, the spotlight and the card are its overlay children. `tour_plan` is the
|
||||
/// The hint hosts that are *not* a pane's heading row: a specific control's own row, so the "?" sits
|
||||
/// beside the thing it explains -- the "record events" switch, the search block's action row, and the
|
||||
/// signals pane's hint line (whose box exists so a hint can join it beside the text).
|
||||
GtkWidget* trigger_record_row = nullptr;
|
||||
GtkWidget* trigger_pre_row = nullptr;
|
||||
GtkWidget* signals_note = nullptr;
|
||||
/// The guided tour. `tour.overlay` is a `GtkOverlay` inserted above the window's content, and the
|
||||
/// four dim rectangles, the spotlight and the card are its overlay children; `tour.plan` is the
|
||||
/// steps whose target was on screen when the tour started, so a surface that is not there is not
|
||||
/// walked over. `tour_hole` is the spotlight's rectangle in the overlay's own coordinates, kept
|
||||
/// from the last frame that could measure the target: a frame can come between two layout passes,
|
||||
/// where `gtk_widget_compute_bounds()` answers nothing, and a hole that emptied there would make
|
||||
/// the spotlight and the card jump for that frame.
|
||||
GtkWidget* tour_overlay = nullptr;
|
||||
GtkWidget* tour_dim[4] = {};
|
||||
GtkWidget* tour_spot = nullptr;
|
||||
GtkWidget* tour_card = nullptr;
|
||||
GtkWidget* tour_title = nullptr;
|
||||
GtkWidget* tour_body = nullptr;
|
||||
GtkWidget* tour_counter = nullptr;
|
||||
GtkWidget* tour_back = nullptr;
|
||||
GtkWidget* tour_next = nullptr;
|
||||
int tour_hole[4] = {0, 0, 0, 0};
|
||||
bool tour_running = false;
|
||||
int tour_index = 0;
|
||||
std::vector<int> tour_plan;
|
||||
/// walked over. The machinery is shared with the source chooser's tour -- see `TourView`.
|
||||
TourView tour;
|
||||
/// 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;
|
||||
@@ -1732,19 +1765,50 @@ void on_contextual_help_toggled(GtkToggleButton* button, gpointer user);
|
||||
/// The "?" for one surface by id, for a host that only exists while a dialog is open (the math
|
||||
/// editor is rebuilt on every open, so it cannot be a row the window's own build visits).
|
||||
void context_help_add(App& app, const char* id, GtkWidget* host);
|
||||
/// The same affordance for a surface that is *not* in `App`'s table -- the source chooser's, which
|
||||
/// runs before `App` exists and so cannot name one of its fields. `host` is a `GtkBox` and the button
|
||||
/// joins it as its last child, which is the rule the table's own rows follow; `on` false takes any
|
||||
/// hint off `host` instead. `shortcut` may be "".
|
||||
void context_help_button(GtkWidget* host, const char* id, const char* title, const char* body,
|
||||
const char* shortcut, bool on);
|
||||
/// How many hints a widget subtree carries, and their ids, for a window that has no `App` (the
|
||||
/// chooser's probe). `context_help_count()` is this with `App::window` as the root.
|
||||
int context_help_count_in(GtkWidget* root, std::string* ids);
|
||||
/// The size of the first *visible* hint button in a subtree, read the way the probe's geometry is
|
||||
/// (`gtk_widget_compute_bounds()`), and the number a shrinking affordance is checked against. Both
|
||||
/// are 0 when the layer is off or nothing on screen carries one.
|
||||
void context_help_size_in(GtkWidget* root, int* width, int* height);
|
||||
/// `context_help_size_in()` with `App::window` as the root.
|
||||
void context_help_size(App& app, int* width, int* height);
|
||||
|
||||
/// Builds the tour's overlay children (the four dim rectangles, the spotlight outline and the card)
|
||||
/// inside `app.tour_overlay`. Called once, as the overlay is inserted.
|
||||
/// Builds a tour's overlay children (the four dim rectangles, the spotlight outline and the card)
|
||||
/// inside `overlay`. Called once, as the overlay is inserted, by either window that draws a tour.
|
||||
void tour_build(TourView& view, GtkWidget* overlay);
|
||||
/// Starts at the first stop. `stops` is the walk, already filtered to what is on screen; `on_done`
|
||||
/// (may be null) runs when the walk passes its last stop, and is how the chooser hands off.
|
||||
void tour_begin(TourView& view, std::vector<TourStop> stops, void (*on_done)(void* user),
|
||||
void* done_user);
|
||||
/// Moves `delta` stops, finishing the walk (and calling `on_done`) on the way past the last one.
|
||||
void tour_advance(TourView& view, int delta);
|
||||
/// Ends the walk without finishing it: every child of the overlay is hidden and nothing is left on
|
||||
/// screen. Skip and Escape.
|
||||
void tour_end(TourView& view);
|
||||
/// Called from the tick while the walk runs: re-measures the target each frame, so a window resize
|
||||
/// keeps the spotlight on the widget, and places the card where it does not cover it.
|
||||
void tour_update(TourView& view);
|
||||
/// The widget the current stop points at, or nullptr when the walk is not on a stop.
|
||||
GtkWidget* tour_target(TourView& view);
|
||||
|
||||
/// Builds the *window's* tour inside `app.tour.overlay`. Called once, as the overlay is inserted.
|
||||
void build_tour(App& app);
|
||||
/// Starts the tour at its first step and writes `tour_seen`. Safe to call while it already runs --
|
||||
/// it restarts at step 0.
|
||||
/// Starts the window's tour at its first step and writes `tour_seen`. Safe to call while it already
|
||||
/// runs -- it restarts at step 0.
|
||||
void start_tour(App& app);
|
||||
/// Moves `delta` steps, ending the tour on the way past the last one.
|
||||
void tour_step(App& app, int delta);
|
||||
/// Ends the tour: every child of the overlay is hidden and nothing of the tour is left on screen.
|
||||
void tour_dismiss(App& app);
|
||||
/// Called from the tick while the tour runs: re-measures the target each frame, so a window resize
|
||||
/// keeps the spotlight on the widget, and places the card where it does not cover it.
|
||||
/// The window's tour, re-measured each frame.
|
||||
void update_tour(App& app);
|
||||
bool tour_active(const App& app);
|
||||
/// The widget the current tour step points at, or nullptr when the tour is not on a step. The probe
|
||||
|
||||
+102
-20
@@ -1,24 +1,30 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
//
|
||||
// The contextual-help layer: one small "?" beside each surface the application can explain, whose
|
||||
// The contextual-help layer: one small "(i)" beside each surface the application can explain, whose
|
||||
// bubble says what that surface is for, in a title, a sentence or two, and the key that reaches it
|
||||
// when there is one.
|
||||
//
|
||||
// The surfaces are the table below -- a surface is a row -- and a hint *joins the surface's own
|
||||
// heading row* rather than being overlaid on it: a sibling of the title can never cover a control,
|
||||
// which an overlay child in a corner can and does. The one exception is the plot, which has no
|
||||
// heading: its "?" is an overlay child of the overlay the layout editor already uses, so it costs
|
||||
// nothing and sits over the plot's top-right corner.
|
||||
// row* rather than being overlaid on it: a sibling of the title, the switch or the pencil can never
|
||||
// cover a control, which an overlay child in a corner can and does. Most rows name a panel's heading,
|
||||
// which is where its switch and its pencil live; the rest name the row of a *specific* control (the
|
||||
// "record events" switch, the search block) so a hint can sit beside the thing it explains. The one
|
||||
// exception is the plot, which has no heading: its "(i)" is an overlay child of the overlay the
|
||||
// layout editor already uses, so it costs nothing and sits over the plot's top-right corner.
|
||||
//
|
||||
// Off means *gone*, not greyed: the toggle removes the buttons instead of disabling them. Which is
|
||||
// also why the buttons are found by a data key rather than kept in a list here -- the math editor's
|
||||
// heading dies with its builder on every reopen, and a list of pointers into it would go stale.
|
||||
//
|
||||
// The same affordance serves a window that has no `App` -- the source chooser, which runs before one
|
||||
// exists -- through `context_help_button()`, so both windows draw *one* button.
|
||||
|
||||
#include "ui_app.hpp"
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
@@ -27,11 +33,14 @@ namespace udpscope {
|
||||
namespace ui_impl {
|
||||
namespace {
|
||||
|
||||
/// The "?" glyph. `help-about-symbolic` is already the status bar's information button, so a second
|
||||
/// thing meaning "help" needs a name of its own: this is Adwaita's question mark, and the dump reads
|
||||
/// it back through `gtk_icon_theme_has_icon()` -- a hint button whose icon does not resolve is a
|
||||
/// blank control, the failure the pane [edit] buttons already paid for once.
|
||||
constexpr const char* kHintIcon = "dialog-question-symbolic";
|
||||
/// The "(i)" glyph. Adwaita's `dialog-information-symbolic` is the plain information mark -- a circle
|
||||
/// with a lower-case i -- and it is the one this layer means; `help-about-symbolic` is already the
|
||||
/// status bar's information button, so a second thing meaning "help" needed a name of its own.
|
||||
/// `dialog-question-symbolic` was the first choice and read as "what is this?" rather than as the
|
||||
/// neutral information mark the ask names. The dump reads the name back through
|
||||
/// `gtk_icon_theme_has_icon()` -- a hint button whose icon does not resolve is a *blank* control, the
|
||||
/// failure the pane [edit] buttons already paid for once.
|
||||
constexpr const char* kHintIcon = "dialog-information-symbolic";
|
||||
/// The data key on a hint button, carrying the surface's id as the value. One key is both the mark
|
||||
/// that says "this is a hint" and the name the probe and the dump report it by.
|
||||
constexpr const char* kHintKey = "udpscope-help-hint";
|
||||
@@ -89,6 +98,22 @@ constexpr HelpHint kHelpHints[] = {
|
||||
"Every triggered acquisition the cache keeps, with a picture of each. Open one to read it as a "
|
||||
"file, or select two or four and have them compared in two plots or overlaid in one.",
|
||||
"", &App::events_head},
|
||||
{"events_record", "Recording events",
|
||||
"With this on, every acquisition the trigger latches is written to the event cache -- the "
|
||||
"samples around it and a picture of it -- so an event can be read again long after it has left "
|
||||
"the live buffer. It is the switch beside it, and the same one the events pane shows the result "
|
||||
"of.",
|
||||
"", &App::trigger_record_row},
|
||||
{"trigger_advanced", "The trigger's finer settings",
|
||||
"Pre-trigger share is how much of the event window is kept *before* the crossing, and hold-off "
|
||||
"is the quiet time after a latch in which nothing new can be armed -- the two that decide what a "
|
||||
"capture looks like. The condition rows above appear and disappear with the condition chosen.",
|
||||
"", &App::trigger_pre_row},
|
||||
{"signals_config", "Configuring signals",
|
||||
"The list is locked until the pencil above opens the per-signal controls: the calibration that "
|
||||
"maps raw counts to a unit, the colour a trace is drawn in, and which plot a signal is dragged "
|
||||
"onto. A signal nothing measures can be switched off to stop acquiring it.",
|
||||
"", &App::signals_note},
|
||||
{"window", "The time window",
|
||||
"How much time is on screen, counted back from the newest sample. Type a span, change its unit, "
|
||||
"or step by decades; the trigger's event window is framed out of the same span. No gesture on "
|
||||
@@ -106,7 +131,7 @@ namespace {
|
||||
|
||||
/// The bubble: a bold title, the paragraph, and the key when there is one. Wrapped at a stated
|
||||
/// width rather than left to the popover's own measure, or a long sentence is one very wide line.
|
||||
GtkWidget* hint_popover(const HelpHint& hint) {
|
||||
GtkWidget* hint_popover(const char* title_text, const char* body_text, const char* shortcut) {
|
||||
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
|
||||
gtk_widget_set_margin_start(box, 12);
|
||||
gtk_widget_set_margin_end(box, 12);
|
||||
@@ -115,19 +140,19 @@ GtkWidget* hint_popover(const HelpHint& hint) {
|
||||
|
||||
GtkWidget* title = gtk_label_new(nullptr);
|
||||
char markup[128];
|
||||
std::snprintf(markup, sizeof(markup), "<b>%s</b>", hint.title);
|
||||
std::snprintf(markup, sizeof(markup), "<b>%s</b>", title_text);
|
||||
gtk_label_set_markup(GTK_LABEL(title), markup);
|
||||
gtk_label_set_xalign(GTK_LABEL(title), 0.0f);
|
||||
gtk_box_append(GTK_BOX(box), title);
|
||||
|
||||
GtkWidget* body = gtk_label_new(hint.body);
|
||||
GtkWidget* body = gtk_label_new(body_text);
|
||||
gtk_label_set_wrap(GTK_LABEL(body), TRUE);
|
||||
gtk_label_set_max_width_chars(GTK_LABEL(body), 42);
|
||||
gtk_label_set_xalign(GTK_LABEL(body), 0.0f);
|
||||
gtk_box_append(GTK_BOX(box), body);
|
||||
|
||||
if (hint.shortcut != nullptr && hint.shortcut[0] != '\0') {
|
||||
const std::string key = std::string("Shortcut: ") + hint.shortcut;
|
||||
if (shortcut != nullptr && shortcut[0] != '\0') {
|
||||
const std::string key = std::string("Shortcut: ") + shortcut;
|
||||
GtkWidget* line = gtk_label_new(key.c_str());
|
||||
gtk_widget_add_css_class(line, "dim-label");
|
||||
gtk_label_set_xalign(GTK_LABEL(line), 0.0f);
|
||||
@@ -139,15 +164,19 @@ GtkWidget* hint_popover(const HelpHint& hint) {
|
||||
return popover;
|
||||
}
|
||||
|
||||
/// One "?" button, marked with its surface's id.
|
||||
GtkWidget* hint_button(const HelpHint& hint) {
|
||||
/// One "(i)" button, marked with its surface's id. `udpscope-hint` is what makes it *tiny*: the
|
||||
/// default menu button is a 34 px control with 8 px of padding, which is the size that read as a
|
||||
/// second toolbar button beside each heading. The class trims the box and the glyph together (see
|
||||
/// `install_css()`), and `flat` keeps Adwaita from drawing it as a button until it is hovered.
|
||||
GtkWidget* hint_button(const char* id, const char* title, const char* body, const char* shortcut) {
|
||||
GtkWidget* button = gtk_menu_button_new();
|
||||
gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(button), kHintIcon);
|
||||
gtk_menu_button_set_popover(GTK_MENU_BUTTON(button), hint_popover(hint));
|
||||
gtk_menu_button_set_popover(GTK_MENU_BUTTON(button), hint_popover(title, body, shortcut));
|
||||
gtk_widget_add_css_class(button, "flat");
|
||||
gtk_widget_add_css_class(button, "udpscope-hint");
|
||||
gtk_widget_set_valign(button, GTK_ALIGN_CENTER);
|
||||
gtk_widget_set_tooltip_text(button, "What is this?");
|
||||
g_object_set_data_full(G_OBJECT(button), kHintKey, g_strdup(hint.id), g_free);
|
||||
g_object_set_data_full(G_OBJECT(button), kHintKey, g_strdup(id), g_free);
|
||||
return button;
|
||||
}
|
||||
|
||||
@@ -186,7 +215,7 @@ void sync_one(App& app, int row, GtkWidget* host) {
|
||||
const HelpHint& hint = kHelpHints[row];
|
||||
remove_hint(host);
|
||||
if (!app.settings.contextual_help) return;
|
||||
GtkWidget* button = hint_button(hint);
|
||||
GtkWidget* button = hint_button(hint.id, hint.title, hint.body, hint.shortcut);
|
||||
if (GTK_IS_BOX(host)) {
|
||||
gtk_box_append(GTK_BOX(host), button);
|
||||
} else if (GTK_IS_OVERLAY(host)) {
|
||||
@@ -220,6 +249,23 @@ int count_hints(GtkWidget* widget, std::string* ids) {
|
||||
return n;
|
||||
}
|
||||
|
||||
/// The first *visible* hint button in the subtree, which is the one whose allocation
|
||||
/// `context_help_size_in()` reports -- every hint shares the one `udpscope-hint` size, so one of them
|
||||
/// is the reading. Invisible ones are skipped: a hint inside a collapsed expander (the trigger's
|
||||
/// condition rows live in one) is off screen and reads 0x0, which would make the number say the
|
||||
/// affordance had shrunk to nothing.
|
||||
GtkWidget* first_hint(GtkWidget* widget) {
|
||||
if (widget == nullptr) return nullptr;
|
||||
if (g_object_get_data(G_OBJECT(widget), kHintKey) != nullptr) {
|
||||
return gtk_widget_is_visible(widget) ? widget : nullptr;
|
||||
}
|
||||
for (GtkWidget* child = gtk_widget_get_first_child(widget); child != nullptr;
|
||||
child = gtk_widget_get_next_sibling(child)) {
|
||||
if (GtkWidget* found = first_hint(child)) return found;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void context_help_sync(App& app) {
|
||||
@@ -232,12 +278,48 @@ void context_help_add(App& app, const char* id, GtkWidget* host) {
|
||||
sync_one(app, static_cast<int>(hint - kHelpHints), host);
|
||||
}
|
||||
|
||||
void context_help_button(GtkWidget* host, const char* id, const char* title, const char* body,
|
||||
const char* shortcut, bool on) {
|
||||
if (host == nullptr || !GTK_IS_BOX(host)) return;
|
||||
remove_hint(host);
|
||||
if (!on) return;
|
||||
gtk_box_append(GTK_BOX(host), hint_button(id, title, body, shortcut));
|
||||
}
|
||||
|
||||
int context_help_count_in(GtkWidget* root, std::string* ids) {
|
||||
if (ids != nullptr) ids->clear();
|
||||
return count_hints(root, ids);
|
||||
}
|
||||
|
||||
int context_help_count(App& app, std::string* ids) {
|
||||
if (ids != nullptr) ids->clear();
|
||||
if (app.window == nullptr) return 0;
|
||||
return count_hints(app.window, ids);
|
||||
}
|
||||
|
||||
void context_help_size_in(GtkWidget* root, int* width, int* height) {
|
||||
if (width != nullptr) *width = 0;
|
||||
if (height != nullptr) *height = 0;
|
||||
GtkWidget* hint = first_hint(root);
|
||||
if (hint == nullptr) return;
|
||||
graphene_rect_t r = GRAPHENE_RECT_INIT(0.0f, 0.0f, 0.0f, 0.0f);
|
||||
// `gtk_widget_compute_bounds()` -- the rectangle on screen -- and not `get_width()`: the latter is
|
||||
// the widget's *content* box, a border and a padding short of what a reader sees, which is the
|
||||
// measurement trap the tour card already paid for (see invariants-gtk-and-chrome.md).
|
||||
if (!gtk_widget_compute_bounds(hint, root, &r)) return;
|
||||
if (width != nullptr) *width = static_cast<int>(std::lround(r.size.width));
|
||||
if (height != nullptr) *height = static_cast<int>(std::lround(r.size.height));
|
||||
}
|
||||
|
||||
void context_help_size(App& app, int* width, int* height) {
|
||||
if (app.window == nullptr) {
|
||||
if (width != nullptr) *width = 0;
|
||||
if (height != nullptr) *height = 0;
|
||||
return;
|
||||
}
|
||||
context_help_size_in(app.window, width, height);
|
||||
}
|
||||
|
||||
void on_contextual_help_toggled(GtkToggleButton* button, gpointer user) {
|
||||
App* app = static_cast<App*>(user);
|
||||
const bool on = gtk_toggle_button_get_active(button) != FALSE;
|
||||
|
||||
+10
-6
@@ -4565,11 +4565,11 @@ void helpui_report_geometry(App& a, const char* what) {
|
||||
graphene_rect_t card = GRAPHENE_RECT_INIT(0, 0, 0, 0);
|
||||
GtkWidget* t = tour_target(a);
|
||||
const bool have_spot =
|
||||
a.tour_spot != nullptr && gtk_widget_compute_bounds(a.tour_spot, a.window, &spot);
|
||||
a.tour.spot != nullptr && gtk_widget_compute_bounds(a.tour.spot, a.window, &spot);
|
||||
const bool have_target = t != nullptr && gtk_widget_is_visible(t) &&
|
||||
gtk_widget_compute_bounds(t, a.window, &target);
|
||||
const bool have_card =
|
||||
a.tour_card != nullptr && gtk_widget_compute_bounds(a.tour_card, a.window, &card);
|
||||
a.tour.card != nullptr && gtk_widget_compute_bounds(a.tour.card, a.window, &card);
|
||||
const auto contains = [](const graphene_rect_t& outer, const graphene_rect_t& inner) {
|
||||
return outer.origin.x <= inner.origin.x + 0.5 && outer.origin.y <= inner.origin.y + 0.5 &&
|
||||
outer.origin.x + outer.size.width >= inner.origin.x + inner.size.width - 0.5 &&
|
||||
@@ -4607,7 +4607,11 @@ gboolean helpui_probe_step(gpointer user) {
|
||||
if (tour_active(*a)) tour_dismiss(*a);
|
||||
std::string ids;
|
||||
const int hints = context_help_count(*a, &ids);
|
||||
std::fprintf(stderr, "[helpui] contextual on: hints=%d ids={%s}\n", hints, ids.c_str());
|
||||
int hint_w = 0;
|
||||
int hint_h = 0;
|
||||
context_help_size(*a, &hint_w, &hint_h);
|
||||
std::fprintf(stderr, "[helpui] contextual on: hints=%d ids={%s} hint=%dx%d\n", hints,
|
||||
ids.c_str(), hint_w, hint_h);
|
||||
int total = 0;
|
||||
int clicks = 0;
|
||||
helpui_count_controllers(a, &total, &clicks);
|
||||
@@ -4736,11 +4740,11 @@ gboolean helpui_probe_step(gpointer user) {
|
||||
"[helpui] after Skip: visible=%d dims_hidden=%d spot_hidden=%d card_hidden=%d "
|
||||
"controllers=%d (was %d) click_gestures=%d\n",
|
||||
tour_active(*a) ? 1 : 0,
|
||||
hidden(a->tour_dim[0]) && hidden(a->tour_dim[1]) && hidden(a->tour_dim[2]) &&
|
||||
hidden(a->tour_dim[3])
|
||||
hidden(a->tour.dim[0]) && hidden(a->tour.dim[1]) && hidden(a->tour.dim[2]) &&
|
||||
hidden(a->tour.dim[3])
|
||||
? 1
|
||||
: 0,
|
||||
hidden(a->tour_spot) ? 1 : 0, hidden(a->tour_card) ? 1 : 0, total,
|
||||
hidden(a->tour.spot) ? 1 : 0, hidden(a->tour.card) ? 1 : 0, total,
|
||||
a->dbg_helpui_controllers, clicks);
|
||||
capture_help_state(*a);
|
||||
std::fprintf(stderr, "[helpui] layer: %s\n", a->dbg_context_help.c_str());
|
||||
|
||||
@@ -594,6 +594,9 @@ GtkWidget* build_signal_panel(App& app) {
|
||||
app.signal_list = GTK_WIDGET(gtk_builder_get_object(ui, "signal_list"));
|
||||
// The heading row, where the contextual help's "?" joins (see `ui_context_help.cpp`).
|
||||
app.signals_head = GTK_WIDGET(gtk_builder_get_object(ui, "signals_head"));
|
||||
// The hint line under it, which is where the "signals_config" hint joins: the heading already
|
||||
// carries one, and the rule the layer follows is a hint beside the thing it explains.
|
||||
app.signals_note = GTK_WIDGET(gtk_builder_get_object(ui, "signals_note"));
|
||||
app.signals_edit = GTK_WIDGET(gtk_builder_get_object(ui, "signals_edit"));
|
||||
if (app.signals_edit != nullptr) {
|
||||
g_signal_connect(app.signals_edit, "clicked", G_CALLBACK(on_pane_edit_clicked), &app);
|
||||
|
||||
+237
-184
@@ -2,7 +2,7 @@
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
//
|
||||
// The guided tour: a walk over the surfaces a first run has no way of knowing about, drawn on a
|
||||
// `GtkOverlay` inserted above the window's content.
|
||||
// `GtkOverlay` inserted above a window's content.
|
||||
//
|
||||
// Why an overlay and not a popover per step: a popover takes a pointer grab, and a run of them takes
|
||||
// one per step. The overlay takes none at all -- what makes the tour modal is the four dim
|
||||
@@ -15,6 +15,13 @@
|
||||
// is re-measured on every frame -- the target's own allocation is 0 on the frame it is first shown
|
||||
// (see the allocation bullets in `docs/claude/invariants-trigger-and-pinning.md`), and a window
|
||||
// resize moves it under a spotlight that a one-shot measurement would leave behind.
|
||||
//
|
||||
// There are *two* tours and one implementation. The main window's walks the plot, the side panel,
|
||||
// the trigger and the like; the source chooser's -- which appears *before* `App` exists -- walks the
|
||||
// chooser's own widgets and then hands the tour on to the window through `Config::tour_pending`. The
|
||||
// machinery below is written against a `TourView` and a list of `TourStop`s, so the difference
|
||||
// between the two is only which widgets they name: the chooser keeps its table in
|
||||
// `source_dialog.cpp`, where its widgets live.
|
||||
|
||||
#include "ui_app.hpp"
|
||||
|
||||
@@ -24,44 +31,13 @@
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace udpscope {
|
||||
namespace ui_impl {
|
||||
namespace {
|
||||
|
||||
/// One step. The target is measured, never named by a stored rectangle: the point of re-measuring
|
||||
/// every frame is that nothing here knows where the widget is.
|
||||
struct TourStep {
|
||||
const char* id;
|
||||
const char* title;
|
||||
const char* body;
|
||||
GtkWidget* App::* target;
|
||||
};
|
||||
|
||||
constexpr TourStep kTourSteps[] = {
|
||||
{"plot", "The plot",
|
||||
"Every signal you have switched on is drawn here, all rows sharing one time axis. The wheel "
|
||||
"zooms, a drag pans, and a right-click on a row opens that plot's own menu.",
|
||||
&App::area_overlay},
|
||||
{"side", "The side panel",
|
||||
"The working surface beside the plot. Measure holds the cursors and the measurements; Signals "
|
||||
"holds the trigger and the list of signals. The buttons in the bar above show and hide it.",
|
||||
&App::side_box},
|
||||
{"trigger", "The trigger",
|
||||
"Describe an event -- a signal, a condition, a level and the window around it -- and arm it: "
|
||||
"the view then freezes on the first event that matches, however rare it is.",
|
||||
&App::trigger_body},
|
||||
{"signals", "The signals",
|
||||
"Every signal the source declares, each with the switch that acquires it. A signal has to be "
|
||||
"switched on before it can be plotted, measured or searched.",
|
||||
&App::signal_list},
|
||||
{"status", "The status line",
|
||||
"The stream's own report, once a second: the display mode, the rate arriving, the window on "
|
||||
"screen and the buffer holding it. The information button opens the counters behind it.",
|
||||
&App::status_row},
|
||||
};
|
||||
constexpr int kTourStepCount = static_cast<int>(sizeof(kTourSteps) / sizeof(kTourSteps[0]));
|
||||
|
||||
/// The card's width. Wide enough for a comfortable line of the paragraph, narrow enough to fit
|
||||
/// beside the plot on the documented window -- at 756 px the side panel is 320 and a card wider than
|
||||
/// that is a card that has to cover its own target. It is a *floor* on the box, not a cap on the
|
||||
@@ -94,173 +70,166 @@ void place_natural(GtkWidget* widget, int x, int y) {
|
||||
gtk_widget_set_margin_top(widget, std::max(0, y));
|
||||
}
|
||||
|
||||
/// The step currently walked, or nullptr when the tour is not on a step.
|
||||
const TourStep* current_step(const App& app) {
|
||||
if (app.tour_index < 0 || app.tour_index >= static_cast<int>(app.tour_plan.size())) return nullptr;
|
||||
return &kTourSteps[app.tour_plan[static_cast<std::size_t>(app.tour_index)]];
|
||||
/// The stop currently walked, or nullptr when the walk is not on a stop.
|
||||
const TourStop* current_stop(const TourView& view) {
|
||||
if (view.index < 0 || view.index >= static_cast<int>(view.plan.size())) return nullptr;
|
||||
return &view.plan[static_cast<std::size_t>(view.index)];
|
||||
}
|
||||
|
||||
/// Restates the card's text for the current step. Called on a change of step, not per frame.
|
||||
void show_step(App& app) {
|
||||
const TourStep* step = current_step(app);
|
||||
if (step == nullptr || app.tour_card == nullptr) return;
|
||||
/// Restates the card's text for the current stop. Called on a change of step, not per frame.
|
||||
void show_step(TourView& view) {
|
||||
const TourStop* stop = current_stop(view);
|
||||
if (stop == nullptr || view.card == nullptr) return;
|
||||
// The hole belongs to the step: reusing the last one for a frame would put the spotlight on the
|
||||
// surface the tour has just left, and the next frame fills this one in.
|
||||
app.tour_hole[0] = app.tour_hole[1] = app.tour_hole[2] = app.tour_hole[3] = 0;
|
||||
view.hole[0] = view.hole[1] = view.hole[2] = view.hole[3] = 0;
|
||||
char markup[128];
|
||||
std::snprintf(markup, sizeof(markup), "<b>%s</b>", step->title);
|
||||
gtk_label_set_markup(GTK_LABEL(app.tour_title), markup);
|
||||
gtk_label_set_text(GTK_LABEL(app.tour_body), step->body);
|
||||
std::snprintf(markup, sizeof(markup), "<b>%s</b>", stop->title);
|
||||
gtk_label_set_markup(GTK_LABEL(view.title), markup);
|
||||
gtk_label_set_text(GTK_LABEL(view.body), stop->body);
|
||||
char counter[64];
|
||||
std::snprintf(counter, sizeof(counter), "%d of %d",
|
||||
app.tour_index + 1, static_cast<int>(app.tour_plan.size()));
|
||||
gtk_label_set_text(GTK_LABEL(app.tour_counter), counter);
|
||||
gtk_widget_set_visible(app.tour_back, app.tour_index > 0);
|
||||
gtk_button_set_label(GTK_BUTTON(app.tour_next),
|
||||
app.tour_index + 1 >= static_cast<int>(app.tour_plan.size()) ? "Finish"
|
||||
: "Next");
|
||||
std::snprintf(counter, sizeof(counter), "%d of %d", view.index + 1,
|
||||
static_cast<int>(view.plan.size()));
|
||||
gtk_label_set_text(GTK_LABEL(view.counter), counter);
|
||||
gtk_widget_set_visible(view.back, view.index > 0);
|
||||
gtk_button_set_label(GTK_BUTTON(view.next),
|
||||
view.index + 1 >= static_cast<int>(view.plan.size()) ? "Finish" : "Next");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
GtkWidget* tour_target(App& app) {
|
||||
const TourStep* step = current_step(app);
|
||||
return step != nullptr ? app.*(step->target) : nullptr;
|
||||
}
|
||||
// --- the machinery, shared by both windows ----------------------------------
|
||||
|
||||
void build_tour(App& app) {
|
||||
if (app.tour_overlay == nullptr) return;
|
||||
for (GtkWidget*& dim : app.tour_dim) {
|
||||
void tour_build(TourView& view, GtkWidget* overlay) {
|
||||
if (overlay == nullptr || view.card != nullptr) return;
|
||||
view.overlay = overlay;
|
||||
for (GtkWidget*& dim : view.dim) {
|
||||
dim = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
gtk_widget_add_css_class(dim, "udpscope-tour-dim");
|
||||
gtk_overlay_add_overlay(GTK_OVERLAY(app.tour_overlay), dim);
|
||||
gtk_overlay_add_overlay(GTK_OVERLAY(overlay), dim);
|
||||
}
|
||||
app.tour_spot = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
gtk_widget_add_css_class(app.tour_spot, "udpscope-tour-spot");
|
||||
view.spot = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
gtk_widget_add_css_class(view.spot, "udpscope-tour-spot");
|
||||
// The outline is a frame, not a shield: a click on the *target* is a click on the target.
|
||||
gtk_widget_set_can_target(app.tour_spot, FALSE);
|
||||
gtk_overlay_add_overlay(GTK_OVERLAY(app.tour_overlay), app.tour_spot);
|
||||
gtk_widget_set_can_target(view.spot, FALSE);
|
||||
gtk_overlay_add_overlay(GTK_OVERLAY(overlay), view.spot);
|
||||
|
||||
// The card, last: overlay children are painted in the order they were added, and the card has to
|
||||
// be above the dim strips that cover the rest of the window.
|
||||
app.tour_card = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
|
||||
gtk_widget_add_css_class(app.tour_card, "udpscope-tour-card");
|
||||
gtk_widget_set_size_request(app.tour_card, kCardWidth, -1);
|
||||
view.card = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
|
||||
gtk_widget_add_css_class(view.card, "udpscope-tour-card");
|
||||
gtk_widget_set_size_request(view.card, kCardWidth, -1);
|
||||
|
||||
app.tour_title = gtk_label_new(nullptr);
|
||||
gtk_label_set_xalign(GTK_LABEL(app.tour_title), 0.0f);
|
||||
gtk_box_append(GTK_BOX(app.tour_card), app.tour_title);
|
||||
view.title = gtk_label_new(nullptr);
|
||||
gtk_label_set_xalign(GTK_LABEL(view.title), 0.0f);
|
||||
gtk_box_append(GTK_BOX(view.card), view.title);
|
||||
|
||||
app.tour_body = gtk_label_new(nullptr);
|
||||
gtk_label_set_wrap(GTK_LABEL(app.tour_body), TRUE);
|
||||
view.body = gtk_label_new(nullptr);
|
||||
gtk_label_set_wrap(GTK_LABEL(view.body), TRUE);
|
||||
// The cap that keeps the card narrow. `max_width_chars` is what bounds a wrapped label's *natural*
|
||||
// width; without it the paragraph's natural width is the whole sentence on one line and the card
|
||||
// is that wide however it is placed.
|
||||
gtk_label_set_max_width_chars(GTK_LABEL(app.tour_body), 30);
|
||||
gtk_label_set_xalign(GTK_LABEL(app.tour_body), 0.0f);
|
||||
gtk_box_append(GTK_BOX(app.tour_card), app.tour_body);
|
||||
gtk_label_set_max_width_chars(GTK_LABEL(view.body), 30);
|
||||
gtk_label_set_xalign(GTK_LABEL(view.body), 0.0f);
|
||||
gtk_box_append(GTK_BOX(view.card), view.body);
|
||||
|
||||
GtkWidget* footer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||||
app.tour_counter = gtk_label_new(nullptr);
|
||||
gtk_widget_add_css_class(app.tour_counter, "dim-label");
|
||||
gtk_label_set_xalign(GTK_LABEL(app.tour_counter), 0.0f);
|
||||
gtk_widget_set_hexpand(app.tour_counter, TRUE);
|
||||
gtk_box_append(GTK_BOX(footer), app.tour_counter);
|
||||
app.tour_back = gtk_button_new_with_label("Back");
|
||||
g_signal_connect(app.tour_back, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
tour_step(*static_cast<App*>(user), -1);
|
||||
view.counter = gtk_label_new(nullptr);
|
||||
gtk_widget_add_css_class(view.counter, "dim-label");
|
||||
gtk_label_set_xalign(GTK_LABEL(view.counter), 0.0f);
|
||||
gtk_widget_set_hexpand(view.counter, TRUE);
|
||||
gtk_box_append(GTK_BOX(footer), view.counter);
|
||||
view.back = gtk_button_new_with_label("Back");
|
||||
g_signal_connect(view.back, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
tour_advance(*static_cast<TourView*>(user), -1);
|
||||
}),
|
||||
&app);
|
||||
gtk_box_append(GTK_BOX(footer), app.tour_back);
|
||||
&view);
|
||||
gtk_box_append(GTK_BOX(footer), view.back);
|
||||
GtkWidget* skip = gtk_button_new_with_label("Skip");
|
||||
g_signal_connect(skip, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
tour_dismiss(*static_cast<App*>(user));
|
||||
tour_end(*static_cast<TourView*>(user));
|
||||
}),
|
||||
&app);
|
||||
&view);
|
||||
gtk_box_append(GTK_BOX(footer), skip);
|
||||
app.tour_next = gtk_button_new_with_label("Next");
|
||||
gtk_widget_add_css_class(app.tour_next, "suggested-action");
|
||||
g_signal_connect(app.tour_next, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
tour_step(*static_cast<App*>(user), 1);
|
||||
view.next = gtk_button_new_with_label("Next");
|
||||
gtk_widget_add_css_class(view.next, "suggested-action");
|
||||
g_signal_connect(view.next, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
tour_advance(*static_cast<TourView*>(user), 1);
|
||||
}),
|
||||
&app);
|
||||
gtk_box_append(GTK_BOX(footer), app.tour_next);
|
||||
gtk_box_append(GTK_BOX(app.tour_card), footer);
|
||||
gtk_overlay_add_overlay(GTK_OVERLAY(app.tour_overlay), app.tour_card);
|
||||
&view);
|
||||
gtk_box_append(GTK_BOX(footer), view.next);
|
||||
gtk_box_append(GTK_BOX(view.card), footer);
|
||||
gtk_overlay_add_overlay(GTK_OVERLAY(overlay), view.card);
|
||||
|
||||
// Everything starts hidden: the tour is not on screen until it is started.
|
||||
for (GtkWidget* dim : app.tour_dim) gtk_widget_set_visible(dim, FALSE);
|
||||
gtk_widget_set_visible(app.tour_spot, FALSE);
|
||||
gtk_widget_set_visible(app.tour_card, FALSE);
|
||||
for (GtkWidget* dim : view.dim) gtk_widget_set_visible(dim, FALSE);
|
||||
gtk_widget_set_visible(view.spot, FALSE);
|
||||
gtk_widget_set_visible(view.card, FALSE);
|
||||
}
|
||||
|
||||
void start_tour(App& app) {
|
||||
if (app.tour_overlay == nullptr || app.tour_card == nullptr) return;
|
||||
// "The tour has run" is written when it *starts*: a user who skips it has still seen it, and the
|
||||
// alternative -- marking it on the last step -- would offer the whole tour again to everyone who
|
||||
// decided in the first second that they did not want it.
|
||||
if (!app.settings.tour_seen) {
|
||||
app.settings.tour_seen = true;
|
||||
(void)save_settings(app.settings);
|
||||
}
|
||||
// The steps whose target is on screen, in table order. A surface that is not there is not walked
|
||||
// over -- the point of asking `is_visible()` rather than `get_visible()` is that a widget under a
|
||||
// hidden parent still reports its own flag as true.
|
||||
app.tour_plan.clear();
|
||||
for (int i = 0; i < kTourStepCount; ++i) {
|
||||
GtkWidget* target = app.*(kTourSteps[i].target);
|
||||
if (target != nullptr && gtk_widget_is_visible(target)) app.tour_plan.push_back(i);
|
||||
}
|
||||
if (app.tour_plan.empty()) return;
|
||||
app.tour_index = 0;
|
||||
app.tour_running = true;
|
||||
for (GtkWidget* dim : app.tour_dim) gtk_widget_set_visible(dim, TRUE);
|
||||
gtk_widget_set_visible(app.tour_spot, TRUE);
|
||||
gtk_widget_set_visible(app.tour_card, TRUE);
|
||||
show_step(app);
|
||||
update_tour(app);
|
||||
void tour_begin(TourView& view, std::vector<TourStop> stops, void (*on_done)(void* user),
|
||||
void* done_user) {
|
||||
if (view.overlay == nullptr || view.card == nullptr) return;
|
||||
view.plan = std::move(stops);
|
||||
if (view.plan.empty()) return;
|
||||
view.on_done = on_done;
|
||||
view.done_user = done_user;
|
||||
view.index = 0;
|
||||
view.running = true;
|
||||
for (GtkWidget* dim : view.dim) gtk_widget_set_visible(dim, TRUE);
|
||||
gtk_widget_set_visible(view.spot, TRUE);
|
||||
gtk_widget_set_visible(view.card, TRUE);
|
||||
show_step(view);
|
||||
tour_update(view);
|
||||
}
|
||||
|
||||
void tour_step(App& app, int delta) {
|
||||
if (!app.tour_running) return;
|
||||
const int next = app.tour_index + delta;
|
||||
if (next < 0 || next >= static_cast<int>(app.tour_plan.size())) {
|
||||
tour_dismiss(app);
|
||||
void tour_advance(TourView& view, int delta) {
|
||||
if (!view.running) return;
|
||||
const int next = view.index + delta;
|
||||
if (next < 0 || next >= static_cast<int>(view.plan.size())) {
|
||||
// Past the last stop: this is the walk *finishing*, which is the one ending that hands the tour
|
||||
// on. Take the callback out before `tour_end()` clears it.
|
||||
void (*done)(void*) = view.on_done;
|
||||
void* user = view.done_user;
|
||||
tour_end(view);
|
||||
if (done != nullptr) done(user);
|
||||
return;
|
||||
}
|
||||
app.tour_index = next;
|
||||
show_step(app);
|
||||
update_tour(app);
|
||||
view.index = next;
|
||||
show_step(view);
|
||||
tour_update(view);
|
||||
}
|
||||
|
||||
void tour_dismiss(App& app) {
|
||||
app.tour_running = false;
|
||||
app.tour_index = 0;
|
||||
for (GtkWidget* dim : app.tour_dim) {
|
||||
void tour_end(TourView& view) {
|
||||
view.running = false;
|
||||
view.index = 0;
|
||||
view.plan.clear();
|
||||
view.on_done = nullptr;
|
||||
view.done_user = nullptr;
|
||||
for (GtkWidget* dim : view.dim) {
|
||||
if (dim != nullptr) gtk_widget_set_visible(dim, FALSE);
|
||||
}
|
||||
if (app.tour_spot != nullptr) gtk_widget_set_visible(app.tour_spot, FALSE);
|
||||
if (app.tour_card != nullptr) gtk_widget_set_visible(app.tour_card, FALSE);
|
||||
if (view.spot != nullptr) gtk_widget_set_visible(view.spot, FALSE);
|
||||
if (view.card != nullptr) gtk_widget_set_visible(view.card, FALSE);
|
||||
}
|
||||
|
||||
bool tour_active(const App& app) { return app.tour_running; }
|
||||
GtkWidget* tour_target(TourView& view) {
|
||||
const TourStop* stop = current_stop(view);
|
||||
return stop != nullptr ? stop->target : nullptr;
|
||||
}
|
||||
|
||||
int tour_step_count(const App& app) { return static_cast<int>(app.tour_plan.size()); }
|
||||
|
||||
int tour_step_index(const App& app) { return app.tour_index; }
|
||||
|
||||
void update_tour(App& app) {
|
||||
if (!app.tour_running || app.tour_overlay == nullptr) return;
|
||||
const int w = gtk_widget_get_width(app.tour_overlay);
|
||||
const int h = gtk_widget_get_height(app.tour_overlay);
|
||||
void tour_update(TourView& view) {
|
||||
if (!view.running || view.overlay == nullptr) return;
|
||||
const int w = gtk_widget_get_width(view.overlay);
|
||||
const int h = gtk_widget_get_height(view.overlay);
|
||||
if (w <= 0 || h <= 0) return; // no allocation yet: nothing to place anything against
|
||||
|
||||
// The target, re-measured every frame. Its allocation is 0 on the frame it is first shown, so a
|
||||
// one-shot measurement would leave the hole in the corner.
|
||||
GtkWidget* target = tour_target(app);
|
||||
GtkWidget* target = tour_target(view);
|
||||
graphene_rect_t bounds = GRAPHENE_RECT_INIT(0.0f, 0.0f, 0.0f, 0.0f);
|
||||
const bool target_present = target != nullptr && gtk_widget_is_visible(target);
|
||||
if (target_present && gtk_widget_compute_bounds(target, app.tour_overlay, &bounds)) {
|
||||
if (target_present && gtk_widget_compute_bounds(target, view.overlay, &bounds)) {
|
||||
// The hole: the target, grown by three pixels so the outline sits *around* the widget rather than
|
||||
// on it, and clamped to the overlay -- a target partly off screen is an ordinary case (a sidebar
|
||||
// dragged wide), and a negative rectangle is not something a margin can express.
|
||||
@@ -268,28 +237,28 @@ void update_tour(App& app) {
|
||||
const int y0 = static_cast<int>(std::floor(bounds.origin.y)) - 3;
|
||||
const int x1 = static_cast<int>(std::ceil(bounds.origin.x + bounds.size.width)) + 3;
|
||||
const int y1 = static_cast<int>(std::ceil(bounds.origin.y + bounds.size.height)) + 3;
|
||||
app.tour_hole[0] = std::clamp(x0, 0, w);
|
||||
app.tour_hole[1] = std::clamp(y0, 0, h);
|
||||
app.tour_hole[2] = std::clamp(x1, 0, w) - app.tour_hole[0];
|
||||
app.tour_hole[3] = std::clamp(y1, 0, h) - app.tour_hole[1];
|
||||
view.hole[0] = std::clamp(x0, 0, w);
|
||||
view.hole[1] = std::clamp(y0, 0, h);
|
||||
view.hole[2] = std::clamp(x1, 0, w) - view.hole[0];
|
||||
view.hole[3] = std::clamp(y1, 0, h) - view.hole[1];
|
||||
} else if (!target_present) {
|
||||
// Nothing on screen to point at: the whole surface dims and the card takes a corner. A step whose
|
||||
// target went away mid-tour is not a step to be skipped silently.
|
||||
app.tour_hole[0] = app.tour_hole[1] = app.tour_hole[2] = app.tour_hole[3] = 0;
|
||||
view.hole[0] = view.hole[1] = view.hole[2] = view.hole[3] = 0;
|
||||
}
|
||||
// ... and when the target *is* there but could not be measured, the previous hole stands: a frame
|
||||
// can arrive between two layout passes, and emptying the hole there is the spotlight and the card
|
||||
// jumping to the corner and back.
|
||||
const int hx = app.tour_hole[0];
|
||||
const int hy = app.tour_hole[1];
|
||||
const int hw = app.tour_hole[2];
|
||||
const int hh = app.tour_hole[3];
|
||||
const int hx = view.hole[0];
|
||||
const int hy = view.hole[1];
|
||||
const int hw = view.hole[2];
|
||||
const int hh = view.hole[3];
|
||||
|
||||
place(app.tour_dim[0], 0, 0, w, hy); // above the hole
|
||||
place(app.tour_dim[1], 0, hy + hh, w, h - hy - hh); // below it
|
||||
place(app.tour_dim[2], 0, hy, hx, hh); // to its left
|
||||
place(app.tour_dim[3], hx + hw, hy, w - hx - hw, hh); // to its right
|
||||
place(app.tour_spot, hx, hy, hw, hh);
|
||||
place(view.dim[0], 0, 0, w, hy); // above the hole
|
||||
place(view.dim[1], 0, hy + hh, w, h - hy - hh); // below it
|
||||
place(view.dim[2], 0, hy, hx, hh); // to its left
|
||||
place(view.dim[3], hx + hw, hy, w - hx - hw, hh); // to its right
|
||||
place(view.spot, hx, hy, hw, hh);
|
||||
|
||||
// The card, placed clear of the hole whenever the window has room for it. The strip order is the
|
||||
// order a card is easiest to read in: under the thing it describes, then over it, then in
|
||||
@@ -316,19 +285,18 @@ void update_tour(App& app) {
|
||||
int cw = 0;
|
||||
int ch = 0;
|
||||
graphene_rect_t card_bounds = GRAPHENE_RECT_INIT(0, 0, 0, 0);
|
||||
if (gtk_widget_is_visible(app.tour_card) && gtk_widget_get_width(app.tour_card) > 0 &&
|
||||
gtk_widget_compute_bounds(app.tour_card, app.tour_overlay, &card_bounds)) {
|
||||
if (gtk_widget_is_visible(view.card) && gtk_widget_get_width(view.card) > 0 &&
|
||||
gtk_widget_compute_bounds(view.card, view.overlay, &card_bounds)) {
|
||||
cw = static_cast<int>(std::ceil(card_bounds.size.width));
|
||||
ch = static_cast<int>(std::ceil(card_bounds.size.height));
|
||||
}
|
||||
if (cw <= 0 || ch <= 0) {
|
||||
gtk_widget_measure(app.tour_card, GTK_ORIENTATION_HORIZONTAL, -1, nullptr, &cw, nullptr,
|
||||
nullptr);
|
||||
cw = std::max(kCardWidth, cw - gtk_widget_get_margin_start(app.tour_card) -
|
||||
gtk_widget_get_margin_end(app.tour_card));
|
||||
gtk_widget_measure(app.tour_card, GTK_ORIENTATION_VERTICAL, -1, nullptr, &ch, nullptr, nullptr);
|
||||
ch = std::max(0, ch - gtk_widget_get_margin_top(app.tour_card) -
|
||||
gtk_widget_get_margin_bottom(app.tour_card));
|
||||
gtk_widget_measure(view.card, GTK_ORIENTATION_HORIZONTAL, -1, nullptr, &cw, nullptr, nullptr);
|
||||
cw = std::max(kCardWidth, cw - gtk_widget_get_margin_start(view.card) -
|
||||
gtk_widget_get_margin_end(view.card));
|
||||
gtk_widget_measure(view.card, GTK_ORIENTATION_VERTICAL, -1, nullptr, &ch, nullptr, nullptr);
|
||||
ch = std::max(0, ch - gtk_widget_get_margin_top(view.card) -
|
||||
gtk_widget_get_margin_bottom(view.card));
|
||||
}
|
||||
int cx = 0, cy = 0;
|
||||
if (h - (hy + hh) >= ch + 2 * kGap) {
|
||||
@@ -352,12 +320,92 @@ void update_tour(App& app) {
|
||||
cx = std::max(kGap, w - cw - kGap);
|
||||
cy = std::max(kGap, h - ch - kGap);
|
||||
}
|
||||
place_natural(app.tour_card, cx, cy);
|
||||
place_natural(view.card, cx, cy);
|
||||
}
|
||||
|
||||
// --- the main window's tour, and its table of stops --------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
/// One stop of the *window's* tour. The target is measured, never named by a stored rectangle: the
|
||||
/// point of re-measuring every frame is that nothing here knows where the widget is.
|
||||
struct TourStep {
|
||||
const char* id;
|
||||
const char* title;
|
||||
const char* body;
|
||||
GtkWidget* App::* target;
|
||||
};
|
||||
|
||||
constexpr TourStep kTourSteps[] = {
|
||||
{"plot", "The plot",
|
||||
"Every signal you have switched on is drawn here, all rows sharing one time axis. The wheel "
|
||||
"zooms, a drag pans, and a right-click on a row opens that plot's own menu.",
|
||||
&App::area_overlay},
|
||||
{"side", "The side panel",
|
||||
"The working surface beside the plot. Measure holds the cursors and the measurements; Signals "
|
||||
"holds the trigger and the list of signals. The buttons in the bar above show and hide it.",
|
||||
&App::side_box},
|
||||
{"trigger", "The trigger",
|
||||
"Describe an event -- a signal, a condition, a level and the window around it -- and arm it: "
|
||||
"the view then freezes on the first event that matches, however rare it is.",
|
||||
&App::trigger_body},
|
||||
{"signals", "The signals",
|
||||
"Every signal the source declares, each with the switch that acquires it. A signal has to be "
|
||||
"switched on before it can be plotted, measured or searched.",
|
||||
&App::signal_list},
|
||||
{"status", "The status line",
|
||||
"The stream's own report, once a second: the display mode, the rate arriving, the window on "
|
||||
"screen and the buffer holding it. The information button opens the counters behind it.",
|
||||
&App::status_row},
|
||||
};
|
||||
constexpr int kTourStepCount = static_cast<int>(sizeof(kTourSteps) / sizeof(kTourSteps[0]));
|
||||
|
||||
} // namespace
|
||||
|
||||
void build_tour(App& app) {
|
||||
tour_build(app.tour, app.tour.overlay);
|
||||
}
|
||||
|
||||
void start_tour(App& app) {
|
||||
if (app.tour.overlay == nullptr || app.tour.card == nullptr) return;
|
||||
// "The tour has run" is written when it *starts*: a user who skips it has still seen it, and the
|
||||
// alternative -- marking it on the last step -- would offer the whole tour again to everyone who
|
||||
// decided in the first second that they did not want it.
|
||||
if (!app.settings.tour_seen) {
|
||||
app.settings.tour_seen = true;
|
||||
(void)save_settings(app.settings);
|
||||
}
|
||||
// The stops whose target is on screen, in table order. A surface that is not there is not walked
|
||||
// over -- the point of asking `is_visible()` rather than `get_visible()` is that a widget under a
|
||||
// hidden parent still reports its own flag as true.
|
||||
std::vector<TourStop> stops;
|
||||
for (int i = 0; i < kTourStepCount; ++i) {
|
||||
GtkWidget* target = app.*(kTourSteps[i].target);
|
||||
if (target != nullptr && gtk_widget_is_visible(target)) {
|
||||
stops.push_back({target, kTourSteps[i].title, kTourSteps[i].body});
|
||||
}
|
||||
}
|
||||
if (stops.empty()) return;
|
||||
tour_begin(app.tour, std::move(stops), nullptr, nullptr);
|
||||
}
|
||||
|
||||
void tour_step(App& app, int delta) { tour_advance(app.tour, delta); }
|
||||
|
||||
void tour_dismiss(App& app) { tour_end(app.tour); }
|
||||
|
||||
bool tour_active(const App& app) { return app.tour.running; }
|
||||
|
||||
int tour_step_count(const App& app) { return static_cast<int>(app.tour.plan.size()); }
|
||||
|
||||
int tour_step_index(const App& app) { return app.tour.index; }
|
||||
|
||||
void update_tour(App& app) { tour_update(app.tour); }
|
||||
|
||||
GtkWidget* tour_target(App& app) { return tour_target(app.tour); }
|
||||
|
||||
gboolean tour_autostart(gpointer user) {
|
||||
App* app = static_cast<App*>(user);
|
||||
if (app->window == nullptr || app->tour_overlay == nullptr) return G_SOURCE_REMOVE;
|
||||
if (app->window == nullptr || app->tour.overlay == nullptr) return G_SOURCE_REMOVE;
|
||||
app->tour_auto_started = true;
|
||||
start_tour(*app);
|
||||
return G_SOURCE_REMOVE;
|
||||
@@ -365,7 +413,7 @@ gboolean tour_autostart(gpointer user) {
|
||||
|
||||
gboolean tour_start_requested(gpointer user) {
|
||||
App* app = static_cast<App*>(user);
|
||||
if (app->window == nullptr || app->tour_overlay == nullptr) return G_SOURCE_REMOVE;
|
||||
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;
|
||||
@@ -406,21 +454,26 @@ void capture_help_state(App& app) {
|
||||
std::string ids;
|
||||
const int hints = context_help_count(app, &ids);
|
||||
// The icon is read back through the theme for the reason the pane [edit] buttons are: a name
|
||||
// Adwaita does not have is a *blank* control that works and reads as nothing at all.
|
||||
// Adwaita does not have is a *blank* control that works and reads as nothing at all. The size is
|
||||
// the reading a "much tinier" affordance is checked against, and it is measured the way every
|
||||
// other rectangle here is (`gtk_widget_compute_bounds()`), on the tick while the widget is alive.
|
||||
GtkIconTheme* theme = gtk_icon_theme_get_for_display(gdk_display_get_default());
|
||||
const bool icon_ok = theme != nullptr && gtk_icon_theme_has_icon(theme, "dialog-question-symbolic");
|
||||
char layer[512];
|
||||
const bool icon_ok = theme != nullptr && gtk_icon_theme_has_icon(theme, "dialog-information-symbolic");
|
||||
int hint_w = 0;
|
||||
int hint_h = 0;
|
||||
context_help_size(app, &hint_w, &hint_h);
|
||||
char layer[640];
|
||||
std::snprintf(layer, sizeof(layer),
|
||||
"contextual=%s hints=%d ids={%s} icon=dialog-question-symbolic(%s); tour=%s steps=%d "
|
||||
"step=%d auto=%d requested=%d",
|
||||
"contextual=%s hints=%d ids={%s} icon=dialog-information-symbolic(%s) hint=%dx%d; "
|
||||
"tour=%s steps=%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,
|
||||
app.tour_requested ? 1 : 0);
|
||||
icon_ok ? "ok" : "MISSING", hint_w, hint_h,
|
||||
app.tour.running ? "running" : "idle", 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);
|
||||
app.dbg_tour_visible = app.tour_running ? 1 : 0;
|
||||
app.dbg_tour_visible = app.tour.running ? 1 : 0;
|
||||
|
||||
std::string figures;
|
||||
for (int i = 0; i < kFigureSurfaceCount; ++i) {
|
||||
|
||||
+7
-1
@@ -530,7 +530,10 @@ GtkWidget* build_trigger_panel(App& app) {
|
||||
row_for(ui, "level_row", app.trigger_level_spin);
|
||||
|
||||
app.trigger_pre_spin = spin(0.0, 99.0, 1.0, 0, 50.0);
|
||||
row_for(ui, "pre_row", app.trigger_pre_spin);
|
||||
// The row is kept, not just filled: the contextual help's "trigger_advanced" hint joins it, beside
|
||||
// the pre-trigger share -- the panel's finer settings, which is what the ask calls the trigger's
|
||||
// advanced ones.
|
||||
app.trigger_pre_row = row_for(ui, "pre_row", app.trigger_pre_spin);
|
||||
|
||||
app.trigger_holdoff_spin = spin(0.0, 3600.0, 0.001, 4,
|
||||
app.model->config().trigger_holdoff_s);
|
||||
@@ -564,6 +567,9 @@ GtkWidget* build_trigger_panel(App& app) {
|
||||
gtk_widget_set_halign(app.events_record_switch, GTK_ALIGN_END);
|
||||
gtk_widget_set_valign(app.events_record_switch, GTK_ALIGN_CENTER);
|
||||
gtk_box_append(GTK_BOX(record_row), app.events_record_switch);
|
||||
// Where the contextual help's "events_record" hint joins -- beside the switch, never over it
|
||||
// (see `ui_context_help.cpp`). Kept on the App because the layer's table names a field.
|
||||
app.trigger_record_row = record_row;
|
||||
}
|
||||
|
||||
app.arm_button = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_arm"));
|
||||
|
||||
+14
-1
@@ -68,7 +68,20 @@ void install_css() {
|
||||
".udpscope-tour-dim { background-color: rgba(0, 0, 0, 0.45); }\n"
|
||||
".udpscope-tour-spot { border: 2px solid @accent_bg_color; border-radius: 8px; }\n"
|
||||
".udpscope-tour-card { background-color: @window_bg_color; border: 1px solid @borders;"
|
||||
" border-radius: 12px; padding: 12px; box-shadow: 0 3px 12px rgba(0, 0, 0, 0.45); }\n";
|
||||
" border-radius: 12px; padding: 12px; box-shadow: 0 3px 12px rgba(0, 0, 0, 0.45); }\n"
|
||||
// The contextual-help "(i)": a *mark*, not a button. Adwaita's menu button is a 34x34 control
|
||||
// (a 24 px floor plus 5 px of padding) with a 16 px glyph, which beside a heading reads as a
|
||||
// second toolbar button -- the size the ask calls too big. Measured on libadwaita 1.10: the
|
||||
// outer `menubutton` node *and* its `button` subnode both carry a floor and padding, and a class
|
||||
// selector on either alone leaves the other at 34 (`min-width` on the widget itself is ignored
|
||||
// by `GtkMenuButton`'s measure, and so is a size request -- tried both). Zeroing all three --
|
||||
// the outer node, the subnode and its glyph -- is what brings it to **16x16**: the icon's own
|
||||
// size and nothing else, because a `min-width` is a floor, not a cap, so 16 px is where a 16 px
|
||||
// Adwaita glyph stops. That is the reading `hint=WxH` in the dump prints.
|
||||
"menubutton.udpscope-hint { min-width: 0; min-height: 0; padding: 0; margin: 0; }\n"
|
||||
"menubutton.udpscope-hint > button { min-width: 0; min-height: 0; padding: 0; }\n"
|
||||
"menubutton.udpscope-hint > button > image { min-width: 12px; min-height: 12px;"
|
||||
" -gtk-icon-size: 12px; }\n";
|
||||
GtkCssProvider* p = gtk_css_provider_new();
|
||||
gtk_css_provider_load_from_string(p, kCss);
|
||||
gtk_style_context_add_provider_for_display(gdk_display_get_default(),
|
||||
|
||||
Reference in new issue
Block a user