Fix TUI layout for halfwidth Japanese sound marks (#35962)

## What changed

- Use Ratatui-compatible terminal widths throughout TUI layout, preserving
  full-width counters for long lines.
- Wrap and truncate on grapheme boundaries so halfwidth kana sound marks and
  emoji sequences stay intact.
- Apply the corrected sizing to text input, selection popups, status and
  history cells, Markdown, hyperlinks, and scrollable cloud diffs.

## Testing

- Add focused unit and snapshot coverage for wrapping, truncation, cursor and
  mask alignment, popup columns, status output, session headers, Markdown
  tables, process output, and scrollable diffs.

GitOrigin-RevId: 294ec0bcff743c6bb16d55ec26308524ce741879
This commit is contained in:
Charlie Marsh
2026-07-29 14:01:31 +00:00
committed by copyberry
parent cef3910ea4
commit 3725f02cf3
38 changed files with 1335 additions and 245 deletions
+23 -10
View File
@@ -49,6 +49,8 @@ use crate::terminal_hyperlinks::annotate_web_urls_in_line;
use crate::terminal_hyperlinks::remap_wrapped_line;
use crate::terminal_hyperlinks::visible_lines;
use crate::terminal_hyperlinks::web_destination;
use crate::width::char_width;
use crate::width::display_width;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_line;
use crate::wrapping::word_wrap_line;
@@ -72,8 +74,6 @@ use std::ops::Range;
use std::path::Path;
use std::path::PathBuf;
use std::sync::LazyLock;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
use url::Url;
mod streaming;
@@ -1302,7 +1302,7 @@ where
let plain = cell.plain_text();
let mut word_count = 0usize;
for token in plain.split_whitespace() {
let token_width = token.width();
let token_width = display_width(token);
body_token_width = body_token_width.max(token_width);
long_body_token_count += usize::from(token_width >= 20);
word_count += 1;
@@ -1311,7 +1311,7 @@ where
body_token_count += word_count;
total_words += word_count;
total_cells += 1;
total_cell_width += plain.width();
total_cell_width += display_width(&plain);
}
}
@@ -1321,7 +1321,7 @@ where
total_words as f64 / total_cells as f64
};
let avg_cell_width = if total_cells == 0 {
header_plain.width() as f64
display_width(&header_plain) as f64
} else {
total_cell_width as f64 / total_cells as f64
};
@@ -1607,7 +1607,7 @@ where
} else {
current_text.push(ch);
}
column += UnicodeWidthChar::width(ch).unwrap_or(/*default*/ 0);
column += char_width(ch);
}
flush(&mut out, &mut current_text, current_destination);
}
@@ -1756,7 +1756,10 @@ where
#[inline]
fn spans_display_width(spans: &[Span<'_>]) -> usize {
spans.iter().map(|span| span.content.width()).sum()
spans
.iter()
.map(|span| display_width(span.content.as_ref()))
.sum()
}
#[inline]
@@ -1775,7 +1778,10 @@ where
#[inline]
fn longest_token_width(text: &str) -> usize {
text.split_whitespace().map(str::width).max().unwrap_or(0)
text.split_whitespace()
.map(display_width)
.max()
.unwrap_or(0)
}
fn push_inline_style(&mut self, style: Style) {
@@ -1887,7 +1893,7 @@ where
}
} else {
let mut spans = self.current_initial_indent.clone();
let shift = spans.iter().map(|span| span.content.width()).sum::<usize>();
let shift = Self::spans_display_width(&spans);
spans.append(&mut line.line.spans);
for hyperlink in &mut line.hyperlinks {
hyperlink.columns =
@@ -1926,7 +1932,7 @@ where
};
let mut spans = self.prefix_spans(pending_marker_line);
let shift = spans.iter().map(|span| span.content.width()).sum::<usize>();
let shift = Self::spans_display_width(&spans);
spans.append(&mut line.line.spans);
for hyperlink in &mut line.hyperlinks {
hyperlink.columns = hyperlink.columns.start + shift..hyperlink.columns.end + shift;
@@ -2844,6 +2850,13 @@ mod tests {
}));
}
#[test]
fn table_widths_count_halfwidth_sound_marks() {
let cell = make_cell("ガパ");
assert_eq!(W::cell_display_width(&cell), 4);
assert_eq!(W::longest_token_width("ガパtail"), 8);
}
#[test]
fn key_value_table_keeps_web_annotations() {
let destination = "https://example.com/a/very/long/path";