mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +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:
1 parent
bd36d69aae
commit
ad6e48ddd3
7 files changed
+355
-37
No files matched your search
+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
|
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
|
dakuten
|
||||||
cursor: Some((1, 1))
|
cursor: Some((1, 1))
|
||||||
@@ -9,12 +9,14 @@ cursor: Some((1, 1))
|
|||||||
|
|
||||||
|
|
||||||
handakuten
|
handakuten
|
||||||
cursor: Some((3, 1))
|
cursor: Some((0, 2))
|
||||||
"ab "
|
"ab "
|
||||||
"パc" Hidden by multi-width symbols: [(1, " ")]
|
"パc" Hidden by multi-width symbols: [(1, " ")]
|
||||||
|
" "
|
||||||
|
|
||||||
|
|
||||||
standalone
|
standalone
|
||||||
cursor: Some((2, 1))
|
cursor: Some((0, 2))
|
||||||
"a "
|
"a "
|
||||||
"゙b"
|
"゙b"
|
||||||
|
" "
|
||||||
+2
-1
@@ -2,5 +2,6 @@
|
|||||||
source: tui/src/bottom_pane/textarea.rs
|
source: tui/src/bottom_pane/textarea.rs
|
||||||
expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())"
|
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
|
//! This module does not implement an Emacs-style multi-entry kill ring. It keeps only the most
|
||||||
//! recent killed span.
|
//! 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::KeyBindingListExt;
|
||||||
use crate::key_hint::is_altgr;
|
use crate::key_hint::is_altgr;
|
||||||
@@ -437,13 +440,20 @@ impl TextArea {
|
|||||||
self.cursor_pos_with_state(area, TextAreaState::default())
|
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)> {
|
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 lines = self.wrapped_lines(area.width);
|
||||||
let effective_scroll = self.effective_scroll(area.height, &lines, state.scroll);
|
let effective_scroll = self.effective_scroll(area.height, &lines, state.scroll);
|
||||||
let i = Self::wrapped_line_index_by_start(&lines, self.cursor_pos)?;
|
let i = Self::wrapped_line_index_by_start(&lines, self.cursor_pos)?;
|
||||||
let ls = &lines[i];
|
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
|
let screen_row = i
|
||||||
.saturating_sub(effective_scroll as usize)
|
.saturating_sub(effective_scroll as usize)
|
||||||
.try_into()
|
.try_into()
|
||||||
@@ -1231,7 +1241,10 @@ impl TextArea {
|
|||||||
if idx > 0 {
|
if idx > 0 {
|
||||||
let prev = &lines[idx - 1];
|
let prev = &lines[idx - 1];
|
||||||
let line_start = prev.start;
|
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))))
|
Some((target_col, Some((line_start, line_end))))
|
||||||
} else {
|
} else {
|
||||||
Some((target_col, None))
|
Some((target_col, None))
|
||||||
@@ -1294,7 +1307,13 @@ impl TextArea {
|
|||||||
if idx + 1 < lines.len() {
|
if idx + 1 < lines.len() {
|
||||||
let next = &lines[idx + 1];
|
let next = &lines[idx + 1];
|
||||||
let line_start = next.start;
|
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))))
|
Some((target_col, Some((line_start, line_end))))
|
||||||
} else {
|
} else {
|
||||||
Some((target_col, None))
|
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)]
|
#[expect(clippy::unwrap_used)]
|
||||||
fn wrapped_lines(&self, width: u16) -> Ref<'_, Vec<Range<usize>>> {
|
fn wrapped_lines(&self, width: u16) -> Ref<'_, Vec<Range<usize>>> {
|
||||||
// Ensure cache is ready (potentially mutably borrow, then drop)
|
// Ensure cache is ready (potentially mutably borrow, then drop)
|
||||||
@@ -1871,10 +1894,39 @@ impl TextArea {
|
|||||||
};
|
};
|
||||||
if needs_recalc {
|
if needs_recalc {
|
||||||
let display_text = text_for_display(&self.text);
|
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(),
|
display_text.as_ref(),
|
||||||
Options::new(width as usize).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
|
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 });
|
*cache = Some(WrapCache { width, lines });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1920,7 +1972,14 @@ impl TextArea {
|
|||||||
impl WidgetRef for &TextArea {
|
impl WidgetRef for &TextArea {
|
||||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||||
let lines = self.wrapped_lines(area.width);
|
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);
|
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(
|
fn render_lines(
|
||||||
&self,
|
&self,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
@@ -1991,10 +2051,11 @@ impl TextArea {
|
|||||||
let line_range = r.start..r.end - 1;
|
let line_range = r.start..r.end - 1;
|
||||||
buf.set_style(Rect::new(area.x, y, area.width, 1), base_style);
|
buf.set_style(Rect::new(area.x, y, area.width, 1), base_style);
|
||||||
// Draw base line with the provided style.
|
// Draw base line with the provided style.
|
||||||
buf.set_string(
|
buf.set_stringn(
|
||||||
area.x,
|
area.x,
|
||||||
y,
|
y,
|
||||||
text_for_display(&self.text[line_range.clone()]),
|
text_for_display(&self.text[line_range.clone()]),
|
||||||
|
usize::from(area.width),
|
||||||
base_style,
|
base_style,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2009,7 +2070,13 @@ impl TextArea {
|
|||||||
let styled = &self.text[overlap_start..overlap_end];
|
let styled = &self.text[overlap_start..overlap_end];
|
||||||
let x_off = display_width(&self.text[line_range.start..overlap_start]) as u16;
|
let x_off = display_width(&self.text[line_range.start..overlap_start]) as u16;
|
||||||
let style = base_style.fg(Color::Cyan);
|
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
|
// 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 highlighted = &self.text[overlap_start..overlap_end];
|
||||||
let x_off = display_width(&self.text[line_range.start..overlap_start]) as u16;
|
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(
|
fn render_lines_masked(
|
||||||
&self,
|
&self,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
@@ -2043,7 +2117,13 @@ impl TextArea {
|
|||||||
.graphemes(/*is_extended*/ true)
|
.graphemes(/*is_extended*/ true)
|
||||||
.flat_map(|grapheme| std::iter::repeat_n(mask_char, display_width(grapheme)))
|
.flat_map(|grapheme| std::iter::repeat_n(mask_char, display_width(grapheme)))
|
||||||
.collect::<String>();
|
.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
|
// crossterm types are intentionally not imported here to avoid unused warnings
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use rand::prelude::*;
|
use rand::prelude::*;
|
||||||
|
use ratatui::Terminal;
|
||||||
|
use ratatui::backend::TestBackend;
|
||||||
|
|
||||||
fn rand_grapheme(rng: &mut rand::rngs::StdRng) -> String {
|
fn rand_grapheme(rng: &mut rand::rngs::StdRng) -> String {
|
||||||
let r: u8 = rng.random_range(0..100);
|
let r: u8 = rng.random_range(0..100);
|
||||||
@@ -3452,6 +3534,185 @@ mod tests {
|
|||||||
assert!(state.scroll < effective_lines);
|
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]
|
#[test]
|
||||||
fn render_highlights_apply_style_without_mutating_text() {
|
fn render_highlights_apply_style_without_mutating_text() {
|
||||||
let t = ta_with("hello world");
|
let t = ta_with("hello world");
|
||||||
@@ -3537,14 +3798,21 @@ mod tests {
|
|||||||
use ratatui::backend::TestBackend;
|
use ratatui::backend::TestBackend;
|
||||||
|
|
||||||
let mut snapshots = Vec::new();
|
let mut snapshots = Vec::new();
|
||||||
for (label, text, width, cursor, cells) in [
|
for (label, text, width, height, cursor, cells) in [
|
||||||
("dakuten", "12ガx", 4, (1, 1), [(2, 0, "ガ"), (0, 1, "x")]),
|
("dakuten", "12ガx", 4, 2, (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")]),
|
"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);
|
let mut t = ta_with(text);
|
||||||
t.set_cursor(text.len());
|
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();
|
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| {
|
.draw(|frame| {
|
||||||
@@ -3556,7 +3824,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.unwrap();
|
.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));
|
assert_eq!(t.cursor_pos(area), Some(cursor));
|
||||||
for (x, y, expected) in cells {
|
for (x, y, expected) in cells {
|
||||||
assert_eq!(terminal.backend().buffer()[(x, y)].symbol(), expected);
|
assert_eq!(terminal.backend().buffer()[(x, y)].symbol(), expected);
|
||||||
@@ -3582,7 +3850,7 @@ mod tests {
|
|||||||
let text = "界゙a";
|
let text = "界゙a";
|
||||||
let mut t = ta_with(text);
|
let mut t = ta_with(text);
|
||||||
t.set_cursor(text.len());
|
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 terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
|
||||||
let mut state = TextAreaState::default();
|
let mut state = TextAreaState::default();
|
||||||
terminal
|
terminal
|
||||||
@@ -3591,7 +3859,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(t.cursor_pos(area), Some((4, 0)));
|
assert_eq!(t.cursor_pos(area), Some((0, 1)));
|
||||||
insta::assert_snapshot!(
|
insta::assert_snapshot!(
|
||||||
"textarea_masked_graphemes_align_with_cursor",
|
"textarea_masked_graphemes_align_with_cursor",
|
||||||
format!("cursor: {:?}\n{}", t.cursor_pos(area), terminal.backend())
|
format!("cursor: {:?}\n{}", t.cursor_pos(area), terminal.backend())
|
||||||
@@ -3599,16 +3867,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn overwide_halfwidth_sound_marks_do_not_add_a_phantom_cursor_row() {
|
fn overwide_halfwidth_sound_marks_keep_the_final_cursor_visible() {
|
||||||
let area = Rect::new(0, 0, /*width*/ 2, /*height*/ 2);
|
let area = Rect::new(0, 0, /*width*/ 2, /*height*/ 3);
|
||||||
|
|
||||||
for grapheme in ["ガ゙", "界゙"] {
|
for grapheme in ["ガ゙", "界゙"] {
|
||||||
let text = format!("{grapheme}ab");
|
let text = format!("{grapheme}ab");
|
||||||
let mut t = ta_with(&text);
|
let mut t = ta_with(&text);
|
||||||
t.set_cursor(text.len());
|
t.set_cursor(text.len());
|
||||||
|
|
||||||
assert_eq!(t.desired_height(area.width), 2);
|
assert_eq!(t.desired_height(area.width), 3);
|
||||||
assert_eq!(t.cursor_pos(area), Some((2, 1)));
|
assert_eq!(t.cursor_pos(area), Some((0, 2)));
|
||||||
|
|
||||||
t.set_cursor(grapheme.len());
|
t.set_cursor(grapheme.len());
|
||||||
assert_eq!(t.cursor_pos(area), Some((0, 1)));
|
assert_eq!(t.cursor_pos(area), Some((0, 1)));
|
||||||
@@ -3742,18 +4010,24 @@ mod tests {
|
|||||||
fn wrapped_navigation_with_newlines_and_spaces() {
|
fn wrapped_navigation_with_newlines_and_spaces() {
|
||||||
// Include spaces and an explicit newline to exercise boundaries
|
// Include spaces and an explicit newline to exercise boundaries
|
||||||
let mut t = ta_with("word1 word2\nword3");
|
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);
|
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();
|
let start_word2 = t.text().find("word2").unwrap();
|
||||||
t.set_cursor(start_word2 + 1);
|
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();
|
t.move_cursor_up();
|
||||||
assert_eq!(t.cursor(), 1);
|
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();
|
t.move_cursor_down();
|
||||||
assert_eq!(t.cursor(), start_word2 + 1);
|
assert_eq!(t.cursor(), start_word2 + 1);
|
||||||
|
|
||||||
@@ -3987,13 +4261,16 @@ mod tests {
|
|||||||
let mut buf = Buffer::empty(full_area);
|
let mut buf = Buffer::empty(full_area);
|
||||||
ratatui::widgets::WidgetRef::render_ref(&(&ta), full_area, &mut buf);
|
ratatui::widgets::WidgetRef::render_ref(&(&ta), full_area, &mut buf);
|
||||||
|
|
||||||
// cursor_pos: x must be within width when present
|
// Both cursor helpers must keep the insertion point inside the viewport.
|
||||||
let _ = ta.cursor_pos(area);
|
for (x, y) in [ta.cursor_pos(area), ta.cursor_pos_with_state(area, state)]
|
||||||
|
.into_iter()
|
||||||
// cursor_pos_with_state: always within viewport rows
|
.flatten()
|
||||||
let (_x, _y) = ta
|
{
|
||||||
.cursor_pos_with_state(area, state)
|
assert!(
|
||||||
.unwrap_or((area.x, area.y));
|
area.contains((x, y).into()),
|
||||||
|
"cursor escaped {area:?}: ({x}, {y})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Stateful render should not panic, and updates scroll
|
// Stateful render should not panic, and updates scroll
|
||||||
let mut sbuf = Buffer::empty(area);
|
let mut sbuf = Buffer::empty(area);
|
||||||
|
|||||||
Reference in new issue
Block a user