From a16863f8704831d13e041ed7dba2c4a57a2a940b Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 9 Aug 2026 15:44:33 +0000 Subject: [PATCH] Keep wrapped composer whitespace with following text (#37709) ## Why Overflowing whitespace in the TUI composer could occupy a separate blank row instead of remaining attached to the text that follows it. ## What changed - Add composer-specific, grapheme-safe wrapping that keeps breakable Unicode whitespace with the following word while preserving semantic breakpoints such as hyphens and nonbreaking spaces. - Preserve cursor insertion rows and positions as content reflows, including across full lines and wrapped fragments. - Keep wrapping linear for maximum-length unbroken input. ## Testing Add focused rendering and cursor tests for ASCII and Unicode whitespace, hyphenated words, reflowed fragments, and maximum-length input. GitOrigin-RevId: fb7713e33bfddf37460b6c2f6f0c9700f31d86bf --- ...icode_space_stays_with_following_text.snap | 8 + ...r_full_line_stays_with_following_text.snap | 7 + ...ws_across_following_wrapped_fragments.snap | 10 + ...eeps_fitting_remainder_on_current_row.snap | 7 + ..._preserves_hyphenated_word_breakpoint.snap | 7 + ...led_overlays_stay_inside_the_textarea.snap | 8 +- codex-rs/tui/src/bottom_pane/textarea.rs | 217 ++++++++++++++---- .../tui/src/bottom_pane/textarea/wrapping.rs | 153 ++++++++++++ .../bottom_pane/textarea/wrapping_tests.rs | 106 +++++++++ 9 files changed, 468 insertions(+), 55 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_breakable_unicode_space_stays_with_following_text.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_leading_space_after_full_line_stays_with_following_text.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_leading_space_reflows_across_following_wrapped_fragments.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_reflow_keeps_fitting_remainder_on_current_row.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_reflow_preserves_hyphenated_word_breakpoint.snap create mode 100644 codex-rs/tui/src/bottom_pane/textarea/wrapping.rs create mode 100644 codex-rs/tui/src/bottom_pane/textarea/wrapping_tests.rs diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_breakable_unicode_space_stays_with_following_text.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_breakable_unicode_space_stays_with_following_text.snap new file mode 100644 index 0000000000..a7769536fd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_breakable_unicode_space_stays_with_following_text.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())" +--- +cursor: Some((3, 2)) +"abad" +" ab" Hidden by multi-width symbols: [(1, " ")] +"cde " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_leading_space_after_full_line_stays_with_following_text.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_leading_space_after_full_line_stays_with_following_text.snap new file mode 100644 index 0000000000..6adc45549a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_leading_space_after_full_line_stays_with_following_text.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())" +--- +cursor: Some((2, 1)) +"abad" +" a " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_leading_space_reflows_across_following_wrapped_fragments.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_leading_space_reflows_across_following_wrapped_fragments.snap new file mode 100644 index 0000000000..e5cf6dd2db --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_leading_space_reflows_across_following_wrapped_fragments.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())" +--- +cursor: Some((2, 4)) +"abad" +" abc" +"de " +"xy " +"zz " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_reflow_keeps_fitting_remainder_on_current_row.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_reflow_keeps_fitting_remainder_on_current_row.snap new file mode 100644 index 0000000000..d87d057d97 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_reflow_keeps_fitting_remainder_on_current_row.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())" +--- +cursor: Some((4, 1)) +"p " +" a b " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_reflow_preserves_hyphenated_word_breakpoint.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_reflow_preserves_hyphenated_word_breakpoint.snap new file mode 100644 index 0000000000..fbc9a5ce14 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_reflow_preserves_hyphenated_word_breakpoint.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())" +--- +cursor: Some((6, 1)) +"a foo- " +"barbaz " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_styled_overlays_stay_inside_the_textarea.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_styled_overlays_stay_inside_the_textarea.snap index 91e2d72f80..2be86414e2 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_styled_overlays_stay_inside_the_textarea.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_styled_overlays_stay_inside_the_textarea.snap @@ -4,11 +4,11 @@ expression: "snapshots.join(\"\\n\\n\")" --- element " KEEP" -"next " -" " +" nex " +"t " highlight " KEEP" -"next " -" " +" nex " +"t " diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 36ab177ee0..95ef9ddc82 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -11,7 +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. +//! 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. use crate::key_hint::KeyBindingListExt; use crate::key_hint::is_altgr; @@ -38,10 +39,10 @@ use std::borrow::Cow; use std::cell::Ref; use std::cell::RefCell; use std::ops::Range; -use textwrap::Options; use unicode_segmentation::UnicodeSegmentation; mod vim; +mod wrapping; use self::vim::VimMode; use self::vim::VimMotion; use self::vim::VimOperator; @@ -1881,8 +1882,9 @@ impl TextArea { /// Returns cached grapheme-safe visual ranges, including cursor-position sentinel bytes. /// - /// Overflowing spaces get their own rows, and full logical lines receive a continuation row - /// so their insertion point stays visible. + /// Overflowing spaces wrap without separating a partial whitespace continuation from the next + /// word, existing word breakpoints stay intact, and full logical lines receive a continuation + /// row so their insertion point stays visible. #[expect(clippy::unwrap_used)] fn wrapped_lines(&self, width: u16) -> Ref<'_, Vec>> { // Ensure cache is ready (potentially mutably borrow, then drop) @@ -1894,39 +1896,7 @@ impl TextArea { }; if needs_recalc { let display_text = text_for_display(&self.text); - let mut lines = crate::wrapping::wrap_ranges( - display_text.as_ref(), - Options::new(width as usize).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), - ); - if width > 0 { - let wrapped_lines = lines; - lines = Vec::with_capacity(wrapped_lines.len()); - for wrapped_line in wrapped_lines { - let line_end = wrapped_line.end.saturating_sub(1); - let mut line_start = wrapped_line.start; - let mut line_width = 0; - - for (offset, grapheme) in display_text[wrapped_line.start..line_end] - .grapheme_indices(/*is_extended*/ true) - { - let grapheme_width = display_width(grapheme); - if line_width > 0 && line_width + grapheme_width > usize::from(width) { - let next_line_start = wrapped_line.start + offset; - lines.push(line_start..next_line_start + 1); - line_start = next_line_start; - line_width = 0; - } - line_width += grapheme_width; - } - - lines.push(line_start..line_end + 1); - if line_width >= usize::from(width) - && matches!(display_text.as_bytes().get(line_end), None | Some(b'\n')) - { - lines.push(line_end..line_end + 1); - } - } - } + let lines = wrapping::wrapped_lines(display_text.as_ref(), width); *cache = Some(WrapCache { width, lines }); } } @@ -3602,14 +3572,161 @@ mod tests { ); } + #[test] + fn leading_space_after_full_line_stays_with_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); + let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); + let mut state = TextAreaState::default(); + terminal + .draw(|frame| { + StatefulWidgetRef::render_ref(&(&t), frame.area(), frame.buffer_mut(), &mut state); + }) + .unwrap(); + + assert_eq!(t.desired_height(area.width), 2); + assert_eq!(t.cursor_pos(area), Some((2, 1))); + insta::assert_snapshot!( + "textarea_leading_space_after_full_line_stays_with_following_text", + format!("cursor: {:?}\n{}", t.cursor_pos(area), terminal.backend()) + ); + + for text in ["界界 a", "abガ a", "abad\ta"] { + let mut t = ta_with(text); + t.set_cursor(t.text().len()); + + assert_eq!(t.desired_height(area.width), 2); + assert_eq!(t.cursor_pos(area), Some((2, 1))); + } + } + + #[test] + fn breakable_unicode_space_stays_with_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); + let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); + let mut state = TextAreaState::default(); + terminal + .draw(|frame| { + StatefulWidgetRef::render_ref(&(&t), frame.area(), frame.buffer_mut(), &mut state); + }) + .unwrap(); + + assert_eq!(t.desired_height(area.width), 3); + assert_eq!(t.cursor_pos(area), Some((3, 2))); + insta::assert_snapshot!( + "textarea_breakable_unicode_space_stays_with_following_text", + format!("cursor: {:?}\n{}", t.cursor_pos(area), terminal.backend()) + ); + } + + #[test] + fn leading_space_reflows_across_following_wrapped_fragments() { + let mut t = ta_with("abad abcde wxyz"); + let area = Rect::new(0, 0, /*width*/ 4, /*height*/ 5); + assert_eq!(t.desired_height(area.width), 5); + for (cursor, expected) in [(11, (0, 3)), (14, (3, 3)), (15, (0, 4))] { + t.set_cursor(cursor); + assert_eq!(t.cursor_pos(area), Some(expected)); + } + + let mut t = ta_with("abad abcde xy zz"); + assert_eq!(t.desired_height(area.width), 5); + 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)), + ] { + t.set_cursor(cursor); + assert_eq!(t.cursor_pos(area), Some(expected)); + } + + let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); + let mut state = TextAreaState::default(); + terminal + .draw(|frame| { + StatefulWidgetRef::render_ref(&(&t), frame.area(), frame.buffer_mut(), &mut state); + }) + .unwrap(); + + insta::assert_snapshot!( + "textarea_leading_space_reflows_across_following_wrapped_fragments", + format!("cursor: {:?}\n{}", t.cursor_pos(area), terminal.backend()) + ); + } + + #[test] + 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)), + (" aaaa a ", 3, 4, (1, 3)), + ] { + let mut t = ta_with(text); + t.set_cursor(t.text().len()); + let area = Rect::new(0, 0, width, expected_height); + + 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)); + } + } + + let mut t = ta_with("p a b"); + t.set_cursor(t.text().len()); + let area = Rect::new(0, 0, /*width*/ 5, /*height*/ 2); + let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); + let mut state = TextAreaState::default(); + terminal + .draw(|frame| { + StatefulWidgetRef::render_ref(&(&t), frame.area(), frame.buffer_mut(), &mut state); + }) + .unwrap(); + + insta::assert_snapshot!( + "textarea_reflow_keeps_fitting_remainder_on_current_row", + format!("cursor: {:?}\n{}", t.cursor_pos(area), terminal.backend()) + ); + } + + #[test] + fn reflow_preserves_hyphenated_word_breakpoint_when_rendering() { + let mut t = ta_with("a foo-barbaz"); + t.set_cursor(t.text().len()); + let area = Rect::new(0, 0, /*width*/ 10, /*height*/ 2); + let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); + let mut state = TextAreaState::default(); + terminal + .draw(|frame| { + StatefulWidgetRef::render_ref(&(&t), frame.area(), frame.buffer_mut(), &mut state); + }) + .unwrap(); + + insta::assert_snapshot!( + "textarea_reflow_preserves_hyphenated_word_breakpoint", + format!("cursor: {:?}\n{}", t.cursor_pos(area), terminal.backend()) + ); + } + #[test] fn space_runs_wrap_before_later_text() { let mut t = ta_with("abad next"); - let area = Rect::new(0, 0, /*width*/ 4, /*height*/ 5); + let area = Rect::new(0, 0, /*width*/ 4, /*height*/ 4); - assert_eq!(t.desired_height(area.width), 5); + assert_eq!(t.desired_height(area.width), 4); - for (cursor, expected) in [(4, (0, 1)), (6, (2, 1)), (8, (0, 2)), (9, (0, 3))] { + for (cursor, expected) in [(4, (0, 1)), (6, (2, 1)), (8, (0, 2)), (9, (1, 2))] { t.set_cursor(cursor); assert_eq!(t.cursor_pos(area), Some(expected)); } @@ -4010,28 +4127,26 @@ 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 ", its remaining space, and then "word2" before the newline. + // Width 6 wraps "word1 " and " word2" before the newline. let _ = t.desired_height(/*width*/ 6); // Put the cursor at column 1 of "word2". let start_word2 = t.text().find("word2").unwrap(); t.set_cursor(start_word2 + 1); - // Up first lands on the short whitespace continuation instead of skipping it. + // Up moves directly to the preceding visual line while preserving the visual column. t.move_cursor_up(); - assert_eq!(t.cursor(), start_word2 - 1); + assert_eq!(t.cursor(), 2); - // A second Up preserves the preferred column on the first wrapped line. - t.move_cursor_up(); - assert_eq!(t.cursor(), 1); - - // Down visits the whitespace continuation before returning to "word2". - t.move_cursor_down(); - assert_eq!(t.cursor(), start_word2 - 1); + // Down returns to the same visual column of " word2". t.move_cursor_down(); assert_eq!(t.cursor(), start_word2 + 1); - // Down again should cross the logical newline to the next visual line ("word3"), clamped to its length if needed + // 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"). t.move_cursor_down(); let start_word3 = t.text().find("word3").unwrap(); assert!(t.cursor() >= start_word3 && t.cursor() <= start_word3 + "word3".len()); diff --git a/codex-rs/tui/src/bottom_pane/textarea/wrapping.rs b/codex-rs/tui/src/bottom_pane/textarea/wrapping.rs new file mode 100644 index 0000000000..bd4b96690a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/wrapping.rs @@ -0,0 +1,153 @@ +//! 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. + +use crate::width::display_width; +use std::ops::Range; +use textwrap::Options; +use unicode_segmentation::UnicodeSegmentation; + +/// Cached source span and display width for a word identified by `textwrap`. +struct WrappedWord { + range: Range, + width: usize, +} + +/// 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. +pub(super) fn wrapped_lines(text: &str, width: u16) -> Vec> { + let width = usize::from(width); + let options = Options::new(width).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit); + let wrapped_lines = crate::wrapping::wrap_ranges(text, &options); + if width == 0 { + return wrapped_lines; + } + + let mut breakpoints = vec![false; text.len() + 1]; + let mut words = Vec::new(); + let mut logical_start = 0; + for logical_line in text.split('\n') { + let mut word_start = logical_start; + for word in options.word_separator.find_words(logical_line) { + let word_end = word_start + word.word.len(); + for breakpoint in options.word_splitter.split_points(word.word) { + breakpoints[word_start + breakpoint] = true; + } + breakpoints[word_end] = true; + words.push(WrappedWord { + range: word_start..word_end, + width: display_width(word.word), + }); + word_start = word_end + word.whitespace.len(); + } + logical_start += logical_line.len() + 1; + } + + let mut lines = Vec::with_capacity(wrapped_lines.len()); + let mut line_start = 0; + let mut line_width = 0; + let mut line_has_text = false; + let mut line_active = false; + let mut processed_end = 0; + let mut previous_was_whitespace = false; + let mut word_index = 0; + let mut consumed_word_width = 0; + + for wrapped_line in wrapped_lines { + let line_end = wrapped_line.end.saturating_sub(1); + // textwrap can repeat leading whitespace around punctuation boundaries. + let fragment_start = wrapped_line.start.max(processed_end); + if fragment_start > line_end { + continue; + } + + while word_index < words.len() && words[word_index].range.end <= fragment_start { + word_index += 1; + consumed_word_width = 0; + } + + if !line_active { + line_start = fragment_start; + line_active = true; + } else if line_has_text && breakpoints[fragment_start] { + let remaining_word_width = words + .get(word_index) + .filter(|word| word.range.contains(&fragment_start)) + .map_or(0, |word| word.width.saturating_sub(consumed_word_width)); + // Keep textwrap's break if crossing it would split the next word. + if line_width + remaining_word_width > width { + lines.push(line_start..fragment_start + 1); + line_start = fragment_start; + line_width = 0; + line_has_text = false; + } + } + + for (offset, grapheme) in + text[fragment_start..line_end].grapheme_indices(/*is_extended*/ true) + { + let grapheme_start = fragment_start + offset; + while word_index < words.len() && words[word_index].range.end <= grapheme_start { + word_index += 1; + consumed_word_width = 0; + } + + let grapheme_end = grapheme_start + grapheme.len(); + let is_whitespace = grapheme.chars().all(char::is_whitespace) + && (grapheme == " " || breakpoints[grapheme_end]); + // 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 + .get(word_index) + .filter(|word| word.range.contains(&grapheme_start)) + .map_or(line_end, |word| word.range.end.min(line_end)); + if line_width + display_width(&text[grapheme_start..word_end]) > width { + lines.push(line_start..grapheme_start + 1); + line_start = grapheme_start; + line_width = 0; + line_has_text = false; + } + } + + let grapheme_width = display_width(grapheme); + if line_width > 0 && line_width + grapheme_width > width { + lines.push(line_start..grapheme_start + 1); + line_start = grapheme_start; + line_width = 0; + line_has_text = false; + } + line_width += grapheme_width; + line_has_text |= !is_whitespace; + previous_was_whitespace = is_whitespace; + + if words + .get(word_index) + .is_some_and(|word| word.range.contains(&grapheme_start)) + { + consumed_word_width += grapheme_width; + } + } + + if matches!(text.as_bytes().get(line_end), None | Some(b'\n')) { + lines.push(line_start..line_end + 1); + if line_width >= width { + lines.push(line_end..line_end + 1); + } + line_width = 0; + line_has_text = false; + line_active = false; + previous_was_whitespace = false; + } + processed_end = line_end; + } + + lines +} + +#[cfg(test)] +#[path = "wrapping_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/textarea/wrapping_tests.rs b/codex-rs/tui/src/bottom_pane/textarea/wrapping_tests.rs new file mode 100644 index 0000000000..989689c875 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/wrapping_tests.rs @@ -0,0 +1,106 @@ +use super::super::TextArea; +use super::wrapped_lines; +use crate::width::display_width; +use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; +use pretty_assertions::assert_eq; +use ratatui::layout::Rect; + +#[test] +fn preserves_textwrap_word_boundaries() { + for (text, width, expected_rows) in [ + ("a foo-barbaz", 10, ["a foo-", "barbaz"]), + ("a foo-barbazqux", 10, ["a foo-", "barbazqux"]), + ("a café-barbaz", 10, ["a café-", "barbaz"]), + ("a foo/barbaz", 10, ["a foo/", "barbaz"]), + ("a foo—barbaz", 10, ["a foo—", "barbaz"]), + ("a abc\u{a0}de", 7, ["a ", "abc\u{a0}de"]), + ("a abc\u{2007}de", 7, ["a ", "abc\u{2007}de"]), + ("a abc\u{202f}de", 7, ["a ", "abc\u{202f}de"]), + ("a \u{a0}abc", 5, ["a ", "\u{a0}abc"]), + ] { + let rows = wrapped_lines(text, width) + .iter() + .map(|range| &text[range.start..range.end - 1]) + .collect::>(); + + assert_eq!(rows, expected_rows, "text={text:?}, width={width}"); + } +} + +#[test] +fn breakable_unicode_spaces_stay_with_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"]), + ] { + let rows = wrapped_lines(text, /*width*/ 4) + .iter() + .map(|range| &text[range.start..range.end - 1]) + .collect::>(); + + assert_eq!(rows, expected_rows, "text={text:?}"); + } +} + +#[test] +fn wraps_maximum_length_unbroken_word_in_one_pass() { + let text = "x".repeat(MAX_USER_INPUT_TEXT_CHARS); + let width = 80; + let ranges = wrapped_lines(&text, width); + + assert_eq!( + ranges.len(), + MAX_USER_INPUT_TEXT_CHARS.div_ceil(usize::from(width)) + ); + assert_eq!(ranges.first(), Some(&(0..usize::from(width) + 1))); + assert_eq!( + ranges.last().map(|range| range.end), + Some(MAX_USER_INPUT_TEXT_CHARS + 1) + ); +} + +#[test] +fn ascii_wrapped_rows_fit_and_preserve_cursor_positions() { + for len in 0_u32..=7 { + for mut encoded in 0..4_usize.pow(len) { + let mut text = String::with_capacity(len as usize); + for _ in 0..len { + text.push([' ', 'a', 'b', '-'][encoded % 4]); + encoded /= 4; + } + + for width in 1_u16..=5 { + let mut t = TextArea::new(); + t.insert_str(&text); + let ranges = t.wrapped_lines(width).to_vec(); + let mut end = 0; + for range in &ranges { + assert_eq!(range.start, end, "text={text:?}, width={width}"); + end = range.end - 1; + let row = &text[range.start..end]; + assert!( + display_width(row) <= usize::from(width), + "text={text:?}, width={width}, row={row:?}" + ); + } + assert_eq!(end, text.len(), "text={text:?}, width={width}"); + + let area = Rect::new(0, 0, width, text.len() as u16 + 1); + let mut previous: Option<(u16, u16)> = None; + for cursor in 0..=text.len() { + t.set_cursor(cursor); + let position = t.cursor_pos(area).unwrap(); + if let Some(previous) = previous { + assert!( + position.1 > previous.1 + || (position.1 == previous.1 && position.0 > previous.0), + "text={text:?}, width={width}, ranges={ranges:?}, cursor={cursor}, previous={previous:?}, position={position:?}" + ); + } + previous = Some(position); + } + } + } + } +}