Skip to content

docs: N-3 tab-bar label design spec (and the defect measuring it turned up) - #100

Merged
mizu-jun merged 2 commits into
masterfrom
n3-tab-label-spec
Aug 29, 2026
Merged

docs: N-3 tab-bar label design spec (and the defect measuring it turned up)#100
mizu-jun merged 2 commits into
masterfrom
n3-tab-label-spec

Conversation

@mizu-jun

Copy link
Copy Markdown
Owner

Docs only — no code. Requesting sign-off on the two open questions in §7 before any implementation PR.

Why the spec exists

P4b excluded tab-bar labels with one line — "requires rebuilding tab width and hit computation off measured text" — and P4e repeated it. Measuring the tab bar first, as P4c–P4f each did, moves the work in both directions.

1. The stated blocker is smaller than it reads

The hit computation is already downstream of the width. tab_hit_rects, tab_close_hit_rects and tab_tearout_hit_rects are published by the renderer into ClientState every frame (ui_verts.rs) and only read by mouse.rs:422, 503, 1396. There is no mirrored formula to keep in step — the P4c footer-link problem does not exist here. Correcting the width corrects all seven of its consumers at once.

2. There is a live defect underneath it

A tab is sized by character count:

let label_w = (label.chars().count() as f32 * cell_w + padding * 2.0)...

and drawn by display width (add_string_verts advances via UnicodeWidthChar, so a full-width char takes two cells).

Title chars().count() drawn tab
build 5 5 cells 5 cells + padding
ビルド 3 6 cells 3 cells + padding

Today, with no ramp involved:

  • the label draws past its own pill, over the neighbouring tab;
  • tab_hit_rects records the narrow width, so clicking the text you are reading activates the next pane;
  • the close and tear-out buttons are placed from the narrow edge and land inside the drawn text.

The 24-character truncation cap has the same bug — 24 chars is 24 cells of Latin or 48 of Japanese.

This is not a regression the ramp would introduce; it is a defect the ramp forces into the open, because once glyphs advance by their own measured width no character count could ever be right.

Decisions in the spec

  • D1 measure the label, never count it — one function, seven consumers unchanged.
  • D2 truncate to a width budget via truncate_run_to_width, not to 24 characters.
  • D3 the Nerd Font process icon leaves the label string and is drawn as its own run: it resolves through the terminal font's fallback chain while measure_run asks the chrome face, and a glyph present in one and absent in the other measures wrong in exactly the direction that overflows.
  • D4 tabs stay content-sized. Equal-width tabs and a scrolling strip (WT's model) are a layout redesign with their own hit-region, drag and overflow questions; folding them in would make a visual regression unattributable.

Six gates (G-width, G-fit, G-hit, G-single, G-truncate, G-icon) and a three-PR breakdown (N-3a pure width + tests → N-3b adopt in the builder → N-3c the icon run). G-hit looks tautological and is worth writing precisely because it fails today.

Open questions — §7, for the maintainer

  1. Minimum tab width. The draw loop stops at label_w < cell_w * 2.0. Measured text has no natural cell, so the floor becomes either a fixed pixel minimum or "room for the ellipsis plus padding". Spec recommends the ellipsis rule (same rule truncate_run_to_width already applies).
  2. Does the activity dot stay inside the label? It is the same font-fallback question D3 moves the process icon out of the string to avoid. More common glyph, so it probably measures — but "probably measures" is what the CJK defect is made of. Spec recommends measuring it once on a real font stack before deciding.

🤖 Generated with Claude Code

…ed up

P4b excluded tab-bar labels with one line — "requires rebuilding tab width and
hit computation off measured text" — and P4e repeated it. Measuring the tab bar
first changes the shape of the work in both directions.

The stated blocker is smaller than it reads: the hit computation is already
downstream of the width. `tab_hit_rects`, `tab_close_hit_rects` and
`tab_tearout_hit_rects` are published by the renderer into `ClientState` every
frame and only read by `mouse.rs`, so there is no mirrored formula to keep in
step — the P4c footer-link problem does not exist here.

Underneath it is a defect that has nothing to do with typography: a tab is
sized by `label.chars().count() * cell_w` and drawn by `UnicodeWidthChar`, so a
full-width character takes two cells of pill it was never given. A Japanese
title overflows its own tab, its click region is the narrow one, and the close
and tear-out buttons land inside the drawn text.

The spec carries the measurement, four decisions (measure don't count; truncate
to a width budget; the Nerd Font process icon leaves the label string because
it resolves through the terminal font's fallback chain rather than the chrome
face; tabs stay content-sized — equal-width tabs and a scrolling strip are a
layout redesign, not a typography change), six gates and a three-PR breakdown.

Two open questions are left for the maintainer rather than assumed: the minimum
tab width once there is no natural cell, and whether the `●` activity dot is
safe to keep inside a chrome-measured run.

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>
@github-actions

Copy link
Copy Markdown

Coverage report


Generated by cargo llvm-cov (workspace minus nexterm-client-gpu and nexterm-i18n).

…e a font

Checking the claim before anyone builds on it: the spec said a Nerd Font glyph
"resolves through the terminal font's fallback chain" while `measure_run` asks
"the chrome face". There is no such split. `FontManager::chrome_attrs` builds
from `self.family` out of the same `FontSystem` as `rasterize_char`; chrome and
terminal differ only in size and weight, which is what P4b §5.3 already said.

More decisively, `measure_run` and `add_run_verts` both consult
`chrome_advance`, so whatever a font answers is used for the measurement and
the draw alike — a run cannot overflow its own measurement.

What survives is narrower and still worth D3: `rasterize_chrome_char` boxes a
glyph to its advance without cropping to ink, so a Nerd Font icon that overhangs
is clipped on the chrome path. D3 is rescoped to clipping, and the activity dot
question in §7 becomes "what should a zero-advance glyph do", which is the only
failure the shared-measurement argument leaves open.

`G-width` is rephrased with it. A probe in the devcontainer returns the same
advance (8.43 px at 14 px) for `A`, `あ`, `●`, `↗` and two Nerd Font private-use
codepoints — one substituting face answering for everything — so a gate that
assumed double-width CJK would have passed vacuously in CI. The gate now asserts
that the sizing path and the drawing path agree, which holds whatever the font
reports. §6 records the probe.

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>
@github-actions

Copy link
Copy Markdown

Coverage report


Generated by cargo llvm-cov (workspace minus nexterm-client-gpu and nexterm-i18n).

@mizu-jun
mizu-jun merged commit a93088d into master Aug 29, 2026
13 checks passed
@mizu-jun
mizu-jun deleted the n3-tab-label-spec branch August 29, 2026 23:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant