diff --git a/README.md b/README.md index cd6c241..db1ac0b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Since this fork was created, it has added or integrated: - Wrapped-line Up/Down cursor navigation that follows visual rows - Row measurement via `TextAreaMeasure`, `TextArea::measure()`, `set_min_rows()`, and `set_max_rows()` - Bulk whole-buffer replacement via `TextArea::set_lines()` +- Opt-in atomic ranges for caller-owned placeholders, mentions, or other indivisible spans **Features:** @@ -33,6 +34,7 @@ Since this fork was created, it has added or integrated: - Search with regular expressions - Text selection - Custom highlighted ranges +- Opt-in atomic ranges for indivisible editing spans - Placeholder and masking support - Mouse scrolling - Yank support. Paste text deleted with `C-k`, `C-j`, ... @@ -453,6 +455,32 @@ textarea.custom_highlight( Call `TextArea::clear_custom_highlight()` to remove all custom highlighted ranges. +### Configure atomic ranges + +Applications can mark caller-parsed text spans as atomic with `TextArea::set_atomic_ranges()`. Atomic ranges use row and +character-column coordinates, remain separate from rendering, and are cleared after successful content mutations so the +application can recompute them from the new text. + +```rust,ignore +use ratatui::style::{Color, Style}; +use tui_textarea::{AtomicRange, TextArea}; + +let mut textarea = TextArea::from(["Send [[image:cat.png]] now"]); +textarea.set_atomic_ranges([AtomicRange { + row: 0, + start_col: 5, + end_col: 22, +}]); + +textarea.custom_highlight( + ((0, 5), (0, 22)), + Style::default().fg(Color::Yellow), + 10, +); +``` + +See [`atomic_ranges` example](./examples/atomic_ranges.rs) for a small caller-owned parsing flow. + ### Configure max history size By default, past 50 modifications are stored as edit history. The history is used for undo/redo. To change how many past @@ -569,6 +597,11 @@ notify how to move the cursor. | `textarea.select_all()` | Select entire text | | `textarea.custom_highlight(range, style, priority)` | Add a custom highlighted range | | `textarea.clear_custom_highlight()` | Clear all custom highlights | +| `textarea.set_atomic_ranges(ranges)` | Set caller-owned indivisible text spans | +| `textarea.try_set_atomic_ranges(ranges)` | Validate and set atomic ranges without panics | +| `textarea.clear_atomic_ranges()` | Clear all atomic ranges | +| `textarea.atomic_ranges()` | Get configured atomic ranges | +| `textarea.delete_atomic_range_at_cursor(direction)` | Delete an atom at the cursor as one edit | | `textarea.move_cursor(CursorMove::Forward)` | Move cursor forward by one character | | `textarea.move_cursor(CursorMove::Back)` | Move cursor backward by one character | | `textarea.move_cursor(CursorMove::Up)` | Move cursor up by one line | @@ -760,6 +793,12 @@ Values of the following types can be serialized/deserialized: - `CursorMove` - `Scrolling` - `WrapMode` +- `AtomicRange` +- `AtomicCursorBias` +- `AtomicDeleteDirection` +- `AtomicRangeError` +- `RejectedAtomicRange` +- `AtomicRangeRejectReason` Here is an example for deserializing key input from JSON using [serde_json][]. diff --git a/examples/atomic_ranges.rs b/examples/atomic_ranges.rs new file mode 100644 index 0000000..e9db879 --- /dev/null +++ b/examples/atomic_ranges.rs @@ -0,0 +1,67 @@ +use ratatui::style::{Color, Style}; +use tui_textarea::{AtomicDeleteDirection, AtomicRange, CursorMove, TextArea}; + +fn placeholder_ranges(row: usize, line: &str) -> Vec { + let mut ranges = Vec::new(); + let mut offset = 0; + + while let Some(start_delta) = line[offset..].find("[[") { + let start_byte = offset + start_delta; + let Some(end_delta) = line[start_byte..].find("]]") else { + break; + }; + let end_byte = start_byte + end_delta + 2; + ranges.push(AtomicRange { + row, + start_col: line[..start_byte].chars().count(), + end_col: line[..end_byte].chars().count(), + }); + offset = end_byte; + } + + ranges +} + +fn byte_offset(line: &str, col: usize) -> usize { + line.char_indices() + .nth(col) + .map(|(offset, _)| offset) + .unwrap_or(line.len()) +} + +fn refresh_atoms(textarea: &mut TextArea<'_>) { + let ranges: Vec<_> = textarea + .lines() + .iter() + .enumerate() + .flat_map(|(row, line)| placeholder_ranges(row, line)) + .collect(); + let highlights: Vec<_> = ranges + .iter() + .map(|range| { + let line = &textarea.lines()[range.row]; + ( + ( + (range.row, byte_offset(line, range.start_col)), + (range.row, byte_offset(line, range.end_col)), + ), + Style::default().fg(Color::Yellow), + ) + }) + .collect(); + + textarea.set_atomic_ranges(ranges); + textarea.clear_custom_highlight(); + for (range, style) in highlights { + textarea.custom_highlight(range, style, 10); + } +} + +fn main() { + let mut textarea = TextArea::from(["See [[image:cat.png]] before sending."]); + refresh_atoms(&mut textarea); + + textarea.move_cursor(CursorMove::Jump(0, 4)); + textarea.delete_atomic_range_at_cursor(AtomicDeleteDirection::Forward); + refresh_atoms(&mut textarea); +} diff --git a/src/lib.rs b/src/lib.rs index 97412eb..89c6e0d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,5 +42,8 @@ use termion; pub use cursor::CursorMove; pub use input::{Input, Key}; pub use scroll::Scrolling; -pub use textarea::{TextArea, TextAreaMeasure}; +pub use textarea::{ + AtomicCursorBias, AtomicDeleteDirection, AtomicRange, AtomicRangeError, + AtomicRangeRejectReason, RejectedAtomicRange, TextArea, TextAreaMeasure, +}; pub use wrap::WrapMode; diff --git a/src/textarea.rs b/src/textarea.rs index b2bac81..7b29e9e 100644 --- a/src/textarea.rs +++ b/src/textarea.rs @@ -14,6 +14,10 @@ use crate::util::{Pos, num_digits, spaces}; use crate::widget::Viewport; use crate::word::{find_word_exclusive_end_forward, find_word_start_backward}; use crate::wrap::{WrapMode, WrappedLine, effective_wrap_width, wrapped_rows}; +#[cfg(feature = "arbitrary")] +use arbitrary::Arbitrary; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; use std::cell::{Cell, RefCell}; use std::cmp::{self, Ordering}; use std::fmt; @@ -62,6 +66,62 @@ struct CustomHighlight { priority: u8, } +/// A caller-provided text span that should be treated as an indivisible editing unit. +/// +/// Columns are character columns, matching [`TextArea::cursor`]. Ranges are half-open: +/// `start_col..end_col`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct AtomicRange { + pub row: usize, + pub start_col: usize, + pub end_col: usize, +} + +/// Bias used when a cursor position falls strictly inside an atomic range. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum AtomicCursorBias { + Backward, + Forward, +} + +/// Direction used when checking whether an atomic range should be deleted at the cursor. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum AtomicDeleteDirection { + Backward, + Forward, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct AtomicRangeError { + pub rejected: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct RejectedAtomicRange { + pub range: AtomicRange, + pub reason: AtomicRangeRejectReason, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum AtomicRangeRejectReason { + Empty, + RowOutOfBounds, + ColumnOutOfBounds, + OverlapsPrevious, +} + /// Measured textarea height information for a given width. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct TextAreaMeasure { @@ -149,6 +209,7 @@ pub struct TextArea<'a> { selection_start: Option, select_style: Style, custom_highlights: Vec, + atomic_ranges: Vec, measure_cache: Option<(u16, TextAreaMeasure)>, pub(crate) screen_lines: RefCell>, pub(crate) data_pointers: RefCell>, @@ -266,6 +327,7 @@ impl<'a> TextArea<'a> { selection_start: None, select_style: Style::default().bg(Color::LightBlue), custom_highlights: Default::default(), + atomic_ranges: Vec::new(), measure_cache: None, screen_lines: RefCell::new(Vec::new()), data_pointers: RefCell::new(Vec::new()), @@ -787,10 +849,165 @@ impl<'a> TextArea<'a> { let after = Pos::new(row, col, after_offset); let edit = Edit::new(kind, before, after); self.history.push(edit); + self.atomic_ranges.clear(); self.refresh_screen_map(); self.reset_measure_cache(); } + fn validate_atomic_ranges(&self, ranges: I) -> Result, AtomicRangeError> + where + I: IntoIterator, + { + let mut ranges: Vec<_> = ranges.into_iter().collect(); + ranges.sort_by_key(|range| (range.row, range.start_col)); + + let mut valid = Vec::with_capacity(ranges.len()); + let mut rejected = Vec::new(); + let mut previous_valid: Option = None; + + for range in ranges { + let reason = if range.start_col == range.end_col { + Some(AtomicRangeRejectReason::Empty) + } else if range.row >= self.lines.len() { + Some(AtomicRangeRejectReason::RowOutOfBounds) + } else { + let line_len = self.lines[range.row].chars().count(); + if range.start_col > range.end_col || range.end_col > line_len { + Some(AtomicRangeRejectReason::ColumnOutOfBounds) + } else if previous_valid.is_some_and(|previous| { + previous.row == range.row && range.start_col < previous.end_col + }) { + Some(AtomicRangeRejectReason::OverlapsPrevious) + } else { + None + } + }; + + if let Some(reason) = reason { + rejected.push(RejectedAtomicRange { range, reason }); + } else { + previous_valid = Some(range); + valid.push(range); + } + } + + if rejected.is_empty() { + Ok(valid) + } else { + Err(AtomicRangeError { rejected }) + } + } + + fn atomic_range_containing_cursor(&self, cursor: DataCursor) -> Option { + self.atomic_ranges.iter().copied().find(|range| { + range.row == cursor.0 && range.start_col < cursor.1 && cursor.1 < range.end_col + }) + } + + fn normalize_data_cursor_around_atomic_ranges( + &self, + cursor: DataCursor, + bias: AtomicCursorBias, + ) -> DataCursor { + let Some(range) = self.atomic_range_containing_cursor(cursor) else { + return cursor; + }; + + match bias { + AtomicCursorBias::Backward => DataCursor(range.row, range.start_col), + AtomicCursorBias::Forward => DataCursor(range.row, range.end_col), + } + } + + fn atomic_range_at_data_cursor( + &self, + cursor: DataCursor, + direction: AtomicDeleteDirection, + ) -> Option { + self.atomic_ranges + .iter() + .copied() + .find(|range| match direction { + AtomicDeleteDirection::Backward => { + range.row == cursor.0 && range.start_col < cursor.1 && cursor.1 <= range.end_col + } + AtomicDeleteDirection::Forward => { + range.row == cursor.0 && range.start_col <= cursor.1 && cursor.1 < range.end_col + } + }) + } + + fn movement_atomic_bias(m: CursorMove) -> AtomicCursorBias { + match m { + CursorMove::Back + | CursorMove::Up + | CursorMove::Head + | CursorMove::Top + | CursorMove::WordBack + | CursorMove::ParagraphBack => AtomicCursorBias::Backward, + CursorMove::Forward + | CursorMove::Down + | CursorMove::End + | CursorMove::Bottom + | CursorMove::WordForward + | CursorMove::WordEnd + | CursorMove::ParagraphForward + | CursorMove::Jump(_, _) + | CursorMove::InViewport => AtomicCursorBias::Forward, + } + } + + fn pos_at(&self, row: usize, col: usize) -> Pos { + Pos::new(row, col, self.line_offset(row, col)) + } + + fn expand_range_around_atomic_ranges(&self, start: Pos, end: Pos) -> (Pos, Pos) { + if self.atomic_ranges.is_empty() { + return (start, end); + } + + let mut start_col = start.col; + let mut end_col = end.col; + + if start.row == end.row { + for range in self + .atomic_ranges + .iter() + .filter(|range| range.row == start.row) + { + if range.start_col < end_col && start_col < range.end_col { + start_col = start_col.min(range.start_col); + end_col = end_col.max(range.end_col); + } + } + } else { + for range in self + .atomic_ranges + .iter() + .filter(|range| range.row == start.row) + { + if start_col < range.end_col { + start_col = start_col.min(range.start_col); + } + } + + for range in self + .atomic_ranges + .iter() + .filter(|range| range.row == end.row) + { + if range.start_col < end_col { + end_col = end_col.max(range.end_col); + } + } + } + + ( + self.pos_at(start.row, start_col), + self.pos_at(end.row, end_col), + ) + } + /// Insert a single character at current cursor position. /// ``` /// use tui_textarea::TextArea; @@ -807,6 +1024,7 @@ impl<'a> TextArea<'a> { } self.delete_selection(false); + self.normalize_cursor_around_atomic_ranges(AtomicCursorBias::Forward); let DataCursor(row, col) = self.cursor; let line = &mut self.lines[row]; let i = line @@ -838,6 +1056,7 @@ impl<'a> TextArea<'a> { /// ``` pub fn insert_str>(&mut self, s: S) -> bool { let modified = self.delete_selection(false); + self.normalize_cursor_around_atomic_ranges(AtomicCursorBias::Forward); let mut lines: Vec<_> = s .as_ref() .split('\n') @@ -845,13 +1064,14 @@ impl<'a> TextArea<'a> { .collect(); match lines.len() { 0 => modified, - 1 => self.insert_piece(lines.remove(0)), - _ => self.insert_chunk(lines), + 1 => self.insert_piece(lines.remove(0)) || modified, + _ => self.insert_chunk(lines) || modified, } } fn insert_chunk(&mut self, chunk: Vec) -> bool { debug_assert!(chunk.len() > 1, "Chunk size must be > 1: {:?}", chunk); + self.normalize_cursor_around_atomic_ranges(AtomicCursorBias::Forward); let DataCursor(row, col) = self.cursor; let line = &mut self.lines[row]; @@ -881,6 +1101,7 @@ impl<'a> TextArea<'a> { if s.is_empty() { return false; } + self.normalize_cursor_around_atomic_ranges(AtomicCursorBias::Forward); let DataCursor(row, col) = self.cursor; let line = &mut self.lines[row]; @@ -904,6 +1125,7 @@ impl<'a> TextArea<'a> { } fn delete_range(&mut self, start: Pos, end: Pos, should_yank: bool) { + let (start, end) = self.expand_range_around_atomic_ranges(start, end); self.cursor = DataCursor(start.row, start.col); if start.row == end.row { @@ -1002,15 +1224,10 @@ impl<'a> TextArea<'a> { if let Some((offset_delta, col_delta)) = find_end(&line[start_offset..]) { let end_offset = start_offset + offset_delta; let end_col = start_col + col_delta; - let removed = self.lines[start_row] - .drain(start_offset..end_offset) - .as_str() - .to_string(); - self.yank = removed.clone().into(); - self.push_history( - EditKind::DeleteStr(removed), + self.delete_range( + Pos::new(start_row, start_col, start_offset), Pos::new(start_row, end_col, end_offset), - start_offset, + true, ); return true; } @@ -1062,18 +1279,12 @@ impl<'a> TextArea<'a> { } let DataCursor(row, _) = self.cursor; - let line = &mut self.lines[row]; + let line = &self.lines[row]; if let Some((i, _)) = line.char_indices().nth(col) { let (bytes, chars) = bytes_and_chars(chars, &line[i..]); - let removed = line.drain(i..i + bytes).as_str().to_string(); - - self.cursor = DataCursor(row, col); - self.push_history( - EditKind::DeleteStr(removed.clone()), - Pos::new(row, col + chars, i + bytes), - i, - ); - self.yank = removed.into(); + let start = Pos::new(row, col, i); + let end = Pos::new(row, col + chars, i + bytes); + self.delete_range(start, end, true); true } else { false @@ -1127,6 +1338,7 @@ impl<'a> TextArea<'a> { /// ``` pub fn insert_newline(&mut self) { self.delete_selection(false); + self.normalize_cursor_around_atomic_ranges(AtomicCursorBias::Forward); let DataCursor(row, col) = self.cursor; let line = &mut self.lines[row]; @@ -1190,6 +1402,12 @@ impl<'a> TextArea<'a> { if self.delete_selection(false) { return true; } + if self + .delete_atomic_range_at_cursor(AtomicDeleteDirection::Backward) + .is_some() + { + return true; + } let DataCursor(row, col) = self.cursor; if col == 0 { @@ -1226,6 +1444,12 @@ impl<'a> TextArea<'a> { if self.delete_selection(false) { return true; } + if self + .delete_atomic_range_at_cursor(AtomicDeleteDirection::Forward) + .is_some() + { + return true; + } let before = self.cursor; self.move_cursor_with_shift(CursorMove::Forward, false); @@ -1306,6 +1530,12 @@ impl<'a> TextArea<'a> { if self.delete_selection(false) { return true; } + if self + .delete_atomic_range_at_cursor(AtomicDeleteDirection::Backward) + .is_some() + { + return true; + } let DataCursor(r, c) = self.cursor; if let Some(col) = find_word_start_backward(&self.lines[r], c) { self.delete_piece(col, c - col) @@ -1336,6 +1566,12 @@ impl<'a> TextArea<'a> { if self.delete_selection(false) { return true; } + if self + .delete_atomic_range_at_cursor(AtomicDeleteDirection::Forward) + .is_some() + { + return true; + } let DataCursor(r, c) = self.cursor; let line = &self.lines[r]; if let Some(col) = find_word_exclusive_end_forward(line, c) { @@ -1399,6 +1635,7 @@ impl<'a> TextArea<'a> { /// ``` pub fn paste(&mut self) -> bool { self.delete_selection(false); + self.normalize_cursor_around_atomic_ranges(AtomicCursorBias::Forward); match self.yank.clone() { YankText::Piece(s) => self.insert_piece(s), YankText::Chunk(c) => self.insert_chunk(c), @@ -1493,6 +1730,83 @@ impl<'a> TextArea<'a> { self.selection_start.is_some() } + /// Set atomic ranges, panicking if any range is invalid. + /// + /// This is the ergonomic API for trusted callers. Use [`TextArea::try_set_atomic_ranges`] when + /// parser errors should be reported instead of panicking. + pub fn set_atomic_ranges(&mut self, ranges: I) + where + I: IntoIterator, + { + if let Err(err) = self.try_set_atomic_ranges(ranges) { + panic!("invalid atomic ranges: {err:?}"); + } + } + + /// Set atomic ranges and report invalid input without changing the current ranges. + pub fn try_set_atomic_ranges(&mut self, ranges: I) -> Result<(), AtomicRangeError> + where + I: IntoIterator, + { + let ranges = self.validate_atomic_ranges(ranges)?; + self.atomic_ranges = ranges; + self.normalize_cursor_around_atomic_ranges(AtomicCursorBias::Forward); + if let Some(selection_start) = self.selection_start { + let normalized = self.normalize_data_cursor_around_atomic_ranges( + selection_start, + AtomicCursorBias::Backward, + ); + self.selection_start = Some(normalized); + if self.selection_positions().is_none() { + self.cancel_selection(); + } + } + Ok(()) + } + + /// Remove all configured atomic ranges. + pub fn clear_atomic_ranges(&mut self) { + self.atomic_ranges.clear(); + } + + /// Get the configured atomic ranges, sorted by `(row, start_col)`. + pub fn atomic_ranges(&self) -> &[AtomicRange] { + &self.atomic_ranges + } + + /// Normalize the current cursor if it is strictly inside an atomic range. + /// + /// Returns `true` when the cursor moved. + pub fn normalize_cursor_around_atomic_ranges(&mut self, bias: AtomicCursorBias) -> bool { + let normalized = self.normalize_data_cursor_around_atomic_ranges(self.cursor, bias); + if normalized == self.cursor { + false + } else { + self.cursor = normalized; + true + } + } + + /// Return the atomic range hit by a directional deletion at the current cursor. + pub fn atomic_range_at_cursor(&self, direction: AtomicDeleteDirection) -> Option { + self.atomic_range_at_data_cursor(self.cursor, direction) + } + + /// Delete the atomic range hit by a directional deletion at the current cursor. + /// + /// The returned range is the pre-delete range. As with other successful content mutations, + /// configured atomic ranges are cleared after the edit is recorded. + pub fn delete_atomic_range_at_cursor( + &mut self, + direction: AtomicDeleteDirection, + ) -> Option { + let range = self.atomic_range_at_cursor(direction)?; + let start = self.pos_at(range.row, range.start_col); + let end = self.pos_at(range.row, range.end_col); + self.delete_range(start, end, false); + Some(range) + } + fn line_offset(&self, row: usize, col: usize) -> usize { let line = self .lines @@ -1656,7 +1970,9 @@ impl<'a> TextArea<'a> { } else { self.cancel_selection(); } - self.cursor = self.screen_to_data_cursor(cursor); + let cursor = self.screen_to_data_cursor(cursor); + self.cursor = self + .normalize_data_cursor_around_atomic_ranges(cursor, Self::movement_atomic_bias(m)); } } @@ -1675,6 +1991,7 @@ impl<'a> TextArea<'a> { if let Some(cursor) = self.history.undo(&mut self.lines) { self.cancel_selection(); self.cursor = self.clamp_cursor_to_buffer(cursor); + self.atomic_ranges.clear(); self.refresh_screen_map(); self.reset_measure_cache(); true @@ -1700,6 +2017,7 @@ impl<'a> TextArea<'a> { if let Some(cursor) = self.history.redo(&mut self.lines) { self.cancel_selection(); self.cursor = self.clamp_cursor_to_buffer(cursor); + self.atomic_ranges.clear(); self.refresh_screen_map(); self.reset_measure_cache(); true @@ -2238,6 +2556,7 @@ impl<'a> TextArea<'a> { self.history = History::new(self.history.max_items()); self.selection_start = None; self.custom_highlights.clear(); + self.atomic_ranges.clear(); self.viewport = Viewport::default(); self.refresh_screen_map(); self.reset_measure_cache(); diff --git a/tests/atomic.rs b/tests/atomic.rs new file mode 100644 index 0000000..0fcdbe0 --- /dev/null +++ b/tests/atomic.rs @@ -0,0 +1,255 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::Widget as _; +use tui_textarea::{ + AtomicDeleteDirection, AtomicRange, AtomicRangeRejectReason, CursorMove, TextArea, WrapMode, +}; + +fn atom(start_col: usize, end_col: usize) -> AtomicRange { + AtomicRange { + row: 0, + start_col, + end_col, + } +} + +fn render(textarea: &TextArea<'_>, width: u16, height: u16) { + let area = Rect { + x: 0, + y: 0, + width, + height, + }; + let mut buf = Buffer::empty(area); + textarea.render(area, &mut buf); +} + +#[test] +fn atomic_ranges_validate_sort_and_clear() { + let mut textarea = TextArea::from(["abcdef"]); + + textarea + .try_set_atomic_ranges([atom(4, 6), atom(1, 3)]) + .unwrap(); + assert_eq!(textarea.atomic_ranges(), &[atom(1, 3), atom(4, 6)]); + + textarea.clear_atomic_ranges(); + assert!(textarea.atomic_ranges().is_empty()); + + textarea.set_atomic_ranges([atom(1, 3)]); + let err = textarea + .try_set_atomic_ranges([ + atom(2, 2), + AtomicRange { + row: 9, + start_col: 0, + end_col: 1, + }, + atom(5, 7), + atom(1, 4), + atom(3, 5), + ]) + .unwrap_err(); + + let reasons: Vec<_> = err + .rejected + .iter() + .map(|rejected| rejected.reason) + .collect(); + assert_eq!( + reasons, + [ + AtomicRangeRejectReason::Empty, + AtomicRangeRejectReason::OverlapsPrevious, + AtomicRangeRejectReason::ColumnOutOfBounds, + AtomicRangeRejectReason::RowOutOfBounds, + ] + ); + assert_eq!(textarea.atomic_ranges(), &[atom(1, 3)]); + + textarea.set_atomic_ranges([]); + assert!(textarea.atomic_ranges().is_empty()); +} + +#[test] +#[should_panic(expected = "invalid atomic ranges")] +fn set_atomic_ranges_panics_on_invalid_ranges() { + let mut textarea = TextArea::from(["abc"]); + textarea.set_atomic_ranges([atom(1, 1)]); +} + +#[test] +fn setting_atomic_ranges_normalizes_cursor_and_selection_start() { + let mut textarea = TextArea::from(["abcdef"]); + textarea.move_cursor(CursorMove::Jump(0, 2)); + textarea.set_atomic_ranges([atom(1, 4)]); + assert_eq!(textarea.cursor(), (0, 4)); + + let mut textarea = TextArea::from(["abcdef"]); + textarea.move_cursor(CursorMove::Jump(0, 2)); + textarea.start_selection(); + textarea.move_cursor(CursorMove::Jump(0, 6)); + textarea.set_atomic_ranges([atom(1, 4)]); + assert_eq!(textarea.selection_range(), Some(((0, 1), (0, 6)))); + + let mut textarea = TextArea::from(["abcdef"]); + textarea.move_cursor(CursorMove::Jump(0, 2)); + textarea.start_selection(); + textarea.move_cursor(CursorMove::Jump(0, 1)); + textarea.set_atomic_ranges([atom(1, 4)]); + assert!(!textarea.is_selecting()); + assert_eq!(textarea.cursor(), (0, 1)); +} + +#[test] +fn cursor_movement_skips_atom_interiors() { + let mut textarea = TextArea::from(["abXYZcd"]); + textarea.set_atomic_ranges([atom(2, 5)]); + + textarea.move_cursor(CursorMove::Jump(0, 2)); + textarea.move_cursor(CursorMove::Forward); + assert_eq!(textarea.cursor(), (0, 5)); + + textarea.move_cursor(CursorMove::Back); + assert_eq!(textarea.cursor(), (0, 2)); + + textarea.move_cursor(CursorMove::Jump(0, 3)); + assert_eq!(textarea.cursor(), (0, 5)); + + let mut textarea = TextArea::from(["aa XYZ bb"]); + textarea.set_atomic_ranges([atom(3, 6)]); + textarea.move_cursor(CursorMove::Jump(0, 3)); + textarea.move_cursor(CursorMove::WordEnd); + assert_eq!(textarea.cursor(), (0, 6)); +} + +#[test] +fn directional_atomic_deletion_respects_boundaries() { + let mut textarea = TextArea::from(["abXYZcd"]); + textarea.set_atomic_ranges([atom(2, 5)]); + textarea.move_cursor(CursorMove::Jump(0, 5)); + assert_eq!( + textarea.atomic_range_at_cursor(AtomicDeleteDirection::Backward), + Some(atom(2, 5)) + ); + assert!(textarea.delete_char()); + assert_eq!(textarea.lines(), ["abcd"]); + assert_eq!(textarea.cursor(), (0, 2)); + assert!(textarea.atomic_ranges().is_empty()); + assert!(textarea.undo()); + assert_eq!(textarea.lines(), ["abXYZcd"]); + assert!(textarea.atomic_ranges().is_empty()); + assert!(textarea.redo()); + assert_eq!(textarea.lines(), ["abcd"]); + + let mut textarea = TextArea::from(["abXYZcd"]); + textarea.set_atomic_ranges([atom(2, 5)]); + textarea.move_cursor(CursorMove::Jump(0, 2)); + assert!(textarea.delete_next_char()); + assert_eq!(textarea.lines(), ["abcd"]); + + let mut textarea = TextArea::from(["abXYZcd"]); + textarea.set_atomic_ranges([atom(2, 5)]); + textarea.move_cursor(CursorMove::Jump(0, 2)); + assert!(textarea.delete_char()); + assert_eq!(textarea.lines(), ["aXYZcd"]); + + let mut textarea = TextArea::from(["abXYZcd"]); + textarea.set_atomic_ranges([atom(2, 5)]); + textarea.move_cursor(CursorMove::Jump(0, 5)); + assert!(textarea.delete_next_char()); + assert_eq!(textarea.lines(), ["abXYZd"]); +} + +#[test] +fn range_deletion_expands_partial_atom_coverage() { + let mut textarea = TextArea::from(["abXYZcd"]); + textarea.set_atomic_ranges([atom(2, 5)]); + textarea.move_cursor(CursorMove::Jump(0, 2)); + assert!(textarea.delete_str(2)); + assert_eq!(textarea.lines(), ["abcd"]); + assert_eq!(textarea.yank_text(), "XYZ"); + assert_eq!(textarea.cursor(), (0, 2)); + + let mut textarea = TextArea::from(["abXYZcd"]); + textarea.set_atomic_ranges([atom(2, 5)]); + textarea.move_cursor(CursorMove::Jump(0, 1)); + textarea.start_selection(); + textarea.move_cursor(CursorMove::Jump(0, 3)); + textarea.set_atomic_ranges([atom(2, 5)]); + assert!(textarea.cut()); + assert_eq!(textarea.lines(), ["acd"]); + assert_eq!(textarea.yank_text(), "bXYZ"); + assert_eq!(textarea.cursor(), (0, 1)); +} + +#[test] +fn word_and_line_deletions_use_atomic_boundaries() { + let mut textarea = TextArea::from(["abXYZ cd"]); + textarea.set_atomic_ranges([atom(2, 5)]); + textarea.move_cursor(CursorMove::Jump(0, 5)); + assert!(textarea.delete_word()); + assert_eq!(textarea.lines(), ["ab cd"]); + assert_eq!(textarea.cursor(), (0, 2)); + + let mut textarea = TextArea::from(["abXYZ cd"]); + textarea.set_atomic_ranges([atom(2, 5)]); + textarea.move_cursor(CursorMove::Jump(0, 2)); + assert!(textarea.delete_next_word()); + assert_eq!(textarea.lines(), ["ab cd"]); + + let mut textarea = TextArea::from(["abXYZcd"]); + textarea.set_atomic_ranges([atom(2, 5)]); + textarea.move_cursor(CursorMove::Jump(0, 5)); + assert!(textarea.delete_line_by_head()); + assert_eq!(textarea.lines(), ["cd"]); + assert_eq!(textarea.yank_text(), "abXYZ"); +} + +#[test] +fn insertion_after_atom_normalized_jump_clears_ranges() { + let mut textarea = TextArea::from(["abXYZcd"]); + textarea.set_atomic_ranges([atom(2, 5)]); + textarea.move_cursor(CursorMove::Jump(0, 3)); + assert_eq!(textarea.cursor(), (0, 5)); + textarea.insert_char('!'); + assert_eq!(textarea.lines(), ["abXYZ!cd"]); + assert!(textarea.atomic_ranges().is_empty()); + + let mut textarea = TextArea::from(["abXYZcd"]); + textarea.set_atomic_ranges([atom(2, 5)]); + textarea.move_cursor(CursorMove::Jump(0, 3)); + assert!(textarea.insert_str("!!")); + assert_eq!(textarea.lines(), ["abXYZ!!cd"]); +} + +#[test] +fn set_lines_and_clear_content_mutations_clear_atomic_ranges() { + let mut textarea = TextArea::from(["abXYZcd"]); + textarea.set_atomic_ranges([atom(2, 5)]); + textarea.set_lines(vec!["abc".to_string()], (0, 0)); + assert!(textarea.atomic_ranges().is_empty()); + + textarea.set_atomic_ranges([atom(0, 2)]); + assert!(textarea.clear()); + assert!(textarea.atomic_ranges().is_empty()); +} + +#[test] +fn wrapped_vertical_movement_normalizes_atom_interiors() { + let mut textarea = TextArea::from(["abcdefghij"]); + textarea.set_wrap_mode(WrapMode::WordOrGlyph); + render(&textarea, 5, 4); + textarea.set_atomic_ranges([atom(6, 8)]); + textarea.move_cursor(CursorMove::Jump(0, 2)); + textarea.move_cursor(CursorMove::Down); + assert_eq!(textarea.cursor(), (0, 8)); + + let mut textarea = TextArea::from(["abcdefghij"]); + textarea.set_wrap_mode(WrapMode::WordOrGlyph); + render(&textarea, 5, 4); + textarea.set_atomic_ranges([atom(2, 4)]); + textarea.move_cursor(CursorMove::Jump(0, 7)); + textarea.move_cursor(CursorMove::Up); + assert_eq!(textarea.cursor(), (0, 2)); +} diff --git a/tests/serde.rs b/tests/serde.rs index 9eccb72..d480c07 100644 --- a/tests/serde.rs +++ b/tests/serde.rs @@ -1,6 +1,9 @@ #![cfg(feature = "serde")] -use tui_textarea::{CursorMove, Input, Key, Scrolling}; +use tui_textarea::{ + AtomicCursorBias, AtomicDeleteDirection, AtomicRange, AtomicRangeError, + AtomicRangeRejectReason, CursorMove, Input, Key, RejectedAtomicRange, Scrolling, +}; #[test] fn test_serde_key() { @@ -45,3 +48,35 @@ fn test_serde_cursor_move() { let d: CursorMove = serde_json::from_str(&s).unwrap(); assert_eq!(d, c); } + +#[test] +fn test_serde_atomic_types() { + let range = AtomicRange { + row: 1, + start_col: 2, + end_col: 5, + }; + let s = serde_json::to_string(&range).unwrap(); + assert_eq!(s, r#"{"row":1,"start_col":2,"end_col":5}"#); + let d: AtomicRange = serde_json::from_str(&s).unwrap(); + assert_eq!(d, range); + + let error = AtomicRangeError { + rejected: vec![RejectedAtomicRange { + range, + reason: AtomicRangeRejectReason::OverlapsPrevious, + }], + }; + let s = serde_json::to_string(&error).unwrap(); + let d: AtomicRangeError = serde_json::from_str(&s).unwrap(); + assert_eq!(d, error); + + assert_eq!( + serde_json::to_string(&AtomicCursorBias::Forward).unwrap(), + r#""Forward""# + ); + assert_eq!( + serde_json::to_string(&AtomicDeleteDirection::Backward).unwrap(), + r#""Backward""# + ); +}