ui: the tabs are icons, and the pills keep their names

Each sidebar page gets its own glyph and no visible text. The Measure tab reuses the ruler
(`udpscope-measure-symbolic`, the icon its deleted toolbar toggle used and the Plot preferences page
still carries); the Signals tab gets a new `udpscope-signals-symbolic` — three rows of a colour chip
and a name, which is what that tab mostly is — rather than a second copy of the trigger glyph, since
one shape meaning two things in one window is worse than a new one. Its bars are all one length: a
set of unequal ones reads as a bar chart, and a chart is not a list.

`AdwViewSwitcherPolicy.narrow` alone does **not** give an icon-only bar. Measured on libadwaita 1.10
by walking the switcher's tree: with `narrow` and a title, each pill holds a `GtkImage` *and* a
`GtkLabel` — 48px of "Measure" inside a 54px pill. So the pages' `title` is **empty** (their `name` is
what identifies them to `select_side_tab()` and to the session file, and both are unchanged) and the
words are re-attached in code: `name_side_tabs()` runs on the first tick — the pills are
`AdwViewSwitcher`'s own and exist only once it is mapped — and gives each pill a **tooltip and an
accessible name** from the page's name, then hides the empty label that would otherwise leave the
icon off-centre. An icon-only pill is still a named control for a screen reader, and the page icon
that does not resolve leaves a *blank* pill, which is why it is read like the toolbar's icons are.

Two readings, because the claim is two claims:

    switcher: policy=narrow pills=2 labels=0 names=2
    page icons: measure=udpscope-measure-symbolic(ok) signals=udpscope-signals-symbolic(ok)

`labels` counts *mapped labels with text* — a plain mapped count reads 2 either way, because the
narrow pill keeps an empty label in its layout — and `pills=2` is the guard that makes it mean
something: with nothing mapped the rest of the line is void rather than a pass.

Docs: the icon census (six action icons, eight bundled, and what each of the two new users is), the
switcher bullet in `invariants-gtk-and-chrome.md` with the measurement above, the two new dump lines
in `probe-readings.md`, the re-measured GResource figures in `verification.md` (icons 8613 -> 9842
bytes, help 514660 after this session's help rewrite; the count stays four), and the README recording
that the design spec had the switcher showing titles and neither page an icon.

Verified: `make test-ui` 14 files; `[side-tab-probe] … advanced=4` (tab switching by name still
works with empty titles); `signals panel: … divider=1`; `[math-probe] 3 math channels`; zero
`Gtk-CRITICAL` and no missing icon.
This commit is contained in:
Martino Ferrari committed 2026-10-14 13:29:11 +02:00
1 parent f9cea4885b
commit 0b146f1d53
9 files changed
+201 -16

No files matched your search

+109 -2
View File
@@ -2865,6 +2865,48 @@ std::string icon_report(GtkIconTheme* theme, const char* name, const char* resou
/// and Reset is inside the cursors pane. Recorded on the first tick -- the widgets exist and the
/// window is alive, neither of which is true when the dump runs -- so the dump can print a fact
/// instead of asking GTK about a destroyed widget.
/// The words the pills do not draw. The pages' `title` is empty on purpose (see the `.ui`), so the
/// human name of a tab lives here and is put on the pill — its tooltip and its accessible name — by
/// `name_side_tabs()`. That keeps an icon-only pill a *named* control for a screen reader.
static const char* side_tab_title(const char* name) {
if (name != nullptr && std::strcmp(name, "measure") == 0) return "Measure";
if (name != nullptr && std::strcmp(name, "signals") == 0) return "Signals";
return "";
}
/// Names the switcher's pills and takes their empty labels out of the layout: an empty `GtkLabel`
/// still asks for its own width, which leaves the icon off-centre in a pill meant to be one glyph.
/// Called from the first tick, because the pills are `AdwViewSwitcher`'s own and exist only once the
/// widget has been mapped.
void name_side_tabs(App& app) {
if (app.side_tabs_named || app.side_switcher == nullptr) return;
GListModel* pages = app.side_stack != nullptr
? G_LIST_MODEL(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack)))
: nullptr;
// The switcher's children *are* its pills, in page order, and `AdwViewSwitcherButton` is not a
// public type (there is no `ADW_IS_VIEW_SWITCHER_BUTTON`), so the two lists are walked together by
// index rather than checked by type.
GtkWidget* pill = gtk_widget_get_first_child(app.side_switcher);
for (guint i = 0; pages != nullptr && pill != nullptr && i < g_list_model_get_n_items(pages);
pill = gtk_widget_get_next_sibling(pill), ++i) {
AdwViewStackPage* page = ADW_VIEW_STACK_PAGE(g_list_model_get_item(pages, i));
const char* title = side_tab_title(adw_view_stack_page_get_name(page));
g_object_unref(page);
if (title[0] == '\0') continue;
gtk_widget_set_tooltip_text(pill, title);
gtk_accessible_update_property(GTK_ACCESSIBLE(pill), GTK_ACCESSIBLE_PROPERTY_LABEL, title, -1);
const std::function<void(GtkWidget*)> hide_labels = [&](GtkWidget* w) {
if (GTK_IS_LABEL(w)) gtk_widget_set_visible(w, FALSE);
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
hide_labels(c);
}
};
hide_labels(pill);
app.side_tabs_named = true;
}
}
void note_panel_controls(App& app) {
if (!app.dbg_panel_controls.empty()) return;
const auto in_bar = [](GtkWidget* w) {
@@ -2941,6 +2983,62 @@ void note_panel_controls(App& app) {
" " +
icon_report(theme, "org.udpscope.app-symbolic",
"/org/udpscope/icons/scalable/apps/org.udpscope.app-symbolic.svg");
// The two sidebar pages' own icons, named and resolved the same way, and what the switcher is
// drawing for them. The pills are `AdwViewSwitcher`'s own buttons, built when the stack is mapped,
// so this reads on the first tick: `pills` is the guard (0 pills would make a label count of 0
// meaningless) and `labels` must be 0, because the switcher's policy is `narrow` -- the pages are
// icons, not words, in a 300px column.
// Walked through `get_pages()`, not the widget tree: an `AdwViewStackPage` is a plain `GObject`
// (only its `child` is a widget), so `gtk_widget_get_first_child()` on the stack yields the pages'
// scrolled windows and this line read empty the first time it was written.
GListModel* pages = app.side_stack != nullptr
? G_LIST_MODEL(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack)))
: nullptr;
for (guint i = 0; pages != nullptr && i < g_list_model_get_n_items(pages); ++i) {
AdwViewStackPage* page = ADW_VIEW_STACK_PAGE(g_list_model_get_item(pages, i));
const char* page_name = adw_view_stack_page_get_name(page);
const char* icon = adw_view_stack_page_get_icon_name(page);
app.dbg_page_icons += std::string(app.dbg_page_icons.empty() ? "" : " ") +
(page_name != nullptr ? page_name : "?") + "=" +
(icon != nullptr ? icon : "-") +
(icon != nullptr && gtk_icon_theme_has_icon(theme, icon) ? "(ok)"
: "(MISSING)");
g_object_unref(page);
}
// `mapped`, not `visible`: visibility is per widget, so a label whose *parent* is hidden still
// reads visible, and in the narrow policy that is exactly the case. `mapped` requires every
// ancestor, which is what "on screen" means -- and it is why the pill count is read the same way:
// if nothing is mapped the pill count reads 0 and the label count means nothing, rather than
// passing vacuously.
int pills = 0;
int drawn_labels = 0;
const std::function<void(GtkWidget*)> count_drawn = [&](GtkWidget* w) {
if (GTK_IS_TOGGLE_BUTTON(w) && gtk_widget_get_mapped(w)) ++pills;
// A *mapped* label with text: libadwaita's narrow pill keeps an empty label in the layout, so
// "mapped" alone would count the empty ones and read 2 whether the pills were icons or words.
if (GTK_IS_LABEL(w) && gtk_widget_get_mapped(w) && gtk_label_get_text(GTK_LABEL(w))[0] != '\0') {
++drawn_labels;
}
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
count_drawn(c);
}
};
if (app.side_switcher != nullptr) count_drawn(app.side_switcher);
app.dbg_switcher_pills = pills;
app.dbg_switcher_labels = drawn_labels;
int named_pills = 0;
for (GtkWidget* w = app.side_switcher != nullptr ? gtk_widget_get_first_child(app.side_switcher)
: nullptr;
w != nullptr; w = gtk_widget_get_next_sibling(w)) {
const char* tip = gtk_widget_get_tooltip_text(w);
if (tip != nullptr && tip[0] != '\0') ++named_pills;
}
app.dbg_switcher_names = named_pills;
if (app.side_switcher != nullptr) {
g_object_get(G_OBJECT(app.side_switcher), "policy", &app.dbg_switcher_policy, nullptr);
}
}
/// The event-zoom sweep's two halves: a gesture zoom, then the same walk through the window
@@ -3128,6 +3226,11 @@ void update_capture_row(App& app) {
gboolean tick(gpointer user) {
App* app = static_cast<App*>(user);
if (app->window == nullptr) return G_SOURCE_REMOVE;
// Before the reading, and not after it: the pills are `AdwViewSwitcher`'s own widgets and exist
// only once the switcher is mapped, so this is the first frame that can name them -- and their
// page titles are deliberately empty, so a frame that read before this ran would read pills with
// no text and no name either.
name_side_tabs(*app);
note_panel_controls(*app);
ScopeModel& model = *app->model;
const gint64 tick_start_us = g_get_monotonic_time();
@@ -5563,14 +5666,17 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
inside(app.events_list, app.events_revealer));
}
std::printf("[udpscope] panels: side=%dx%d (visible=%d) signals=%dx%d row_h=%d "
"(page_current=%d) trigger=%dx%d (sensitive=%d) switcher=%d cursors=%d window=%.3fs "
"window_cap=%.1fs\n",
"(page_current=%d) trigger=%dx%d (sensitive=%d) switcher=%d "
"cursors=%d window=%.3fs window_cap=%.1fs\n",
app.dbg_side_w, app.dbg_side_h, app.dbg_side_column_after_hide, app.dbg_signal_w,
app.dbg_signal_h, app.dbg_signal_row_h, app.dbg_signal_page_current,
app.dbg_trigger_w, app.dbg_trigger_h,
app.dbg_trigger_sensitive, app.dbg_switcher_visible,
rows.cursors_visible() ? 1 : 0, rows.time_window(),
app.window_limit_s);
std::printf("[udpscope] switcher: policy=%s pills=%d labels=%d names=%d\n",
app.dbg_switcher_policy == 0 ? "narrow" : (app.dbg_switcher_policy == 1 ? "wide" : "?"),
app.dbg_switcher_pills, app.dbg_switcher_labels, app.dbg_switcher_names);
std::printf("[udpscope] mode with trigger on: %s\n", app.dbg_mode_triggered.c_str());
std::printf("[udpscope] trigger marker x=%.3f, view x_max=%.3f; model trigger_time=%.3f "
"valid=%d\n",
@@ -5692,6 +5798,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
// critical (which is how the first version of this line earned four of them).
std::printf("[udpscope] panel controls: %s\n", app.dbg_panel_controls.c_str());
std::printf("[udpscope] toolbar icons: %s\n", app.dbg_toolbar_icons.c_str());
std::printf("[udpscope] page icons: %s\n", app.dbg_page_icons.c_str());
std::printf("[udpscope] app icons: %s\n", app.dbg_app_icons.c_str());
// The measurements pane, with the numbers the engine computed: a probe adds measurements and
// this is where their values are read, so a wrong threshold or a window that moved under a