ui: the tab glyphs are 32x16 pairs, drawn twice as wide

Each tab's icon is now two motifs side by side instead of one in a 16px square: the Measure pill is the
two cursors beside the ruler, the Signals pill the trigger beside three channel rows. A 16px cell carries
one motif — squeezing a pair into it is what turned both into a smear — and the pill has the width for a
pair in a 300px column with two pills in it.

Two measurements decided the size, both of which I had wrong from the outside:

- **GDK paints an icon into a square box whatever the document's aspect.** `gtk_icon_theme_lookup_icon()`
  at 32 reports a `32x32` paintable for a 32x16 document, and librsvg fits it: rendered with aspect kept,
  the artwork comes out `32x16` inside a 32 request. So the pair is letterboxed, not stretched, and
  `pixel_size` 32 draws it at exactly the size it is written — 1:1, so the 2px bars stay crisp. A
  fractional scale (24 or 48) is what would blur them, which is why nothing here is drawn at one.
- **The bar does not get taller for it.** `switcher: … icon=32x32 size=308x36`: 36px is what the switcher
  measured with 16px icons too, because the pill's fixed height takes a 32px box without growing.

Both glyphs were drawn and then read back as ink maps rather than trusted: the first drafts came out at
39% and 44% ink (a blot beside the house 25–36%), and the tab bar is the last place a shape can be
checked, since no screenshot works under this X setup. The finished two measure 23% and 33%.

Verified: `switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36`;
`page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`;
`[side-tab-probe] … advanced=4`; the Preferences dialog opened in the same run with no "not present";
`make test-ui` 14 files; zero `Gtk-CRITICAL`. The icons' GResource blob is 11661 bytes, still over the
C99 limit, so the clean-build warning count stays four.
This commit is contained in:
Martino Ferrari committed 2026-10-14 13:40:09 +02:00
1 parent e615465366
commit dbe6377415
9 files changed
+114 -47

No files matched your search

+2 -2
View File
@@ -2394,8 +2394,8 @@ now — they are listed so they are not mistaken for anything else:
section was short of room, and finding one meant scrolling past the others. The built
sidebar is a **Measure** tab (the cursors readout and the measurements) and a **Signals**
tab (the trigger and the channels), **each a pill with its own glyph and no text** — the
cursors and the ruler for Measure, the trigger and the channel list for Signals — with the words
kept as each pill's tooltip and
cursors and the ruler for Measure, the trigger and the channel list for Signals, drawn as a
32x16 pair so both motifs are legible at a glance — with the words kept as each pill's tooltip and
accessible name. The design spec had the switcher showing *titles* and neither page an
`icon-name`; `narrow` alone does not do it (libadwaita still draws a label beside the icon,
measured), so the pages carry an empty title and the pills are named in code. With the math
+11 -5
View File
@@ -65,11 +65,17 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
`udpscope-tab-measure-symbolic` is the cursors and the ruler they read against, and
`udpscope-tab-signals-symbolic` is the trigger's edge above the channel list it acts on. They are not
reuses because a glyph the toolbar already carries means two things the moment a tab shows it too. Both
are 16px composites with four features each, which is about the limit of this size: the Signals list is
two rows and not three, so the edge and the list get half the cell each, and the bars in it are all one
length because unequal ones read as a bar chart, and a chart is not a list. Their ink fractions (32% and
36%) sit inside the range the icons above set, which is the check that a composite has not turned into a
blot. Beside them in
are **32x16** composites, one motif per half: the Measure pill is the two cursors beside the ruler, the
Signals pill the trigger beside the channel list. Wider than tall on purpose — a 16px square cell carries
one motif, and squeezing a pair into it is what turns both into a smear — and the pill has the width for
it, in a 300px column whose switcher has two pills in it. **GDK paints an icon into a square box whatever
the document's aspect** (measured: `gtk_icon_theme_lookup_icon()` at 32 reports `32x32`, and librsvg
fits it to `32x16`), so the pair is letterboxed, not stretched — and the pill's image is given
`pixel_size` 32 in `name_side_tabs()`, which draws the artwork at exactly the size it is written, so its
2px bars stay crisp. A fractional scale is what blurs them, which is why nothing here is drawn at 24 or
48. The bars in the Signals list are all one length, because unequal ones read as a bar chart and a chart
is not a list; their ink fractions (23% and 33%) sit inside the range the icons above set, which is the
check that a composite has not turned into a blot. Beside them in
`src/icons/scalable/apps/` are the two
**application** icons, `org.udpscope.app.svg` (128px colour artwork) and
`org.udpscope.app-symbolic.svg` (16px). The `apps` context is where an icon theme keeps an
+11 -2
View File
@@ -357,13 +357,22 @@ Two lines of the self-check output are assertions in disguise:
substitutes a missing-image placeholder, the button still works, and every other line of the
dump still passes. Changing an icon name is a one-line change in `build_toolbar()` and this
is the only check that it names something that exists.
- `switcher: policy=narrow pills=2 labels=0 names=2` (in the exit dump, beside `panels:`) — the two
- `switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36` (in the exit dump, beside
`panels:`) — the two
sidebar tabs as the switcher actually draws them. **`pills=2 labels=0`** is the claim: two pills are on
screen and **no text is drawn** (`labels` counts *mapped labels with text*, because `narrow` keeps an
empty label in the pill's layout and a plain mapped count would read 2 either way). `names=2` is the
other half — both pills carry a tooltip and an accessible name set by `name_side_tabs()`, so an
icon-only pill is not an anonymous button — and `pills=0` means nothing is mapped, which makes the
rest of the line meaningless rather than a pass. `policy=narrow` is read rather than assumed.
rest of the line meaningless rather than a pass. `policy=narrow` is read rather than assumed. `icon=`
is the box the pills' `GtkImage` is *rendered* in, which proves the 32px override stuck: the tab
glyphs are 32x16 documents, and GDK paints an icon into a square box whatever the document's aspect,
so the artwork is drawn 32x16 at 1:1 inside that 32x32 box — letterboxed, not stretched, which is what
keeps its 2px bars crisp. It reads `0x0` on the first tick, where nothing has been allocated yet, and
is therefore read by the exit probe with the rest of the sidebar's geometry. `size=` is the bar's own
size, and it is the answer to "what did the wider glyphs cost": `36` is what it was with 16px icons,
because the pill's fixed height takes a 32px box without growing — the glyph gets twice the width for
no vertical room at all.
- `page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)` (in the exit
dump, beside `toolbar icons:`) — each tab's own glyph and `gtk_icon_theme_has_icon()`'s verdict, read
on the first tick for the same reason the toolbar's are. **Both must read `(ok)`**: the page titles are
+1 -1
View File
@@ -16,7 +16,7 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
Two baselines every run is read against. A clean build emits **four** warnings and no more: two
`-Wmissing-field-initializers` on `MathNode::source` and `-Woverlength-strings` on **both**
generated GResource `.c` files — the help pages' (**514660** bytes) and the icons', whose blob is
**11309** against C99's 4095-byte string-literal limit with the seven action icons and the two app icons
**11661** against C99's 4095-byte string-literal limit with the seven action icons and the two app icons
that remain (a note about a generated file, which GCC itself supports) -- re-measured when
the tab work changed the set (that icon left, the two tab glyphs arrived, and this file's
help page was rewritten), which did not move the count; re-measure by touching each `*.gresource.xml`
@@ -1,23 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- The Measure tab: the cursors and the ruler they read against, in one glyph. Two markers, each a
cap over a dashed shaft, stand across a ruler: the ruler is two bars with three hanging ticks.
The ruler's ends are open on purpose. A closed frame puts four more pixels of ink at the left
and right edges, which at this size is what turns a ruler into a rectangle. The shafts stop at
the bars rather than crossing them, the way a marker on a plot sits over the scale. Whole
pixels, filled `path`s wound the same way, and an ink fraction of 36%, in line with the ruler
and the cursors it is made of. -->
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" d="M3 1 H7 V3 H3 Z
M9 1 H13 V3 H9 Z
M4 3 H6 V6 H4 Z
M10 3 H12 V6 H10 Z
M4 14 H6 V16 H4 Z
M10 14 H12 V16 H10 Z
M2 6 H14 V8 H2 Z
M2 12 H14 V14 H2 Z
M4 8 H5 V11 H4 Z
M8 8 H9 V10 H8 Z
M12 8 H13 V11 H12 Z"/>
<!-- The Measure tab, as two motifs side by side in a 32x16 cell: the two cursors on the left (a cap
over a dashed shaft, the shape the toolbar's cursors button has) and the ruler they read against
on the right. The ruler is a closed frame with its ticks hanging inside it, which needs
`fill-rule="evenodd"`: the ticks sit inside the frame's hole, three crossings deep, so they fill
rather than punching a second hole. The cursors do not overlap the ruler, so one evenodd path
carries both without either cancelling. Rendered at 32, i.e. 1:1, so the 2px bars stay crisp,
and every coordinate is a whole pixel of the cell. As with the Signals glyph, GDK's box is
square whatever the document's aspect: this pair is letterboxed, not stretched. -->
<svg height="16px" width="32px" viewBox="0 0 32 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" fill-rule="evenodd" d="M1 1 H7 V4 H1 Z
M3 5 H6 V8 H3 Z
M3 9 H6 V12 H3 Z
M3 13 H6 V16 H3 Z
M9 1 H15 V4 H9 Z
M11 5 H14 V8 H11 Z
M11 9 H14 V12 H11 Z
M11 13 H14 V16 H11 Z
M17 4 H31 V12 H17 Z
M19 6 H29 V10 H19 Z
M21 6 H22 V9 H21 Z
M25 6 H26 V9 H25 Z
M28 6 H29 V8 H28 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,20 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- The Signals tab: the trigger *and* the channel list it acts on, in one glyph. A rising edge
crossing a dashed level is the trigger as the toolbar already draws it; below it, two rows of a
colour chip and a name are the channel list, which is what the page mostly is. Two rows and not
three, so the edge and the list each get half the cell: at 16px a third row costs the edge its
clearance and the whole thing turns into a grid. Whole pixels, filled `path`s wound the same
way, and the 2px bars match the house stroke weight. -->
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" d="M6 1 H8 V6 H6 Z
M6 1 H15 V3 H6 Z
M0 5 H2 V7 H0 Z
M3 5 H5 V7 H3 Z
M12 5 H14 V7 H12 Z
M1 9 H4 V12 H1 Z
M6 10 H14 V12 H6 Z
M1 13 H4 V15 H1 Z
M6 13 H14 V15 H6 Z"/>
<!-- The Signals tab, as two motifs side by side in a 32x16 cell: the trigger on the left (a rising
edge crossing a dashed level, the shape the toolbar's trigger button has) and three rows of a
colour chip and a name on the right, which is the channel list the tab mostly is. Wider than
tall because it has to hold *two* motifs: a 16px square cell can carry one, and squeezing the
pair into it is what turns them into a smear. The pill renders this at 32, i.e. 1:1, so the 2px
bars stay crisp; a fractional scale would blur them, which is why every coordinate is a whole
pixel. GDK paints an icon into a *square* box whatever the document's aspect, so this pair is
letterboxed in a 32x32 box rather than stretched (measured: a 32x16 render inside a 32 request).
All subpaths are filled `path`s wound the same way. -->
<svg height="16px" width="32px" viewBox="0 0 32 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" d="M4 2 H6 V12 H4 Z
M6 2 H14 V4 H6 Z
M0 7 H2 V9 H0 Z
M8 7 H10 V9 H8 Z
M12 7 H14 V9 H12 Z
M18 2 H21 V5 H18 Z
M23 3 H30 V5 H23 Z
M18 7 H21 V10 H18 Z
M23 8 H30 V10 H23 Z
M18 12 H21 V15 H18 Z
M23 13 H30 V15 H23 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+16 -2
View File
@@ -2903,6 +2903,17 @@ void name_side_tabs(App& app) {
}
};
hide_labels(pill);
// The tabs' glyphs are 32x16 documents, and the pill has the width for them: drawn at 32 they are
// 1:1, so both motifs sit side by side at the size they were authored at and the 2px bars stay
// crisp. A 16px box would scale the pair down to a smear, and a fractional scale would blur it.
const std::function<void(GtkWidget*)> size_images = [&](GtkWidget* w) {
if (GTK_IS_IMAGE(w)) gtk_image_set_pixel_size(GTK_IMAGE(w), 32);
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
size_images(c);
}
};
size_images(pill);
app.side_tabs_named = true;
}
}
@@ -5674,9 +5685,12 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
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",
std::printf("[udpscope] switcher: policy=%s pills=%d labels=%d names=%d icon=%dx%d "
"size=%dx%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);
app.dbg_switcher_pills, app.dbg_switcher_labels, app.dbg_switcher_names,
app.dbg_switcher_icon_w, app.dbg_switcher_icon_h, app.dbg_switcher_w,
app.dbg_switcher_h);
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",
+8
View File
@@ -219,6 +219,14 @@ struct App {
int dbg_switcher_pills = -1;
int dbg_switcher_labels = -1;
int dbg_switcher_names = -1;
/// The pills' icon as it is *rendered*: a 32x16 glyph in a 32px box is drawn 1:1, which is what
/// keeps its 2px bars crisp.
int dbg_switcher_icon_w = -1;
int dbg_switcher_icon_h = -1;
/// The switcher's own size, beside it: the pills carry 32px icon boxes now, so the tab bar's height
/// is what it costs.
int dbg_switcher_w = -1;
int dbg_switcher_h = -1;
int dbg_switcher_policy = -1;
/// Whether `name_side_tabs()` has named the pills: it can only run once the switcher is mapped.
bool side_tabs_named = false;
+22
View File
@@ -78,6 +78,28 @@ gboolean exit_probe_step3(gpointer user) {
a->dbg_signal_h = gtk_widget_get_height(a->signal_panel);
a->dbg_trigger_w = gtk_widget_get_width(a->trigger_panel);
a->dbg_trigger_h = gtk_widget_get_height(a->trigger_panel);
// The switcher's icon as it is *rendered*: a 32x16 glyph in a 32px box draws 1:1, which is what
// keeps its 2px bars crisp rather than blurred by a fractional scale. Read here and not on the
// first tick, where the window has not been allocated yet and every widget still measures 0x0.
if (a->side_switcher != nullptr) {
a->dbg_switcher_w = gtk_widget_get_width(a->side_switcher);
a->dbg_switcher_h = gtk_widget_get_height(a->side_switcher);
}
for (GtkWidget* pill = a->side_switcher != nullptr ? gtk_widget_get_first_child(a->side_switcher)
: nullptr;
pill != nullptr; pill = gtk_widget_get_next_sibling(pill)) {
const std::function<void(GtkWidget*)> measure_icon = [&](GtkWidget* node) {
if (GTK_IS_IMAGE(node) && gtk_widget_get_mapped(node)) {
a->dbg_switcher_icon_w = std::max(a->dbg_switcher_icon_w, gtk_widget_get_width(node));
a->dbg_switcher_icon_h = std::max(a->dbg_switcher_icon_h, gtk_widget_get_height(node));
}
for (GtkWidget* c = gtk_widget_get_first_child(node); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
measure_icon(c);
}
};
measure_icon(pill);
}
// The height of one signal row, which is the thing "the channel toggle is too tall"
// was about. The panel's own height cannot stand in for it: it also covers the
// heading, the hint and the box margins.