// 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 #include #include #include #include #include #include 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(view.plan.size())) return nullptr; return &view.plan[static_cast(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), "%s", 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(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(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(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(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(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 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 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(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(std::floor(bounds.origin.x)) - 3; const int y0 = static_cast(std::floor(bounds.origin.y)) - 3; const int x1 = static_cast(std::ceil(bounds.origin.x + bounds.size.width)) + 3; const int y1 = static_cast(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(std::ceil(card_bounds.size.width)); ch = static_cast(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(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 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(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(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(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(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(std::lround(r.origin.x)); y = static_cast(std::lround(r.origin.y)); width = static_cast(std::lround(r.size.width)); height = static_cast(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