зеркало из
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
Этот коммит содержится в:
39
README.md
39
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][].
|
||||
|
||||
|
||||
67
examples/atomic_ranges.rs
Обычный файл
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;
|
||||
|
||||
361
src/textarea.rs
361
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<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
Обычный файл
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