mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Hang soft-break whitespace in the TUI composer (#39022)
## Why Keeping overflowing separators with the following word can add whitespace-only rows and make composer wrapping differ from the queued-input preview. ## What changed - Hang breakable whitespace from the preceding visual row at soft word breaks while preserving the underlying editable text. - Keep indentation, trailing whitespace, mandatory breaks, and nonbreaking spaces visible and editable according to their existing semantics. - Align cursor placement, scrolling, vertical navigation, masked rendering, and styled overlays with the visible wrapped content, including after resizes. ## Testing Add focused wrapping, rendering, cursor, editing, scrolling, navigation, Unicode separator, long whitespace-run, and resize coverage. GitOrigin-RevId: 7fbdb0a4bf33cfee20c4c19dca5aee74df4f892c
This commit is contained in:
+3
-3
@@ -2,7 +2,7 @@
|
||||
source: tui/src/bottom_pane/textarea.rs
|
||||
expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())"
|
||||
---
|
||||
cursor: Some((3, 2))
|
||||
cursor: Some((1, 2))
|
||||
"abad"
|
||||
" ab" Hidden by multi-width symbols: [(1, " ")]
|
||||
"cde "
|
||||
"abcd"
|
||||
"e "
|
||||
+3
-4
@@ -2,9 +2,8 @@
|
||||
source: tui/src/bottom_pane/textarea.rs
|
||||
expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())"
|
||||
---
|
||||
cursor: Some((2, 4))
|
||||
cursor: Some((2, 3))
|
||||
"abad"
|
||||
" abc"
|
||||
"de "
|
||||
"xy "
|
||||
"abcd"
|
||||
"e xy"
|
||||
"zz "
|
||||
|
||||
+2
-2
@@ -2,6 +2,6 @@
|
||||
source: tui/src/bottom_pane/textarea.rs
|
||||
expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())"
|
||||
---
|
||||
cursor: Some((4, 1))
|
||||
cursor: Some((3, 1))
|
||||
"p "
|
||||
" a b "
|
||||
"a b "
|
||||
|
||||
+2
-2
@@ -2,6 +2,6 @@
|
||||
source: tui/src/bottom_pane/textarea.rs
|
||||
expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())"
|
||||
---
|
||||
cursor: Some((2, 1))
|
||||
cursor: Some((1, 1))
|
||||
"abad"
|
||||
" a "
|
||||
"a "
|
||||
+6
-6
@@ -3,12 +3,12 @@ source: tui/src/bottom_pane/textarea.rs
|
||||
expression: "snapshots.join(\"\\n\\n\")"
|
||||
---
|
||||
element
|
||||
" KEEP"
|
||||
" nex "
|
||||
"t "
|
||||
"abadKEEP"
|
||||
"next "
|
||||
" "
|
||||
|
||||
|
||||
highlight
|
||||
" KEEP"
|
||||
" nex "
|
||||
"t "
|
||||
"abadKEEP"
|
||||
"next "
|
||||
" "
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
//! recent killed span.
|
||||
//!
|
||||
//! Wrapping also reserves a visible insertion point: full logical lines get continuation rows,
|
||||
//! and overflowing spaces wrap instead of moving the cursor outside the textarea. A partial
|
||||
//! whitespace continuation stays attached to the following word rather than becoming a blank row.
|
||||
//! and trailing spaces wrap instead of moving the cursor outside the textarea. At soft word
|
||||
//! breaks, interior separators hang off the preceding row without changing the editable text.
|
||||
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::key_hint::is_altgr;
|
||||
@@ -451,16 +451,13 @@ impl TextArea {
|
||||
}
|
||||
|
||||
let lines = self.wrapped_lines(area.width);
|
||||
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 = display_width(&self.text[ls.start..self.cursor_pos])
|
||||
.min(usize::from(area.width.saturating_sub(1))) as u16;
|
||||
let effective_scroll = self.effective_scroll(area, &lines, state.scroll);
|
||||
let (i, col) = wrapping::cursor_position(&self.text, &lines, area.width, self.cursor_pos)?;
|
||||
let screen_row = i
|
||||
.saturating_sub(effective_scroll as usize)
|
||||
.try_into()
|
||||
.unwrap_or(0);
|
||||
Some((area.x + col, area.y + screen_row))
|
||||
Some((area.x + col as u16, area.y + screen_row))
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
@@ -472,13 +469,6 @@ impl TextArea {
|
||||
display_width(&self.text[bol..self.cursor_pos])
|
||||
}
|
||||
|
||||
fn wrapped_line_index_by_start(lines: &[Range<usize>], pos: usize) -> Option<usize> {
|
||||
// partition_point returns the index of the first element for which
|
||||
// the predicate is false, i.e. the count of elements with start <= pos.
|
||||
let idx = lines.partition_point(|r| r.start <= pos);
|
||||
if idx == 0 { None } else { Some(idx - 1) }
|
||||
}
|
||||
|
||||
fn move_to_display_col_on_line(
|
||||
&mut self,
|
||||
line_start: usize,
|
||||
@@ -1250,11 +1240,16 @@ impl TextArea {
|
||||
let cache_ref = self.wrap_cache.borrow();
|
||||
if let Some(cache) = cache_ref.as_ref() {
|
||||
let lines = &cache.lines;
|
||||
if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor_pos) {
|
||||
if let Some((idx, col)) =
|
||||
wrapping::cursor_position(&self.text, lines, cache.width, self.cursor_pos)
|
||||
{
|
||||
let cur_range = &lines[idx];
|
||||
let target_col = self.preferred_col.unwrap_or_else(|| {
|
||||
display_width(&self.text[cur_range.start..self.cursor_pos])
|
||||
});
|
||||
// A saved column can outlive a resize. Do not land in hanging whitespace
|
||||
// that is displayed on the following row.
|
||||
let target_col = self
|
||||
.preferred_col
|
||||
.unwrap_or(col)
|
||||
.min(usize::from(cache.width.saturating_sub(1)));
|
||||
if idx > 0 {
|
||||
let prev = &lines[idx - 1];
|
||||
let line_start = prev.start;
|
||||
@@ -1316,11 +1311,13 @@ impl TextArea {
|
||||
let cache_ref = self.wrap_cache.borrow();
|
||||
if let Some(cache) = cache_ref.as_ref() {
|
||||
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(|| {
|
||||
display_width(&self.text[cur_range.start..self.cursor_pos])
|
||||
});
|
||||
if let Some((idx, col)) =
|
||||
wrapping::cursor_position(&self.text, lines, cache.width, self.cursor_pos)
|
||||
{
|
||||
let target_col = self
|
||||
.preferred_col
|
||||
.unwrap_or(col)
|
||||
.min(usize::from(cache.width.saturating_sub(1)));
|
||||
if idx + 1 < lines.len() {
|
||||
let next = &lines[idx + 1];
|
||||
let line_start = next.start;
|
||||
@@ -1926,30 +1923,24 @@ impl TextArea {
|
||||
///
|
||||
/// - Cursor is always on screen.
|
||||
/// - No scrolling if content fits in the area.
|
||||
fn effective_scroll(
|
||||
&self,
|
||||
area_height: u16,
|
||||
lines: &[Range<usize>],
|
||||
current_scroll: u16,
|
||||
) -> u16 {
|
||||
fn effective_scroll(&self, area: Rect, lines: &[Range<usize>], current_scroll: u16) -> u16 {
|
||||
let total_lines = lines.len() as u16;
|
||||
if area_height >= total_lines {
|
||||
if area.height >= total_lines {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Where is the cursor within wrapped lines? Prefer assigning boundary positions
|
||||
// (where pos equals the start of a wrapped line) to that later line.
|
||||
let cursor_line_idx =
|
||||
Self::wrapped_line_index_by_start(lines, self.cursor_pos).unwrap_or(0) as u16;
|
||||
wrapping::cursor_position(&self.text, lines, area.width, self.cursor_pos)
|
||||
.map_or(0, |(row, _)| row) as u16;
|
||||
|
||||
let max_scroll = total_lines.saturating_sub(area_height);
|
||||
let max_scroll = total_lines.saturating_sub(area.height);
|
||||
let mut scroll = current_scroll.min(max_scroll);
|
||||
|
||||
// Ensure cursor is visible within [scroll, scroll + area_height)
|
||||
if cursor_line_idx < scroll {
|
||||
scroll = cursor_line_idx;
|
||||
} else if cursor_line_idx >= scroll + area_height {
|
||||
scroll = cursor_line_idx + 1 - area_height;
|
||||
} else if cursor_line_idx >= scroll + area.height {
|
||||
scroll = cursor_line_idx + 1 - area.height;
|
||||
}
|
||||
scroll
|
||||
}
|
||||
@@ -1974,7 +1965,7 @@ impl StatefulWidgetRef for &TextArea {
|
||||
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||
let lines = self.wrapped_lines(area.width);
|
||||
let scroll = self.effective_scroll(area.height, &lines, state.scroll);
|
||||
let scroll = self.effective_scroll(area, &lines, state.scroll);
|
||||
state.scroll = scroll;
|
||||
|
||||
let start = scroll as usize;
|
||||
@@ -1992,7 +1983,7 @@ impl TextArea {
|
||||
mask_char: char,
|
||||
) {
|
||||
let lines = self.wrapped_lines(area.width);
|
||||
let scroll = self.effective_scroll(area.height, &lines, state.scroll);
|
||||
let scroll = self.effective_scroll(area, &lines, state.scroll);
|
||||
state.scroll = scroll;
|
||||
|
||||
let start = scroll as usize;
|
||||
@@ -2013,7 +2004,7 @@ impl TextArea {
|
||||
highlights: &[(Range<usize>, Style)],
|
||||
) {
|
||||
let lines = self.wrapped_lines(area.width);
|
||||
let scroll = self.effective_scroll(area.height, &lines, state.scroll);
|
||||
let scroll = self.effective_scroll(area, &lines, state.scroll);
|
||||
state.scroll = scroll;
|
||||
|
||||
let start = scroll as usize;
|
||||
@@ -2031,31 +2022,40 @@ impl TextArea {
|
||||
base_style: Style,
|
||||
highlights: &[(Range<usize>, Style)],
|
||||
) {
|
||||
let element_style = base_style.fg(Color::Cyan);
|
||||
for (row, idx) in range.enumerate() {
|
||||
let r = &lines[idx];
|
||||
let y = area.y + row as u16;
|
||||
let line_range = r.start..r.end - 1;
|
||||
let visible = wrapping::visible_prefix(&self.text[r.start..r.end - 1], area.width);
|
||||
let line_range = r.start..r.start + visible.len();
|
||||
buf.set_style(Rect::new(area.x, y, area.width, 1), base_style);
|
||||
// Draw base line with the provided style.
|
||||
buf.set_stringn(
|
||||
area.x,
|
||||
y,
|
||||
text_for_display(&self.text[line_range.clone()]),
|
||||
text_for_display(visible),
|
||||
usize::from(area.width),
|
||||
base_style,
|
||||
);
|
||||
|
||||
// Overlay styled segments for elements that intersect this line.
|
||||
for elem in &self.elements {
|
||||
// Compute overlap with displayed slice.
|
||||
let overlap_start = elem.range.start.max(line_range.start);
|
||||
let overlap_end = elem.range.end.min(line_range.end);
|
||||
// Apply search highlights last so they remain visible over styled elements.
|
||||
let overlays = self
|
||||
.elements
|
||||
.iter()
|
||||
.map(|element| (&element.range, element_style))
|
||||
.chain(highlights.iter().map(|(range, style)| (range, *style)));
|
||||
for (overlay_range, style) in overlays {
|
||||
let overlap_start = overlay_range.start.max(line_range.start);
|
||||
let overlap_end = overlay_range.end.min(line_range.end);
|
||||
if overlap_start >= overlap_end {
|
||||
continue;
|
||||
}
|
||||
let styled = &self.text[overlap_start..overlap_end];
|
||||
let x_off = display_width(&self.text[line_range.start..overlap_start]) as u16;
|
||||
let style = base_style.fg(Color::Cyan);
|
||||
let x_off = display_width(&self.text[line_range.start..overlap_start]);
|
||||
if x_off >= usize::from(area.width) {
|
||||
continue;
|
||||
}
|
||||
let x_off = x_off as u16;
|
||||
buf.set_stringn(
|
||||
area.x + x_off,
|
||||
y,
|
||||
@@ -2064,25 +2064,6 @@ impl TextArea {
|
||||
style,
|
||||
);
|
||||
}
|
||||
|
||||
// Overlay render-only highlight ranges last so transient search highlighting remains
|
||||
// visible even when it intersects attachment placeholders or other styled elements.
|
||||
for (highlight_range, style) in highlights {
|
||||
let overlap_start = highlight_range.start.max(line_range.start);
|
||||
let overlap_end = highlight_range.end.min(line_range.end);
|
||||
if overlap_start >= overlap_end {
|
||||
continue;
|
||||
}
|
||||
let highlighted = &self.text[overlap_start..overlap_end];
|
||||
let x_off = display_width(&self.text[line_range.start..overlap_start]) as u16;
|
||||
buf.set_stringn(
|
||||
area.x + x_off,
|
||||
y,
|
||||
text_for_display(highlighted),
|
||||
usize::from(area.width.saturating_sub(x_off)),
|
||||
*style,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2098,8 +2079,8 @@ impl TextArea {
|
||||
for (row, idx) in range.enumerate() {
|
||||
let r = &lines[idx];
|
||||
let y = area.y + row as u16;
|
||||
let line_range = r.start..r.end - 1;
|
||||
let masked = self.text[line_range.clone()]
|
||||
let visible = wrapping::visible_prefix(&self.text[r.start..r.end - 1], area.width);
|
||||
let masked = visible
|
||||
.graphemes(/*is_extended*/ true)
|
||||
.flat_map(|grapheme| std::iter::repeat_n(mask_char, display_width(grapheme)))
|
||||
.collect::<String>();
|
||||
@@ -3646,7 +3627,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_space_after_full_line_stays_with_following_text() {
|
||||
fn separator_after_full_line_hangs_before_following_text() {
|
||||
let mut t = ta_with("abad a");
|
||||
t.set_cursor(t.text().len());
|
||||
let area = Rect::new(0, 0, /*width*/ 4, /*height*/ 2);
|
||||
@@ -3659,9 +3640,9 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(t.desired_height(area.width), 2);
|
||||
assert_eq!(t.cursor_pos(area), Some((2, 1)));
|
||||
assert_eq!(t.cursor_pos(area), Some((1, 1)));
|
||||
insta::assert_snapshot!(
|
||||
"textarea_leading_space_after_full_line_stays_with_following_text",
|
||||
"textarea_separator_after_full_line_hangs_before_following_text",
|
||||
format!("cursor: {:?}\n{}", t.cursor_pos(area), terminal.backend())
|
||||
);
|
||||
|
||||
@@ -3670,12 +3651,12 @@ mod tests {
|
||||
t.set_cursor(t.text().len());
|
||||
|
||||
assert_eq!(t.desired_height(area.width), 2);
|
||||
assert_eq!(t.cursor_pos(area), Some((2, 1)));
|
||||
assert_eq!(t.cursor_pos(area), Some((1, 1)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breakable_unicode_space_stays_with_following_text() {
|
||||
fn breakable_unicode_space_hangs_before_following_text() {
|
||||
let mut t = ta_with("abad\u{3000}abcde");
|
||||
t.set_cursor(t.text().len());
|
||||
let area = Rect::new(0, 0, /*width*/ 4, /*height*/ 3);
|
||||
@@ -3688,9 +3669,9 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(t.desired_height(area.width), 3);
|
||||
assert_eq!(t.cursor_pos(area), Some((3, 2)));
|
||||
assert_eq!(t.cursor_pos(area), Some((1, 2)));
|
||||
insta::assert_snapshot!(
|
||||
"textarea_breakable_unicode_space_stays_with_following_text",
|
||||
"textarea_breakable_unicode_space_hangs_before_following_text",
|
||||
format!("cursor: {:?}\n{}", t.cursor_pos(area), terminal.backend())
|
||||
);
|
||||
}
|
||||
@@ -3706,18 +3687,19 @@ mod tests {
|
||||
}
|
||||
|
||||
let mut t = ta_with("abad abcde xy zz");
|
||||
assert_eq!(t.desired_height(area.width), 5);
|
||||
let area = Rect::new(0, 0, /*width*/ 4, /*height*/ 4);
|
||||
assert_eq!(t.desired_height(area.width), 4);
|
||||
for (cursor, expected) in [
|
||||
(4, (0, 1)),
|
||||
(5, (1, 1)),
|
||||
(8, (0, 2)),
|
||||
(9, (1, 2)),
|
||||
(10, (2, 2)),
|
||||
(11, (0, 3)),
|
||||
(12, (1, 3)),
|
||||
(13, (2, 3)),
|
||||
(14, (0, 4)),
|
||||
(16, (2, 4)),
|
||||
(5, (0, 1)),
|
||||
(8, (3, 1)),
|
||||
(9, (0, 2)),
|
||||
(10, (1, 2)),
|
||||
(11, (2, 2)),
|
||||
(12, (3, 2)),
|
||||
(13, (0, 3)),
|
||||
(14, (0, 3)),
|
||||
(16, (2, 3)),
|
||||
] {
|
||||
t.set_cursor(cursor);
|
||||
assert_eq!(t.cursor_pos(area), Some(expected));
|
||||
@@ -3741,8 +3723,8 @@ mod tests {
|
||||
fn reflow_keeps_fitting_remainder_on_current_row() {
|
||||
for (text, width, expected_height, expected_cursor) in [
|
||||
(" -", 1, 4, (0, 3)),
|
||||
("p a b", 3, 3, (2, 2)),
|
||||
("p a b", 5, 2, (4, 1)),
|
||||
("p a b", 3, 3, (0, 2)),
|
||||
("p a b", 5, 2, (3, 1)),
|
||||
(" aaaa a ", 3, 4, (1, 3)),
|
||||
] {
|
||||
let mut t = ta_with(text);
|
||||
@@ -3752,7 +3734,10 @@ mod tests {
|
||||
assert_eq!(t.desired_height(area.width), expected_height);
|
||||
assert_eq!(t.cursor_pos(area), Some(expected_cursor));
|
||||
for range in t.wrapped_lines(width).iter() {
|
||||
assert!(display_width(&text[range.start..range.end - 1]) <= usize::from(width));
|
||||
assert!(
|
||||
display_width(text[range.start..range.end - 1].trim_end_matches(' '))
|
||||
<= usize::from(width)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3793,15 +3778,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_runs_wrap_before_later_text() {
|
||||
fn space_runs_hang_before_later_text() {
|
||||
let mut t = ta_with("abad next");
|
||||
let area = Rect::new(0, 0, /*width*/ 4, /*height*/ 4);
|
||||
let area = Rect::new(0, 0, /*width*/ 4, /*height*/ 3);
|
||||
|
||||
assert_eq!(t.desired_height(area.width), 4);
|
||||
assert_eq!(t.desired_height(area.width), 3);
|
||||
|
||||
for (cursor, expected) in [(4, (0, 1)), (6, (2, 1)), (8, (0, 2)), (9, (1, 2))] {
|
||||
for cursor in 4..=9 {
|
||||
t.set_cursor(cursor);
|
||||
assert_eq!(t.cursor_pos(area), Some(expected));
|
||||
assert_eq!(t.cursor_pos(area), Some((0, 1)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4200,7 +4185,7 @@ mod tests {
|
||||
fn wrapped_navigation_with_newlines_and_spaces() {
|
||||
// Include spaces and an explicit newline to exercise boundaries
|
||||
let mut t = ta_with("word1 word2\nword3");
|
||||
// Width 6 wraps "word1 " and " word2" before the newline.
|
||||
// Width 6 leaves both separators on the first visual row.
|
||||
let _ = t.desired_height(/*width*/ 6);
|
||||
|
||||
// Put the cursor at column 1 of "word2".
|
||||
@@ -4209,20 +4194,16 @@ mod tests {
|
||||
|
||||
// Up moves directly to the preceding visual line while preserving the visual column.
|
||||
t.move_cursor_up();
|
||||
assert_eq!(t.cursor(), 2);
|
||||
assert_eq!(t.cursor(), 1);
|
||||
|
||||
// Down returns to the same visual column of " word2".
|
||||
// Down returns to the same visual column of "word2".
|
||||
t.move_cursor_down();
|
||||
assert_eq!(t.cursor(), start_word2 + 1);
|
||||
|
||||
// A full line reserves an insertion row immediately before the explicit newline.
|
||||
t.move_cursor_down();
|
||||
assert_eq!(t.cursor(), t.text().find('\n').unwrap());
|
||||
|
||||
// Down again crosses the logical newline to the next visual line ("word3").
|
||||
// Down crosses the logical newline to the next visual line ("word3").
|
||||
t.move_cursor_down();
|
||||
let start_word3 = t.text().find("word3").unwrap();
|
||||
assert!(t.cursor() >= start_word3 && t.cursor() <= start_word3 + "word3".len());
|
||||
assert_eq!(t.cursor(), start_word3 + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/textarea/wrapping_tests.rs
|
||||
expression: "format!(\"cursors: {cursors:?}\\nscroll: {}\\ncursor: {scrolled_cursor:?}\\n{}\",\nstate.scroll, terminal.backend())"
|
||||
---
|
||||
cursors: [Some((0, 0)), Some((1, 0)), Some((2, 0)), Some((3, 0)), Some((0, 1)), Some((0, 1)), Some((1, 1))]
|
||||
scroll: 1
|
||||
cursor: Some((0, 0))
|
||||
"a "
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/textarea/wrapping_tests.rs
|
||||
expression: "format!(\"before: {before:?}\\nafter: {after:?}\\n{}\",\nterminal.backend()).replace(separator, \"\\\\u{2028}\")"
|
||||
---
|
||||
before: Some((0, 1))
|
||||
after: (7, Some((1, 1)))
|
||||
"abad"
|
||||
"\u{2028}nex"
|
||||
"t "
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/textarea/wrapping_tests.rs
|
||||
expression: "snapshots.join(\"\\n\\n\")"
|
||||
---
|
||||
full line
|
||||
text: "abad"
|
||||
cursor: (0, 1)
|
||||
"abad"
|
||||
" "
|
||||
|
||||
|
||||
trailing spaces
|
||||
text: "abad "
|
||||
cursor: (3, 1)
|
||||
"abad"
|
||||
" "
|
||||
|
||||
|
||||
following word
|
||||
text: "abad x"
|
||||
cursor: (1, 1)
|
||||
"abad"
|
||||
"x "
|
||||
|
||||
|
||||
delete following word
|
||||
text: "abad "
|
||||
cursor: (3, 1)
|
||||
"abad"
|
||||
" "
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/textarea/wrapping_tests.rs
|
||||
expression: "format!(\"{}\\n\\n{}\", cursors.join(\"\\n\"), layouts.join(\"\\n\"))"
|
||||
---
|
||||
up at width 10: (10, Some((0, 1)))
|
||||
resize to width 4: (10, Some((2, 2)))
|
||||
down to abcd: (14, Some((3, 3)))
|
||||
down to xyz: (20, Some((3, 4)))
|
||||
up to abcd: (14, Some((3, 3)))
|
||||
up to ij: (10, Some((2, 2)))
|
||||
restore width 10: (20, Some((9, 2)))
|
||||
|
||||
width 4
|
||||
"abcd"
|
||||
"efgh"
|
||||
"ij "
|
||||
"abcd"
|
||||
"xyz "
|
||||
|
||||
width 10
|
||||
"abcdefghij"
|
||||
" "
|
||||
"abcd xyz "
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Grapheme-safe composer wrapping that preserves textwrap's semantic breakpoints.
|
||||
//!
|
||||
//! Source ranges include a sentinel byte for cursor placement. Overflowing breakable whitespace
|
||||
//! stays attached to the following word, while full logical lines reserve an insertion row.
|
||||
//! Source ranges include a sentinel byte for cursor placement. Separators at soft breaks hang off
|
||||
//! the preceding row without changing the source text. Full logical lines reserve an insertion row.
|
||||
|
||||
use super::text_for_display;
|
||||
use crate::width::display_width;
|
||||
use std::ops::Range;
|
||||
use textwrap::Options;
|
||||
@@ -14,11 +15,43 @@ struct WrappedWord {
|
||||
width: usize,
|
||||
}
|
||||
|
||||
/// Classifies breakable whitespace; the three Unicode nonbreaking spaces stay in their word.
|
||||
fn is_breakable_space(ch: char) -> bool {
|
||||
ch.is_whitespace() && !matches!(ch, '\u{a0}' | '\u{2007}' | '\u{202f}')
|
||||
}
|
||||
|
||||
/// Mandatory Unicode breaks retain their editable insertion positions instead of hanging.
|
||||
fn is_hangable_space(ch: char) -> bool {
|
||||
is_breakable_space(ch)
|
||||
&& !matches!(
|
||||
ch,
|
||||
'\n' | '\r' | '\u{b}' | '\u{c}' | '\u{85}' | '\u{2028}' | '\u{2029}'
|
||||
)
|
||||
}
|
||||
|
||||
/// Drops a row's hidden suffix once soft whitespace alone fills the viewport.
|
||||
///
|
||||
/// Each hangable separator occupies at least one rendered cell. Keeping the complete grapheme
|
||||
/// preserves any combining marks on the last visible space, while bounding work on long runs.
|
||||
pub(super) fn visible_prefix(text: &str, width: u16) -> &str {
|
||||
if width == 0 {
|
||||
return "";
|
||||
}
|
||||
if text.len() <= usize::from(width) {
|
||||
return text;
|
||||
}
|
||||
text.grapheme_indices(/*is_extended*/ true)
|
||||
.filter(|(_, grapheme)| grapheme.chars().any(is_hangable_space))
|
||||
.nth(usize::from(width) - 1)
|
||||
.map_or(text, |(start, grapheme)| &text[..start + grapheme.len()])
|
||||
}
|
||||
|
||||
/// Returns grapheme-safe visual ranges, each including its cursor-position sentinel byte.
|
||||
///
|
||||
/// Breakable Unicode whitespace stays with the next word, nonbreaking whitespace remains part of
|
||||
/// its word, and full logical lines receive an empty insertion row. Word boundaries and widths are
|
||||
/// indexed once so maximum-size pasted lines remain linear to wrap.
|
||||
/// Interior breakable whitespace can extend past the row width, but indentation and trailing
|
||||
/// whitespace remain visible and editable. Nonbreaking whitespace remains part of its word, and
|
||||
/// full logical lines receive an empty insertion row. Word boundaries and widths are indexed once
|
||||
/// so maximum-size pasted lines remain linear to wrap.
|
||||
pub(super) fn wrapped_lines(text: &str, width: u16) -> Vec<Range<usize>> {
|
||||
let width = usize::from(width);
|
||||
let options = Options::new(width).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit);
|
||||
@@ -52,6 +85,7 @@ pub(super) fn wrapped_lines(text: &str, width: u16) -> Vec<Range<usize>> {
|
||||
let mut line_width = 0;
|
||||
let mut line_has_text = false;
|
||||
let mut line_active = false;
|
||||
let mut logical_content_end = 0;
|
||||
let mut processed_end = 0;
|
||||
let mut previous_was_whitespace = false;
|
||||
let mut word_index = 0;
|
||||
@@ -73,6 +107,12 @@ pub(super) fn wrapped_lines(text: &str, width: u16) -> Vec<Range<usize>> {
|
||||
if !line_active {
|
||||
line_start = fragment_start;
|
||||
line_active = true;
|
||||
let logical_line = text[fragment_start..]
|
||||
.split('\n')
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
logical_content_end =
|
||||
fragment_start + logical_line.trim_end_matches(is_hangable_space).len();
|
||||
} else if line_has_text && breakpoints[fragment_start] {
|
||||
let remaining_word_width = words
|
||||
.get(word_index)
|
||||
@@ -97,8 +137,7 @@ pub(super) fn wrapped_lines(text: &str, width: u16) -> Vec<Range<usize>> {
|
||||
}
|
||||
|
||||
let grapheme_end = grapheme_start + grapheme.len();
|
||||
let is_whitespace = grapheme.chars().all(char::is_whitespace)
|
||||
&& (grapheme == " " || breakpoints[grapheme_end]);
|
||||
let is_whitespace = grapheme.chars().all(is_breakable_space);
|
||||
// Partial whitespace rows absorb the next word; text rows keep it intact.
|
||||
if !is_whitespace && line_has_text && previous_was_whitespace {
|
||||
let word_end = words
|
||||
@@ -114,7 +153,10 @@ pub(super) fn wrapped_lines(text: &str, width: u16) -> Vec<Range<usize>> {
|
||||
}
|
||||
|
||||
let grapheme_width = display_width(grapheme);
|
||||
if line_width > 0 && line_width + grapheme_width > width {
|
||||
let hanging_space = line_has_text
|
||||
&& grapheme_end < logical_content_end
|
||||
&& grapheme.chars().all(is_hangable_space);
|
||||
if !hanging_space && line_width > 0 && line_width + grapheme_width > width {
|
||||
lines.push(line_start..grapheme_start + 1);
|
||||
line_start = grapheme_start;
|
||||
line_width = 0;
|
||||
@@ -148,6 +190,36 @@ pub(super) fn wrapped_lines(text: &str, width: u16) -> Vec<Range<usize>> {
|
||||
lines
|
||||
}
|
||||
|
||||
/// Maps a source insertion point to a zero-based visual row and column.
|
||||
///
|
||||
/// `lines` must come from [`wrapped_lines`] for the display-normalized text and the same width,
|
||||
/// and `pos` must be a valid source boundary. The measured prefix uses the same normalization.
|
||||
/// Positions in overflowing separators share the following row's first column without changing
|
||||
/// their distinct editing offsets. Other columns stay inside the viewport.
|
||||
pub(super) fn cursor_position(
|
||||
text: &str,
|
||||
lines: &[Range<usize>],
|
||||
width: u16,
|
||||
pos: usize,
|
||||
) -> Option<(usize, usize)> {
|
||||
let row = lines
|
||||
.partition_point(|line| line.start <= pos)
|
||||
.checked_sub(1)?;
|
||||
let before_cursor = visible_prefix(&text[lines[row].start..pos], width);
|
||||
let before_cursor = text_for_display(before_cursor);
|
||||
let col = display_width(before_cursor.as_ref());
|
||||
let width = usize::from(width);
|
||||
if col >= width
|
||||
&& lines
|
||||
.get(row + 1)
|
||||
.is_some_and(|next| next.start == lines[row].end - 1)
|
||||
{
|
||||
Some((row + 1, 0))
|
||||
} else {
|
||||
Some((row, col.min(width.saturating_sub(1))))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "wrapping_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,9 +1,102 @@
|
||||
use super::super::TextArea;
|
||||
use super::super::TextAreaState;
|
||||
use super::visible_prefix;
|
||||
use super::wrapped_lines;
|
||||
use crate::width::display_width;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::adaptive_wrap_lines;
|
||||
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::StatefulWidgetRef;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
fn wrapped_rows(text: &str, width: u16) -> Vec<&str> {
|
||||
wrapped_lines(text, width)
|
||||
.into_iter()
|
||||
.map(|range| &text[range.start..range.end - 1])
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_navigation_preserves_destination_tab_columns() {
|
||||
let mut t = TextArea::new();
|
||||
t.insert_str("a\tb\nxyz");
|
||||
let area = Rect::new(0, 0, /*width*/ 8, /*height*/ 2);
|
||||
t.set_cursor(/*pos*/ 5);
|
||||
let _ = t.desired_height(area.width);
|
||||
t.move_cursor_up();
|
||||
assert_eq!((t.cursor(), t.cursor_pos(area)), (1, Some((1, 0))));
|
||||
t.move_cursor_down();
|
||||
assert_eq!((t.cursor(), t.cursor_pos(area)), (5, Some((1, 1))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mandatory_breaks_keep_distinct_cursor_positions() {
|
||||
let area = Rect::new(0, 0, /*width*/ 4, /*height*/ 3);
|
||||
for separator in ["\u{b}", "\u{c}", "\u{85}", "\u{2028}", "\u{2029}", "\r"] {
|
||||
let text = format!("abad{separator}next");
|
||||
let rows = wrapped_rows(&text, area.width);
|
||||
assert_eq!(rows, ["abad", &format!("{separator}nex"), "t"]);
|
||||
|
||||
let mut t = TextArea::new();
|
||||
t.insert_str(&text);
|
||||
t.set_cursor(/*pos*/ 4);
|
||||
let before = t.cursor_pos(area);
|
||||
t.move_cursor_right();
|
||||
let after = (t.cursor(), t.cursor_pos(area));
|
||||
assert_eq!(
|
||||
(before, after),
|
||||
(Some((0, 1)), (4 + separator.len(), Some((1, 1))))
|
||||
);
|
||||
t.move_cursor_left();
|
||||
assert_eq!((t.cursor(), t.cursor_pos(area)), (4, before));
|
||||
assert_eq!(t.text(), text);
|
||||
|
||||
if separator == "\u{2028}" {
|
||||
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
WidgetRef::render_ref(&(&t), frame.area(), frame.buffer_mut());
|
||||
})
|
||||
.unwrap();
|
||||
insta::assert_snapshot!(
|
||||
"mandatory_break_cursor",
|
||||
format!(
|
||||
"before: {before:?}\nafter: {after:?}\n{}",
|
||||
terminal.backend()
|
||||
)
|
||||
.replace(separator, "\\u{2028}")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_prefix_bounds_long_hanging_runs_without_splitting_graphemes() {
|
||||
let width = 80;
|
||||
let count = MAX_USER_INPUT_TEXT_CHARS - 2;
|
||||
let area = Rect::new(0, 0, width, /*height*/ 2);
|
||||
for separator in [" ", "\t", "\u{2003}", "\u{3000}"] {
|
||||
let text = format!("a{}x", separator.repeat(count));
|
||||
assert_eq!(
|
||||
visible_prefix(&text, width),
|
||||
format!("a{}", separator.repeat(usize::from(width)))
|
||||
);
|
||||
let mut t = TextArea::new();
|
||||
t.insert_str(&text);
|
||||
t.set_cursor(text.len() - 1 - separator.len());
|
||||
assert_eq!(t.cursor_pos(area), Some((0, 1)));
|
||||
assert_eq!(t.text(), text);
|
||||
}
|
||||
assert_eq!(
|
||||
visible_prefix("a \u{301} hidden", /*width*/ 2),
|
||||
"a \u{301}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_textwrap_word_boundaries() {
|
||||
@@ -28,18 +121,234 @@ fn preserves_textwrap_word_boundaries() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breakable_unicode_spaces_stay_with_following_words() {
|
||||
fn breakable_unicode_spaces_hang_before_following_words() {
|
||||
for (text, expected_rows) in [
|
||||
("abad abcde", ["abad", " abc", "de"]),
|
||||
("abad\u{2003}abcde", ["abad", "\u{2003}abc", "de"]),
|
||||
("abad\u{3000}abcde", ["abad", "\u{3000}ab", "cde"]),
|
||||
("abad abcde", ["abad ", "abcd", "e"]),
|
||||
("abad\u{2003}abcde", ["abad\u{2003}", "abcd", "e"]),
|
||||
(
|
||||
"abad\u{2003}\u{2003}abcde",
|
||||
["abad\u{2003}\u{2003}", "abcd", "e"],
|
||||
),
|
||||
("abad\u{3000}abcde", ["abad\u{3000}", "abcd", "e"]),
|
||||
(
|
||||
"abad\u{3000} \u{2003}abcde",
|
||||
["abad\u{3000} \u{2003}", "abcd", "e"],
|
||||
),
|
||||
] {
|
||||
let rows = wrapped_lines(text, /*width*/ 4)
|
||||
.iter()
|
||||
.map(|range| &text[range.start..range.end - 1])
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
wrapped_rows(text, /*width*/ 4),
|
||||
expected_rows,
|
||||
"text={text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(rows, expected_rows, "text={text:?}");
|
||||
#[test]
|
||||
fn hanging_spaces_preserve_source_editing_and_visual_navigation() {
|
||||
let text = "abad next";
|
||||
let area = Rect::new(0, 0, /*width*/ 4, /*height*/ 3);
|
||||
let mut t = TextArea::new();
|
||||
t.insert_str(text);
|
||||
assert_eq!(t.desired_height(area.width), 3);
|
||||
assert_eq!(t.text(), text);
|
||||
|
||||
for cursor in 4..=6 {
|
||||
t.set_cursor(cursor);
|
||||
assert_eq!(t.cursor_pos(area), Some((0, 1)));
|
||||
}
|
||||
t.move_cursor_left();
|
||||
assert_eq!(t.cursor(), 5);
|
||||
t.move_cursor_up();
|
||||
assert_eq!(t.cursor(), 0);
|
||||
t.move_cursor_down();
|
||||
assert_eq!(t.cursor(), 6);
|
||||
t.delete_backward(/*n*/ 1);
|
||||
assert_eq!(t.text(), "abad next");
|
||||
assert_eq!(t.cursor_pos(area), Some((0, 1)));
|
||||
t.delete_backward(/*n*/ 1);
|
||||
assert_eq!(t.text(), "abadnext");
|
||||
assert_eq!(t.cursor_pos(area), Some((0, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hanging_tabs_keep_cursor_scrolling_and_navigation_aligned() {
|
||||
let text = "abc\t a";
|
||||
let area = Rect::new(0, 0, /*width*/ 4, /*height*/ 2);
|
||||
let mut t = TextArea::new();
|
||||
t.insert_str(text);
|
||||
let cursors = (0..=text.len())
|
||||
.map(|pos| {
|
||||
t.set_cursor(pos);
|
||||
t.cursor_pos(area)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
cursors,
|
||||
[(0, 0), (1, 0), (2, 0), (3, 0), (0, 1), (0, 1), (1, 1)].map(Some)
|
||||
);
|
||||
|
||||
t.set_cursor(/*pos*/ 4);
|
||||
let mut state = TextAreaState::default();
|
||||
let mut terminal = Terminal::new(TestBackend::new(area.width, /*height*/ 1)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
StatefulWidgetRef::render_ref(&(&t), frame.area(), frame.buffer_mut(), &mut state);
|
||||
})
|
||||
.unwrap();
|
||||
let scrolled_cursor = t.cursor_pos_with_state(terminal.backend().buffer().area, state);
|
||||
assert_eq!((state.scroll, scrolled_cursor), (1, Some((0, 0))));
|
||||
insta::assert_snapshot!(
|
||||
"hanging_tab_cursor_and_scroll",
|
||||
format!(
|
||||
"cursors: {cursors:?}\nscroll: {}\ncursor: {scrolled_cursor:?}\n{}",
|
||||
state.scroll,
|
||||
terminal.backend()
|
||||
)
|
||||
);
|
||||
|
||||
t.move_cursor_up();
|
||||
assert_eq!((t.cursor(), t.cursor_pos(area)), (0, Some((0, 0))));
|
||||
t.move_cursor_down();
|
||||
assert_eq!((t.cursor(), t.cursor_pos(area)), (5, Some((0, 1))));
|
||||
assert_eq!(t.text(), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_navigation_clamps_saved_column_after_resize() {
|
||||
let text = "abcdefghij\nabcd xyz";
|
||||
let mut t = TextArea::new();
|
||||
t.insert_str(text);
|
||||
let mut cursors = Vec::new();
|
||||
let mut record = |t: &TextArea, label: &str, width: u16, expected| {
|
||||
let area = Rect::new(0, 0, width, t.desired_height(width));
|
||||
let cursor = (t.cursor(), t.cursor_pos(area));
|
||||
assert_eq!(cursor, expected, "{label}");
|
||||
cursors.push(format!("{label}: {cursor:?}"));
|
||||
};
|
||||
|
||||
let _ = t.desired_height(/*width*/ 10);
|
||||
t.move_cursor_up();
|
||||
record(&t, "up at width 10", /*width*/ 10, (10, Some((0, 1))));
|
||||
record(
|
||||
&t,
|
||||
"resize to width 4",
|
||||
/*width*/ 4,
|
||||
(10, Some((2, 2))),
|
||||
);
|
||||
t.move_cursor_down();
|
||||
record(&t, "down to abcd", /*width*/ 4, (14, Some((3, 3))));
|
||||
t.move_cursor_down();
|
||||
record(&t, "down to xyz", /*width*/ 4, (20, Some((3, 4))));
|
||||
t.move_cursor_up();
|
||||
record(&t, "up to abcd", /*width*/ 4, (14, Some((3, 3))));
|
||||
t.move_cursor_up();
|
||||
record(&t, "up to ij", /*width*/ 4, (10, Some((2, 2))));
|
||||
let _ = t.desired_height(/*width*/ 10);
|
||||
t.move_cursor_down();
|
||||
record(
|
||||
&t,
|
||||
"restore width 10",
|
||||
/*width*/ 10,
|
||||
(20, Some((9, 2))),
|
||||
);
|
||||
|
||||
assert_eq!(t.text(), text);
|
||||
let layouts = [4, 10].map(|width| {
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, t.desired_height(width))).unwrap();
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
WidgetRef::render_ref(&(&t), frame.area(), frame.buffer_mut());
|
||||
})
|
||||
.unwrap();
|
||||
format!("width {width}\n{}", terminal.backend())
|
||||
});
|
||||
insta::assert_snapshot!(
|
||||
"vertical_navigation_after_resize",
|
||||
format!("{}\n\n{}", cursors.join("\n"), layouts.join("\n"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_after_eol_spaces_reflows_without_changing_text() {
|
||||
let area = Rect::new(0, 0, /*width*/ 4, /*height*/ 2);
|
||||
let mut t = TextArea::new();
|
||||
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
|
||||
let mut snapshots = Vec::new();
|
||||
for (label, input, expected_cursor) in [
|
||||
("full line", "abad", (0, 1)),
|
||||
("trailing spaces", " ", (3, 1)),
|
||||
("following word", "x", (1, 1)),
|
||||
("delete following word", "", (3, 1)),
|
||||
] {
|
||||
if input.is_empty() {
|
||||
t.delete_backward(/*n*/ 1);
|
||||
} else {
|
||||
t.insert_str(input);
|
||||
}
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
WidgetRef::render_ref(&(&t), frame.area(), frame.buffer_mut());
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(t.cursor_pos(area), Some(expected_cursor));
|
||||
snapshots.push(format!(
|
||||
"{label}\ntext: {:?}\ncursor: {expected_cursor:?}\n{}",
|
||||
t.text(),
|
||||
terminal.backend()
|
||||
));
|
||||
}
|
||||
assert_eq!(t.text(), "abad ");
|
||||
insta::assert_snapshot!("typing_after_eol_spaces", snapshots.join("\n\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_indentation_and_trailing_spaces_do_not_hang() {
|
||||
for (text, expected_rows) in [
|
||||
("abad\n a", vec!["abad", "", " a"]),
|
||||
("abad \n a", vec!["abad", " ", " a"]),
|
||||
(" a", vec![" ", "a"]),
|
||||
("abad ", vec!["abad", " "]),
|
||||
("abad\u{2003}", vec!["abad", "\u{2003}"]),
|
||||
("abad\u{3000}\n a", vec!["abad", "\u{3000}", " a"]),
|
||||
] {
|
||||
assert_eq!(
|
||||
wrapped_rows(text, /*width*/ 4),
|
||||
expected_rows,
|
||||
"text={text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonbreaking_spaces_never_hang() {
|
||||
for space in ['\u{a0}', '\u{2007}', '\u{202f}'] {
|
||||
let text = format!("abcd{space} x");
|
||||
let last = format!("{space} x");
|
||||
assert_eq!(wrapped_rows(&text, /*width*/ 4), ["abcd", last.as_str()]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prose_wraps_like_the_queued_input_preview() {
|
||||
for text in [
|
||||
"new access tokens",
|
||||
"aaaa next",
|
||||
"a foo-barbaz",
|
||||
"some ordinary prose",
|
||||
] {
|
||||
for width in 3..=16 {
|
||||
let composer = wrapped_rows(text, width)
|
||||
.into_iter()
|
||||
.map(|row| row.trim_end_matches(' '))
|
||||
.filter(|row| !row.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let preview =
|
||||
adaptive_wrap_lines([Line::from(text)], RtOptions::new(usize::from(width)))
|
||||
.into_iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(composer, preview, "text={text:?}, width={width}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +389,7 @@ fn ascii_wrapped_rows_fit_and_preserve_cursor_positions() {
|
||||
end = range.end - 1;
|
||||
let row = &text[range.start..end];
|
||||
assert!(
|
||||
display_width(row) <= usize::from(width),
|
||||
display_width(row.trim_end_matches(' ')) <= usize::from(width),
|
||||
"text={text:?}, width={width}, row={row:?}"
|
||||
);
|
||||
}
|
||||
@@ -94,7 +403,8 @@ fn ascii_wrapped_rows_fit_and_preserve_cursor_positions() {
|
||||
if let Some(previous) = previous {
|
||||
assert!(
|
||||
position.1 > previous.1
|
||||
|| (position.1 == previous.1 && position.0 > previous.0),
|
||||
|| (position.1 == previous.1 && position.0 > previous.0)
|
||||
|| (position == previous && text.as_bytes()[cursor - 1] == b' '),
|
||||
"text={text:?}, width={width}, ranges={ranges:?}, cursor={cursor}, previous={previous:?}, position={position:?}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user