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:
Charlie Marsh
2026-08-09 15:49:17 +00:00
committed by copyberry
parent 646f7c0a91
commit a16863f870
9 changed files with 468 additions and 55 deletions
@@ -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 "
@@ -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 "
@@ -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 "
@@ -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 "
@@ -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,11 +4,11 @@ expression: "snapshots.join(\"\\n\\n\")"
---
element
" KEEP"
"next "
" "
" nex "
"t "
highlight
" KEEP"
"next "
" "
" nex "
"t "
+166 -51
View File
@@ -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);
}
}
}
}
}