Skip to content

Commit 09b63af

Browse files
mizu-junclaudehappy-otter
authored
feat(client): the width rule for a tab, measured rather than counted (UI/UX v3 N-3a) (#101)
First of the three N-3 PRs, and deliberately the one with no call sites: the tab bar still counts characters after this, so the width rule can be reviewed apart from the migration that adopts it. `renderer/tab_layout.rs` holds two functions. `tab_width` measures the label through `measure_run` — the same function `truncate_run_to_width` and `add_run_verts` use — so the width a tab is sized by and the width its label is drawn at come from one place and cannot disagree, whatever a font reports for any particular glyph. `fit_tab_width` is pure and does the rest: padding on both sides, a clamp to the room left in the strip, and the floor. The floor is the §7 decision the maintainer signed off today: a measured ellipsis plus its padding, replacing `label_w < cell_w * 2.0` — a threshold with no meaning once text stops being measured in cells. The second decision, that a zero-advance glyph is left to occupy no space rather than being given a substituted width, needed no code: it is what `chrome_advance` already does. The tests are shaped by what CI can actually observe. `G-width` is asserted as an equality between the sizing path and the drawing path rather than as a claim about CJK metrics, because the devcontainer's font stack answers the same advance for every character (spec §6) and a metric-based test would have passed for the wrong reason. 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 a93088d commit 09b63af

4 files changed

Lines changed: 227 additions & 23 deletions

File tree

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

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# N-3 — Tab-bar labels on the chrome type ramp (design spec)
22

3-
Status: **draft 2026-08-30**measured, not yet approved
3+
Status: **approved 2026-08-30**§7 signed off by the maintainer; N-3a shipped
44
Date: 2026-08-30
55
Parent plan: [`ui-ux-modernization-v3.md`](./ui-ux-modernization-v3.md) § P4
66
Predecessor: [`2026-08-29-p4-iconography-and-chrome-typography.md`](./2026-08-29-p4-iconography-and-chrome-typography.md) §§ 5.2, 8
@@ -236,7 +236,7 @@ though it looks tautological: today it fails.
236236

237237
| PR | Scope | Gate |
238238
|---|---|---|
239-
| **N-3a** | `fit_tab_width` + the measured `tab_width`, with the pure tests. No call sites; the tab bar still counts characters. | G-fit |
239+
| **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 |
240240
| **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. | G-width, G-hit, G-single, G-truncate |
241241
| **N-3c** | The process icon becomes its own run (D3). | G-icon |
242242

@@ -264,22 +264,24 @@ like and where every tab click lands.
264264

265265
---
266266

267-
## 7. Open questions
268-
269-
Two, both for the maintainer:
270-
271-
1. **Minimum tab width.** The loop stops drawing at `label_w < cell_w * 2.0`, a
272-
cell-derived threshold. Measured text has no natural cell, so the floor
273-
becomes either a fixed pixel minimum or "enough room for the ellipsis plus
274-
padding". The second is self-describing; the first is easier to reason about
275-
on a HiDPI display. Recommendation: the ellipsis rule, since it is the same
276-
rule `truncate_run_to_width` already applies inside a budget.
277-
2. **What should a zero-advance glyph do?** Now that §1.3 has been checked, the
278-
activity dot cannot overflowmeasurement and drawing agree by
279-
construction. The remaining case is a font with no `●`: `chrome_advance`
280-
returns 0 deliberately, so the dot would occupy no space and draw on top of
281-
the character beside it. The choice is between leaving that (the dot is a
282-
hint, and its absence is survivable) and substituting a minimum advance when
283-
a glyph measures zero. Recommendation: leave it, and revisit if a real font
284-
stack is ever seen to drop ita substituted width would put a guessed
285-
number back into the one formula this phase exists to make honest.
267+
## 7. Decisions taken (signed off 2026-08-30)
268+
269+
Both questions this spec opened are settled; no implementation PR is gated on
270+
further input. The reasoning is kept because a future change proposing the
271+
opposite should have to answer it.
272+
273+
1. **Minimum tab widththe ellipsis rule.** The loop used to stop at
274+
`label_w < cell_w * 2.0`, a threshold with no meaning once text stops being
275+
measured in cells. The floor is now a measured ellipsis plus the padding
276+
either side (`tab_layout::min_tab_width`): a tab too narrow to say "there was
277+
more text here" is not worth the strip space. Chosen over a fixed pixel
278+
minimum because it describes itself and cannot be falsified by a HiDPI
279+
display, and because it is the rule `truncate_run_to_width` already applies
280+
inside a budget.
281+
2. **A zero-advance glyph is left alone.** Once §1.3 was checked, the activity
282+
dot turned out not to be able to overflow: measurement and drawing agree by
283+
construction. The remaining case is a font with no `●`, where
284+
`chrome_advance` returns 0 deliberately and the dot occupies no space. That
285+
stands: the dot is a hint, its absence is survivable, and substituting a
286+
width would put a guessed number back into the one formula this phase exists
287+
to make honest. Revisit only if a real font stack is *seen* to drop it.

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -728,8 +728,9 @@ gated behind a spike.
728728
label clears 4.5:1 by construction. The invented purple is dropped for
729729
`accent_primary`; the SSH green becomes `semantic_success` (spec §8.4,
730730
2026-08-30)
731-
- [ ] N-3 tab-bar labels — spec drafted 2026-08-30
732-
(`2026-08-30-n3-tab-bar-labels.md`), awaiting sign-off. Measuring found
731+
- [ ] N-3 tab-bar labels — spec approved 2026-08-30
732+
(`2026-08-30-n3-tab-bar-labels.md`), §7 signed off; N-3a shipped
733+
(`renderer/tab_layout.rs`), N-3b/N-3c open. Measuring found
733734
the roadmap's framing wrong in both directions: the hit computation
734735
does **not** need rebuilding (the renderer already publishes
735736
`tab_hit_rects` every frame), and there is a live defect underneath —

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ use crate::state::{ContextMenu, CopyModeState, SearchState};
3434
// ---- Vertex-builder submodules (Sprint 2-1 Phase A) ----
3535
// Sprint 5-4 / A2: overlay_verts.rs (1,958 lines) was further split into the overlay/ subdirectory.
3636
mod grid_verts;
37+
// UI/UX v3 N-3a: how wide a tab is. Ships ahead of its call sites so the
38+
// width rule can be reviewed apart from the tab-bar migration that adopts it.
3739
pub(crate) mod overlay;
40+
mod tab_layout;
3841
mod ui_verts;
3942

4043
// ---- In-app acrylic (UI/UX v3 P2b) ----
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
//! How wide a tab is (UI/UX v3 N-3a).
2+
//!
3+
//! The tab bar sizes a tab, records its click region, places its close and
4+
//! tear-out buttons, and scales its accent underline and progress bar from a
5+
//! single number. Until N-3 that number was `label.chars().count() * cell_w` —
6+
//! a count of *characters*, while `add_string_verts` advanced by *display
7+
//! width*. The two disagree by a factor of two for CJK, so a Japanese tab title
8+
//! drew past its own pill and the overhanging half of the label belonged, for
9+
//! click purposes, to the next tab.
10+
//!
11+
//! This module is where that number comes from now: [`tab_width`] measures the
12+
//! label the same way the drawing pass will, and [`fit_tab_width`] — pure, so
13+
//! the clamp and the floor are testable without a device — decides whether what
14+
//! is left of the strip can hold it.
15+
//!
16+
//! N-3a ships the two functions and their tests; the tab bar adopts them in
17+
//! N-3b, which is when the seven consumers of the width start following a
18+
//! correct one.
19+
// Every item here is called for the first time in N-3b, when
20+
// `ui_verts::build_tab_bar_verts` stops counting characters. The allowance is
21+
// this module's whole reason for shipping early, and it goes with that change —
22+
// P4b's identical markers outlived their adoption and had to be cleaned up in
23+
// P4c, so this one names the PR that removes it.
24+
#![allow(dead_code)]
25+
26+
use crate::font::FontManager;
27+
use crate::vertex_util::measure_run;
28+
29+
/// The glyph a truncated label ends with.
30+
///
31+
/// A tab must always have room for this plus its padding, which is the floor
32+
/// the maintainer signed off on 2026-08-30 (spec §7): a tab too narrow to show
33+
/// even "there was more text here" is not worth the strip space, and the rule
34+
/// describes itself instead of naming a pixel count that a HiDPI display would
35+
/// falsify.
36+
pub(crate) const ELLIPSIS: &str = "…";
37+
38+
/// Narrowest tab worth drawing: an ellipsis plus the padding either side.
39+
///
40+
/// Measured rather than assumed, for the same reason everything else in N-3 is:
41+
/// the ellipsis is a glyph like any other, and on a font that lacks it the
42+
/// advance is zero — in which case the floor is just the padding, and a tab
43+
/// that small still draws nothing legible but costs nothing either.
44+
pub(crate) fn min_tab_width(
45+
style: &nexterm_config::TypeStyle,
46+
padding: f32,
47+
font: &mut FontManager,
48+
) -> f32 {
49+
measure_run(ELLIPSIS, style, font) + padding * 2.0
50+
}
51+
52+
/// Fit a tab of `content_w` into the room left in the strip.
53+
///
54+
/// Returns the width to draw, or `None` when the strip cannot hold even
55+
/// `min_w` — the caller stops laying out tabs at that point, which is what the
56+
/// old `label_w < cell_w * 2.0` break did.
57+
///
58+
/// Pure: the caller measures. `content_w` is the measured label (plus any icon
59+
/// drawn beside it), `padding` is applied once on each side, and the result is
60+
/// clamped to `room_left` so a tab never spills out of the strip — the clamp
61+
/// the character-count formula also had, and the only part of it that was
62+
/// right.
63+
pub(crate) fn fit_tab_width(
64+
content_w: f32,
65+
padding: f32,
66+
room_left: f32,
67+
min_w: f32,
68+
) -> Option<f32> {
69+
let floor = min_w.max(0.0);
70+
if room_left < floor || room_left <= 0.0 {
71+
return None;
72+
}
73+
let wanted = content_w.max(0.0) + padding * 2.0;
74+
Some(wanted.clamp(floor, room_left))
75+
}
76+
77+
/// Width of one tab: its label measured at `style`, plus `icon_w` for a
78+
/// process icon drawn beside it, fitted to the room left.
79+
///
80+
/// The measurement goes through [`measure_run`], the same function
81+
/// `truncate_run_to_width` and `add_run_verts` use, so the width a tab is sized
82+
/// by and the width its label is drawn at cannot disagree — whatever the font
83+
/// reports for any particular glyph (spec §4, `G-width`).
84+
pub(crate) fn tab_width(
85+
label: &str,
86+
style: &nexterm_config::TypeStyle,
87+
icon_w: f32,
88+
padding: f32,
89+
room_left: f32,
90+
font: &mut FontManager,
91+
) -> Option<f32> {
92+
let content_w = measure_run(label, style, font) + icon_w.max(0.0);
93+
let min_w = min_tab_width(style, padding, font);
94+
fit_tab_width(content_w, padding, room_left, min_w)
95+
}
96+
97+
#[cfg(test)]
98+
mod tests {
99+
use super::*;
100+
101+
const PADDING: f32 = 10.0;
102+
const MIN: f32 = 25.0;
103+
104+
#[test]
105+
fn a_tab_is_its_content_plus_padding_on_both_sides() {
106+
assert_eq!(fit_tab_width(100.0, PADDING, 1000.0, MIN), Some(120.0));
107+
}
108+
109+
/// The clamp the character-count formula also had — the one part of it that
110+
/// was right. A tab never spills past the end of the strip.
111+
#[test]
112+
fn a_tab_never_exceeds_the_room_left() {
113+
assert_eq!(fit_tab_width(500.0, PADDING, 200.0, MIN), Some(200.0));
114+
}
115+
116+
/// The floor, as signed off in spec §7: a strip that cannot hold an
117+
/// ellipsis and its padding holds no more tabs. This is what replaces
118+
/// `label_w < cell_w * 2.0`, which had no meaning once text stopped being
119+
/// measured in cells.
120+
#[test]
121+
fn a_strip_too_narrow_for_the_floor_draws_no_further_tab() {
122+
assert_eq!(fit_tab_width(100.0, PADDING, MIN - 0.5, MIN), None);
123+
assert_eq!(fit_tab_width(100.0, PADDING, MIN, MIN), Some(MIN));
124+
}
125+
126+
/// A tab whose content is narrower than the floor still gets the floor, so
127+
/// a one-character title cannot produce a pill too small to click.
128+
#[test]
129+
fn a_tiny_label_is_widened_to_the_floor() {
130+
assert_eq!(fit_tab_width(0.0, 1.0, 1000.0, MIN), Some(MIN));
131+
}
132+
133+
/// Degenerate inputs must not produce a negative or reversed rect: a
134+
/// collapsed strip draws nothing, and a negative content width is treated
135+
/// as empty rather than eating the padding.
136+
#[test]
137+
fn degenerate_inputs_are_refused_rather_than_inverted() {
138+
assert_eq!(fit_tab_width(100.0, PADDING, 0.0, MIN), None);
139+
assert_eq!(fit_tab_width(100.0, PADDING, -10.0, MIN), None);
140+
assert_eq!(fit_tab_width(-100.0, PADDING, 1000.0, 0.0), Some(20.0));
141+
}
142+
143+
/// The floor is measured, not assumed. This runs on whatever font stack CI
144+
/// has — which answers the same advance for every character (spec §6) — so
145+
/// it asserts the shape of the rule, not a number: the floor is the
146+
/// padding plus something non-negative, and more padding moves it.
147+
#[test]
148+
fn the_floor_is_the_measured_ellipsis_plus_padding() {
149+
let mut font = FontManager::new("monospace", 14.0, &[], 1.0, true);
150+
let style = nexterm_config::MetricTokens::default().type_ramp.body;
151+
152+
let narrow = min_tab_width(&style, 4.0, &mut font);
153+
let wide = min_tab_width(&style, 12.0, &mut font);
154+
155+
assert!(narrow >= 8.0, "the floor includes its padding: {narrow}");
156+
assert!(
157+
(wide - narrow - 16.0).abs() < 1e-3,
158+
"extra padding must move the floor by exactly twice: {narrow} → {wide}"
159+
);
160+
}
161+
162+
/// G-width in miniature: the width a tab is *sized* by comes from the same
163+
/// `measure_run` the drawing pass uses, so the two cannot disagree. Phrased
164+
/// as an equality between paths rather than as a claim about CJK metrics —
165+
/// CI's font stack has no real CJK face (spec §6), so the latter would pass
166+
/// for the wrong reason.
167+
#[test]
168+
fn a_tabs_width_contains_the_run_that_will_be_drawn_in_it() {
169+
let mut font = FontManager::new("monospace", 14.0, &[], 1.0, true);
170+
let style = nexterm_config::MetricTokens::default().type_ramp.body;
171+
172+
for label in ["pane:1", "ビルド", "a very long tab title indeed"] {
173+
let drawn = measure_run(label, &style, &mut font);
174+
let width = tab_width(label, &style, 0.0, PADDING, 10_000.0, &mut font)
175+
.expect("a wide strip fits any tab");
176+
assert!(
177+
width >= drawn,
178+
"{label:?} is drawn {drawn} wide in a {width}-wide tab"
179+
);
180+
}
181+
}
182+
183+
/// A process icon widens the tab it sits in — the icon is drawn beside the
184+
/// label (spec D3), so its width has to be in the tab's.
185+
#[test]
186+
fn a_process_icon_widens_its_tab() {
187+
let mut font = FontManager::new("monospace", 14.0, &[], 1.0, true);
188+
let style = nexterm_config::MetricTokens::default().type_ramp.body;
189+
190+
let bare = tab_width("build", &style, 0.0, PADDING, 10_000.0, &mut font);
191+
let with_icon = tab_width("build", &style, 16.0, PADDING, 10_000.0, &mut font);
192+
assert_eq!(
193+
with_icon.zip(bare).map(|(a, b)| a - b),
194+
Some(16.0),
195+
"the icon's width lands in the tab's, unrounded"
196+
);
197+
}
198+
}

0 commit comments

Comments
 (0)