Skip to content

Commit eba9abe

Browse files
mizu-junclaudehappy-otter
committed
fix(client): the info bar's budget and its cut share a unit (UI/UX v3 N-7a)
The InfoBar sized its message with a column budget and then spent it in characters: `max_chars` came from `sw / cell_w`, and the message was cut with `chars().take(max_chars)`. Those are the same number only in Latin. All three bar kinds get their text from `fl!` — update, offline and error — so every non-Latin locale bought twice the room it was allotted and ran under the `[Esc]` hint at the right edge. The budget is now pixels on both sides: `measure_run` for the hint and the overflow count, `truncate_run_to_width` for the message. The `…` that appears on a cut is new; the old truncation was silent. The hint's own `x` came off `hint.len()`, a byte count that was correct only because `[Esc]` is ASCII and untranslated. It is measured now, so that stops being load-bearing. Both draws move to `add_run_verts`, which puts the bar's text on the chrome ramp — body for the message, caption for the key hint — and takes its line box from `chrome_metrics` instead of `cell_h`. The gate is per builder, not per file: `ui_verts.rs` still draws on the cell path on purpose in `build_quick_select_verts`, whose labels sit on real terminal cells. A file-wide check would need an exemption list, and those grow. 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 8d5feae commit eba9abe

1 file changed

Lines changed: 89 additions & 22 deletions

File tree

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

Lines changed: 89 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use crate::glyph_atlas::{BgVertex, GlyphAtlas, TextVertex};
88
use crate::state::ClientState;
99
use crate::vertex_util::{
1010
add_icon_verts, add_px_rect, add_px_rounded_rect_sdf, add_run_verts, add_string_verts,
11-
icon_size_for_slot,
11+
icon_size_for_slot, measure_run, truncate_run_to_width,
1212
};
1313

1414
use super::WgpuState;
@@ -1378,7 +1378,16 @@ impl WgpuState {
13781378
bg_verts,
13791379
bg_idx,
13801380
);
1381-
let baseline_y = rect.y + (rect.h - cell_h) * 0.5;
1381+
// UI/UX v3 N-7a: the bar's text is chrome, so it takes its size
1382+
// from the ramp and its line box from `chrome_metrics` rather than
1383+
// from `cell_h`. The message is body; the key hint is caption,
1384+
// which is what the ramp reserves for exactly that.
1385+
let ramp = nexterm_config::MetricTokens::default().type_ramp;
1386+
let (label_style, hint_style) = (ramp.body, ramp.caption);
1387+
let (_, label_line_h, _) = font.chrome_metrics(&label_style);
1388+
let (_, hint_line_h, _) = font.chrome_metrics(&hint_style);
1389+
let label_y = rect.y + (rect.h - label_line_h) * 0.5;
1390+
let hint_y = rect.y + (rect.h - hint_line_h) * 0.5;
13821391

13831392
// The hint is drawn only for a bar `Esc` can actually dismiss, so
13841393
// the offline bar no longer offers a key that does nothing.
@@ -1403,29 +1412,30 @@ impl WgpuState {
14031412
// property of the text, not of the kind. The count is part of the
14041413
// budget rather than appended after it, so a long message cannot
14051414
// push it off the edge.
1406-
let max_chars = ((sw / cell_w) as usize)
1407-
.saturating_sub(hint.chars().count() + more.chars().count() + 4)
1408-
.max(8);
1409-
let label: String = bar
1410-
.kind
1411-
.label(now)
1412-
.chars()
1413-
.take(max_chars)
1414-
.chain(more.chars())
1415-
.collect();
1416-
add_string_verts(
1415+
//
1416+
// N-7a: the budget and the cut are now in the same unit. It used
1417+
// to divide a pixel width by `cell_w` to get a column budget and
1418+
// then spend it in *characters*, so every translated message —
1419+
// `label` is `fl!`-backed for all three kinds — bought twice the
1420+
// room it was allotted in Japanese and slid under the hint.
1421+
let text_x = cell_w * 1.2;
1422+
let hint_w = measure_run(hint, &hint_style, font);
1423+
let more_w = measure_run(&more, &label_style, font);
1424+
let budget = (sw - text_x - hint_w - more_w - cell_w).max(0.0);
1425+
let mut label = truncate_run_to_width(&bar.kind.label(now), &label_style, budget, font);
1426+
label.push_str(&more);
1427+
add_run_verts(
14171428
&label,
1418-
cell_w * 1.2,
1419-
baseline_y,
1429+
&label_style,
1430+
text_x,
1431+
label_y,
14201432
nexterm_config::contrast_correct(
14211433
tokens.text_on(SurfaceLevel::S2).primary,
14221434
banner_bg,
14231435
nexterm_config::MIN_TEXT_CONTRAST,
14241436
),
1425-
false,
14261437
sw,
14271438
sh,
1428-
cell_w,
14291439
font,
14301440
atlas,
14311441
&self.queue,
@@ -1434,20 +1444,23 @@ impl WgpuState {
14341444
);
14351445

14361446
if !hint.is_empty() {
1437-
let hint_x = sw - hint.len() as f32 * cell_w - cell_w;
1438-
add_string_verts(
1447+
// Right-aligned off the measured width, not off `hint.len()` —
1448+
// that was a *byte* count, correct only because the string is
1449+
// ASCII. `[Esc]` is untranslated today; the measurement holds
1450+
// if that stops being true.
1451+
let hint_x = sw - hint_w - cell_w;
1452+
add_run_verts(
14391453
hint,
1454+
&hint_style,
14401455
hint_x,
1441-
baseline_y,
1456+
hint_y,
14421457
nexterm_config::contrast_correct(
14431458
tokens.text_on(SurfaceLevel::S2).muted,
14441459
banner_bg,
14451460
nexterm_config::MIN_TEXT_CONTRAST,
14461461
),
1447-
false,
14481462
sw,
14491463
sh,
1450-
cell_w,
14511464
font,
14521465
atlas,
14531466
&self.queue,
@@ -1713,3 +1726,57 @@ mod progress_indicator_tests {
17131726
}
17141727
}
17151728
}
1729+
1730+
#[cfg(test)]
1731+
mod cell_path_gate_tests {
1732+
/// One `pub(super) fn` builder's source, from its signature to the next
1733+
/// builder's.
1734+
///
1735+
/// The gates below are per *builder* rather than per file, because
1736+
/// `ui_verts.rs` is the one chrome module that still draws on the cell
1737+
/// path on purpose — see [`super::Renderer::build_quick_select_verts`],
1738+
/// whose labels sit on real terminal cells at `m.col_start * cell_w` and
1739+
/// must keep advancing by `cell_w`. A file-wide `!contains` would either
1740+
/// fail on that or have to exempt it by name, and an exemption list is the
1741+
/// thing that quietly grows.
1742+
fn builder_body(name: &str) -> &'static str {
1743+
include_str!("ui_verts.rs")
1744+
.split(" pub(super) fn ")
1745+
.find(|seg| seg.starts_with(name))
1746+
.unwrap_or_else(|| panic!("{name} is no longer a `pub(super) fn` in ui_verts.rs"))
1747+
}
1748+
1749+
/// UI/UX v3 N-7a: the InfoBar's budget and its cut are in the same unit.
1750+
///
1751+
/// It used to divide a pixel width by `cell_w` for a column budget and
1752+
/// then spend that budget in `chars().take(..)`. All three bar kinds get
1753+
/// their message from `fl!`, so every non-Latin locale bought twice the
1754+
/// room it was allotted and ran under the `[Esc]` hint. The hint's own `x`
1755+
/// came off `hint.len()` — a byte count that happened to be right only
1756+
/// because the string is ASCII.
1757+
#[test]
1758+
fn the_info_bar_measures_its_text_instead_of_counting_cells() {
1759+
let body = builder_body("build_info_bar_verts");
1760+
// Prove the extraction found a real body before trusting any
1761+
// `!contains` below it — a segment cut short would pass all three.
1762+
assert!(
1763+
body.contains("truncate_run_to_width") && body.contains("add_run_verts"),
1764+
"builder_body did not return the InfoBar builder; the gates below \
1765+
would pass vacuously"
1766+
);
1767+
assert!(
1768+
!body.contains("add_string_verts"),
1769+
"the InfoBar draws text on the cell path again; its text is chrome \
1770+
and belongs on the ramp (N-7a)"
1771+
);
1772+
assert!(
1773+
!body.contains("sw / cell_w"),
1774+
"the InfoBar budgets its text in columns again; the budget and the \
1775+
truncation must share a unit, which is pixels (N-7a)"
1776+
);
1777+
assert!(
1778+
!body.contains(".len() as f32 * cell_w"),
1779+
"the InfoBar places text by a byte count again; use measure_run (N-7a)"
1780+
);
1781+
}
1782+
}

0 commit comments

Comments
 (0)