Files
llmx/codex-rs/tui/src/bottom_pane/textarea.rs
Jeremy Rose 5f3a0473f1 tui: refine text area word separator handling (#5541)
## Summary
- replace the word part enum with a simple `is_word_separator` helper
- keep word-boundary logic aligned with the helper and punctuation-aware
behavior
- extend forward/backward deletion tests to cover whitespace around
separators

## Testing
- just fix -p codex-tui
- cargo test -p codex-tui


------
https://chatgpt.com/codex/tasks/task_i_68f91c71d838832ca2a3c4f0ec1b55d4
2025-11-03 11:33:34 -08:00

1976 lines
72 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::WidgetRef;
use std::cell::Ref;
use std::cell::RefCell;
use std::ops::Range;
use textwrap::Options;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
const WORD_SEPARATORS: &str = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?";
fn is_word_separator(ch: char) -> bool {
WORD_SEPARATORS.contains(ch)
}
#[derive(Debug, Clone)]
struct TextElement {
range: Range<usize>,
}
#[derive(Debug)]
pub(crate) struct TextArea {
text: String,
cursor_pos: usize,
wrap_cache: RefCell<Option<WrapCache>>,
preferred_col: Option<usize>,
elements: Vec<TextElement>,
kill_buffer: String,
}
#[derive(Debug, Clone)]
struct WrapCache {
width: u16,
lines: Vec<Range<usize>>,
}
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct TextAreaState {
/// Index into wrapped lines of the first visible line.
scroll: u16,
}
impl TextArea {
pub fn new() -> Self {
Self {
text: String::new(),
cursor_pos: 0,
wrap_cache: RefCell::new(None),
preferred_col: None,
elements: Vec::new(),
kill_buffer: String::new(),
}
}
pub fn set_text(&mut self, text: &str) {
self.text = text.to_string();
self.cursor_pos = self.cursor_pos.clamp(0, self.text.len());
self.wrap_cache.replace(None);
self.preferred_col = None;
self.elements.clear();
self.kill_buffer.clear();
}
pub fn text(&self) -> &str {
&self.text
}
pub fn insert_str(&mut self, text: &str) {
self.insert_str_at(self.cursor_pos, text);
}
pub fn insert_str_at(&mut self, pos: usize, text: &str) {
let pos = self.clamp_pos_for_insertion(pos);
self.text.insert_str(pos, text);
self.wrap_cache.replace(None);
if pos <= self.cursor_pos {
self.cursor_pos += text.len();
}
self.shift_elements(pos, 0, text.len());
self.preferred_col = None;
}
pub fn replace_range(&mut self, range: std::ops::Range<usize>, text: &str) {
let range = self.expand_range_to_element_boundaries(range);
self.replace_range_raw(range, text);
}
fn replace_range_raw(&mut self, range: std::ops::Range<usize>, text: &str) {
assert!(range.start <= range.end);
let start = range.start.clamp(0, self.text.len());
let end = range.end.clamp(0, self.text.len());
let removed_len = end - start;
let inserted_len = text.len();
if removed_len == 0 && inserted_len == 0 {
return;
}
let diff = inserted_len as isize - removed_len as isize;
self.text.replace_range(range, text);
self.wrap_cache.replace(None);
self.preferred_col = None;
self.update_elements_after_replace(start, end, inserted_len);
// Update the cursor position to account for the edit.
self.cursor_pos = if self.cursor_pos < start {
// Cursor was before the edited range no shift.
self.cursor_pos
} else if self.cursor_pos <= end {
// Cursor was inside the replaced range move to end of the new text.
start + inserted_len
} else {
// Cursor was after the replaced range shift by the length diff.
((self.cursor_pos as isize) + diff) as usize
}
.min(self.text.len());
// Ensure cursor is not inside an element
self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
}
pub fn cursor(&self) -> usize {
self.cursor_pos
}
pub fn set_cursor(&mut self, pos: usize) {
self.cursor_pos = pos.clamp(0, self.text.len());
self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
self.preferred_col = None;
}
pub fn desired_height(&self, width: u16) -> u16 {
self.wrapped_lines(width).len() as u16
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.cursor_pos_with_state(area, TextAreaState::default())
}
/// Compute the on-screen cursor position taking scrolling into account.
pub fn cursor_pos_with_state(&self, area: Rect, state: TextAreaState) -> Option<(u16, u16)> {
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 = self.text[ls.start..self.cursor_pos].width() as u16;
let screen_row = i
.saturating_sub(effective_scroll as usize)
.try_into()
.unwrap_or(0);
Some((area.x + col, area.y + screen_row))
}
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
fn current_display_col(&self) -> usize {
let bol = self.beginning_of_current_line();
self.text[bol..self.cursor_pos].width()
}
fn wrapped_line_index_by_start(lines: &[Range<usize>], pos: usize) -> Option<usize> {
// partition_point returns the index of the first element for which
// the predicate is false, i.e. the count of elements with start <= pos.
let idx = lines.partition_point(|r| r.start <= pos);
if idx == 0 { None } else { Some(idx - 1) }
}
fn move_to_display_col_on_line(
&mut self,
line_start: usize,
line_end: usize,
target_col: usize,
) {
let mut width_so_far = 0usize;
for (i, g) in self.text[line_start..line_end].grapheme_indices(true) {
width_so_far += g.width();
if width_so_far > target_col {
self.cursor_pos = line_start + i;
// Avoid landing inside an element; round to nearest boundary
self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
return;
}
}
self.cursor_pos = line_end;
self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
}
fn beginning_of_line(&self, pos: usize) -> usize {
self.text[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0)
}
fn beginning_of_current_line(&self) -> usize {
self.beginning_of_line(self.cursor_pos)
}
fn end_of_line(&self, pos: usize) -> usize {
self.text[pos..]
.find('\n')
.map(|i| i + pos)
.unwrap_or(self.text.len())
}
fn end_of_current_line(&self) -> usize {
self.end_of_line(self.cursor_pos)
}
pub fn input(&mut self, event: KeyEvent) {
match event {
// Some terminals (or configurations) send Control key chords as
// C0 control characters without reporting the CONTROL modifier.
// Handle common fallbacks for Ctrl-B/Ctrl-F here so they don't get
// inserted as literal control bytes.
KeyEvent { code: KeyCode::Char('\u{0002}'), modifiers: KeyModifiers::NONE, .. } /* ^B */ => {
self.move_cursor_left();
}
KeyEvent { code: KeyCode::Char('\u{0006}'), modifiers: KeyModifiers::NONE, .. } /* ^F */ => {
self.move_cursor_right();
}
KeyEvent {
code: KeyCode::Char(c),
// Insert plain characters (and Shift-modified). Do NOT insert when ALT is held,
// because many terminals map Option/Meta combos to ALT+<char> (e.g. ESC f/ESC b)
// for word navigation. Those are handled explicitly below.
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
..
} => self.insert_str(&c.to_string()),
KeyEvent {
code: KeyCode::Char('j' | 'm'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Enter,
..
} => self.insert_str("\n"),
KeyEvent {
code: KeyCode::Char('h'),
modifiers,
..
} if modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT) => {
self.delete_backward_word()
},
KeyEvent {
code: KeyCode::Backspace,
modifiers: KeyModifiers::ALT,
..
} => self.delete_backward_word(),
KeyEvent {
code: KeyCode::Backspace,
..
}
| KeyEvent {
code: KeyCode::Char('h'),
modifiers: KeyModifiers::CONTROL,
..
} => self.delete_backward(1),
KeyEvent {
code: KeyCode::Delete,
modifiers: KeyModifiers::ALT,
..
} => self.delete_forward_word(),
KeyEvent {
code: KeyCode::Delete,
..
}
| KeyEvent {
code: KeyCode::Char('d'),
modifiers: KeyModifiers::CONTROL,
..
} => self.delete_forward(1),
KeyEvent {
code: KeyCode::Char('w'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.delete_backward_word();
}
// Meta-b -> move to beginning of previous word
// Meta-f -> move to end of next word
// Many terminals map Option (macOS) to Alt. Some send Alt|Shift, so match contains(ALT).
KeyEvent {
code: KeyCode::Char('b'),
modifiers: KeyModifiers::ALT,
..
} => {
self.set_cursor(self.beginning_of_previous_word());
}
KeyEvent {
code: KeyCode::Char('f'),
modifiers: KeyModifiers::ALT,
..
} => {
self.set_cursor(self.end_of_next_word());
}
KeyEvent {
code: KeyCode::Char('u'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.kill_to_beginning_of_line();
}
KeyEvent {
code: KeyCode::Char('k'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.kill_to_end_of_line();
}
KeyEvent {
code: KeyCode::Char('y'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.yank();
}
// Cursor movement
KeyEvent {
code: KeyCode::Left,
modifiers: KeyModifiers::NONE,
..
} => {
self.move_cursor_left();
}
KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::NONE,
..
} => {
self.move_cursor_right();
}
KeyEvent {
code: KeyCode::Char('b'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.move_cursor_left();
}
KeyEvent {
code: KeyCode::Char('f'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.move_cursor_right();
}
// Some terminals send Alt+Arrow for word-wise movement:
// Option/Left -> Alt+Left (previous word start)
// Option/Right -> Alt+Right (next word end)
KeyEvent {
code: KeyCode::Left,
modifiers: KeyModifiers::ALT,
..
}
| KeyEvent {
code: KeyCode::Left,
modifiers: KeyModifiers::CONTROL,
..
} => {
self.set_cursor(self.beginning_of_previous_word());
}
KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::ALT,
..
}
| KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::CONTROL,
..
} => {
self.set_cursor(self.end_of_next_word());
}
KeyEvent {
code: KeyCode::Up, ..
} => {
self.move_cursor_up();
}
KeyEvent {
code: KeyCode::Down,
..
} => {
self.move_cursor_down();
}
KeyEvent {
code: KeyCode::Home,
..
} => {
self.move_cursor_to_beginning_of_line(false);
}
KeyEvent {
code: KeyCode::Char('a'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.move_cursor_to_beginning_of_line(true);
}
KeyEvent {
code: KeyCode::End, ..
} => {
self.move_cursor_to_end_of_line(false);
}
KeyEvent {
code: KeyCode::Char('e'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.move_cursor_to_end_of_line(true);
}
_o => {
#[cfg(feature = "debug-logs")]
tracing::debug!("Unhandled key event in TextArea: {:?}", _o);
}
}
}
// ####### Input Functions #######
pub fn delete_backward(&mut self, n: usize) {
if n == 0 || self.cursor_pos == 0 {
return;
}
let mut target = self.cursor_pos;
for _ in 0..n {
target = self.prev_atomic_boundary(target);
if target == 0 {
break;
}
}
self.replace_range(target..self.cursor_pos, "");
}
pub fn delete_forward(&mut self, n: usize) {
if n == 0 || self.cursor_pos >= self.text.len() {
return;
}
let mut target = self.cursor_pos;
for _ in 0..n {
target = self.next_atomic_boundary(target);
if target >= self.text.len() {
break;
}
}
self.replace_range(self.cursor_pos..target, "");
}
pub fn delete_backward_word(&mut self) {
let start = self.beginning_of_previous_word();
self.kill_range(start..self.cursor_pos);
}
/// Delete text to the right of the cursor using "word" semantics.
///
/// Deletes from the current cursor position through the end of the next word as determined
/// by `end_of_next_word()`. Any whitespace (including newlines) between the cursor and that
/// word is included in the deletion.
pub fn delete_forward_word(&mut self) {
let end = self.end_of_next_word();
if end > self.cursor_pos {
self.kill_range(self.cursor_pos..end);
}
}
pub fn kill_to_end_of_line(&mut self) {
let eol = self.end_of_current_line();
let range = if self.cursor_pos == eol {
if eol < self.text.len() {
Some(self.cursor_pos..eol + 1)
} else {
None
}
} else {
Some(self.cursor_pos..eol)
};
if let Some(range) = range {
self.kill_range(range);
}
}
pub fn kill_to_beginning_of_line(&mut self) {
let bol = self.beginning_of_current_line();
let range = if self.cursor_pos == bol {
if bol > 0 { Some(bol - 1..bol) } else { None }
} else {
Some(bol..self.cursor_pos)
};
if let Some(range) = range {
self.kill_range(range);
}
}
pub fn yank(&mut self) {
if self.kill_buffer.is_empty() {
return;
}
let text = self.kill_buffer.clone();
self.insert_str(&text);
}
fn kill_range(&mut self, range: Range<usize>) {
let range = self.expand_range_to_element_boundaries(range);
if range.start >= range.end {
return;
}
let removed = self.text[range.clone()].to_string();
if removed.is_empty() {
return;
}
self.kill_buffer = removed;
self.replace_range_raw(range, "");
}
/// Move the cursor left by a single grapheme cluster.
pub fn move_cursor_left(&mut self) {
self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos);
self.preferred_col = None;
}
/// Move the cursor right by a single grapheme cluster.
pub fn move_cursor_right(&mut self) {
self.cursor_pos = self.next_atomic_boundary(self.cursor_pos);
self.preferred_col = None;
}
pub fn move_cursor_up(&mut self) {
// If we have a wrapping cache, prefer navigating across wrapped (visual) lines.
if let Some((target_col, maybe_line)) = {
let cache_ref = self.wrap_cache.borrow();
if let Some(cache) = cache_ref.as_ref() {
let lines = &cache.lines;
if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor_pos) {
let cur_range = &lines[idx];
let target_col = self
.preferred_col
.unwrap_or_else(|| self.text[cur_range.start..self.cursor_pos].width());
if idx > 0 {
let prev = &lines[idx - 1];
let line_start = prev.start;
let line_end = prev.end.saturating_sub(1);
Some((target_col, Some((line_start, line_end))))
} else {
Some((target_col, None))
}
} else {
None
}
} else {
None
}
} {
// We had wrapping info. Apply movement accordingly.
match maybe_line {
Some((line_start, line_end)) => {
if self.preferred_col.is_none() {
self.preferred_col = Some(target_col);
}
self.move_to_display_col_on_line(line_start, line_end, target_col);
return;
}
None => {
// Already at first visual line -> move to start
self.cursor_pos = 0;
self.preferred_col = None;
return;
}
}
}
// Fallback to logical line navigation if we don't have wrapping info yet.
if let Some(prev_nl) = self.text[..self.cursor_pos].rfind('\n') {
let target_col = match self.preferred_col {
Some(c) => c,
None => {
let c = self.current_display_col();
self.preferred_col = Some(c);
c
}
};
let prev_line_start = self.text[..prev_nl].rfind('\n').map(|i| i + 1).unwrap_or(0);
let prev_line_end = prev_nl;
self.move_to_display_col_on_line(prev_line_start, prev_line_end, target_col);
} else {
self.cursor_pos = 0;
self.preferred_col = None;
}
}
pub fn move_cursor_down(&mut self) {
// If we have a wrapping cache, prefer navigating across wrapped (visual) lines.
if let Some((target_col, move_to_last)) = {
let cache_ref = self.wrap_cache.borrow();
if let Some(cache) = cache_ref.as_ref() {
let lines = &cache.lines;
if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor_pos) {
let cur_range = &lines[idx];
let target_col = self
.preferred_col
.unwrap_or_else(|| self.text[cur_range.start..self.cursor_pos].width());
if idx + 1 < lines.len() {
let next = &lines[idx + 1];
let line_start = next.start;
let line_end = next.end.saturating_sub(1);
Some((target_col, Some((line_start, line_end))))
} else {
Some((target_col, None))
}
} else {
None
}
} else {
None
}
} {
match move_to_last {
Some((line_start, line_end)) => {
if self.preferred_col.is_none() {
self.preferred_col = Some(target_col);
}
self.move_to_display_col_on_line(line_start, line_end, target_col);
return;
}
None => {
// Already on last visual line -> move to end
self.cursor_pos = self.text.len();
self.preferred_col = None;
return;
}
}
}
// Fallback to logical line navigation if we don't have wrapping info yet.
let target_col = match self.preferred_col {
Some(c) => c,
None => {
let c = self.current_display_col();
self.preferred_col = Some(c);
c
}
};
if let Some(next_nl) = self.text[self.cursor_pos..]
.find('\n')
.map(|i| i + self.cursor_pos)
{
let next_line_start = next_nl + 1;
let next_line_end = self.text[next_line_start..]
.find('\n')
.map(|i| i + next_line_start)
.unwrap_or(self.text.len());
self.move_to_display_col_on_line(next_line_start, next_line_end, target_col);
} else {
self.cursor_pos = self.text.len();
self.preferred_col = None;
}
}
pub fn move_cursor_to_beginning_of_line(&mut self, move_up_at_bol: bool) {
let bol = self.beginning_of_current_line();
if move_up_at_bol && self.cursor_pos == bol {
self.set_cursor(self.beginning_of_line(self.cursor_pos.saturating_sub(1)));
} else {
self.set_cursor(bol);
}
self.preferred_col = None;
}
pub fn move_cursor_to_end_of_line(&mut self, move_down_at_eol: bool) {
let eol = self.end_of_current_line();
if move_down_at_eol && self.cursor_pos == eol {
let next_pos = (self.cursor_pos.saturating_add(1)).min(self.text.len());
self.set_cursor(self.end_of_line(next_pos));
} else {
self.set_cursor(eol);
}
}
// ===== Text elements support =====
pub fn insert_element(&mut self, text: &str) {
let start = self.clamp_pos_for_insertion(self.cursor_pos);
self.insert_str_at(start, text);
let end = start + text.len();
self.add_element(start..end);
// Place cursor at end of inserted element
self.set_cursor(end);
}
fn add_element(&mut self, range: Range<usize>) {
let elem = TextElement { range };
self.elements.push(elem);
self.elements.sort_by_key(|e| e.range.start);
}
fn find_element_containing(&self, pos: usize) -> Option<usize> {
self.elements
.iter()
.position(|e| pos > e.range.start && pos < e.range.end)
}
fn clamp_pos_to_nearest_boundary(&self, mut pos: usize) -> usize {
if pos > self.text.len() {
pos = self.text.len();
}
if let Some(idx) = self.find_element_containing(pos) {
let e = &self.elements[idx];
let dist_start = pos.saturating_sub(e.range.start);
let dist_end = e.range.end.saturating_sub(pos);
if dist_start <= dist_end {
e.range.start
} else {
e.range.end
}
} else {
pos
}
}
fn clamp_pos_for_insertion(&self, pos: usize) -> usize {
// Do not allow inserting into the middle of an element
if let Some(idx) = self.find_element_containing(pos) {
let e = &self.elements[idx];
// Choose closest edge for insertion
let dist_start = pos.saturating_sub(e.range.start);
let dist_end = e.range.end.saturating_sub(pos);
if dist_start <= dist_end {
e.range.start
} else {
e.range.end
}
} else {
pos
}
}
fn expand_range_to_element_boundaries(&self, mut range: Range<usize>) -> Range<usize> {
// Expand to include any intersecting elements fully
loop {
let mut changed = false;
for e in &self.elements {
if e.range.start < range.end && e.range.end > range.start {
let new_start = range.start.min(e.range.start);
let new_end = range.end.max(e.range.end);
if new_start != range.start || new_end != range.end {
range.start = new_start;
range.end = new_end;
changed = true;
}
}
}
if !changed {
break;
}
}
range
}
fn shift_elements(&mut self, at: usize, removed: usize, inserted: usize) {
// Generic shift: for pure insert, removed = 0; for delete, inserted = 0.
let end = at + removed;
let diff = inserted as isize - removed as isize;
// Remove elements fully deleted by the operation and shift the rest
self.elements
.retain(|e| !(e.range.start >= at && e.range.end <= end));
for e in &mut self.elements {
if e.range.end <= at {
// before edit
} else if e.range.start >= end {
// after edit
e.range.start = ((e.range.start as isize) + diff) as usize;
e.range.end = ((e.range.end as isize) + diff) as usize;
} else {
// Overlap with element but not fully contained (shouldn't happen when using
// element-aware replace, but degrade gracefully by snapping element to new bounds)
let new_start = at.min(e.range.start);
let new_end = at + inserted.max(e.range.end.saturating_sub(end));
e.range.start = new_start;
e.range.end = new_end;
}
}
}
fn update_elements_after_replace(&mut self, start: usize, end: usize, inserted_len: usize) {
self.shift_elements(start, end.saturating_sub(start), inserted_len);
}
fn prev_atomic_boundary(&self, pos: usize) -> usize {
if pos == 0 {
return 0;
}
// If currently at an element end or inside, jump to start of that element.
if let Some(idx) = self
.elements
.iter()
.position(|e| pos > e.range.start && pos <= e.range.end)
{
return self.elements[idx].range.start;
}
let mut gc = unicode_segmentation::GraphemeCursor::new(pos, self.text.len(), false);
match gc.prev_boundary(&self.text, 0) {
Ok(Some(b)) => {
if let Some(idx) = self.find_element_containing(b) {
self.elements[idx].range.start
} else {
b
}
}
Ok(None) => 0,
Err(_) => pos.saturating_sub(1),
}
}
fn next_atomic_boundary(&self, pos: usize) -> usize {
if pos >= self.text.len() {
return self.text.len();
}
// If currently at an element start or inside, jump to end of that element.
if let Some(idx) = self
.elements
.iter()
.position(|e| pos >= e.range.start && pos < e.range.end)
{
return self.elements[idx].range.end;
}
let mut gc = unicode_segmentation::GraphemeCursor::new(pos, self.text.len(), false);
match gc.next_boundary(&self.text, 0) {
Ok(Some(b)) => {
if let Some(idx) = self.find_element_containing(b) {
self.elements[idx].range.end
} else {
b
}
}
Ok(None) => self.text.len(),
Err(_) => pos.saturating_add(1),
}
}
pub(crate) fn beginning_of_previous_word(&self) -> usize {
let prefix = &self.text[..self.cursor_pos];
let Some((first_non_ws_idx, ch)) = prefix
.char_indices()
.rev()
.find(|&(_, ch)| !ch.is_whitespace())
else {
return 0;
};
let is_separator = is_word_separator(ch);
let mut start = first_non_ws_idx;
for (idx, ch) in prefix[..first_non_ws_idx].char_indices().rev() {
if ch.is_whitespace() || is_word_separator(ch) != is_separator {
start = idx + ch.len_utf8();
break;
}
start = idx;
}
self.adjust_pos_out_of_elements(start, true)
}
pub(crate) fn end_of_next_word(&self) -> usize {
let Some(first_non_ws) = self.text[self.cursor_pos..].find(|c: char| !c.is_whitespace())
else {
return self.text.len();
};
let word_start = self.cursor_pos + first_non_ws;
let mut iter = self.text[word_start..].char_indices();
let Some((_, first_ch)) = iter.next() else {
return word_start;
};
let is_separator = is_word_separator(first_ch);
let mut end = self.text.len();
for (idx, ch) in iter {
if ch.is_whitespace() || is_word_separator(ch) != is_separator {
end = word_start + idx;
break;
}
}
self.adjust_pos_out_of_elements(end, false)
}
fn adjust_pos_out_of_elements(&self, pos: usize, prefer_start: bool) -> usize {
if let Some(idx) = self.find_element_containing(pos) {
let e = &self.elements[idx];
if prefer_start {
e.range.start
} else {
e.range.end
}
} else {
pos
}
}
#[expect(clippy::unwrap_used)]
fn wrapped_lines(&self, width: u16) -> Ref<'_, Vec<Range<usize>>> {
// Ensure cache is ready (potentially mutably borrow, then drop)
{
let mut cache = self.wrap_cache.borrow_mut();
let needs_recalc = match cache.as_ref() {
Some(c) => c.width != width,
None => true,
};
if needs_recalc {
let lines = crate::wrapping::wrap_ranges(
&self.text,
Options::new(width as usize).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
);
*cache = Some(WrapCache { width, lines });
}
}
let cache = self.wrap_cache.borrow();
Ref::map(cache, |c| &c.as_ref().unwrap().lines)
}
/// Calculate the scroll offset that should be used to satisfy the
/// invariants given the current area size and wrapped lines.
///
/// - Cursor is always on screen.
/// - No scrolling if content fits in the area.
fn effective_scroll(
&self,
area_height: u16,
lines: &[Range<usize>],
current_scroll: u16,
) -> u16 {
let total_lines = lines.len() as u16;
if area_height >= total_lines {
return 0;
}
// Where is the cursor within wrapped lines? Prefer assigning boundary positions
// (where pos equals the start of a wrapped line) to that later line.
let cursor_line_idx =
Self::wrapped_line_index_by_start(lines, self.cursor_pos).unwrap_or(0) as u16;
let max_scroll = total_lines.saturating_sub(area_height);
let mut scroll = current_scroll.min(max_scroll);
// Ensure cursor is visible within [scroll, scroll + area_height)
if cursor_line_idx < scroll {
scroll = cursor_line_idx;
} else if cursor_line_idx >= scroll + area_height {
scroll = cursor_line_idx + 1 - area_height;
}
scroll
}
}
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());
}
}
impl StatefulWidgetRef for &TextArea {
type State = TextAreaState;
fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let lines = self.wrapped_lines(area.width);
let scroll = self.effective_scroll(area.height, &lines, state.scroll);
state.scroll = scroll;
let start = scroll as usize;
let end = (scroll + area.height).min(lines.len() as u16) as usize;
self.render_lines(area, buf, &lines, start..end);
}
}
impl TextArea {
fn render_lines(
&self,
area: Rect,
buf: &mut Buffer,
lines: &[Range<usize>],
range: std::ops::Range<usize>,
) {
for (row, idx) in range.enumerate() {
let r = &lines[idx];
let y = area.y + row as u16;
let line_range = r.start..r.end - 1;
// Draw base line with default style.
buf.set_string(area.x, y, &self.text[line_range.clone()], Style::default());
// Overlay styled segments for elements that intersect this line.
for elem in &self.elements {
// Compute overlap with displayed slice.
let overlap_start = elem.range.start.max(line_range.start);
let overlap_end = elem.range.end.min(line_range.end);
if overlap_start >= overlap_end {
continue;
}
let styled = &self.text[overlap_start..overlap_end];
let x_off = self.text[line_range.start..overlap_start].width() as u16;
let style = Style::default().fg(Color::Cyan);
buf.set_string(area.x + x_off, y, styled, style);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// crossterm types are intentionally not imported here to avoid unused warnings
use rand::prelude::*;
fn rand_grapheme(rng: &mut rand::rngs::StdRng) -> String {
let r: u8 = rng.random_range(0..100);
match r {
0..=4 => "\n".to_string(),
5..=12 => " ".to_string(),
13..=35 => (rng.random_range(b'a'..=b'z') as char).to_string(),
36..=45 => (rng.random_range(b'A'..=b'Z') as char).to_string(),
46..=52 => (rng.random_range(b'0'..=b'9') as char).to_string(),
53..=65 => {
// Some emoji (wide graphemes)
let choices = ["👍", "😊", "🐍", "🚀", "🧪", "🌟"];
choices[rng.random_range(0..choices.len())].to_string()
}
66..=75 => {
// CJK wide characters
let choices = ["", "", "", "", "", "", "", "", ""];
choices[rng.random_range(0..choices.len())].to_string()
}
76..=85 => {
// Combining mark sequences
let base = ["e", "a", "o", "n", "u"][rng.random_range(0..5)];
let marks = ["\u{0301}", "\u{0308}", "\u{0302}", "\u{0303}"];
format!("{base}{}", marks[rng.random_range(0..marks.len())])
}
86..=92 => {
// Some non-latin single codepoints (Greek, Cyrillic, Hebrew)
let choices = ["Ω", "β", "Ж", "ю", "ש", "م", ""];
choices[rng.random_range(0..choices.len())].to_string()
}
_ => {
// ZWJ sequences (single graphemes but multi-codepoint)
let choices = [
"👩\u{200D}💻", // woman technologist
"👨\u{200D}💻", // man technologist
"🏳️\u{200D}🌈", // rainbow flag
];
choices[rng.random_range(0..choices.len())].to_string()
}
}
}
fn ta_with(text: &str) -> TextArea {
let mut t = TextArea::new();
t.insert_str(text);
t
}
#[test]
fn insert_and_replace_update_cursor_and_text() {
// insert helpers
let mut t = ta_with("hello");
t.set_cursor(5);
t.insert_str("!");
assert_eq!(t.text(), "hello!");
assert_eq!(t.cursor(), 6);
t.insert_str_at(0, "X");
assert_eq!(t.text(), "Xhello!");
assert_eq!(t.cursor(), 7);
// Insert after the cursor should not move it
t.set_cursor(1);
let end = t.text().len();
t.insert_str_at(end, "Y");
assert_eq!(t.text(), "Xhello!Y");
assert_eq!(t.cursor(), 1);
// replace_range cases
// 1) cursor before range
let mut t = ta_with("abcd");
t.set_cursor(1);
t.replace_range(2..3, "Z");
assert_eq!(t.text(), "abZd");
assert_eq!(t.cursor(), 1);
// 2) cursor inside range
let mut t = ta_with("abcd");
t.set_cursor(2);
t.replace_range(1..3, "Q");
assert_eq!(t.text(), "aQd");
assert_eq!(t.cursor(), 2);
// 3) cursor after range with shifted by diff
let mut t = ta_with("abcd");
t.set_cursor(4);
t.replace_range(0..1, "AA");
assert_eq!(t.text(), "AAbcd");
assert_eq!(t.cursor(), 5);
}
#[test]
fn delete_backward_and_forward_edges() {
let mut t = ta_with("abc");
t.set_cursor(1);
t.delete_backward(1);
assert_eq!(t.text(), "bc");
assert_eq!(t.cursor(), 0);
// deleting backward at start is a no-op
t.set_cursor(0);
t.delete_backward(1);
assert_eq!(t.text(), "bc");
assert_eq!(t.cursor(), 0);
// forward delete removes next grapheme
t.set_cursor(1);
t.delete_forward(1);
assert_eq!(t.text(), "b");
assert_eq!(t.cursor(), 1);
// forward delete at end is a no-op
t.set_cursor(t.text().len());
t.delete_forward(1);
assert_eq!(t.text(), "b");
}
#[test]
fn delete_backward_word_and_kill_line_variants() {
// delete backward word at end removes the whole previous word
let mut t = ta_with("hello world ");
t.set_cursor(t.text().len());
t.delete_backward_word();
assert_eq!(t.text(), "hello ");
assert_eq!(t.cursor(), 8);
// From inside a word, delete from word start to cursor
let mut t = ta_with("foo bar");
t.set_cursor(6); // inside "bar" (after 'a')
t.delete_backward_word();
assert_eq!(t.text(), "foo r");
assert_eq!(t.cursor(), 4);
// From end, delete the last word only
let mut t = ta_with("foo bar");
t.set_cursor(t.text().len());
t.delete_backward_word();
assert_eq!(t.text(), "foo ");
assert_eq!(t.cursor(), 4);
// kill_to_end_of_line when not at EOL
let mut t = ta_with("abc\ndef");
t.set_cursor(1); // on first line, middle
t.kill_to_end_of_line();
assert_eq!(t.text(), "a\ndef");
assert_eq!(t.cursor(), 1);
// kill_to_end_of_line when at EOL deletes newline
let mut t = ta_with("abc\ndef");
t.set_cursor(3); // EOL of first line
t.kill_to_end_of_line();
assert_eq!(t.text(), "abcdef");
assert_eq!(t.cursor(), 3);
// kill_to_beginning_of_line from middle of line
let mut t = ta_with("abc\ndef");
t.set_cursor(5); // on second line, after 'e'
t.kill_to_beginning_of_line();
assert_eq!(t.text(), "abc\nef");
// kill_to_beginning_of_line at beginning of non-first line removes the previous newline
let mut t = ta_with("abc\ndef");
t.set_cursor(4); // beginning of second line
t.kill_to_beginning_of_line();
assert_eq!(t.text(), "abcdef");
assert_eq!(t.cursor(), 3);
}
#[test]
fn delete_forward_word_variants() {
let mut t = ta_with("hello world ");
t.set_cursor(0);
t.delete_forward_word();
assert_eq!(t.text(), " world ");
assert_eq!(t.cursor(), 0);
let mut t = ta_with("hello world ");
t.set_cursor(1);
t.delete_forward_word();
assert_eq!(t.text(), "h world ");
assert_eq!(t.cursor(), 1);
let mut t = ta_with("hello world");
t.set_cursor(t.text().len());
t.delete_forward_word();
assert_eq!(t.text(), "hello world");
assert_eq!(t.cursor(), t.text().len());
let mut t = ta_with("foo \nbar");
t.set_cursor(3);
t.delete_forward_word();
assert_eq!(t.text(), "foo");
assert_eq!(t.cursor(), 3);
let mut t = ta_with("foo\nbar");
t.set_cursor(3);
t.delete_forward_word();
assert_eq!(t.text(), "foo");
assert_eq!(t.cursor(), 3);
let mut t = ta_with("hello world ");
t.set_cursor(t.text().len() + 10);
t.delete_forward_word();
assert_eq!(t.text(), "hello world ");
assert_eq!(t.cursor(), t.text().len());
}
#[test]
fn delete_forward_word_handles_atomic_elements() {
let mut t = TextArea::new();
t.insert_element("<element>");
t.insert_str(" tail");
t.set_cursor(0);
t.delete_forward_word();
assert_eq!(t.text(), " tail");
assert_eq!(t.cursor(), 0);
let mut t = TextArea::new();
t.insert_str(" ");
t.insert_element("<element>");
t.insert_str(" tail");
t.set_cursor(0);
t.delete_forward_word();
assert_eq!(t.text(), " tail");
assert_eq!(t.cursor(), 0);
let mut t = TextArea::new();
t.insert_str("prefix ");
t.insert_element("<element>");
t.insert_str(" tail");
// cursor in the middle of the element, delete_forward_word deletes the element
let elem_range = t.elements[0].range.clone();
t.cursor_pos = elem_range.start + (elem_range.len() / 2);
t.delete_forward_word();
assert_eq!(t.text(), "prefix tail");
assert_eq!(t.cursor(), elem_range.start);
}
#[test]
fn delete_backward_word_respects_word_separators() {
let mut t = ta_with("path/to/file");
t.set_cursor(t.text().len());
t.delete_backward_word();
assert_eq!(t.text(), "path/to/");
assert_eq!(t.cursor(), t.text().len());
t.delete_backward_word();
assert_eq!(t.text(), "path/to");
assert_eq!(t.cursor(), t.text().len());
let mut t = ta_with("foo/ ");
t.set_cursor(t.text().len());
t.delete_backward_word();
assert_eq!(t.text(), "foo");
assert_eq!(t.cursor(), 3);
let mut t = ta_with("foo /");
t.set_cursor(t.text().len());
t.delete_backward_word();
assert_eq!(t.text(), "foo ");
assert_eq!(t.cursor(), 4);
}
#[test]
fn delete_forward_word_respects_word_separators() {
let mut t = ta_with("path/to/file");
t.set_cursor(0);
t.delete_forward_word();
assert_eq!(t.text(), "/to/file");
assert_eq!(t.cursor(), 0);
t.delete_forward_word();
assert_eq!(t.text(), "to/file");
assert_eq!(t.cursor(), 0);
let mut t = ta_with("/ foo");
t.set_cursor(0);
t.delete_forward_word();
assert_eq!(t.text(), " foo");
assert_eq!(t.cursor(), 0);
let mut t = ta_with(" /foo");
t.set_cursor(0);
t.delete_forward_word();
assert_eq!(t.text(), "foo");
assert_eq!(t.cursor(), 0);
}
#[test]
fn yank_restores_last_kill() {
let mut t = ta_with("hello");
t.set_cursor(0);
t.kill_to_end_of_line();
assert_eq!(t.text(), "");
assert_eq!(t.cursor(), 0);
t.yank();
assert_eq!(t.text(), "hello");
assert_eq!(t.cursor(), 5);
let mut t = ta_with("hello world");
t.set_cursor(t.text().len());
t.delete_backward_word();
assert_eq!(t.text(), "hello ");
assert_eq!(t.cursor(), 6);
t.yank();
assert_eq!(t.text(), "hello world");
assert_eq!(t.cursor(), 11);
let mut t = ta_with("hello");
t.set_cursor(5);
t.kill_to_beginning_of_line();
assert_eq!(t.text(), "");
assert_eq!(t.cursor(), 0);
t.yank();
assert_eq!(t.text(), "hello");
assert_eq!(t.cursor(), 5);
}
#[test]
fn cursor_left_and_right_handle_graphemes() {
let mut t = ta_with("a👍b");
t.set_cursor(t.text().len());
t.move_cursor_left(); // before 'b'
let after_first_left = t.cursor();
t.move_cursor_left(); // before '👍'
let after_second_left = t.cursor();
t.move_cursor_left(); // before 'a'
let after_third_left = t.cursor();
assert!(after_first_left < t.text().len());
assert!(after_second_left < after_first_left);
assert!(after_third_left < after_second_left);
// Move right back to end safely
t.move_cursor_right();
t.move_cursor_right();
t.move_cursor_right();
assert_eq!(t.cursor(), t.text().len());
}
#[test]
fn control_b_and_f_move_cursor() {
let mut t = ta_with("abcd");
t.set_cursor(1);
t.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL));
assert_eq!(t.cursor(), 2);
t.input(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL));
assert_eq!(t.cursor(), 1);
}
#[test]
fn control_b_f_fallback_control_chars_move_cursor() {
let mut t = ta_with("abcd");
t.set_cursor(2);
// Simulate terminals that send C0 control chars without CONTROL modifier.
// ^B (U+0002) should move left
t.input(KeyEvent::new(KeyCode::Char('\u{0002}'), KeyModifiers::NONE));
assert_eq!(t.cursor(), 1);
// ^F (U+0006) should move right
t.input(KeyEvent::new(KeyCode::Char('\u{0006}'), KeyModifiers::NONE));
assert_eq!(t.cursor(), 2);
}
#[test]
fn delete_backward_word_alt_keys() {
// Test the custom Alt+Ctrl+h binding
let mut t = ta_with("hello world");
t.set_cursor(t.text().len()); // cursor at the end
t.input(KeyEvent::new(
KeyCode::Char('h'),
KeyModifiers::CONTROL | KeyModifiers::ALT,
));
assert_eq!(t.text(), "hello ");
assert_eq!(t.cursor(), 6);
// Test the standard Alt+Backspace binding
let mut t = ta_with("hello world");
t.set_cursor(t.text().len()); // cursor at the end
t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT));
assert_eq!(t.text(), "hello ");
assert_eq!(t.cursor(), 6);
}
#[test]
fn delete_backward_word_handles_narrow_no_break_space() {
let mut t = ta_with("32\u{202F}AM");
t.set_cursor(t.text().len());
t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT));
pretty_assertions::assert_eq!(t.text(), "32\u{202F}");
pretty_assertions::assert_eq!(t.cursor(), t.text().len());
}
#[test]
fn delete_forward_word_with_without_alt_modifier() {
let mut t = ta_with("hello world");
t.set_cursor(0);
t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT));
assert_eq!(t.text(), " world");
assert_eq!(t.cursor(), 0);
let mut t = ta_with("hello");
t.set_cursor(0);
t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
assert_eq!(t.text(), "ello");
assert_eq!(t.cursor(), 0);
}
#[test]
fn control_h_backspace() {
// Test Ctrl+H as backspace
let mut t = ta_with("12345");
t.set_cursor(3); // cursor after '3'
t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
assert_eq!(t.text(), "1245");
assert_eq!(t.cursor(), 2);
// Test Ctrl+H at beginning (should be no-op)
t.set_cursor(0);
t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
assert_eq!(t.text(), "1245");
assert_eq!(t.cursor(), 0);
// Test Ctrl+H at end
t.set_cursor(t.text().len());
t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
assert_eq!(t.text(), "124");
assert_eq!(t.cursor(), 3);
}
#[test]
fn cursor_vertical_movement_across_lines_and_bounds() {
let mut t = ta_with("short\nloooooooooong\nmid");
// Place cursor on second line, column 5
let second_line_start = 6; // after first '\n'
t.set_cursor(second_line_start + 5);
// Move up: target column preserved, clamped by line length
t.move_cursor_up();
assert_eq!(t.cursor(), 5); // first line has len 5
// Move up again goes to start of text
t.move_cursor_up();
assert_eq!(t.cursor(), 0);
// Move down: from start to target col tracked
t.move_cursor_down();
// On first move down, we should land on second line, at col 0 (target col remembered as 0)
let pos_after_down = t.cursor();
assert!(pos_after_down >= second_line_start);
// Move down again to third line; clamp to its length
t.move_cursor_down();
let third_line_start = t.text().find("mid").unwrap();
let third_line_end = third_line_start + 3;
assert!(t.cursor() >= third_line_start && t.cursor() <= third_line_end);
// Moving down at last line jumps to end
t.move_cursor_down();
assert_eq!(t.cursor(), t.text().len());
}
#[test]
fn home_end_and_emacs_style_home_end() {
let mut t = ta_with("one\ntwo\nthree");
// Position at middle of second line
let second_line_start = t.text().find("two").unwrap();
t.set_cursor(second_line_start + 1);
t.move_cursor_to_beginning_of_line(false);
assert_eq!(t.cursor(), second_line_start);
// Ctrl-A behavior: if at BOL, go to beginning of previous line
t.move_cursor_to_beginning_of_line(true);
assert_eq!(t.cursor(), 0); // beginning of first line
// Move to EOL of first line
t.move_cursor_to_end_of_line(false);
assert_eq!(t.cursor(), 3);
// Ctrl-E: if at EOL, go to end of next line
t.move_cursor_to_end_of_line(true);
// end of second line ("two") is right before its '\n'
let end_second_nl = t.text().find("\nthree").unwrap();
assert_eq!(t.cursor(), end_second_nl);
}
#[test]
fn end_of_line_or_down_at_end_of_text() {
let mut t = ta_with("one\ntwo");
// Place cursor at absolute end of the text
t.set_cursor(t.text().len());
// Should remain at end without panicking
t.move_cursor_to_end_of_line(true);
assert_eq!(t.cursor(), t.text().len());
// Also verify behavior when at EOL of a non-final line:
let eol_first_line = 3; // index of '\n' in "one\ntwo"
t.set_cursor(eol_first_line);
t.move_cursor_to_end_of_line(true);
assert_eq!(t.cursor(), t.text().len()); // moves to end of next (last) line
}
#[test]
fn word_navigation_helpers() {
let t = ta_with(" alpha beta gamma");
let mut t = t; // make mutable for set_cursor
// Put cursor after "alpha"
let after_alpha = t.text().find("alpha").unwrap() + "alpha".len();
t.set_cursor(after_alpha);
assert_eq!(t.beginning_of_previous_word(), 2); // skip initial spaces
// Put cursor at start of beta
let beta_start = t.text().find("beta").unwrap();
t.set_cursor(beta_start);
assert_eq!(t.end_of_next_word(), beta_start + "beta".len());
// If at end, end_of_next_word returns len
t.set_cursor(t.text().len());
assert_eq!(t.end_of_next_word(), t.text().len());
}
#[test]
fn wrapping_and_cursor_positions() {
let mut t = ta_with("hello world here");
let area = Rect::new(0, 0, 6, 10); // width 6 -> wraps words
// desired height counts wrapped lines
assert!(t.desired_height(area.width) >= 3);
// Place cursor in "world"
let world_start = t.text().find("world").unwrap();
t.set_cursor(world_start + 3);
let (_x, y) = t.cursor_pos(area).unwrap();
assert_eq!(y, 1); // world should be on second wrapped line
// With state and small height, cursor is mapped onto visible row
let mut state = TextAreaState::default();
let small_area = Rect::new(0, 0, 6, 1);
// First call: cursor not visible -> effective scroll ensures it is
let (_x, y) = t.cursor_pos_with_state(small_area, state).unwrap();
assert_eq!(y, 0);
// Render with state to update actual scroll value
let mut buf = Buffer::empty(small_area);
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), small_area, &mut buf, &mut state);
// After render, state.scroll should be adjusted so cursor row fits
let effective_lines = t.desired_height(small_area.width);
assert!(state.scroll < effective_lines);
}
#[test]
fn cursor_pos_with_state_basic_and_scroll_behaviors() {
// Case 1: No wrapping needed, height fits — scroll ignored, y maps directly.
let mut t = ta_with("hello world");
t.set_cursor(3);
let area = Rect::new(2, 5, 20, 3);
// Even if an absurd scroll is provided, when content fits the area the
// effective scroll is 0 and the cursor position matches cursor_pos.
let bad_state = TextAreaState { scroll: 999 };
let (x1, y1) = t.cursor_pos(area).unwrap();
let (x2, y2) = t.cursor_pos_with_state(area, bad_state).unwrap();
assert_eq!((x2, y2), (x1, y1));
// Case 2: Cursor below the current window — y should be clamped to the
// bottom row (area.height - 1) after adjusting effective scroll.
let mut t = ta_with("one two three four five six");
// Force wrapping to many visual lines.
let wrap_width = 4;
let _ = t.desired_height(wrap_width);
// Put cursor somewhere near the end so it's definitely below the first window.
t.set_cursor(t.text().len().saturating_sub(2));
let small_area = Rect::new(0, 0, wrap_width, 2);
let state = TextAreaState { scroll: 0 };
let (_x, y) = t.cursor_pos_with_state(small_area, state).unwrap();
assert_eq!(y, small_area.y + small_area.height - 1);
// Case 3: Cursor above the current window — y should be top row (0)
// when the provided scroll is too large.
let mut t = ta_with("alpha beta gamma delta epsilon zeta");
let wrap_width = 5;
let lines = t.desired_height(wrap_width);
// Place cursor near start so an excessive scroll moves it to top row.
t.set_cursor(1);
let area = Rect::new(0, 0, wrap_width, 3);
let state = TextAreaState {
scroll: lines.saturating_mul(2),
};
let (_x, y) = t.cursor_pos_with_state(area, state).unwrap();
assert_eq!(y, area.y);
}
#[test]
fn wrapped_navigation_across_visual_lines() {
let mut t = ta_with("abcdefghij");
// Force wrapping at width 4: lines -> ["abcd", "efgh", "ij"]
let _ = t.desired_height(4);
// From the very start, moving down should go to the start of the next wrapped line (index 4)
t.set_cursor(0);
t.move_cursor_down();
assert_eq!(t.cursor(), 4);
// Cursor at boundary index 4 should be displayed at start of second wrapped line
t.set_cursor(4);
let area = Rect::new(0, 0, 4, 10);
let (x, y) = t.cursor_pos(area).unwrap();
assert_eq!((x, y), (0, 1));
// With state and small height, cursor should be visible at row 0, col 0
let small_area = Rect::new(0, 0, 4, 1);
let state = TextAreaState::default();
let (x, y) = t.cursor_pos_with_state(small_area, state).unwrap();
assert_eq!((x, y), (0, 0));
// Place cursor in the middle of the second wrapped line ("efgh"), at 'g'
t.set_cursor(6);
// Move up should go to same column on previous wrapped line -> index 2 ('c')
t.move_cursor_up();
assert_eq!(t.cursor(), 2);
// Move down should return to same position on the next wrapped line -> back to index 6 ('g')
t.move_cursor_down();
assert_eq!(t.cursor(), 6);
// Move down again should go to third wrapped line. Target col is 2, but the line has len 2 -> clamp to end
t.move_cursor_down();
assert_eq!(t.cursor(), t.text().len());
}
#[test]
fn cursor_pos_with_state_after_movements() {
let mut t = ta_with("abcdefghij");
// Wrap width 4 -> visual lines: abcd | efgh | ij
let _ = t.desired_height(4);
let area = Rect::new(0, 0, 4, 2);
let mut state = TextAreaState::default();
let mut buf = Buffer::empty(area);
// Start at beginning
t.set_cursor(0);
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state);
let (x, y) = t.cursor_pos_with_state(area, state).unwrap();
assert_eq!((x, y), (0, 0));
// Move down to second visual line; should be at bottom row (row 1) within 2-line viewport
t.move_cursor_down();
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state);
let (x, y) = t.cursor_pos_with_state(area, state).unwrap();
assert_eq!((x, y), (0, 1));
// Move down to third visual line; viewport scrolls and keeps cursor on bottom row
t.move_cursor_down();
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state);
let (x, y) = t.cursor_pos_with_state(area, state).unwrap();
assert_eq!((x, y), (0, 1));
// Move up to second visual line; with current scroll, it appears on top row
t.move_cursor_up();
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state);
let (x, y) = t.cursor_pos_with_state(area, state).unwrap();
assert_eq!((x, y), (0, 0));
// Column preservation across moves: set to col 2 on first line, move down
t.set_cursor(2);
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state);
let (x0, y0) = t.cursor_pos_with_state(area, state).unwrap();
assert_eq!((x0, y0), (2, 0));
t.move_cursor_down();
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state);
let (x1, y1) = t.cursor_pos_with_state(area, state).unwrap();
assert_eq!((x1, y1), (2, 1));
}
#[test]
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
let _ = t.desired_height(6);
// Put cursor on the second wrapped line before the newline, 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
t.move_cursor_up();
assert_eq!(t.cursor(), 1);
// Down should return to the same visual column on "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
t.move_cursor_down();
let start_word3 = t.text().find("word3").unwrap();
assert!(t.cursor() >= start_word3 && t.cursor() <= start_word3 + "word3".len());
}
#[test]
fn wrapped_navigation_with_wide_graphemes() {
// Four thumbs up, each of display width 2, with width 3 to force wrapping inside grapheme boundaries
let mut t = ta_with("👍👍👍👍");
let _ = t.desired_height(3);
// Put cursor after the second emoji (which should be on first wrapped line)
t.set_cursor("👍👍".len());
// Move down should go to the start of the next wrapped line (same column preserved but clamped)
t.move_cursor_down();
// We expect to land somewhere within the third emoji or at the start of it
let pos_after_down = t.cursor();
assert!(pos_after_down >= "👍👍".len());
// Moving up should take us back to the original position
t.move_cursor_up();
assert_eq!(t.cursor(), "👍👍".len());
}
#[test]
fn fuzz_textarea_randomized() {
// Deterministic seed for reproducibility
// Seed the RNG based on the current day in Pacific Time (PST/PDT). This
// keeps the fuzz test deterministic within a day while still varying
// day-to-day to improve coverage.
let pst_today_seed: u64 = (chrono::Utc::now() - chrono::Duration::hours(8))
.date_naive()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.timestamp() as u64;
let mut rng = rand::rngs::StdRng::seed_from_u64(pst_today_seed);
for _case in 0..500 {
let mut ta = TextArea::new();
let mut state = TextAreaState::default();
// Track element payloads we insert. Payloads use characters '[' and ']' which
// are not produced by rand_grapheme(), avoiding accidental collisions.
let mut elem_texts: Vec<String> = Vec::new();
let mut next_elem_id: usize = 0;
// Start with a random base string
let base_len = rng.random_range(0..30);
let mut base = String::new();
for _ in 0..base_len {
base.push_str(&rand_grapheme(&mut rng));
}
ta.set_text(&base);
// Choose a valid char boundary for initial cursor
let mut boundaries: Vec<usize> = vec![0];
boundaries.extend(ta.text().char_indices().map(|(i, _)| i).skip(1));
boundaries.push(ta.text().len());
let init = boundaries[rng.random_range(0..boundaries.len())];
ta.set_cursor(init);
let mut width: u16 = rng.random_range(1..=12);
let mut height: u16 = rng.random_range(1..=4);
for _step in 0..60 {
// Mostly stable width/height, occasionally change
if rng.random_bool(0.1) {
width = rng.random_range(1..=12);
}
if rng.random_bool(0.1) {
height = rng.random_range(1..=4);
}
// Pick an operation
match rng.random_range(0..18) {
0 => {
// insert small random string at cursor
let len = rng.random_range(0..6);
let mut s = String::new();
for _ in 0..len {
s.push_str(&rand_grapheme(&mut rng));
}
ta.insert_str(&s);
}
1 => {
// replace_range with small random slice
let mut b: Vec<usize> = vec![0];
b.extend(ta.text().char_indices().map(|(i, _)| i).skip(1));
b.push(ta.text().len());
let i1 = rng.random_range(0..b.len());
let i2 = rng.random_range(0..b.len());
let (start, end) = if b[i1] <= b[i2] {
(b[i1], b[i2])
} else {
(b[i2], b[i1])
};
let insert_len = rng.random_range(0..=4);
let mut s = String::new();
for _ in 0..insert_len {
s.push_str(&rand_grapheme(&mut rng));
}
let before = ta.text().len();
// If the chosen range intersects an element, replace_range will expand to
// element boundaries, so the naive size delta assertion does not hold.
let intersects_element = elem_texts.iter().any(|payload| {
if let Some(pstart) = ta.text().find(payload) {
let pend = pstart + payload.len();
pstart < end && pend > start
} else {
false
}
});
ta.replace_range(start..end, &s);
if !intersects_element {
let after = ta.text().len();
assert_eq!(
after as isize,
before as isize + (s.len() as isize) - ((end - start) as isize)
);
}
}
2 => ta.delete_backward(rng.random_range(0..=3)),
3 => ta.delete_forward(rng.random_range(0..=3)),
4 => ta.delete_backward_word(),
5 => ta.kill_to_beginning_of_line(),
6 => ta.kill_to_end_of_line(),
7 => ta.move_cursor_left(),
8 => ta.move_cursor_right(),
9 => ta.move_cursor_up(),
10 => ta.move_cursor_down(),
11 => ta.move_cursor_to_beginning_of_line(true),
12 => ta.move_cursor_to_end_of_line(true),
13 => {
// Insert an element with a unique sentinel payload
let payload =
format!("[[EL#{}:{}]]", next_elem_id, rng.random_range(1000..9999));
next_elem_id += 1;
ta.insert_element(&payload);
elem_texts.push(payload);
}
14 => {
// Try inserting inside an existing element (should clamp to boundary)
if let Some(payload) = elem_texts.choose(&mut rng).cloned()
&& let Some(start) = ta.text().find(&payload)
{
let end = start + payload.len();
if end - start > 2 {
let pos = rng.random_range(start + 1..end - 1);
let ins = rand_grapheme(&mut rng);
ta.insert_str_at(pos, &ins);
}
}
}
15 => {
// Replace a range that intersects an element -> whole element should be replaced
if let Some(payload) = elem_texts.choose(&mut rng).cloned()
&& let Some(start) = ta.text().find(&payload)
{
let end = start + payload.len();
// Create an intersecting range [start-δ, end-δ2)
let mut s = start.saturating_sub(rng.random_range(0..=2));
let mut e = (end + rng.random_range(0..=2)).min(ta.text().len());
// Align to char boundaries to satisfy String::replace_range contract
let txt = ta.text();
while s > 0 && !txt.is_char_boundary(s) {
s -= 1;
}
while e < txt.len() && !txt.is_char_boundary(e) {
e += 1;
}
if s < e {
// Small replacement text
let mut srep = String::new();
for _ in 0..rng.random_range(0..=2) {
srep.push_str(&rand_grapheme(&mut rng));
}
ta.replace_range(s..e, &srep);
}
}
}
16 => {
// Try setting the cursor to a position inside an element; it should clamp out
if let Some(payload) = elem_texts.choose(&mut rng).cloned()
&& let Some(start) = ta.text().find(&payload)
{
let end = start + payload.len();
if end - start > 2 {
let pos = rng.random_range(start + 1..end - 1);
ta.set_cursor(pos);
}
}
}
_ => {
// Jump to word boundaries
if rng.random_bool(0.5) {
let p = ta.beginning_of_previous_word();
ta.set_cursor(p);
} else {
let p = ta.end_of_next_word();
ta.set_cursor(p);
}
}
}
// Sanity invariants
assert!(ta.cursor() <= ta.text().len());
// Element invariants
for payload in &elem_texts {
if let Some(start) = ta.text().find(payload) {
let end = start + payload.len();
// 1) Text inside elements matches the initially set payload
assert_eq!(&ta.text()[start..end], payload);
// 2) Cursor is never strictly inside an element
let c = ta.cursor();
assert!(
c <= start || c >= end,
"cursor inside element: {start}..{end} at {c}"
);
}
}
// Render and compute cursor positions; ensure they are in-bounds and do not panic
let area = Rect::new(0, 0, width, height);
// Stateless render into an area tall enough for all wrapped lines
let total_lines = ta.desired_height(width);
let full_area = Rect::new(0, 0, width, total_lines.max(1));
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));
// Stateful render should not panic, and updates scroll
let mut sbuf = Buffer::empty(area);
ratatui::widgets::StatefulWidgetRef::render_ref(
&(&ta),
area,
&mut sbuf,
&mut state,
);
// After wrapping, desired height equals the number of lines we would render without scroll
let total_lines = total_lines as usize;
// state.scroll must not exceed total_lines when content fits within area height
if (height as usize) >= total_lines {
assert_eq!(state.scroll, 0);
}
}
}
}
}