mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
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:
+8
@@ -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"
|
||||
" "
|
||||
+8
@@ -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 "
|
||||
+5
-3
@@ -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
-1
@@ -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))
|
||||
"****"
|
||||
" "
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/textarea.rs
|
||||
expression: "snapshots.join(\"\\n\\n\")"
|
||||
---
|
||||
element
|
||||
" KEEP"
|
||||
"next "
|
||||
" "
|
||||
|
||||
|
||||
highlight
|
||||
" KEEP"
|
||||
"next "
|
||||
" "
|
||||
+8
@@ -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 "
|
||||
" "
|
||||
" "
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user