mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
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:#?}")
|
||||
);
|
||||
}
|
||||
+33
@@ -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);
|
||||
|
||||
|
||||
+30
@@ -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
|
||||
+21
@@ -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,
|
||||
]
|
||||
}
|
||||
+20
@@ -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"
|
||||
+6
@@ -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))
|
||||
"****"
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user