зеркало из
https://github.com/glebtv/tui-textarea.git
synced 2026-09-04 06:45:50 +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
Этот коммит содержится в:
255
tests/atomic.rs
Обычный файл
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""#
|
||||
);
|
||||
}
|
||||
|
||||
Ссылка в новой задаче
Block a user