From ad6e48ddd35af1ab477d417ceb1becb5dbb96a60 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 5 Aug 2026 21:21:28 +0000 Subject: [PATCH] Keep textarea cursors and rendering inside the viewport (#37166) ## What changed - Reserve a continuation row for the insertion point when a logical line exactly fills the textarea width. - Wrap overflowing spaces and keep cursor movement aligned with the resulting visual rows. - Clip plain, masked, and styled text rendering to the textarea bounds, and return no cursor position for empty viewports. ## Testing Add regression coverage for full lines, trailing spaces, scrolling, multi-width graphemes, empty areas, and styled overlays at textarea boundaries. GitOrigin-RevId: 4147530b523cf4bc300e50221d25d22dbfdfbefe --- ...al_line_scrolls_to_visible_cursor_row.snap | 8 + ...n_final_line_has_a_visible_cursor_row.snap | 8 + ...ound_marks_wrap_and_align_with_cursor.snap | 8 +- ...ea_masked_graphemes_align_with_cursor.snap | 3 +- ...led_overlays_stay_inside_the_textarea.snap | 14 + ...g_spaces_wrap_without_cursor_overflow.snap | 8 + codex-rs/tui/src/bottom_pane/textarea.rs | 343 ++++++++++++++++-- 7 files changed, 355 insertions(+), 37 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_full_final_line_scrolls_to_visible_cursor_row.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_full_non_final_line_has_a_visible_cursor_row.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_styled_overlays_stay_inside_the_textarea.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_trailing_spaces_wrap_without_cursor_overflow.snap diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_full_final_line_scrolls_to_visible_cursor_row.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_full_final_line_scrolls_to_visible_cursor_row.snap new file mode 100644 index 000000000000..2e7df724b41d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_full_final_line_scrolls_to_visible_cursor_row.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "format!(\"scroll: {}\\ncursor: {:?}\\n{}\", state.scroll,\nt.cursor_pos_with_state(area, state), terminal.backend())" +--- +scroll: 1 +cursor: Some((0, 1)) +"efgh" +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_full_non_final_line_has_a_visible_cursor_row.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_full_non_final_line_has_a_visible_cursor_row.snap new file mode 100644 index 000000000000..1984373cbf5a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_full_non_final_line_has_a_visible_cursor_row.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())" +--- +cursor: Some((0, 1)) +"abad" +" " +"ef " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_halfwidth_sound_marks_wrap_and_align_with_cursor.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_halfwidth_sound_marks_wrap_and_align_with_cursor.snap index 7ea4a38fbaed..403d087188ee 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_halfwidth_sound_marks_wrap_and_align_with_cursor.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_halfwidth_sound_marks_wrap_and_align_with_cursor.snap @@ -1,6 +1,6 @@ --- source: tui/src/bottom_pane/textarea.rs -expression: "format!(\"dakuten\\n{dakuten}\\n\\nhandakuten\\n{handakuten}\\n\\nstandalone\\n{standalone}\")" +expression: "snapshots.join(\"\\n\\n\")" --- dakuten cursor: Some((1, 1)) @@ -9,12 +9,14 @@ cursor: Some((1, 1)) handakuten -cursor: Some((3, 1)) +cursor: Some((0, 2)) "ab " "パc" Hidden by multi-width symbols: [(1, " ")] +" " standalone -cursor: Some((2, 1)) +cursor: Some((0, 2)) "a " "゙b" +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_masked_graphemes_align_with_cursor.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_masked_graphemes_align_with_cursor.snap index b82038f930e5..2b3b4ec43edf 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_masked_graphemes_align_with_cursor.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_masked_graphemes_align_with_cursor.snap @@ -2,5 +2,6 @@ source: tui/src/bottom_pane/textarea.rs expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())" --- -cursor: Some((4, 0)) +cursor: Some((0, 1)) "****" +" " 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 new file mode 100644 index 000000000000..91e2d72f8072 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_styled_overlays_stay_inside_the_textarea.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +element +" KEEP" +"next " +" " + + +highlight +" KEEP" +"next " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_trailing_spaces_wrap_without_cursor_overflow.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_trailing_spaces_wrap_without_cursor_overflow.snap new file mode 100644 index 000000000000..e192d6d73de8 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_trailing_spaces_wrap_without_cursor_overflow.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())" +--- +cursor: Some((2, 2)) +"abad " +" " +" " diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 98bf0126d8c3..36ab177ee060 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -9,6 +9,9 @@ //! //! This module does not implement an Emacs-style multi-entry kill ring. It keeps only the most //! 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. use crate::key_hint::KeyBindingListExt; use crate::key_hint::is_altgr; @@ -437,13 +440,20 @@ impl TextArea { self.cursor_pos_with_state(area, TextAreaState::default()) } - /// Compute the on-screen cursor position taking scrolling into account. + /// Returns an on-screen cursor position within `area`, accounting for wrapping and scrolling. + /// + /// Returns `None` when the viewport has no visible cells. pub fn cursor_pos_with_state(&self, area: Rect, state: TextAreaState) -> Option<(u16, u16)> { + if area.is_empty() { + return None; + } + 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]) as u16; + let col = display_width(&self.text[ls.start..self.cursor_pos]) + .min(usize::from(area.width.saturating_sub(1))) as u16; let screen_row = i .saturating_sub(effective_scroll as usize) .try_into() @@ -1231,7 +1241,10 @@ impl TextArea { if idx > 0 { let prev = &lines[idx - 1]; let line_start = prev.start; - let line_end = prev.end.saturating_sub(1); + let mut line_end = prev.end.saturating_sub(1); + if line_end == cur_range.start { + line_end = self.prev_atomic_boundary(line_end).max(line_start); + } Some((target_col, Some((line_start, line_end)))) } else { Some((target_col, None)) @@ -1294,7 +1307,13 @@ impl TextArea { if idx + 1 < lines.len() { let next = &lines[idx + 1]; let line_start = next.start; - let line_end = next.end.saturating_sub(1); + let mut line_end = next.end.saturating_sub(1); + if lines + .get(idx + 2) + .is_some_and(|following| following.start == line_end) + { + line_end = self.prev_atomic_boundary(line_end).max(line_start); + } Some((target_col, Some((line_start, line_end)))) } else { Some((target_col, None)) @@ -1860,6 +1879,10 @@ 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. #[expect(clippy::unwrap_used)] fn wrapped_lines(&self, width: u16) -> Ref<'_, Vec>> { // Ensure cache is ready (potentially mutably borrow, then drop) @@ -1871,10 +1894,39 @@ impl TextArea { }; if needs_recalc { let display_text = text_for_display(&self.text); - let lines = crate::wrapping::wrap_ranges( + 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); + } + } + } *cache = Some(WrapCache { width, lines }); } } @@ -1920,7 +1972,14 @@ impl TextArea { impl WidgetRef for &TextArea { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let lines = self.wrapped_lines(area.width); - self.render_lines(area, buf, &lines, 0..lines.len(), Style::default(), &[]); + self.render_lines( + area, + buf, + &lines, + 0..lines.len().min(usize::from(area.height)), + Style::default(), + &[], + ); } } @@ -1976,6 +2035,7 @@ impl TextArea { self.render_lines(area, buf, &lines, start..end, base_style, highlights); } + /// Renders visible text and styled overlays without writing outside the textarea viewport. fn render_lines( &self, area: Rect, @@ -1991,10 +2051,11 @@ impl TextArea { let line_range = r.start..r.end - 1; buf.set_style(Rect::new(area.x, y, area.width, 1), base_style); // Draw base line with the provided style. - buf.set_string( + buf.set_stringn( area.x, y, text_for_display(&self.text[line_range.clone()]), + usize::from(area.width), base_style, ); @@ -2009,7 +2070,13 @@ impl TextArea { 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); - buf.set_string(area.x + x_off, y, text_for_display(styled), style); + buf.set_stringn( + area.x + x_off, + y, + text_for_display(styled), + usize::from(area.width.saturating_sub(x_off)), + style, + ); } // Overlay render-only highlight ranges last so transient search highlighting remains @@ -2022,11 +2089,18 @@ impl TextArea { } let highlighted = &self.text[overlap_start..overlap_end]; let x_off = display_width(&self.text[line_range.start..overlap_start]) as u16; - buf.set_string(area.x + x_off, y, text_for_display(highlighted), *style); + buf.set_stringn( + area.x + x_off, + y, + text_for_display(highlighted), + usize::from(area.width.saturating_sub(x_off)), + *style, + ); } } } + /// Renders width-preserving mask glyphs without writing outside the textarea viewport. fn render_lines_masked( &self, area: Rect, @@ -2043,7 +2117,13 @@ impl TextArea { .graphemes(/*is_extended*/ true) .flat_map(|grapheme| std::iter::repeat_n(mask_char, display_width(grapheme))) .collect::(); - buf.set_string(area.x, y, &masked, Style::default()); + buf.set_stringn( + area.x, + y, + &masked, + usize::from(area.width), + Style::default(), + ); } } } @@ -2055,6 +2135,8 @@ mod tests { // crossterm types are intentionally not imported here to avoid unused warnings use pretty_assertions::assert_eq; use rand::prelude::*; + use ratatui::Terminal; + use ratatui::backend::TestBackend; fn rand_grapheme(rng: &mut rand::rngs::StdRng) -> String { let r: u8 = rng.random_range(0..100); @@ -3452,6 +3534,185 @@ mod tests { assert!(state.scroll < effective_lines); } + #[test] + fn cursor_wraps_after_filling_the_final_line() { + for text in ["abad", "界界", "abガ"] { + let mut t = ta_with(text); + t.set_cursor(t.text().len()); + let area = Rect::new(2, 3, /*width*/ 4, /*height*/ 2); + + assert_eq!(t.desired_height(area.width), 2); + assert_eq!(t.cursor_pos(area), Some((2, 4))); + } + } + + #[test] + fn full_non_final_lines_reserve_visible_cursor_rows() { + for text in ["abad\nef", "界界\nef", "abガ\nef"] { + let mut t = ta_with(text); + let area = Rect::new(0, 0, /*width*/ 4, /*height*/ 3); + let newline = text.find('\n').unwrap(); + + assert_eq!(t.desired_height(area.width), 3); + + t.set_cursor(newline); + assert_eq!(t.cursor_pos(area), Some((0, 1))); + + t.set_cursor(newline + 1); + assert_eq!(t.cursor_pos(area), Some((0, 2))); + } + } + + #[test] + fn full_non_final_line_cursor_snapshot() { + let mut t = ta_with("abad\nef"); + t.set_cursor(/*pos*/ 4); + 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(); + + insta::assert_snapshot!( + "textarea_full_non_final_line_has_a_visible_cursor_row", + format!("cursor: {:?}\n{}", t.cursor_pos(area), terminal.backend()) + ); + } + + #[test] + fn trailing_spaces_wrap_without_letting_the_cursor_escape() { + let mut t = ta_with("abad "); + t.set_cursor(t.text().len()); + let area = Rect::new(0, 0, /*width*/ 5, /*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); + insta::assert_snapshot!( + "textarea_trailing_spaces_wrap_without_cursor_overflow", + 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); + + assert_eq!(t.desired_height(area.width), 5); + + for (cursor, expected) in [(4, (0, 1)), (6, (2, 1)), (8, (0, 2)), (9, (0, 3))] { + t.set_cursor(cursor); + assert_eq!(t.cursor_pos(area), Some(expected)); + } + } + + #[test] + fn full_final_line_scrolls_to_its_visible_cursor_row() { + let mut t = ta_with("abcdefgh"); + 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), 3); + insta::assert_snapshot!( + "textarea_full_final_line_scrolls_to_visible_cursor_row", + format!( + "scroll: {}\ncursor: {:?}\n{}", + state.scroll, + t.cursor_pos_with_state(area, state), + terminal.backend() + ) + ); + } + + #[test] + fn text_rendering_does_not_overwrite_cells_outside_the_textarea() { + let t = ta_with("abad next"); + let area = Rect::new(0, 0, /*width*/ 4, /*height*/ 3); + let mut state = TextAreaState::default(); + let mut buf = Buffer::empty(Rect::new(0, 0, /*width*/ 8, /*height*/ 3)); + let protected_style = Style::default().fg(Color::Red); + buf.set_style(Rect::new(4, 0, /*width*/ 4, /*height*/ 3), protected_style); + let protected_cell = buf[(4, 0)].clone(); + + StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); + + assert_eq!(buf[(4, 0)], protected_cell); + } + + #[test] + fn styled_text_rendering_does_not_overwrite_cells_outside_the_textarea() { + let mut snapshots = Vec::new(); + for overlay in ["element", "highlight"] { + let mut t = ta_with("abad next"); + if overlay == "element" { + t.add_element_range(0..9); + } + let highlights = if overlay == "highlight" { + vec![(0..9, Style::default().fg(Color::Magenta))] + } else { + Vec::new() + }; + let area = Rect::new(0, 0, /*width*/ 4, /*height*/ 3); + let mut terminal = Terminal::new(TestBackend::new(/*width*/ 8, area.height)).unwrap(); + let mut state = TextAreaState::default(); + terminal + .draw(|frame| { + frame.buffer_mut().set_string( + /*x*/ 4, + /*y*/ 0, + "KEEP", + Style::default().fg(Color::Red), + ); + t.render_ref_styled_with_highlights( + area, + frame.buffer_mut(), + &mut state, + Style::default(), + &highlights, + ); + }) + .unwrap(); + + assert_eq!(terminal.backend().buffer()[(4, 0)].symbol(), "K"); + snapshots.push(format!("{overlay}\n{}", terminal.backend())); + } + + insta::assert_snapshot!( + "textarea_styled_overlays_stay_inside_the_textarea", + snapshots.join("\n\n") + ); + } + + #[test] + fn empty_textarea_areas_do_not_have_a_cursor() { + let t = ta_with("abad"); + + assert_eq!( + t.cursor_pos(Rect::new(0, 0, /*width*/ 0, /*height*/ 1)), + None + ); + assert_eq!( + t.cursor_pos(Rect::new(0, 0, /*width*/ 1, /*height*/ 0)), + None + ); + } + #[test] fn render_highlights_apply_style_without_mutating_text() { let t = ta_with("hello world"); @@ -3537,14 +3798,21 @@ mod tests { use ratatui::backend::TestBackend; let mut snapshots = Vec::new(); - for (label, text, width, cursor, cells) in [ - ("dakuten", "12ガx", 4, (1, 1), [(2, 0, "ガ"), (0, 1, "x")]), - ("handakuten", "abパc", 3, (3, 1), [(0, 1, "パ"), (2, 1, "c")]), - ("standalone", "a ゙b", 2, (2, 1), [(0, 1, "゙"), (1, 1, "b")]), + for (label, text, width, height, cursor, cells) in [ + ("dakuten", "12ガx", 4, 2, (1, 1), [(2, 0, "ガ"), (0, 1, "x")]), + ( + "handakuten", + "abパc", + 3, + 3, + (0, 2), + [(0, 1, "パ"), (2, 1, "c")], + ), + ("standalone", "a ゙b", 2, 3, (0, 2), [(0, 1, "゙"), (1, 1, "b")]), ] { let mut t = ta_with(text); t.set_cursor(text.len()); - let area = Rect::new(0, 0, width, /*height*/ 2); + let area = Rect::new(0, 0, width, height); let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); terminal .draw(|frame| { @@ -3556,7 +3824,7 @@ mod tests { }) .unwrap(); - assert_eq!(t.desired_height(area.width), 2); + assert_eq!(t.desired_height(area.width), height); assert_eq!(t.cursor_pos(area), Some(cursor)); for (x, y, expected) in cells { assert_eq!(terminal.backend().buffer()[(x, y)].symbol(), expected); @@ -3582,7 +3850,7 @@ mod tests { let text = "界゙a"; let mut t = ta_with(text); t.set_cursor(text.len()); - let area = Rect::new(0, 0, /*width*/ 4, /*height*/ 1); + let 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 @@ -3591,7 +3859,7 @@ mod tests { }) .unwrap(); - assert_eq!(t.cursor_pos(area), Some((4, 0))); + assert_eq!(t.cursor_pos(area), Some((0, 1))); insta::assert_snapshot!( "textarea_masked_graphemes_align_with_cursor", format!("cursor: {:?}\n{}", t.cursor_pos(area), terminal.backend()) @@ -3599,16 +3867,16 @@ mod tests { } #[test] - fn overwide_halfwidth_sound_marks_do_not_add_a_phantom_cursor_row() { - let area = Rect::new(0, 0, /*width*/ 2, /*height*/ 2); + fn overwide_halfwidth_sound_marks_keep_the_final_cursor_visible() { + let area = Rect::new(0, 0, /*width*/ 2, /*height*/ 3); for grapheme in ["ガ゙", "界゙"] { let text = format!("{grapheme}ab"); let mut t = ta_with(&text); t.set_cursor(text.len()); - assert_eq!(t.desired_height(area.width), 2); - assert_eq!(t.cursor_pos(area), Some((2, 1))); + assert_eq!(t.desired_height(area.width), 3); + assert_eq!(t.cursor_pos(area), Some((0, 2))); t.set_cursor(grapheme.len()); assert_eq!(t.cursor_pos(area), Some((0, 1))); @@ -3742,18 +4010,24 @@ 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 will wrap "word1 " and then "word2" before the newline + // Width 6 wraps "word1 ", its remaining space, and then "word2" before the newline. let _ = t.desired_height(/*width*/ 6); - // Put cursor on the second wrapped line before the newline, at column 1 of "word2" + // Put the cursor at column 1 of "word2". let start_word2 = t.text().find("word2").unwrap(); t.set_cursor(start_word2 + 1); - // Up should go to first wrapped line, column 1 -> index 1 + // Up first lands on the short whitespace continuation instead of skipping it. + t.move_cursor_up(); + assert_eq!(t.cursor(), start_word2 - 1); + + // A second Up preserves the preferred column on the first wrapped line. t.move_cursor_up(); assert_eq!(t.cursor(), 1); - // Down should return to the same visual column on "word2" + // Down visits the whitespace continuation before returning to "word2". + t.move_cursor_down(); + assert_eq!(t.cursor(), start_word2 - 1); t.move_cursor_down(); assert_eq!(t.cursor(), start_word2 + 1); @@ -3987,13 +4261,16 @@ mod tests { let mut buf = Buffer::empty(full_area); ratatui::widgets::WidgetRef::render_ref(&(&ta), full_area, &mut buf); - // cursor_pos: x must be within width when present - let _ = ta.cursor_pos(area); - - // cursor_pos_with_state: always within viewport rows - let (_x, _y) = ta - .cursor_pos_with_state(area, state) - .unwrap_or((area.x, area.y)); + // Both cursor helpers must keep the insertion point inside the viewport. + for (x, y) in [ta.cursor_pos(area), ta.cursor_pos_with_state(area, state)] + .into_iter() + .flatten() + { + assert!( + area.contains((x, y).into()), + "cursor escaped {area:?}: ({x}, {y})" + ); + } // Stateful render should not panic, and updates scroll let mut sbuf = Buffer::empty(area);