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
This commit is contained in:
Charlie Marsh
2026-08-05 21:40:50 +00:00
committed by copyberry
parent bd36d69aae
commit ad6e48ddd3
7 changed files with 355 additions and 37 deletions
@@ -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"
" "
@@ -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 "
@@ -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"
" "
@@ -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))
"****"
" "
@@ -0,0 +1,14 @@
---
source: tui/src/bottom_pane/textarea.rs
expression: "snapshots.join(\"\\n\\n\")"
---
element
" KEEP"
"next "
" "
highlight
" KEEP"
"next "
" "
@@ -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 "
" "
" "
+310 -33
View File
@@ -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<Range<usize>>> {
// 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::<String>();
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);