зеркало из
https://github.com/glebtv/tui-textarea.git
synced 2026-09-08 00:15: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
Этот коммит содержится в:
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
|
- Wrapped-line Up/Down cursor navigation that follows visual rows
|
||||||
- Row measurement via `TextAreaMeasure`, `TextArea::measure()`, `set_min_rows()`, and `set_max_rows()`
|
- Row measurement via `TextAreaMeasure`, `TextArea::measure()`, `set_min_rows()`, and `set_max_rows()`
|
||||||
- Bulk whole-buffer replacement via `TextArea::set_lines()`
|
- Bulk whole-buffer replacement via `TextArea::set_lines()`
|
||||||
|
- Opt-in atomic ranges for caller-owned placeholders, mentions, or other indivisible spans
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ Since this fork was created, it has added or integrated:
|
|||||||
- Search with regular expressions
|
- Search with regular expressions
|
||||||
- Text selection
|
- Text selection
|
||||||
- Custom highlighted ranges
|
- Custom highlighted ranges
|
||||||
|
- Opt-in atomic ranges for indivisible editing spans
|
||||||
- Placeholder and masking support
|
- Placeholder and masking support
|
||||||
- Mouse scrolling
|
- Mouse scrolling
|
||||||
- Yank support. Paste text deleted with `C-k`, `C-j`, ...
|
- 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.
|
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
|
### 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
|
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.select_all()` | Select entire text |
|
||||||
| `textarea.custom_highlight(range, style, priority)` | Add a custom highlighted range |
|
| `textarea.custom_highlight(range, style, priority)` | Add a custom highlighted range |
|
||||||
| `textarea.clear_custom_highlight()` | Clear all custom highlights |
|
| `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::Forward)` | Move cursor forward by one character |
|
||||||
| `textarea.move_cursor(CursorMove::Back)` | Move cursor backward 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 |
|
| `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`
|
- `CursorMove`
|
||||||
- `Scrolling`
|
- `Scrolling`
|
||||||
- `WrapMode`
|
- `WrapMode`
|
||||||
|
- `AtomicRange`
|
||||||
|
- `AtomicCursorBias`
|
||||||
|
- `AtomicDeleteDirection`
|
||||||
|
- `AtomicRangeError`
|
||||||
|
- `RejectedAtomicRange`
|
||||||
|
- `AtomicRangeRejectReason`
|
||||||
|
|
||||||
Here is an example for deserializing key input from JSON using [serde_json][].
|
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 cursor::CursorMove;
|
||||||
pub use input::{Input, Key};
|
pub use input::{Input, Key};
|
||||||
pub use scroll::Scrolling;
|
pub use scroll::Scrolling;
|
||||||
pub use textarea::{TextArea, TextAreaMeasure};
|
pub use textarea::{
|
||||||
|
AtomicCursorBias, AtomicDeleteDirection, AtomicRange, AtomicRangeError,
|
||||||
|
AtomicRangeRejectReason, RejectedAtomicRange, TextArea, TextAreaMeasure,
|
||||||
|
};
|
||||||
pub use wrap::WrapMode;
|
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::widget::Viewport;
|
||||||
use crate::word::{find_word_exclusive_end_forward, find_word_start_backward};
|
use crate::word::{find_word_exclusive_end_forward, find_word_start_backward};
|
||||||
use crate::wrap::{WrapMode, WrappedLine, effective_wrap_width, wrapped_rows};
|
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::cell::{Cell, RefCell};
|
||||||
use std::cmp::{self, Ordering};
|
use std::cmp::{self, Ordering};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
@@ -62,6 +66,62 @@ struct CustomHighlight {
|
|||||||
priority: u8,
|
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.
|
/// Measured textarea height information for a given width.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub struct TextAreaMeasure {
|
pub struct TextAreaMeasure {
|
||||||
@@ -149,6 +209,7 @@ pub struct TextArea<'a> {
|
|||||||
selection_start: Option<DataCursor>,
|
selection_start: Option<DataCursor>,
|
||||||
select_style: Style,
|
select_style: Style,
|
||||||
custom_highlights: Vec<CustomHighlight>,
|
custom_highlights: Vec<CustomHighlight>,
|
||||||
|
atomic_ranges: Vec<AtomicRange>,
|
||||||
measure_cache: Option<(u16, TextAreaMeasure)>,
|
measure_cache: Option<(u16, TextAreaMeasure)>,
|
||||||
pub(crate) screen_lines: RefCell<Vec<ScreenLine>>,
|
pub(crate) screen_lines: RefCell<Vec<ScreenLine>>,
|
||||||
pub(crate) data_pointers: RefCell<Vec<DataLine>>,
|
pub(crate) data_pointers: RefCell<Vec<DataLine>>,
|
||||||
@@ -266,6 +327,7 @@ impl<'a> TextArea<'a> {
|
|||||||
selection_start: None,
|
selection_start: None,
|
||||||
select_style: Style::default().bg(Color::LightBlue),
|
select_style: Style::default().bg(Color::LightBlue),
|
||||||
custom_highlights: Default::default(),
|
custom_highlights: Default::default(),
|
||||||
|
atomic_ranges: Vec::new(),
|
||||||
measure_cache: None,
|
measure_cache: None,
|
||||||
screen_lines: RefCell::new(Vec::new()),
|
screen_lines: RefCell::new(Vec::new()),
|
||||||
data_pointers: 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 after = Pos::new(row, col, after_offset);
|
||||||
let edit = Edit::new(kind, before, after);
|
let edit = Edit::new(kind, before, after);
|
||||||
self.history.push(edit);
|
self.history.push(edit);
|
||||||
|
self.atomic_ranges.clear();
|
||||||
self.refresh_screen_map();
|
self.refresh_screen_map();
|
||||||
self.reset_measure_cache();
|
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.
|
/// Insert a single character at current cursor position.
|
||||||
/// ```
|
/// ```
|
||||||
/// use tui_textarea::TextArea;
|
/// use tui_textarea::TextArea;
|
||||||
@@ -807,6 +1024,7 @@ impl<'a> TextArea<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.delete_selection(false);
|
self.delete_selection(false);
|
||||||
|
self.normalize_cursor_around_atomic_ranges(AtomicCursorBias::Forward);
|
||||||
let DataCursor(row, col) = self.cursor;
|
let DataCursor(row, col) = self.cursor;
|
||||||
let line = &mut self.lines[row];
|
let line = &mut self.lines[row];
|
||||||
let i = line
|
let i = line
|
||||||
@@ -838,6 +1056,7 @@ impl<'a> TextArea<'a> {
|
|||||||
/// ```
|
/// ```
|
||||||
pub fn insert_str<S: AsRef<str>>(&mut self, s: S) -> bool {
|
pub fn insert_str<S: AsRef<str>>(&mut self, s: S) -> bool {
|
||||||
let modified = self.delete_selection(false);
|
let modified = self.delete_selection(false);
|
||||||
|
self.normalize_cursor_around_atomic_ranges(AtomicCursorBias::Forward);
|
||||||
let mut lines: Vec<_> = s
|
let mut lines: Vec<_> = s
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.split('\n')
|
.split('\n')
|
||||||
@@ -845,13 +1064,14 @@ impl<'a> TextArea<'a> {
|
|||||||
.collect();
|
.collect();
|
||||||
match lines.len() {
|
match lines.len() {
|
||||||
0 => modified,
|
0 => modified,
|
||||||
1 => self.insert_piece(lines.remove(0)),
|
1 => self.insert_piece(lines.remove(0)) || modified,
|
||||||
_ => self.insert_chunk(lines),
|
_ => self.insert_chunk(lines) || modified,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn insert_chunk(&mut self, chunk: Vec<String>) -> bool {
|
fn insert_chunk(&mut self, chunk: Vec<String>) -> bool {
|
||||||
debug_assert!(chunk.len() > 1, "Chunk size must be > 1: {:?}", chunk);
|
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 DataCursor(row, col) = self.cursor;
|
||||||
let line = &mut self.lines[row];
|
let line = &mut self.lines[row];
|
||||||
@@ -881,6 +1101,7 @@ impl<'a> TextArea<'a> {
|
|||||||
if s.is_empty() {
|
if s.is_empty() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
self.normalize_cursor_around_atomic_ranges(AtomicCursorBias::Forward);
|
||||||
|
|
||||||
let DataCursor(row, col) = self.cursor;
|
let DataCursor(row, col) = self.cursor;
|
||||||
let line = &mut self.lines[row];
|
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) {
|
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);
|
self.cursor = DataCursor(start.row, start.col);
|
||||||
|
|
||||||
if start.row == end.row {
|
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..]) {
|
if let Some((offset_delta, col_delta)) = find_end(&line[start_offset..]) {
|
||||||
let end_offset = start_offset + offset_delta;
|
let end_offset = start_offset + offset_delta;
|
||||||
let end_col = start_col + col_delta;
|
let end_col = start_col + col_delta;
|
||||||
let removed = self.lines[start_row]
|
self.delete_range(
|
||||||
.drain(start_offset..end_offset)
|
Pos::new(start_row, start_col, start_offset),
|
||||||
.as_str()
|
|
||||||
.to_string();
|
|
||||||
self.yank = removed.clone().into();
|
|
||||||
self.push_history(
|
|
||||||
EditKind::DeleteStr(removed),
|
|
||||||
Pos::new(start_row, end_col, end_offset),
|
Pos::new(start_row, end_col, end_offset),
|
||||||
start_offset,
|
true,
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1062,18 +1279,12 @@ impl<'a> TextArea<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let DataCursor(row, _) = self.cursor;
|
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) {
|
if let Some((i, _)) = line.char_indices().nth(col) {
|
||||||
let (bytes, chars) = bytes_and_chars(chars, &line[i..]);
|
let (bytes, chars) = bytes_and_chars(chars, &line[i..]);
|
||||||
let removed = line.drain(i..i + bytes).as_str().to_string();
|
let start = Pos::new(row, col, i);
|
||||||
|
let end = Pos::new(row, col + chars, i + bytes);
|
||||||
self.cursor = DataCursor(row, col);
|
self.delete_range(start, end, true);
|
||||||
self.push_history(
|
|
||||||
EditKind::DeleteStr(removed.clone()),
|
|
||||||
Pos::new(row, col + chars, i + bytes),
|
|
||||||
i,
|
|
||||||
);
|
|
||||||
self.yank = removed.into();
|
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
@@ -1127,6 +1338,7 @@ impl<'a> TextArea<'a> {
|
|||||||
/// ```
|
/// ```
|
||||||
pub fn insert_newline(&mut self) {
|
pub fn insert_newline(&mut self) {
|
||||||
self.delete_selection(false);
|
self.delete_selection(false);
|
||||||
|
self.normalize_cursor_around_atomic_ranges(AtomicCursorBias::Forward);
|
||||||
|
|
||||||
let DataCursor(row, col) = self.cursor;
|
let DataCursor(row, col) = self.cursor;
|
||||||
let line = &mut self.lines[row];
|
let line = &mut self.lines[row];
|
||||||
@@ -1190,6 +1402,12 @@ impl<'a> TextArea<'a> {
|
|||||||
if self.delete_selection(false) {
|
if self.delete_selection(false) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if self
|
||||||
|
.delete_atomic_range_at_cursor(AtomicDeleteDirection::Backward)
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
let DataCursor(row, col) = self.cursor;
|
let DataCursor(row, col) = self.cursor;
|
||||||
if col == 0 {
|
if col == 0 {
|
||||||
@@ -1226,6 +1444,12 @@ impl<'a> TextArea<'a> {
|
|||||||
if self.delete_selection(false) {
|
if self.delete_selection(false) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if self
|
||||||
|
.delete_atomic_range_at_cursor(AtomicDeleteDirection::Forward)
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
let before = self.cursor;
|
let before = self.cursor;
|
||||||
self.move_cursor_with_shift(CursorMove::Forward, false);
|
self.move_cursor_with_shift(CursorMove::Forward, false);
|
||||||
@@ -1306,6 +1530,12 @@ impl<'a> TextArea<'a> {
|
|||||||
if self.delete_selection(false) {
|
if self.delete_selection(false) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if self
|
||||||
|
.delete_atomic_range_at_cursor(AtomicDeleteDirection::Backward)
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
let DataCursor(r, c) = self.cursor;
|
let DataCursor(r, c) = self.cursor;
|
||||||
if let Some(col) = find_word_start_backward(&self.lines[r], c) {
|
if let Some(col) = find_word_start_backward(&self.lines[r], c) {
|
||||||
self.delete_piece(col, c - col)
|
self.delete_piece(col, c - col)
|
||||||
@@ -1336,6 +1566,12 @@ impl<'a> TextArea<'a> {
|
|||||||
if self.delete_selection(false) {
|
if self.delete_selection(false) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if self
|
||||||
|
.delete_atomic_range_at_cursor(AtomicDeleteDirection::Forward)
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
let DataCursor(r, c) = self.cursor;
|
let DataCursor(r, c) = self.cursor;
|
||||||
let line = &self.lines[r];
|
let line = &self.lines[r];
|
||||||
if let Some(col) = find_word_exclusive_end_forward(line, c) {
|
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 {
|
pub fn paste(&mut self) -> bool {
|
||||||
self.delete_selection(false);
|
self.delete_selection(false);
|
||||||
|
self.normalize_cursor_around_atomic_ranges(AtomicCursorBias::Forward);
|
||||||
match self.yank.clone() {
|
match self.yank.clone() {
|
||||||
YankText::Piece(s) => self.insert_piece(s),
|
YankText::Piece(s) => self.insert_piece(s),
|
||||||
YankText::Chunk(c) => self.insert_chunk(c),
|
YankText::Chunk(c) => self.insert_chunk(c),
|
||||||
@@ -1493,6 +1730,83 @@ impl<'a> TextArea<'a> {
|
|||||||
self.selection_start.is_some()
|
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 {
|
fn line_offset(&self, row: usize, col: usize) -> usize {
|
||||||
let line = self
|
let line = self
|
||||||
.lines
|
.lines
|
||||||
@@ -1656,7 +1970,9 @@ impl<'a> TextArea<'a> {
|
|||||||
} else {
|
} else {
|
||||||
self.cancel_selection();
|
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) {
|
if let Some(cursor) = self.history.undo(&mut self.lines) {
|
||||||
self.cancel_selection();
|
self.cancel_selection();
|
||||||
self.cursor = self.clamp_cursor_to_buffer(cursor);
|
self.cursor = self.clamp_cursor_to_buffer(cursor);
|
||||||
|
self.atomic_ranges.clear();
|
||||||
self.refresh_screen_map();
|
self.refresh_screen_map();
|
||||||
self.reset_measure_cache();
|
self.reset_measure_cache();
|
||||||
true
|
true
|
||||||
@@ -1700,6 +2017,7 @@ impl<'a> TextArea<'a> {
|
|||||||
if let Some(cursor) = self.history.redo(&mut self.lines) {
|
if let Some(cursor) = self.history.redo(&mut self.lines) {
|
||||||
self.cancel_selection();
|
self.cancel_selection();
|
||||||
self.cursor = self.clamp_cursor_to_buffer(cursor);
|
self.cursor = self.clamp_cursor_to_buffer(cursor);
|
||||||
|
self.atomic_ranges.clear();
|
||||||
self.refresh_screen_map();
|
self.refresh_screen_map();
|
||||||
self.reset_measure_cache();
|
self.reset_measure_cache();
|
||||||
true
|
true
|
||||||
@@ -2238,6 +2556,7 @@ impl<'a> TextArea<'a> {
|
|||||||
self.history = History::new(self.history.max_items());
|
self.history = History::new(self.history.max_items());
|
||||||
self.selection_start = None;
|
self.selection_start = None;
|
||||||
self.custom_highlights.clear();
|
self.custom_highlights.clear();
|
||||||
|
self.atomic_ranges.clear();
|
||||||
self.viewport = Viewport::default();
|
self.viewport = Viewport::default();
|
||||||
self.refresh_screen_map();
|
self.refresh_screen_map();
|
||||||
self.reset_measure_cache();
|
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")]
|
#![cfg(feature = "serde")]
|
||||||
|
|
||||||
use tui_textarea::{CursorMove, Input, Key, Scrolling};
|
use tui_textarea::{
|
||||||
|
AtomicCursorBias, AtomicDeleteDirection, AtomicRange, AtomicRangeError,
|
||||||
|
AtomicRangeRejectReason, CursorMove, Input, Key, RejectedAtomicRange, Scrolling,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_serde_key() {
|
fn test_serde_key() {
|
||||||
@@ -45,3 +48,35 @@ fn test_serde_cursor_move() {
|
|||||||
let d: CursorMove = serde_json::from_str(&s).unwrap();
|
let d: CursorMove = serde_json::from_str(&s).unwrap();
|
||||||
assert_eq!(d, c);
|
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