Skip to content

Commit 6a536e5

Browse files
mizu-junclaudehappy-otter
authored
refactor(client): every status-bar zone measures its own width (UI/UX v3 N-7c) (#115)
Each of the five zones advanced by a character count times the cell width. That is a *character* count against a *display-width* advance, and the two agree only in Latin. `status_bar_text` and `status_bar_right_text` come from a Lua widget, so they are arbitrary user text: a Japanese one overlapped the zone beside it on the left and slid off the screen on the right. Zone 4 now takes its `x` from what `add_run_verts` reports it actually drew, so there is no second width formula left to disagree with the drawing. The three right-edge indicators and the right widget share one `right_run` shape — measure, claim off the edge, draw — so a fifth indicator cannot invent a fifth way of stacking. The call order is the stacking order now; it used to be an accident of which blocks incremented `right_offset`, the scroll indicator being the one that never did. The right edge is built before the left so its total is a budget the left side can be clamped to. Neither side had one before, so on a narrow window they simply ran into each other. The "N" is centred in its icon zone rather than padded to it with spaces. 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 a587f9e commit 6a536e5

1 file changed

Lines changed: 154 additions & 94 deletions

File tree

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

Lines changed: 154 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,78 +1059,81 @@ impl WgpuState {
10591059
bg_verts,
10601060
bg_idx,
10611061
);
1062-
add_string_verts(
1063-
" N ",
1064-
0.0,
1065-
py,
1062+
// UI/UX v3 N-7c: the status bar is chrome, so its widths are measured.
1063+
// Every zone below used to advance by a character count times the cell
1064+
// width — a *character* count against a *display-width* advance, which
1065+
// are the same number only in Latin. `status_bar_text` comes from a
1066+
// Lua widget, so it is arbitrary user text; a Japanese one overlapped
1067+
// the zone beside it on the left and slid off the screen on the right.
1068+
let ramp = nexterm_config::MetricTokens::default().type_ramp;
1069+
let (prose_style, badge_style) = (ramp.body, ramp.body_strong);
1070+
let (_, prose_line_h, _) = font.chrome_metrics(&prose_style);
1071+
let (_, badge_line_h, _) = font.chrome_metrics(&badge_style);
1072+
let prose_y = py + (cell_h - prose_line_h) * 0.5;
1073+
let badge_y = py + (cell_h - badge_line_h) * 0.5;
1074+
1075+
// The "N" is centred in the icon zone rather than padded to it with
1076+
// spaces; a space is text, and it centres nothing once measured.
1077+
let n_w = measure_run("N", &badge_style, font);
1078+
add_run_verts(
1079+
"N",
1080+
&badge_style,
1081+
(icon_zone_w - n_w) * 0.5,
1082+
badge_y,
10661083
tokens.text_on_accent,
1067-
true,
10681084
sw,
10691085
sh,
1070-
cell_w,
10711086
font,
10721087
atlas,
10731088
&self.queue,
10741089
text_verts,
10751090
text_idx,
10761091
);
10771092

1078-
// Zone 3: pane info text, starting just after the icon zone.
1079-
let pane_id = state.focused_pane_id.unwrap_or(0);
1080-
let activity_ids = state.active_pane_ids();
1081-
let pane_count = state.pane_layouts.len();
1082-
let info = if activity_ids.is_empty() {
1083-
format!(" nexterm │ pane {}/{}", pane_id, pane_count)
1084-
} else {
1085-
let ids: Vec<String> = activity_ids.iter().map(|id| id.to_string()).collect();
1086-
format!(
1087-
" nexterm │ pane {}/{} │ ●{}",
1088-
pane_id,
1089-
pane_count,
1090-
ids.join(",")
1091-
)
1092-
};
1093-
add_string_verts(
1094-
&info,
1095-
icon_zone_w,
1096-
py,
1097-
tokens.text_on(SurfaceLevel::S1).secondary,
1098-
false,
1099-
sw,
1100-
sh,
1101-
cell_w,
1102-
font,
1103-
atlas,
1104-
&self.queue,
1105-
text_verts,
1106-
text_idx,
1107-
);
1108-
1109-
// Zone 4: left widget (status_bar_text), rendered after the info block.
1110-
if !state.status_bar_text.is_empty() {
1111-
let info_w = (1 + info.chars().count()) as f32 * cell_w;
1112-
let left_x = icon_zone_w + info_w;
1113-
let left_text = format!("│ {} ", state.status_bar_text);
1114-
add_string_verts(
1115-
&left_text,
1116-
left_x,
1117-
py,
1118-
tokens.text_on(SurfaceLevel::S1).muted,
1119-
false,
1093+
// Zone 5 (right edge) is built first, because its total width is the
1094+
// budget the left side has to live within. It used to be drawn last
1095+
// and the left side had no budget at all, so the two simply ran into
1096+
// each other on a narrow window.
1097+
//
1098+
// Each indicator measures, claims its width off the right edge, then
1099+
// draws at `sw - right_offset` — one shape, four times, so a fifth
1100+
// indicator cannot invent a fifth way of stacking.
1101+
let mut right_offset = 0.0f32;
1102+
let mut right_run = |text: &str,
1103+
style: &nexterm_config::TypeStyle,
1104+
y: f32,
1105+
fg: [f32; 4],
1106+
font: &mut FontManager,
1107+
atlas: &mut GlyphAtlas,
1108+
text_verts: &mut Vec<TextVertex>,
1109+
text_idx: &mut Vec<u16>| {
1110+
right_offset += measure_run(text, style, font);
1111+
add_run_verts(
1112+
text,
1113+
style,
1114+
sw - right_offset,
1115+
y,
1116+
fg,
11201117
sw,
11211118
sh,
1122-
cell_w,
11231119
font,
11241120
atlas,
11251121
&self.queue,
11261122
text_verts,
11271123
text_idx,
11281124
);
1129-
}
1125+
};
11301126

1131-
// Zone 5 (right edge): right widget, stacked indicators.
1132-
// Source: prefer status_bar_right_text, fall back to status_bar_text when
1133-
// status_bar_text is not also being shown on the left.
1127+
// The call order below is the stacking order, outermost first: each
1128+
// indicator claims its width off the edge and the next one lands to
1129+
// its left. It reproduces what the cell path drew — widget at the
1130+
// edge, then zoom, copy mode, and the scroll position furthest in.
1131+
// That order used to be an accident of which blocks incremented
1132+
// `right_offset`; the scroll indicator was the one that never did, so
1133+
// everything else stacked inside it. It is the call order now.
1134+
1135+
// Right widget. Source: prefer status_bar_right_text, fall back to
1136+
// status_bar_text when status_bar_text is not also shown on the left.
11341137
let right_widget_src = if !state.status_bar_right_text.is_empty() {
11351138
&state.status_bar_right_text
11361139
} else if state.status_bar_text.is_empty() {
@@ -1139,47 +1142,29 @@ impl WgpuState {
11391142
// status_bar_text is already shown on the left; don't duplicate on the right.
11401143
""
11411144
};
1142-
let right_widget_src = right_widget_src.to_owned();
1143-
let mut right_offset = 0.0f32;
11441145
if !right_widget_src.is_empty() {
11451146
let widget_text = format!(" {} ", right_widget_src);
1146-
let text_w = widget_text.chars().count() as f32 * cell_w;
1147-
right_offset = text_w;
1148-
let right_px = sw - text_w;
1149-
add_string_verts(
1147+
right_run(
11501148
&widget_text,
1151-
right_px,
1152-
py,
1149+
&prose_style,
1150+
prose_y,
11531151
tokens.accent_muted,
1154-
false,
1155-
sw,
1156-
sh,
1157-
cell_w,
11581152
font,
11591153
atlas,
1160-
&self.queue,
11611154
text_verts,
11621155
text_idx,
11631156
);
11641157
}
11651158

11661159
// Zoom indicator — semantic_warning colour.
11671160
if state.is_zoomed {
1168-
let zoom_text = " [Z] ";
1169-
right_offset += zoom_text.chars().count() as f32 * cell_w;
1170-
let right_px = sw - right_offset;
1171-
add_string_verts(
1172-
zoom_text,
1173-
right_px,
1174-
py,
1161+
right_run(
1162+
" [Z] ",
1163+
&badge_style,
1164+
badge_y,
11751165
tokens.semantic_warning,
1176-
true,
1177-
sw,
1178-
sh,
1179-
cell_w,
11801166
font,
11811167
atlas,
1182-
&self.queue,
11831168
text_verts,
11841169
text_idx,
11851170
);
@@ -1193,20 +1178,13 @@ impl WgpuState {
11931178
ViMode::Visual => " VISUAL ",
11941179
ViMode::VisualLine => " V-LINE ",
11951180
};
1196-
right_offset += mode_label.chars().count() as f32 * cell_w;
1197-
let right_px = sw - right_offset;
1198-
add_string_verts(
1181+
right_run(
11991182
mode_label,
1200-
right_px,
1201-
py,
1183+
&badge_style,
1184+
badge_y,
12021185
tokens.accent_primary,
1203-
true,
1204-
sw,
1205-
sh,
1206-
cell_w,
12071186
font,
12081187
atlas,
1209-
&self.queue,
12101188
text_verts,
12111189
text_idx,
12121190
);
@@ -1217,16 +1195,70 @@ impl WgpuState {
12171195
&& pane.scroll_offset > 0
12181196
{
12191197
let scroll_text = format!(" ↑{} ", pane.scroll_offset);
1220-
let right_px = sw - scroll_text.chars().count() as f32 * cell_w - right_offset;
1221-
add_string_verts(
1198+
right_run(
12221199
&scroll_text,
1223-
right_px,
1224-
py,
1200+
&badge_style,
1201+
badge_y,
12251202
tokens.semantic_warning,
1226-
true,
1203+
font,
1204+
atlas,
1205+
text_verts,
1206+
text_idx,
1207+
);
1208+
}
1209+
1210+
// Zone 3: pane info text, starting just after the icon zone. What is
1211+
// left of the bar after the right edge has claimed its share is the
1212+
// budget for this and Zone 4 together.
1213+
let mut left_x = icon_zone_w;
1214+
let mut left_budget = (sw - icon_zone_w - right_offset - cell_w).max(0.0);
1215+
let pane_id = state.focused_pane_id.unwrap_or(0);
1216+
let activity_ids = state.active_pane_ids();
1217+
let pane_count = state.pane_layouts.len();
1218+
let info = if activity_ids.is_empty() {
1219+
format!(" nexterm │ pane {}/{}", pane_id, pane_count)
1220+
} else {
1221+
let ids: Vec<String> = activity_ids.iter().map(|id| id.to_string()).collect();
1222+
format!(
1223+
" nexterm │ pane {}/{} │ ●{}",
1224+
pane_id,
1225+
pane_count,
1226+
ids.join(",")
1227+
)
1228+
};
1229+
let info = truncate_run_to_width(&info, &prose_style, left_budget, font);
1230+
let info_w = add_run_verts(
1231+
&info,
1232+
&prose_style,
1233+
left_x,
1234+
prose_y,
1235+
tokens.text_on(SurfaceLevel::S1).secondary,
1236+
sw,
1237+
sh,
1238+
font,
1239+
atlas,
1240+
&self.queue,
1241+
text_verts,
1242+
text_idx,
1243+
);
1244+
left_x += info_w;
1245+
left_budget -= info_w;
1246+
1247+
// Zone 4: left widget (status_bar_text), rendered after the info
1248+
// block. Its x is the width the info block actually consumed, taken
1249+
// from the drawing call itself, so there is no second width formula
1250+
// that could disagree with what was drawn.
1251+
if !state.status_bar_text.is_empty() {
1252+
let left_text = format!("│ {} ", state.status_bar_text);
1253+
let left_text = truncate_run_to_width(&left_text, &prose_style, left_budget, font);
1254+
add_run_verts(
1255+
&left_text,
1256+
&prose_style,
1257+
left_x,
1258+
prose_y,
1259+
tokens.text_on(SurfaceLevel::S1).muted,
12271260
sw,
12281261
sh,
1229-
cell_w,
12301262
font,
12311263
atlas,
12321264
&self.queue,
@@ -1779,4 +1811,32 @@ mod cell_path_gate_tests {
17791811
"the InfoBar places text by a byte count again; use measure_run (N-7a)"
17801812
);
17811813
}
1814+
1815+
/// UI/UX v3 N-7c: no zone of the status bar advances by a character count.
1816+
///
1817+
/// Every zone used to: the info block handed Zone 4 its `x` as
1818+
/// `(1 + info.chars().count()) * cell_w`, and the three right-edge
1819+
/// indicators stacked by adding the same product to `right_offset`. A
1820+
/// character count and a display-width advance are the same number only
1821+
/// in Latin, and `status_bar_text` comes from a Lua widget — arbitrary
1822+
/// user text, Japanese as often as not.
1823+
#[test]
1824+
fn the_status_bar_measures_every_zone() {
1825+
let body = builder_body("build_status_verts");
1826+
assert!(
1827+
body.contains("measure_run") && body.contains("add_run_verts"),
1828+
"builder_body did not return the status-bar builder; the gates \
1829+
below would pass vacuously"
1830+
);
1831+
assert!(
1832+
!body.contains("add_string_verts"),
1833+
"the status bar draws text on the cell path again (N-7c)"
1834+
);
1835+
assert!(
1836+
!body.contains("chars().count()"),
1837+
"a status-bar zone advances by a character count again; widths \
1838+
here come from measure_run or from what add_run_verts reports it \
1839+
drew (N-7c)"
1840+
);
1841+
}
17821842
}

0 commit comments

Comments
 (0)