Skip to content

Commit b532f10

Browse files
authored
fix(client): text wraps to a measured width, not a column count (UI/UX v3 N-6b) (#110)
`wrap_text(s, max_cols)` は表示列数で折り返していた。列で折り返して正しい のはセルサイズで描く場合だけだが、**3 つの呼び出し元すべてが `add_run_verts` で ramp step 描画している**。つまりどの呼び出しも、実際に 描く幅とは無関係な予算に対して折り返していた。 `wrap_run(s, style, max_w_px, font)` に置き換える。advance は描画パスと 同じ `chrome_advance` から取るため、返る行が予算を超えることは原理的に ない(1 文字が予算より広い場合を除く。これ以上は分割できない)。 呼び出し元 3 箇所: - dialog.rs: consent preview の `56` は 60 桁パネルから推測した列数リテラル だった → パネル幅そのものを渡す - dialog.rs: close-window message の `(pw - cell_w*2)/cell_w` は px を列に 変換していた → px のまま渡す - settings/row.rs: `draw_description_rows` の `max_cols: usize` を `max_w: f32` に変更。呼び出し元 security_tab.rs も同様 単語境界ではなく文字境界で折る点は列版と同じ。単語折り返しは別の変更で、 呼び出し側の見え方が変わる。 Spec: docs/plans/2026-08-30-n4-menus-and-dialogs.md §8(settings 残件の②)
1 parent 1eb83dd commit b532f10

4 files changed

Lines changed: 124 additions & 31 deletions

File tree

nexterm-client-gpu/src/renderer/overlay/dialog.rs

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use crate::vertex_util::{add_px_rect, add_run_verts, measure_run};
1212
use super::super::WgpuState;
1313
use super::util::{
1414
SCRIM_ALPHA_FLOOR, caution_fill, danger_fill, draw_overlay_panel, pane_id_for, preview_text,
15-
scrim_color, wrap_text,
15+
scrim_color, wrap_run,
1616
};
1717
use nexterm_config::SurfaceLevel;
1818

@@ -450,9 +450,12 @@ impl WgpuState {
450450
content_y += cell_h * 1.3;
451451
}
452452

453-
// Payload preview (up to 2 lines, 56 chars each)
453+
// Payload preview (up to 2 lines). The budget is the panel's own
454+
// width now, measured at the step the preview is drawn at — the old
455+
// literal 56 was a column count guessed from a 60-cell panel.
454456
let preview = preview_text(&dialog.kind);
455-
for (i, line) in wrap_text(&preview, 56).iter().take(2).enumerate() {
457+
let preview_lines = wrap_run(&preview, &metrics.type_ramp.body, pw - cell_w * 2.0, font);
458+
for (i, line) in preview_lines.iter().take(2).enumerate() {
456459
add_run_verts(
457460
line,
458461
&metrics.type_ramp.body,
@@ -631,14 +634,15 @@ impl WgpuState {
631634
add_px_rect(px, py, pw, 3.0, err_color, sw, sh, bg_verts, bg_idx);
632635

633636
// Title = render the confirmation message directly (short enough to skip a separate title).
634-
// If it overflows the width, wrap_text breaks it to up to 2 lines.
637+
// If it overflows the width, `wrap_run` breaks it to up to 3 lines.
635638
let content_y = py + cell_h * 1.2;
636-
let max_cols = ((pw - cell_w * 2.0) / cell_w).max(20.0) as usize;
637-
for (i, line) in wrap_text(&dialog.message, max_cols)
638-
.iter()
639-
.take(3)
640-
.enumerate()
641-
{
639+
let message_lines = wrap_run(
640+
&dialog.message,
641+
&metrics.type_ramp.body,
642+
pw - cell_w * 2.0,
643+
font,
644+
);
645+
for (i, line) in message_lines.iter().take(3).enumerate() {
642646
add_run_verts(
643647
line,
644648
&metrics.type_ramp.body,

nexterm-client-gpu/src/renderer/overlay/settings/row.rs

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
//! block below a row.
1111
//!
1212
//! All three truncate their text to the column widths in [`RowLayout`] via
13-
//! [`truncate_to_width`] / [`wrap_text`] so long labels/values/descriptions
13+
//! [`truncate_run_to_width`] / [`wrap_run`] so long labels/values/descriptions
1414
//! can no longer overflow the content area.
1515
//!
1616
//! UI/UX v3 P5d retired `ensure_readable` from here. It raised alpha and
@@ -23,9 +23,9 @@
2323
2424
use crate::font::FontManager;
2525
use crate::glyph_atlas::{GlyphAtlas, TextVertex};
26-
use crate::vertex_util::{add_run_verts, add_string_verts, truncate_run_to_width};
26+
use crate::vertex_util::{add_run_verts, truncate_run_to_width};
2727

28-
use super::super::util::{danger_fill, wrap_text};
28+
use super::super::util::{danger_fill, wrap_run};
2929
use nexterm_config::SurfaceLevel;
3030

3131
/// Fill / label pair for a destructive-confirmation button.
@@ -93,37 +93,41 @@ pub(in crate::renderer) fn draw_section_header(
9393
);
9494
}
9595

96-
/// Draw a word-wrapped description/hint block starting at `(x, y)`, one
97-
/// line per `line_h`. Returns the y position immediately below the last
98-
/// line, so callers can stack further content beneath it.
96+
/// Draw a wrapped description/hint block starting at `(x, y)`, one line per
97+
/// `line_h`. Returns the y position immediately below the last line, so
98+
/// callers can stack further content beneath it.
99+
///
100+
/// `max_w` is a pixel budget (UI/UX v3 N-6b). It used to be a column count,
101+
/// which only bounds the drawn width if the text is drawn at the cell — and it
102+
/// never was here: the block is prose at `body`, so the wrap and the draw were
103+
/// measuring different things.
99104
#[allow(clippy::too_many_arguments)]
100105
pub(in crate::renderer) fn draw_description_rows(
101106
text: &str,
102107
x: f32,
103108
y: f32,
104109
line_h: f32,
105-
max_cols: usize,
110+
max_w: f32,
106111
color: [f32; 4],
107112
sw: f32,
108113
sh: f32,
109-
cell_w: f32,
110114
font: &mut FontManager,
111115
atlas: &mut GlyphAtlas,
112116
queue: &wgpu::Queue,
113117
text_verts: &mut Vec<TextVertex>,
114118
text_idx: &mut Vec<u16>,
115119
) -> f32 {
116-
let lines = wrap_text(text, max_cols);
120+
let style = nexterm_config::MetricTokens::default().type_ramp.body;
121+
let lines = wrap_run(text, &style, max_w, font);
117122
for (i, line) in lines.iter().enumerate() {
118-
add_string_verts(
123+
add_run_verts(
119124
line,
125+
&style,
120126
x,
121127
y + line_h * i as f32,
122128
color,
123-
false,
124129
sw,
125130
sh,
126-
cell_w,
127131
font,
128132
atlas,
129133
queue,

nexterm-client-gpu/src/renderer/overlay/settings/security_tab.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,10 @@ pub(in crate::renderer) fn draw_security_tab(
6868
content_inner_x,
6969
note_y(&geometry),
7070
cell_h,
71-
(content_w / cell_w).floor() as usize,
71+
content_w,
7272
tokens.text_on(SurfaceLevel::S2).muted,
7373
sw,
7474
sh,
75-
cell_w,
7675
font,
7776
atlas,
7877
queue,

nexterm-client-gpu/src/renderer/overlay/util.rs

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
//! Shared helpers used by overlay rendering.
22
3+
use crate::font::FontManager;
34
use nexterm_config::MIN_TEXT_CONTRAST;
45

56
/// Extract the requesting pane ID from a consent-dialog kind
@@ -38,19 +39,39 @@ pub(super) fn preview_text(kind: &crate::state::ConsentKind) -> String {
3839
}
3940
}
4041

41-
/// Wrap text to multiple lines at the given column width (CJK full-width chars count as 2 columns)
42-
pub(super) fn wrap_text(s: &str, max_cols: usize) -> Vec<String> {
42+
/// Wrap text to lines that fit `max_w_px` when drawn at `style` (UI/UX v3 N-6b).
43+
///
44+
/// Replaces a column-counting `wrap_text`. Counting columns wraps correctly
45+
/// only if the text is drawn at the cell — and all three callers draw through
46+
/// `add_run_verts`, at a ramp step, which is a different size. Every one of
47+
/// them therefore wrapped against a budget that had nothing to do with the
48+
/// width it then drew.
49+
///
50+
/// The advance comes from the same `chrome_advance` the drawing pass uses, so
51+
/// a line this returns cannot be wider than the budget it was given, whatever
52+
/// the font reports for any glyph.
53+
///
54+
/// Breaks between characters, not at word boundaries — the same thing the
55+
/// column version did. Wrapping on words is a separate change and would alter
56+
/// what every caller looks like.
57+
pub(super) fn wrap_run(
58+
s: &str,
59+
style: &nexterm_config::TypeStyle,
60+
max_w_px: f32,
61+
font: &mut FontManager,
62+
) -> Vec<String> {
63+
let (size_px, _line_h, bold) = font.chrome_metrics(style);
4364
let mut lines = Vec::new();
4465
let mut current = String::new();
45-
let mut current_cols = 0usize;
66+
let mut current_w = 0.0_f32;
4667
for c in s.chars() {
47-
let w = unicode_width::UnicodeWidthChar::width(c).unwrap_or(1);
48-
if current_cols + w > max_cols && !current.is_empty() {
68+
let w = font.chrome_advance(c, size_px, bold);
69+
if current_w + w > max_w_px && !current.is_empty() {
4970
lines.push(std::mem::take(&mut current));
50-
current_cols = 0;
71+
current_w = 0.0;
5172
}
5273
current.push(c);
53-
current_cols += w;
74+
current_w += w;
5475
}
5576
if !current.is_empty() {
5677
lines.push(current);
@@ -515,6 +536,71 @@ mod tests {
515536
}
516537
}
517538
}
539+
540+
/// UI/UX v3 N-6b: a wrapped line fits the budget it was given.
541+
///
542+
/// The property the column version could not offer: it counted display
543+
/// columns while all three callers drew at a ramp step, so the budget it
544+
/// enforced was not the width that got drawn. Asserted as an equality
545+
/// between the wrap and the measurement, never as a claim about CJK
546+
/// metrics — CI's font stack answers one advance for every character
547+
/// (N-3 spec §6).
548+
#[test]
549+
fn every_wrapped_line_fits_the_budget() {
550+
let mut font = FontManager::new("monospace", 14.0, &[], 1.0, true);
551+
let style = nexterm_config::MetricTokens::default().type_ramp.body;
552+
let budget = 120.0;
553+
554+
for text in [
555+
"a short line",
556+
"a much longer line that will certainly have to be broken somewhere",
557+
"このウィンドウだけ閉じるかどうかを確認しています",
558+
"mixed 日本語 and latin in one run",
559+
] {
560+
let lines = wrap_run(text, &style, budget, &mut font);
561+
assert!(!lines.is_empty(), "{text:?} produced no lines");
562+
for line in &lines {
563+
let w = crate::vertex_util::measure_run(line, &style, &mut font);
564+
// A single character wider than the budget cannot be broken
565+
// further, so the guarantee is "fits, or is one character".
566+
assert!(
567+
w <= budget || line.chars().count() == 1,
568+
"{line:?} measures {w} against a {budget} budget"
569+
);
570+
}
571+
assert_eq!(
572+
lines.concat(),
573+
text,
574+
"wrapping must not add or drop characters"
575+
);
576+
}
577+
}
578+
579+
/// A narrower budget never yields fewer lines.
580+
#[test]
581+
fn a_narrower_budget_never_wraps_into_fewer_lines() {
582+
let mut font = FontManager::new("monospace", 14.0, &[], 1.0, true);
583+
let style = nexterm_config::MetricTokens::default().type_ramp.body;
584+
let text = "a line long enough to be broken at several different budgets";
585+
586+
let wide = wrap_run(text, &style, 400.0, &mut font).len();
587+
let narrow = wrap_run(text, &style, 100.0, &mut font).len();
588+
assert!(narrow >= wide, "{wide} lines at 400px, {narrow} at 100px");
589+
}
590+
591+
/// No caller counts columns any more.
592+
#[test]
593+
fn no_overlay_wraps_text_by_column_count() {
594+
for (name, src) in [
595+
("dialog.rs", include_str!("dialog.rs")),
596+
("settings/row.rs", include_str!("settings/row.rs")),
597+
] {
598+
assert!(
599+
!src.contains("wrap_text("),
600+
"{name} wraps by column count again; wrap_run measures"
601+
);
602+
}
603+
}
518604
}
519605

520606
#[cfg(test)]

0 commit comments

Comments
 (0)