mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
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
This commit is contained in:
+8
@@ -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 "
|
||||
+7
@@ -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 "
|
||||
+10
@@ -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 "
|
||||
+7
@@ -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 "
|
||||
+7
@@ -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 "
|
||||
+4
-4
@@ -4,11 +4,11 @@ expression: "snapshots.join(\"\\n\\n\")"
|
||||
---
|
||||
element
|
||||
" KEEP"
|
||||
"next "
|
||||
" "
|
||||
" nex "
|
||||
"t "
|
||||
|
||||
|
||||
highlight
|
||||
" KEEP"
|
||||
"next "
|
||||
" "
|
||||
" nex "
|
||||
"t "
|
||||
|
||||
@@ -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<Range<usize>>> {
|
||||
// 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());
|
||||
|
||||
@@ -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<usize>,
|
||||
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<Range<usize>> {
|
||||
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;
|
||||
@@ -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::<Vec<_>>();
|
||||
|
||||
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::<Vec<_>>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user