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 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** 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 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 cursors and the ruler for Measure, the trigger and the channel list for Signals, drawn as a
kept as each pill's tooltip and 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 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, `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 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-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 `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 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 are **32x16** composites, one motif per half: the Measure pill is the two cursors beside the ruler, the
two rows and not three, so the edge and the list get half the cell each, and the bars in it are all one Signals pill the trigger beside the channel list. Wider than tall on purpose — a 16px square cell carries
length because unequal ones read as a bar chart, and a chart is not a list. Their ink fractions (32% and one motif, and squeezing a pair into it is what turns both into a smear — and the pill has the width for
36%) sit inside the range the icons above set, which is the check that a composite has not turned into a it, in a 300px column whose switcher has two pills in it. **GDK paints an icon into a square box whatever
blot. Beside them in 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 `src/icons/scalable/apps/` are the two
**application** icons, `org.udpscope.app.svg` (128px colour artwork) and **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 `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 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 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. 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 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 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 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 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 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 - `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 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 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 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** `-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 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 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 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` 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"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT --> <!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari --> <!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- The Measure tab: the cursors and the ruler they read against, in one glyph. Two markers, each a <!-- The Measure tab, as two motifs side by side in a 32x16 cell: the two cursors on the left (a cap
cap over a dashed shaft, stand across a ruler: the ruler is two bars with three hanging ticks. over a dashed shaft, the shape the toolbar's cursors button has) and the ruler they read against
The ruler's ends are open on purpose. A closed frame puts four more pixels of ink at the left on the right. The ruler is a closed frame with its ticks hanging inside it, which needs
and right edges, which at this size is what turns a ruler into a rectangle. The shafts stop at `fill-rule="evenodd"`: the ticks sit inside the frame's hole, three crossings deep, so they fill
the bars rather than crossing them, the way a marker on a plot sits over the scale. Whole rather than punching a second hole. The cursors do not overlap the ruler, so one evenodd path
pixels, filled `path`s wound the same way, and an ink fraction of 36%, in line with the ruler carries both without either cancelling. Rendered at 32, i.e. 1:1, so the 2px bars stay crisp,
and the cursors it is made of. --> and every coordinate is a whole pixel of the cell. As with the Signals glyph, GDK's box is
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"> square whatever the document's aspect: this pair is letterboxed, not stretched. -->
<path fill="#2e3436" d="M3 1 H7 V3 H3 Z <svg height="16px" width="32px" viewBox="0 0 32 16" xmlns="http://www.w3.org/2000/svg">
M9 1 H13 V3 H9 Z <path fill="#2e3436" fill-rule="evenodd" d="M1 1 H7 V4 H1 Z
M4 3 H6 V6 H4 Z M3 5 H6 V8 H3 Z
M10 3 H12 V6 H10 Z M3 9 H6 V12 H3 Z
M4 14 H6 V16 H4 Z M3 13 H6 V16 H3 Z
M10 14 H12 V16 H10 Z M9 1 H15 V4 H9 Z
M2 6 H14 V8 H2 Z M11 5 H14 V8 H11 Z
M2 12 H14 V14 H2 Z M11 9 H14 V12 H11 Z
M4 8 H5 V11 H4 Z M11 13 H14 V16 H11 Z
M8 8 H9 V10 H8 Z M17 4 H31 V12 H17 Z
M12 8 H13 V11 H12 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> </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"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT --> <!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari --> <!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- The Signals tab: the trigger *and* the channel list it acts on, in one glyph. A rising edge <!-- The Signals tab, as two motifs side by side in a 32x16 cell: the trigger on the left (a rising
crossing a dashed level is the trigger as the toolbar already draws it; below it, two rows of a edge crossing a dashed level, the shape the toolbar's trigger button has) and three rows of a
colour chip and a name are the channel list, which is what the page mostly is. Two rows and not colour chip and a name on the right, which is the channel list the tab mostly is. Wider than
three, so the edge and the list each get half the cell: at 16px a third row costs the edge its tall because it has to hold *two* motifs: a 16px square cell can carry one, and squeezing the
clearance and the whole thing turns into a grid. Whole pixels, filled `path`s wound the same pair into it is what turns them into a smear. The pill renders this at 32, i.e. 1:1, so the 2px
way, and the 2px bars match the house stroke weight. --> bars stay crisp; a fractional scale would blur them, which is why every coordinate is a whole
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"> pixel. GDK paints an icon into a *square* box whatever the document's aspect, so this pair is
<path fill="#2e3436" d="M6 1 H8 V6 H6 Z letterboxed in a 32x32 box rather than stretched (measured: a 32x16 render inside a 32 request).
M6 1 H15 V3 H6 Z All subpaths are filled `path`s wound the same way. -->
M0 5 H2 V7 H0 Z <svg height="16px" width="32px" viewBox="0 0 32 16" xmlns="http://www.w3.org/2000/svg">
M3 5 H5 V7 H3 Z <path fill="#2e3436" d="M4 2 H6 V12 H4 Z
M12 5 H14 V7 H12 Z M6 2 H14 V4 H6 Z
M1 9 H4 V12 H1 Z M0 7 H2 V9 H0 Z
M6 10 H14 V12 H6 Z M8 7 H10 V9 H8 Z
M1 13 H4 V15 H1 Z M12 7 H14 V9 H12 Z
M6 13 H14 V15 H6 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> </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); 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; 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, app.dbg_trigger_sensitive, app.dbg_switcher_visible,
rows.cursors_visible() ? 1 : 0, rows.time_window(), rows.cursors_visible() ? 1 : 0, rows.time_window(),
app.window_limit_s); 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_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] 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 " std::printf("[udpscope] trigger marker x=%.3f, view x_max=%.3f; model trigger_time=%.3f "
"valid=%d\n", "valid=%d\n",
+8
View File
@@ -219,6 +219,14 @@ struct App {
int dbg_switcher_pills = -1; int dbg_switcher_pills = -1;
int dbg_switcher_labels = -1; int dbg_switcher_labels = -1;
int dbg_switcher_names = -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; int dbg_switcher_policy = -1;
/// Whether `name_side_tabs()` has named the pills: it can only run once the switcher is mapped. /// Whether `name_side_tabs()` has named the pills: it can only run once the switcher is mapped.
bool side_tabs_named = false; 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_signal_h = gtk_widget_get_height(a->signal_panel);
a->dbg_trigger_w = gtk_widget_get_width(a->trigger_panel); a->dbg_trigger_w = gtk_widget_get_width(a->trigger_panel);
a->dbg_trigger_h = gtk_widget_get_height(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" // 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 // was about. The panel's own height cannot stand in for it: it also covers the
// heading, the hint and the box margins. // heading, the hint and the box margins.