1
0
зеркало из https://github.com/glebtv/tui-textarea.git synced 2026-08-28 11:36:17 +03:00

feat(textarea): add atomic range editing support (#19)

- add public AtomicRange API with validation and diagnostic errors
- make cursor movement, deletion, insertion, selection, undo/redo, and set_lines atom-aware
- clear caller-derived atomic ranges after successful content mutations
- add focused atomic range tests plus serde coverage
- document atomic ranges and add a caller-owned parsing example
Этот коммит содержится в:
srothgan
2026-06-30 20:17:20 +02:00
коммит произвёл GitHub
родитель 8f708cfdc7
Коммит 9c4db800ef
6 изменённых файлов: 741 добавлений и 23 удалений

Просмотреть файл

@@ -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][].

67
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<AtomicRange> {
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);
}

Просмотреть файл

@@ -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;

Просмотреть файл

@@ -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<RejectedAtomicRange>,
}
#[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<DataCursor>,
select_style: Style,
custom_highlights: Vec<CustomHighlight>,
atomic_ranges: Vec<AtomicRange>,
measure_cache: Option<(u16, TextAreaMeasure)>,
pub(crate) screen_lines: RefCell<Vec<ScreenLine>>,
pub(crate) data_pointers: RefCell<Vec<DataLine>>,
@@ -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<I>(&self, ranges: I) -> Result<Vec<AtomicRange>, AtomicRangeError>
where
I: IntoIterator<Item = AtomicRange>,
{
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<AtomicRange> = 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<AtomicRange> {
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<AtomicRange> {
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<S: AsRef<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<String>) -> 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<I>(&mut self, ranges: I)
where
I: IntoIterator<Item = AtomicRange>,
{
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<I>(&mut self, ranges: I) -> Result<(), AtomicRangeError>
where
I: IntoIterator<Item = AtomicRange>,
{
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<AtomicRange> {
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<AtomicRange> {
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();

255
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));
}

Просмотреть файл

@@ -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""#
);
}