Files
udpscope/src/ui_tour.cpp
T

548 lines
27 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The guided tour: a walk over the surfaces a first run has no way of knowing about, drawn on a
// `GtkOverlay` inserted above 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
// rectangles, which are ordinary widgets that swallow the clicks that land on them, and dismissing
// the tour is hiding them. There is no grab to leak and nothing to unparent.
//
// The spotlight is *four* rectangles and not a drawing: the top, bottom, left and right strips
// around the target, positioned by margins each frame from `gtk_widget_compute_bounds()`. That is
// the whole of "dims everything except the target", it needs no cairo and no paint function, and it
// is re-measured on every frame -- the target's own allocation is 0 on the frame it is first shown
// (see the allocation bullets in `docs/claude/invariants-trigger-and-pinning.md`), and a window
// resize moves it under a spotlight that a one-shot measurement would leave behind.
//
// 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"
#include <gtk/gtk.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <string>
#include <utility>
#include <vector>
namespace udpscope {
namespace ui_impl {
namespace {
/// The card's width. Wide enough for a comfortable line of the paragraph, narrow enough to fit
/// beside the plot on the documented window -- at 756 px the side panel is 320 and a card wider than
/// that is a card that has to cover its own target. It is a *floor* on the box, not a cap on the
/// paragraph: the wrapped label is capped by `max_width_chars` so the natural width never exceeds it.
constexpr int kCardWidth = 280;
/// The gap between the hole and whatever is placed beside it. Eight and not twelve: the side panel
/// is 320 px, and it has to hold the card and both gaps.
constexpr int kGap = 8;
/// Places an overlay child of the tour overlay at an exact rectangle. `GtkOverlay` positions an
/// overlay child by its alignment and its margins and gives it its measured size, so a fixed
/// rectangle is a size request plus two margins.
void place(GtkWidget* widget, int x, int y, int width, int height) {
gtk_widget_set_halign(widget, GTK_ALIGN_START);
gtk_widget_set_valign(widget, GTK_ALIGN_START);
gtk_widget_set_margin_start(widget, std::max(0, x));
gtk_widget_set_margin_top(widget, std::max(0, y));
gtk_widget_set_size_request(widget, std::max(0, width), std::max(0, height));
}
/// Places an overlay child by its *natural* size at a point. The card sizes itself -- its paragraph
/// wraps at a stated width -- and only its position is ours. Giving it the width we measured back as
/// a size request is a feedback loop: a request is a floor on the widget's own measure, so the width
/// it was placed at becomes the width it reports next frame, and a starting value that is one pixel
/// too large grows by that pixel for ever.
void place_natural(GtkWidget* widget, int x, int y) {
gtk_widget_set_halign(widget, GTK_ALIGN_START);
gtk_widget_set_valign(widget, GTK_ALIGN_START);
gtk_widget_set_margin_start(widget, std::max(0, x));
gtk_widget_set_margin_top(widget, std::max(0, y));
}
/// The 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)];
}
/// Selects a stop's page on the view's stack, before anything is measured against the target. Both
/// windows' stacks are `AdwViewStack`s and their page names are the ones the same tables name their
/// `.ui` pages with, so the machinery can do this itself; the `get_child_by_name()` guard keeps a
/// stop that names a page a later `.ui` dropped from blanking the stack. The stack's own
/// `notify::visible-child-name` is what tells the window which page it is now on.
void select_view_page(TourView& view, const char* page) {
if (view.page_stack == nullptr || page == nullptr) return;
if (adw_view_stack_get_child_by_name(ADW_VIEW_STACK(view.page_stack), page) == nullptr) return;
adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(view.page_stack), page);
}
/// Whether a stop belongs in the walk. `get_mapped()`, not `is_visible()`: a stack hides its other
/// pages, and the split view its sidebar, with `gtk_widget_set_child_visible()`, which leaves the
/// widget's own visible flag -- the one `is_visible()` walks up the parents -- **true**. So the only
/// honest "is this on screen" test is whether it is mapped, and it is the one that keeps the filter's
/// two cases apart:
/// * a target not mapped because it is on *another page* is walked over when the stop names that
/// page and the stack itself is on screen -- selecting the page is what puts the target there;
/// * a target not mapped because its *pane is collapsed* leaves the stack unmapped too, so no page
/// can reveal it and the stop is still dropped.
bool stop_is_walkable(const TourView& view, const TourStop& stop) {
if (stop.target == nullptr) return false;
if (gtk_widget_get_mapped(stop.target)) return true;
return stop.page != nullptr && view.page_stack != nullptr &&
gtk_widget_get_mapped(view.page_stack);
}
/// 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 surface first, and nothing is measured here: the stop may live on a page the user has left,
// and a stack's new page is allocated on the frame *after* the switch, so a one-shot measure in
// this tick would read the page the walk has just left. `tour_update()` runs every frame and fills
// the hole in on the next one.
select_view_page(view, stop->page);
// 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.
view.hole[0] = view.hole[1] = view.hole[2] = view.hole[3] = 0;
char markup[128];
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", 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
// --- the machinery, shared by both windows ----------------------------------
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(overlay), dim);
}
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(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.
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);
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);
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(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);
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);
}),
&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_end(*static_cast<TourView*>(user));
}),
&view);
gtk_box_append(GTK_BOX(footer), skip);
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);
}),
&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 : view.dim) gtk_widget_set_visible(dim, FALSE);
gtk_widget_set_visible(view.spot, FALSE);
gtk_widget_set_visible(view.card, FALSE);
}
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;
// The walk is the stops whose surface is on screen *or* can be selected into view, filtered here
// rather than by each caller so the two windows cannot drift into two rules -- and because the
// rule needs the view's own `page_stack`, which the caller's table does not carry. See
// `stop_is_walkable()` for what separates a wrong page from a collapsed pane.
std::vector<TourStop> plan;
for (const TourStop& stop : stops) {
if (stop_is_walkable(view, stop)) plan.push_back(stop);
}
if (plan.empty()) return;
view.plan = std::move(plan);
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_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;
}
view.index = next;
show_step(view);
tour_update(view);
}
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 (view.spot != nullptr) gtk_widget_set_visible(view.spot, FALSE);
if (view.card != nullptr) gtk_widget_set_visible(view.card, FALSE);
}
GtkWidget* tour_target(TourView& view) {
const TourStop* stop = current_stop(view);
return stop != nullptr ? stop->target : nullptr;
}
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(view);
graphene_rect_t bounds = GRAPHENE_RECT_INIT(0.0f, 0.0f, 0.0f, 0.0f);
// Mapped, not `is_visible()`: a widget on another page of a stack, or in a collapsed pane, keeps
// its own visible flag -- a stack hides its pages with `set_child_visible()` -- and a widget that
// is not on screen is not one to leave the spotlight standing on.
const bool target_present = target != nullptr && gtk_widget_get_mapped(target);
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.
const int x0 = static_cast<int>(std::floor(bounds.origin.x)) - 3;
const int y0 = static_cast<int>(std::floor(bounds.origin.y)) - 3;
const int x1 = static_cast<int>(std::ceil(bounds.origin.x + bounds.size.width)) + 3;
const int y1 = static_cast<int>(std::ceil(bounds.origin.y + bounds.size.height)) + 3;
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.
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 = view.hole[0];
const int hy = view.hole[1];
const int hw = view.hole[2];
const int hh = view.hole[3];
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
// whichever column is beside it -- which is, on this window, the side panel for the plot's step and
// the plot for every step in the side panel.
//
// The card's size is the *rectangle it was allocated*, read the same way the probe reads it and
// the help's figures are drawn from: `gtk_widget_compute_bounds()`. Not `gtk_widget_get_width()`,
// which is the widget's *content* size -- a border and a padding short of the rectangle on screen
// (measured here: 254 against the card's real 280), so a card placed from it overhangs its own size
// into the target; that call is read only to tell a frame that has been laid out from one that is
// too early. And, once a frame has run, not a measure either:
// * `gtk_widget_measure()` reports the size *including the widget's own margins* (a 280 px box
// with `margin-start` 453 measures 733), and ours are the card's position -- reading one back
// without taking them off is a feedback loop.
// * asking for a **height** with a width (`for_size`) on a widget whose margins are then left out
// of that width is a question GTK refuses: it reports `Trying to measure GtkBox ... for width
// of N, but it needs at least M` once per frame, because the for_size derived from the natural
// width minus the margins lands one margin short of the minimum the box answers with. The
// allocation the widget was actually given cannot be short of it.
// A measure survives only as the fallback for a frame with nothing allocated yet (the first frame
// the card is shown, before any layout pass), and that fallback is taken with **no for_size at
// all**: `for_size == -1` is the form GTK measures without the check that warns.
int cw = 0;
int ch = 0;
graphene_rect_t card_bounds = GRAPHENE_RECT_INIT(0, 0, 0, 0);
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(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) {
cx = std::clamp(hx, kGap, std::max(kGap, w - cw - kGap));
cy = hy + hh + kGap;
} else if (hy >= ch + 2 * kGap) {
cx = std::clamp(hx, kGap, std::max(kGap, w - cw - kGap));
cy = hy - ch - kGap;
} else if (w - (hx + hw) >= cw + 2 * kGap) {
cx = hx + hw + kGap;
cy = std::clamp(hy, kGap, std::max(kGap, h - ch - kGap));
} else if (hx >= cw + 2 * kGap) {
cx = hx - cw - kGap;
cy = std::clamp(hy, kGap, std::max(kGap, h - ch - kGap));
} else {
// No clear side at all -- the target is nearly the whole window, which is the plot's step in a
// window narrow enough for the split view to *overlay* the side panel rather than put it beside
// the plot. The card goes to the bottom-right corner, which is where the least of the target is,
// and the two readings the probe prints (`spot=`, `card=`) are what show how little choice there
// was rather than the card having been placed badly.
cx = std::max(kGap, w - cw - kGap);
cy = std::max(kGap, h - ch - kGap);
}
place_natural(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;
/// The sidebar page the target lives on, or nullptr when the stop is not on either page. The
/// trigger and the channel list are both on the Signals page, so a stop about either has to put
/// the sidebar there before it can spotlight one.
const char* page;
const char* title;
const char* body;
GtkWidget* App::* target;
};
constexpr TourStep kTourSteps[] = {
{"plot", nullptr, "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", nullptr, "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", "signals", "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", "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", nullptr, "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 main window has no page stack any more (the dock is one scroller): `page_stack` stays null, so
// a stop's `page` selects nothing and a stop whose target is unmapped is dropped. The slide-over hook
// that lets a stop open the trigger page is the next step's.
// The whole table, in walk order: `tour_begin()` drops the stops whose surface is not on screen.
std::vector<TourStop> stops;
stops.reserve(kTourStepCount);
for (int i = 0; i < kTourStepCount; ++i) {
GtkWidget* target = app.*(kTourSteps[i].target);
stops.push_back({target, kTourSteps[i].page, kTourSteps[i].title, kTourSteps[i].body});
}
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;
app->tour_auto_started = true;
start_tour(*app);
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 {
/// The surfaces the help's annotated figures label. Their geometry is what a figure's crop box is
/// drawn from, and it has to be *measured* -- at 1280x800 the panels are not where a plausible guess
/// puts them, and a box drawn from a guess is a box over the wrong thing.
struct FigureSurface {
const char* name;
GtkWidget* App::* widget;
};
constexpr FigureSurface kFigureSurfaces[] = {
{"trigger_head", &App::trigger_head},
{"trigger_summary", &App::trigger_summary},
{"trigger_panel", &App::trigger_panel},
{"signal_list", &App::signal_list},
{"side_stack", &App::side_box},
{"window_button", &App::window_button},
{"status_label", &App::status_label},
{"header_bar", &App::toolbar},
{"plot", &App::area_overlay},
};
constexpr int kFigureSurfaceCount =
static_cast<int>(sizeof(kFigureSurfaces) / sizeof(kFigureSurfaces[0]));
} // namespace
void capture_help_state(App& app) {
std::string ids;
const int hints = context_help_count(app, &ids);
// The icon is read back through the theme for the reason the pane [edit] buttons are: a name
// Adwaita does not have is a *blank* control that works and reads as nothing at all. 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-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-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", 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;
std::string figures;
for (int i = 0; i < kFigureSurfaceCount; ++i) {
const FigureSurface& surface = kFigureSurfaces[i];
GtkWidget* widget = app.*(surface.widget);
graphene_rect_t r = GRAPHENE_RECT_INIT(0.0f, 0.0f, 0.0f, 0.0f);
int x = -1, y = -1, width = 0, height = 0;
// `is_visible()`, not `get_visible()`: a widget under a hidden parent -- the trigger summary
// while the pane is in configuration mode, the Measure page while the Signals tab is showing --
// keeps its own flag true and reads 0x0, which a crop box would take for a real rectangle.
if (widget != nullptr && gtk_widget_is_visible(widget) &&
gtk_widget_compute_bounds(widget, app.window, &r)) {
x = static_cast<int>(std::lround(r.origin.x));
y = static_cast<int>(std::lround(r.origin.y));
width = static_cast<int>(std::lround(r.size.width));
height = static_cast<int>(std::lround(r.size.height));
}
if (!figures.empty()) figures.push_back(' ');
figures += std::string(surface.name) + "=" + std::to_string(x) + "," + std::to_string(y) + "," +
std::to_string(width) + "," + std::to_string(height);
}
app.dbg_help_figures = figures;
}
} // namespace ui_impl
} // namespace udpscope