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 authored and copyberry committed 2026-07-29 14:01:31 +00:00
1 parent cef3910ea4
commit 3725f02cf3
38 files changed
+1335 -245

No files matched your search

@@ -2569,6 +2569,41 @@ mod tests {
);
}
#[test]
fn snapshot_narrow_width_counts_halfwidth_sound_marks() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let items = vec![
SelectionItem {
name: "abガc".to_string(),
description: Some("dakuten description".to_string()),
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "aパc".to_string(),
description: Some("handakuten description".to_string()),
dismiss_on_select: true,
..Default::default()
},
];
let view = new_view(
SelectionViewParams {
title: Some("Halfwidth sound marks".to_string()),
items,
..Default::default()
},
tx,
);
let rendered = format!(
"width 20:\n{}\n\nwidth 24:\n{}",
render_lines_with_width(&view, /*width*/ 20),
render_lines_with_width(&view, /*width*/ 24)
);
assert_snapshot!("list_selection_halfwidth_sound_marks_narrow", rendered);
}
#[test]
fn snapshot_auto_visible_col_width_mode_scroll_behavior() {
assert_snapshot!(
@@ -7,8 +7,7 @@ use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use std::borrow::Cow;
use std::time::Instant;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
use unicode_segmentation::UnicodeSegmentation;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::bottom_pane::scroll_state::ScrollState;
@@ -18,7 +17,9 @@ use crate::bottom_pane::selection_popup_common::menu_surface_padding_height;
use crate::bottom_pane::selection_popup_common::render_menu_surface;
use crate::bottom_pane::selection_popup_common::render_rows;
use crate::bottom_pane::selection_popup_common::wrap_styled_line;
use crate::line_truncation::line_width;
use crate::render::renderable::Renderable;
use crate::width::display_width;
use super::DESIRED_SPACERS_BETWEEN_SECTIONS;
use super::RequestUserInputOverlay;
@@ -421,12 +422,6 @@ impl RequestUserInputOverlay {
}
}
fn line_width(line: &Line<'_>) -> usize {
line.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.sum()
}
/// Render rows into `area`, bottom-aligning the visible rows when fewer than
/// `area.height` lines are produced.
///
@@ -489,7 +484,7 @@ fn truncate_line_word_boundary_with_ellipsis(
}
let ellipsis = "…";
let ellipsis_width = UnicodeWidthStr::width(ellipsis);
let ellipsis_width = display_width(ellipsis);
if ellipsis_width >= max_width {
return Line::from(ellipsis);
}
@@ -509,19 +504,19 @@ fn truncate_line_word_boundary_with_ellipsis(
'outer: for (span_idx, span) in line.spans.iter().enumerate() {
let text = span.content.as_ref();
for (byte_idx, ch) in text.char_indices() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if used.saturating_add(ch_width) > limit {
for (byte_idx, grapheme) in text.grapheme_indices(/*is_extended*/ true) {
let grapheme_width = display_width(grapheme);
if used.saturating_add(grapheme_width) > limit {
overflowed = true;
break 'outer;
}
used = used.saturating_add(ch_width);
used = used.saturating_add(grapheme_width);
let bp = BreakPoint {
span_idx,
byte_end: byte_idx + ch.len_utf8(),
byte_end: byte_idx + grapheme.len(),
};
last_fit = Some(bp);
if ch.is_whitespace() {
if grapheme.chars().all(char::is_whitespace) {
last_word_break = Some(bp);
}
}
@@ -576,3 +571,7 @@ fn truncate_line_word_boundary_with_ellipsis(
Line::from(spans_out).style(line_style)
}
#[cfg(test)]
#[path = "render_tests.rs"]
mod tests;
@@ -0,0 +1,37 @@
use super::truncate_line_word_boundary_with_ellipsis;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
#[test]
fn halfwidth_sound_marks_are_truncated_at_a_grapheme_boundary() {
let line = Line::from("abガ tail");
assert_eq!(
truncate_line_word_boundary_with_ellipsis(line, /*max_width*/ 4),
Line::from(vec![Span::raw("ab"), Span::raw("…")])
);
}
#[test]
fn halfwidth_sound_marks_are_truncated_and_rendered_at_a_grapheme_boundary() {
let lines = [
Line::from(vec!["ab".bold(), "ガ".cyan(), " tail".dim()]),
Line::from(vec!["xy".bold(), "パ".magenta(), " tail".dim()]),
]
.map(|line| truncate_line_word_boundary_with_ellipsis(line, /*max_width*/ 5));
let area = Rect::new(0, 0, 5, 2);
let mut buf = Buffer::empty(area);
Paragraph::new(lines.to_vec()).render(area, &mut buf);
insta::assert_snapshot!(
"request_user_input_halfwidth_sound_marks_truncation",
format!("{buf:?}\n{lines:#?}")
);
}
@@ -0,0 +1,33 @@
---
source: tui/src/bottom_pane/request_user_input/render_tests.rs
expression: "format!(\"{buf:?}\\n{lines:#?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 5, height: 2 },
content: [
"abガ…", // hidden by multi-width symbols: [(3, " ")]
"xyパ…", // hidden by multi-width symbols: [(3, " ")]
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 2, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE,
x: 3, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 2, y: 1, fg: Magenta, bg: Reset, underline: Reset, modifier: NONE,
x: 3, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 1, fg: Magenta, bg: Reset, underline: Reset, modifier: NONE,
]
}
[
Line::from_iter([
Span::from("ab").bold(),
Span::from("カ\u{ff9e}").cyan(),
Span::from("…").cyan(),
]),
Line::from_iter([
Span::from("xy").bold(),
Span::from("ハ\u{ff9f}").magenta(),
Span::from("…").magenta(),
]),
]
@@ -7,14 +7,15 @@ use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Widget;
use unicode_width::UnicodeWidthStr;
use crate::key_hint::KeyBinding;
use crate::line_truncation::line_width;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::style::accent_style;
use crate::style::user_message_style;
use crate::width::display_width;
use super::scroll_state::ScrollState;
use super::selection_row_layout::SelectionDescriptionLayout;
@@ -170,7 +171,7 @@ fn compute_desc_col(
if row.disabled_reason.is_some() {
spans.push(" (disabled)".dim());
}
Line::from(spans).width()
line_width(&Line::from(spans))
})
.max()
.unwrap_or(0),
@@ -182,7 +183,7 @@ fn compute_desc_col(
if row.disabled_reason.is_some() {
spans.push(" (disabled)".dim());
}
Line::from(spans).width()
line_width(&Line::from(spans))
})
.max()
.unwrap_or(0),
@@ -225,6 +226,9 @@ fn should_wrap_name_in_column(row: &GenericDisplayRow) -> bool {
}
fn wrap_two_column_row(row: &GenericDisplayRow, desc_col: usize, width: u16) -> Vec<Line<'static>> {
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_lines;
let Some(description) = row.description.as_deref() else {
return Vec::new();
};
@@ -245,14 +249,13 @@ fn wrap_two_column_row(row: &GenericDisplayRow, desc_col: usize, width: u16) ->
.unwrap_or(0)
.min(left_width.saturating_sub(1));
let name_subsequent_indent = " ".repeat(name_wrap_indent);
let name_options = textwrap::Options::new(left_width)
.initial_indent("")
.subsequent_indent(name_subsequent_indent.as_str());
let name_lines = textwrap::wrap(row.name.as_str(), name_options);
let name_options = RtOptions::new(left_width)
.initial_indent(Line::from(""))
.subsequent_indent(Line::from(" ".repeat(name_wrap_indent)));
let name_lines = word_wrap_lines(row.name.lines(), name_options);
let desc_options = textwrap::Options::new(right_width).initial_indent("");
let desc_lines = textwrap::wrap(description, desc_options);
let desc_options = RtOptions::new(right_width).initial_indent(Line::from(""));
let desc_lines = word_wrap_lines(description.lines(), desc_options);
let rows = name_lines.len().max(desc_lines.len()).max(1);
let mut out = Vec::with_capacity(rows);
@@ -265,7 +268,7 @@ fn wrap_two_column_row(row: &GenericDisplayRow, desc_col: usize, width: u16) ->
if let Some(desc) = desc_lines.get(idx) {
let left_used = spans
.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.map(|span| display_width(span.content.as_ref()))
.sum::<usize>();
let gap = if left_used == 0 {
desc_col
@@ -797,6 +800,123 @@ mod tests {
assert_eq!(two_col.len(), 0);
}
#[test]
fn popup_name_truncation_counts_halfwidth_sound_marks() {
for (name, desc_col, match_index, expected) in
[("abガc", 6, None, "abガ…"), ("aガc", 4, Some(1), "a…")]
{
let row = GenericDisplayRow {
name: name.to_string(),
description: Some("description".to_string()),
match_indices: match_index.map(|index| vec![index]),
..Default::default()
};
let text =
build_full_line(&row, desc_col, SelectionDescriptionLayout::Columns).to_string();
assert!(text.starts_with(expected), "unexpected row: {text:?}");
}
}
#[test]
fn fuzzy_matched_emoji_graphemes_keep_description_alignment() {
let rows = [
GenericDisplayRow {
name: "👍🏻".to_string(),
match_indices: Some(vec![1]),
description: Some("description".to_string()),
..Default::default()
},
GenericDisplayRow {
name: "👨‍👩‍👧‍👦".to_string(),
match_indices: Some(vec![2]),
description: Some("description".to_string()),
..Default::default()
},
];
let area = Rect::new(0, 0, /*width*/ 20, /*height*/ 2);
let mut buf = Buffer::empty(area);
for (row_index, row) in rows.iter().enumerate() {
let line = build_full_line(
row,
/*desc_col*/ 4,
SelectionDescriptionLayout::Columns,
);
let name = line.spans.first().expect("fuzzy-matched name span");
assert_eq!(name.content.as_ref(), row.name);
assert!(name.style.add_modifier.contains(Modifier::BOLD));
let row_area = Rect::new(area.x, area.y + row_index as u16, area.width, 1);
ratatui::widgets::Widget::render(
ratatui::widgets::Paragraph::new(line),
row_area,
&mut buf,
);
assert_eq!(buf[(4, row_index as u16)].symbol(), "d");
}
insta::assert_snapshot!("popup_fuzzy_matched_emoji_graphemes", format!("{buf:?}"));
}
#[test]
fn wrapped_two_column_rows_count_halfwidth_sound_marks() {
let rows = vec![GenericDisplayRow {
name: "abガc".to_string(),
description: Some("abパc".to_string()),
wrap_indent: Some(0),
..Default::default()
}];
let area = Rect::new(0, 0, 9, 2);
let mut buf = Buffer::empty(area);
let rendered_lines = render_rows(
area,
&mut buf,
&rows,
&ScrollState::default(),
/*max_results*/ 1,
"no rows",
);
let rendered = (0..area.height)
.map(|y| {
(0..area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n");
assert_eq!(rendered_lines, 2);
insta::assert_snapshot!(rendered, @r"
abガ ab
c パ c
");
}
#[test]
fn wrapped_two_column_rows_preserve_hard_line_breaks() {
let row = GenericDisplayRow {
name: "first\nsecond".to_string(),
description: Some("alpha\nbeta".to_string()),
wrap_indent: Some(0),
..Default::default()
};
let rendered = wrap_two_column_row(&row, /*desc_col*/ 8, /*width*/ 24)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(rendered, @r"
first alpha
second beta
");
}
#[test]
fn selected_rows_use_the_shared_accent_style() {
let rows = vec![GenericDisplayRow {
@@ -3,9 +3,11 @@ use std::borrow::Cow;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use unicode_width::UnicodeWidthChar;
use unicode_segmentation::UnicodeSegmentation;
use super::selection_popup_common::GenericDisplayRow;
use crate::line_truncation::line_width;
use crate::width::display_width;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
@@ -82,35 +84,28 @@ fn build_name_spans(row: &GenericDisplayRow, name_limit: usize) -> Vec<Span<'sta
let mut used_width = 0usize;
let mut truncated = false;
if let Some(idxs) = row.match_indices.as_ref() {
let mut idx_iter = idxs.iter().peekable();
for (char_idx, ch) in row.name.chars().enumerate() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
let next_width = used_width.saturating_add(ch_width);
if next_width > name_limit {
truncated = true;
break;
}
used_width = next_width;
let mut match_indices = row.match_indices.iter().flatten().peekable();
let mut char_idx = 0usize;
for grapheme in row.name.graphemes(/*is_extended*/ true) {
let next_width = used_width.saturating_add(display_width(grapheme));
if next_width > name_limit {
truncated = true;
break;
}
used_width = next_width;
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
idx_iter.next();
name_spans.push(ch.to_string().bold());
} else {
name_spans.push(ch.to_string().into());
}
}
} else {
for ch in row.name.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
let next_width = used_width.saturating_add(ch_width);
if next_width > name_limit {
truncated = true;
break;
}
used_width = next_width;
name_spans.push(ch.to_string().into());
let mut matched = false;
for _ in grapheme.chars() {
matched |= match_indices.next_if(|next| **next == char_idx).is_some();
char_idx += 1;
}
let grapheme = grapheme.to_string();
name_spans.push(if matched {
grapheme.bold()
} else {
grapheme.into()
});
}
if truncated {
@@ -145,13 +140,13 @@ pub(super) fn build_full_line(
description_layout: SelectionDescriptionLayout,
) -> Line<'static> {
let description = combined_description(row, description_layout);
let name_prefix_width = Line::from(row.name_prefix_spans.clone()).width();
let name_prefix_width = line_width(&Line::from(row.name_prefix_spans.clone()));
let name_limit = description
.as_ref()
.map(|_| desc_col.saturating_sub(2).saturating_sub(name_prefix_width))
.unwrap_or(usize::MAX);
let name_spans = build_name_spans(row, name_limit);
let name_width = name_prefix_width + Line::from(name_spans.clone()).width();
let name_width = name_prefix_width + line_width(&Line::from(name_spans.clone()));
let mut spans = row.name_prefix_spans.clone();
spans.extend(name_spans);
@@ -170,8 +165,7 @@ pub(super) fn build_full_line(
/// Render a row as a full-width label followed by an indented description.
pub(super) fn wrap_stacked_row(row: &GenericDisplayRow, width: u16) -> Vec<Line<'static>> {
let width = width.max(1);
let prefix_width = Line::from(row.name_prefix_spans.clone())
.width()
let prefix_width = line_width(&Line::from(row.name_prefix_spans.clone()))
.min(width.saturating_sub(1) as usize);
let indent = " ".repeat(prefix_width);
@@ -0,0 +1,30 @@
---
source: tui/src/bottom_pane/list_selection_view.rs
expression: rendered
---
width 20:
Halfwidth sound
› 1. abガc
dakute
n
descri
ption
2. aパc
handak
uten
descri
ption
width 24:
Halfwidth sound mark
› 1. abガc dakuten
descriptio
n
2. aパc handakuten
descriptio
n
@@ -0,0 +1,21 @@
---
source: tui/src/bottom_pane/selection_popup_common.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 20, height: 2 },
content: [
"👍🏻 description ", // hidden by multi-width symbols: [(1, " ")]
"👨‍👩‍👧‍👦 description ", // hidden by multi-width symbols: [(1, " ")]
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 1, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 15, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 1, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 15, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -0,0 +1,20 @@
---
source: tui/src/bottom_pane/textarea.rs
expression: "format!(\"dakuten\\n{dakuten}\\n\\nhandakuten\\n{handakuten}\\n\\nstandalone\\n{standalone}\")"
---
dakuten
cursor: Some((1, 1))
"12ガ" Hidden by multi-width symbols: [(3, " ")]
"x "
handakuten
cursor: Some((3, 1))
"ab "
"パc" Hidden by multi-width symbols: [(1, " ")]
standalone
cursor: Some((2, 1))
"a "
"゙b"
@@ -0,0 +1,6 @@
---
source: tui/src/bottom_pane/textarea.rs
expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())"
---
cursor: Some((4, 0))
"****"
+98 -14
View File
@@ -17,6 +17,7 @@ use crate::keymap::RuntimeKeymap;
use crate::keymap::VimNormalKeymap;
use crate::keymap::VimOperatorKeymap;
use crate::keymap::VimTextObjectKeymap;
use crate::width::display_width;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement as UserTextElement;
use crossterm::event::KeyCode;
@@ -35,7 +36,6 @@ use std::cell::RefCell;
use std::ops::Range;
use textwrap::Options;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
mod vim;
use self::vim::VimMode;
@@ -430,7 +430,7 @@ impl TextArea {
let effective_scroll = self.effective_scroll(area.height, &lines, state.scroll);
let i = Self::wrapped_line_index_by_start(&lines, self.cursor_pos)?;
let ls = &lines[i];
let col = self.text[ls.start..self.cursor_pos].width() as u16;
let col = display_width(&self.text[ls.start..self.cursor_pos]) as u16;
let screen_row = i
.saturating_sub(effective_scroll as usize)
.try_into()
@@ -444,7 +444,7 @@ impl TextArea {
fn current_display_col(&self) -> usize {
let bol = self.beginning_of_current_line();
self.text[bol..self.cursor_pos].width()
display_width(&self.text[bol..self.cursor_pos])
}
fn wrapped_line_index_by_start(lines: &[Range<usize>], pos: usize) -> Option<usize> {
@@ -462,7 +462,7 @@ impl TextArea {
) {
let mut width_so_far = 0usize;
for (i, g) in self.text[line_start..line_end].grapheme_indices(true) {
width_so_far += g.width();
width_so_far += display_width(g);
if width_so_far > target_col {
self.cursor_pos = line_start + i;
// Avoid landing inside an element; round to nearest boundary
@@ -1212,9 +1212,9 @@ impl TextArea {
let lines = &cache.lines;
if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor_pos) {
let cur_range = &lines[idx];
let target_col = self
.preferred_col
.unwrap_or_else(|| self.text[cur_range.start..self.cursor_pos].width());
let target_col = self.preferred_col.unwrap_or_else(|| {
display_width(&self.text[cur_range.start..self.cursor_pos])
});
if idx > 0 {
let prev = &lines[idx - 1];
let line_start = prev.start;
@@ -1275,9 +1275,9 @@ impl TextArea {
let lines = &cache.lines;
if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor_pos) {
let cur_range = &lines[idx];
let target_col = self
.preferred_col
.unwrap_or_else(|| self.text[cur_range.start..self.cursor_pos].width());
let target_col = self.preferred_col.unwrap_or_else(|| {
display_width(&self.text[cur_range.start..self.cursor_pos])
});
if idx + 1 < lines.len() {
let next = &lines[idx + 1];
let line_start = next.start;
@@ -1994,7 +1994,7 @@ impl TextArea {
continue;
}
let styled = &self.text[overlap_start..overlap_end];
let x_off = self.text[line_range.start..overlap_start].width() as u16;
let x_off = display_width(&self.text[line_range.start..overlap_start]) as u16;
let style = base_style.fg(Color::Cyan);
buf.set_string(area.x + x_off, y, text_for_display(styled), style);
}
@@ -2008,7 +2008,7 @@ impl TextArea {
continue;
}
let highlighted = &self.text[overlap_start..overlap_end];
let x_off = self.text[line_range.start..overlap_start].width() as u16;
let x_off = display_width(&self.text[line_range.start..overlap_start]) as u16;
buf.set_string(area.x + x_off, y, text_for_display(highlighted), *style);
}
}
@@ -2027,8 +2027,8 @@ impl TextArea {
let y = area.y + row as u16;
let line_range = r.start..r.end - 1;
let masked = self.text[line_range.clone()]
.chars()
.map(|_| mask_char)
.graphemes(/*is_extended*/ true)
.flat_map(|grapheme| std::iter::repeat_n(mask_char, display_width(grapheme)))
.collect::<String>();
buf.set_string(area.x, y, &masked, Style::default());
}
@@ -3518,6 +3518,90 @@ mod tests {
assert_eq!(buf[(0, 1)].symbol(), "5");
}
#[test]
fn halfwidth_sound_marks_wrap_and_align_with_the_cursor() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut snapshots = Vec::new();
for (label, text, width, cursor, cells) in [
("dakuten", "12ガx", 4, (1, 1), [(2, 0, "ガ"), (0, 1, "x")]),
("handakuten", "abパc", 3, (3, 1), [(0, 1, "パ"), (2, 1, "c")]),
("standalone", "a ゙b", 2, (2, 1), [(0, 1, "゙"), (1, 1, "b")]),
] {
let mut t = ta_with(text);
t.set_cursor(text.len());
let area = Rect::new(0, 0, width, /*height*/ 2);
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
ratatui::widgets::WidgetRef::render_ref(
&(&t),
frame.area(),
frame.buffer_mut(),
);
})
.unwrap();
assert_eq!(t.desired_height(area.width), 2);
assert_eq!(t.cursor_pos(area), Some(cursor));
for (x, y, expected) in cells {
assert_eq!(terminal.backend().buffer()[(x, y)].symbol(), expected);
}
snapshots.push(format!(
"{label}\ncursor: {:?}\n{}",
t.cursor_pos(area),
terminal.backend()
));
}
insta::assert_snapshot!(
"textarea_halfwidth_sound_marks_wrap_and_align_with_cursor",
snapshots.join("\n\n")
);
}
#[test]
fn masked_graphemes_align_with_the_cursor() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let text = "界゙a";
let mut t = ta_with(text);
t.set_cursor(text.len());
let area = Rect::new(0, 0, /*width*/ 4, /*height*/ 1);
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
let mut state = TextAreaState::default();
terminal
.draw(|frame| {
t.render_ref_masked(frame.area(), frame.buffer_mut(), &mut state, '*');
})
.unwrap();
assert_eq!(t.cursor_pos(area), Some((4, 0)));
insta::assert_snapshot!(
"textarea_masked_graphemes_align_with_cursor",
format!("cursor: {:?}\n{}", t.cursor_pos(area), terminal.backend())
);
}
#[test]
fn overwide_halfwidth_sound_marks_do_not_add_a_phantom_cursor_row() {
let area = Rect::new(0, 0, /*width*/ 2, /*height*/ 2);
for grapheme in ["ガ゙", "界゙"] {
let text = format!("{grapheme}ab");
let mut t = ta_with(&text);
t.set_cursor(text.len());
assert_eq!(t.desired_height(area.width), 2);
assert_eq!(t.cursor_pos(area), Some((2, 1)));
t.set_cursor(grapheme.len());
assert_eq!(t.cursor_pos(area), Some((0, 1)));
}
}
#[test]
fn cursor_pos_with_state_basic_and_scroll_behaviors() {
// Case 1: No wrapping needed, height fits — scroll ignored, y maps directly.