Skip to content

Commit ffa7d9c

Browse files
mizu-junclaudehappy-otter
authored
fix(client): the tab's process icon leaves the label, and stays on the cell path (UI/UX v3 N-3c) (#103)
Last of the three N-3 PRs. The Nerd Font glyph for the foreground process was prepended to the label string, so after N-3b it was measured and drawn as part of a chrome run. `rasterize_chrome_char` boxes a glyph to its advance without cropping to ink, and Nerd Font icons commonly overhang, so it drew the icon into a box that could cut it. It is now drawn beside the label, in a slot the tab reserves for it through `tab_width`'s `icon_w` parameter — the one N-3a shipped and tested. The spec's D3 said to draw it "through the icon path, which crops". Checking that before building on it: `icons.rs` documents that the bundled Fluent subset's codepoints "live in the Private Use Area, which overlaps the Nerd Font range `tab_icons.rs` uses", and that they are only safe to draw through `FontRole::Icon` precisely so the two sets cannot resolve against each other. Asking the icon path for a process glyph would have silently returned a Fluent icon at that codepoint — a wrong glyph rather than a clipped one. So the icon stays on the cell path, which is where it has always come from: it resolves against the user's terminal font and boxes a glyph to a whole cell rather than to its advance, which is the clipping fix. D3 is corrected in the spec with the evidence. A test pins that the process glyph is not routed through the chrome icon path. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Happy <yesreply@happy.engineering>
1 parent ce11a34 commit ffa7d9c

4 files changed

Lines changed: 100 additions & 35 deletions

File tree

docs/plans/2026-08-30-n3-tab-bar-labels.md

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -145,23 +145,37 @@ The budget is the room left in the tab area, and truncation goes through
145145
`truncate_run_to_width`, so the drawn label provably fits the pill it is
146146
measured into. This is what closes §1.2 for long titles as well as CJK ones.
147147

148-
### D3 — The process icon is its own run, for clipping, not for width
149-
150-
The Nerd Font glyph leaves the label string and is drawn as a separate run at a
151-
measured offset. **The reason is clipping, not measurement** (§1.3): the chrome
152-
rasteriser boxes a glyph to its advance without cropping to ink, and a Nerd Font
153-
icon commonly overhangs. Drawing it through the icon path, which crops, keeps it
154-
whole.
155-
156-
Width is *not* a reason: chrome and terminal share a family and a fallback
157-
chain, and measurement and drawing share `chrome_advance`, so an icon left in
158-
the label would be measured exactly as wide as it is drawn — merely, perhaps,
159-
clipped.
148+
### D3 — The process icon leaves the label and stays on the *cell* path
149+
150+
The Nerd Font glyph is drawn beside the label, in a cell-wide slot reserved
151+
before it, rather than prepended to the label string.
152+
153+
**The reason is clipping, not measurement** (§1.3): the chrome rasteriser boxes
154+
a glyph to its advance without cropping to ink, and a Nerd Font icon commonly
155+
overhangs. Width is *not* a reason — chrome and terminal share a family and a
156+
fallback chain, and measurement and drawing share `chrome_advance`, so an icon
157+
left in the label would be measured exactly as wide as it is drawn, merely
158+
perhaps clipped.
159+
160+
**And it must not go through the chrome icon path.** An earlier draft of this
161+
decision said to draw it "through the icon path, which crops". That would be a
162+
worse bug than the one it fixes: `icons.rs` states that the bundled Fluent
163+
subset's codepoints "live in the Private Use Area, which overlaps the Nerd Font
164+
range `tab_icons.rs` uses", and that they are only safe to draw through
165+
`FontRole::Icon` precisely so the two sets cannot resolve against each other.
166+
Asking the icon path for a process glyph would silently return a *Fluent* icon
167+
at that codepoint.
168+
169+
The cell path is where the glyph has always come from, it resolves against the
170+
user's terminal font, and it boxes a glyph to a whole cell instead of to its
171+
advance — which is the clipping fix. The tab reserves
172+
`visual_width(glyph) * cell_w` for it through `tab_width`'s `icon_w` parameter,
173+
so the label cannot be drawn over it.
160174

161175
The `[N]` prefix and the activity dot stay inside the label. The dot carries the
162176
same non-risk: a missing glyph measures **zero** (`font.rs:339` keeps it at zero
163177
deliberately), which costs the dot its space rather than overflowing the tab.
164-
§7 asks what to do in that case.
178+
§7 records what was decided about that.
165179

166180
### D4 — Tabs stay content-sized
167181

@@ -238,7 +252,7 @@ though it looks tautological: today it fails.
238252
|---|---|---|
239253
| **N-3a** | `fit_tab_width` + the measured `tab_width`, with the pure tests. No call sites; the tab bar still counts characters. **Shipped 2026-08-30** as `renderer/tab_layout.rs`. | G-fit |
240254
| **N-3b** | Adopt both in `build_tab_bar_verts`: measured width, width-budget truncation, ramp step, `line_h` centring. The seven consumers follow the corrected number. **Shipped 2026-08-30.** | G-width, G-hit, G-single, G-truncate |
241-
| **N-3c** | The process icon becomes its own run (D3). | G-icon |
255+
| **N-3c** | The process icon becomes its own run (D3). **Shipped 2026-08-30**, on the cell path rather than the icon pathsee D3. | G-icon |
242256

243257
N-3b is the risk concentration: it is the PR that changes what every tab looks
244258
like and where every tab click lands.

docs/plans/ui-ux-modernization-v3.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -732,8 +732,10 @@ gated behind a spike.
732732
(`2026-08-30-n3-tab-bar-labels.md`), §7 signed off; N-3a shipped
733733
(`renderer/tab_layout.rs`) and N-3b with it — tabs are measured, cut to
734734
the room the strip has, and drawn at the ramp, so the CJK overflow and
735-
the click region that belonged to the next tab are both fixed. N-3c
736-
(the process icon as its own run, for clipping) is open. Measuring found
735+
the click region that belonged to the next tab are both fixed. N-3c shipped
736+
with them: the process icon is drawn beside the label on the cell path,
737+
because the bundled chrome-icon subset shares its Private Use Area and
738+
the icon path would resolve a Fluent glyph in its place (spec D3). Measuring found
737739
the roadmap's framing wrong in both directions: the hit computation
738740
does **not** need rebuilding (the renderer already publishes
739741
`tab_hit_rects` every frame), and there is a live defect underneath —

nexterm-client-gpu/src/renderer/tab_layout.rs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,8 +227,29 @@ mod tests {
227227
);
228228
}
229229

230-
/// A process icon widens the tab it sits in — the icon is drawn beside the
231-
/// label (spec D3), so its width has to be in the tab's.
230+
/// N-3c: the process glyph is a Nerd Font codepoint from the *user's*
231+
/// terminal font, and `icons.rs` documents that the bundled chrome-icon
232+
/// subset occupies the same Private Use Area. Drawing it through the icon
233+
/// path would therefore resolve a Fluent icon in its place — a silently
234+
/// wrong glyph rather than a missing one.
235+
#[test]
236+
fn the_process_icon_is_not_drawn_through_the_chrome_icon_path() {
237+
let src = include_str!("ui_verts.rs");
238+
let region = tab_region(src);
239+
assert!(
240+
region.contains("glyph_for_process"),
241+
"the tab bar still draws a process icon"
242+
);
243+
assert!(
244+
!region.contains("add_icon_verts(\n glyph"),
245+
"the process glyph is drawn through the chrome icon path, which \
246+
resolves the bundled Fluent subset over the same PUA range"
247+
);
248+
}
249+
250+
/// A process icon widens the tab it sits in — since N-3c it is drawn
251+
/// beside the label in a cell-wide slot rather than prepended to it, so
252+
/// its width has to be in the tab's or the label would be drawn over it.
232253
#[test]
233254
fn a_process_icon_widens_its_tab() {
234255
let mut font = FontManager::new("monospace", 14.0, &[], 1.0, true);

nexterm-client-gpu/src/renderer/ui_verts.rs

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -342,24 +342,34 @@ impl WgpuState {
342342
} else {
343343
raw_title.clone()
344344
};
345-
// Phase 2c: prepend the Nerd Font glyph when (a) the user
346-
// opted in via `tab_bar.show_process_icon` and (b) the
347-
// glyph map has an entry for the current foreground
348-
// process. Unknown processes render the label as before
349-
// (no fallback glyph — the absence is signal).
350-
let iconified = if cfg.show_process_icon
351-
&& let Some(name) = process_name.as_deref()
352-
&& let Some(glyph) = crate::tab_icons::glyph_for_process(name)
353-
{
354-
format!("{} {}", glyph, base_label)
345+
// Phase 2c: the Nerd Font glyph for the foreground process, when
346+
// (a) the user opted in via `tab_bar.show_process_icon` and (b) the
347+
// glyph map has an entry for it. Unknown processes get nothing —
348+
// no fallback glyph, because the absence is signal.
349+
//
350+
// N-3c: it is drawn beside the label rather than prepended to it,
351+
// and on the *cell* path. It is a Nerd Font codepoint from the
352+
// user's terminal font, and `icons.rs` warns that the bundled
353+
// chrome-icon subset occupies the same Private Use Area — so
354+
// drawing it through the icon path would resolve a Fluent icon in
355+
// its place. The cell path is where it has always come from, and it
356+
// boxes a glyph to a whole cell instead of to its advance, which is
357+
// what keeps an overhanging icon from being clipped.
358+
let process_glyph = if cfg.show_process_icon {
359+
process_name
360+
.as_deref()
361+
.and_then(crate::tab_icons::glyph_for_process)
355362
} else {
356-
base_label
363+
None
357364
};
365+
let icon_w = process_glyph
366+
.map(|glyph| crate::vertex_util::visual_width(glyph) as f32 * cell_w)
367+
.unwrap_or(0.0);
358368
// Tab number prefix (Windows Terminal style): prepends `[N]` when the option is on
359369
let numbered = if cfg.show_tab_number {
360-
format!("[{}] {}", i + 1, iconified)
370+
format!("[{}] {}", i + 1, base_label)
361371
} else {
362-
iconified
372+
base_label
363373
};
364374
// The cell path wrapped every label in spaces because `padding`
365375
// alone did not read as padding at cell precision. It does now, and
@@ -385,11 +395,11 @@ impl WgpuState {
385395
let label = crate::vertex_util::truncate_run_to_width(
386396
&label,
387397
&tab_style,
388-
(room_left - padding * 2.0).max(0.0),
398+
(room_left - padding * 2.0 - icon_w).max(0.0),
389399
font,
390400
);
391401
let Some(label_w) = crate::renderer::tab_layout::tab_width(
392-
&label, &tab_style, 0.0, padding, room_left, font,
402+
&label, &tab_style, icon_w, padding, room_left, font,
393403
) else {
394404
break; // no more room to draw additional tabs
395405
};
@@ -491,12 +501,30 @@ impl WgpuState {
491501
);
492502
}
493503

494-
// Tab label (vertically centred on the run's own line box).
504+
// Tab label (vertically centred on the run's own line box), with
505+
// the process icon in the cell-wide slot reserved before it.
495506
let fg = if is_active { text_fg } else { inactive_fg };
507+
if let Some(glyph) = process_glyph {
508+
add_string_verts(
509+
glyph,
510+
x_offset + padding,
511+
text_y,
512+
fg,
513+
false,
514+
sw,
515+
sh,
516+
cell_w,
517+
font,
518+
atlas,
519+
&self.queue,
520+
text_verts,
521+
text_idx,
522+
);
523+
}
496524
add_run_verts(
497525
&label,
498526
&tab_style,
499-
x_offset + padding,
527+
x_offset + padding + icon_w,
500528
tab_text_y,
501529
fg,
502530
sw,

0 commit comments

Comments
 (0)