Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
548 lines
27 KiB
C++
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
|