зеркало из
https://github.com/glebtv/tui-textarea.git
synced 2026-08-28 11:36:17 +03:00
fix(textarea): clamp undo/redo cursor after oversized delete_str (#9)
Этот коммит содержится в:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -1,3 +1,13 @@
|
||||
<a id="v0.9.1"></a>
|
||||
# [v0.9.1](https://github.com/srothgan/tui-textarea/releases/tag/v0.9.1) - 2026-02-18
|
||||
|
||||
- Fix panic on `Ctrl+U`/`undo()` after oversized `delete_str()` (including `select_all() + delete_str(usize::MAX)` on empty buffers).
|
||||
- Fix root cause by clamping exhausted `delete_str()` ranges to EOF so history never stores out-of-bounds rows.
|
||||
- Add defensive cursor clamping in `undo()`/`redo()` when restoring from history.
|
||||
- Add regression tests for the reported panic paths and invalid-history cursor restore edge cases.
|
||||
|
||||
[Changes][v0.9.1]
|
||||
|
||||
<a id="v0.9.0"></a>
|
||||
# [v0.9.0](https://github.com/srothgan/tui-textarea/releases/tag/v0.9.0) - 2026-02-18
|
||||
|
||||
@@ -474,6 +484,7 @@ First release :tada:
|
||||
|
||||
[Changes][v0.1.0]
|
||||
|
||||
[v0.9.1]: https://github.com/srothgan/tui-textarea/compare/v0.9.0...v0.9.1
|
||||
[v0.9.0]: https://github.com/srothgan/tui-textarea/compare/v0.8.0...v0.9.0
|
||||
[v0.8.0]: https://github.com/srothgan/tui-textarea/compare/v0.7.1...v0.8.0
|
||||
[v0.7.1]: https://github.com/srothgan/tui-textarea/compare/v0.7.0...v0.7.1
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "tui-textarea-2"
|
||||
homepage = "https://github.com/srothgan/tui-textarea#readme"
|
||||
repository = "https://github.com/srothgan/tui-textarea"
|
||||
documentation = "https://docs.rs/tui-textarea-2/latest/tui_textarea/"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.56.1" # for `tui` crate support
|
||||
authors = ["Simon Peter Rothgang <simonrothgang@icloud.com>", "rhysd <lin90162@yahoo.co.jp>"]
|
||||
|
||||
@@ -998,6 +998,14 @@ impl<'a> TextArea<'a> {
|
||||
r += 1;
|
||||
}
|
||||
|
||||
if r == self.lines.len() {
|
||||
// Clamp exhausted deletions to EOF on the last line so history keeps a valid cursor.
|
||||
r = self.lines.len() - 1;
|
||||
let line = &self.lines[r];
|
||||
offset = line.len();
|
||||
col = line.chars().count();
|
||||
}
|
||||
|
||||
let start = Pos::new(start_row, start_col, start_offset);
|
||||
let end = Pos::new(r, col, offset);
|
||||
self.delete_range(start, end, true);
|
||||
@@ -1465,6 +1473,18 @@ impl<'a> TextArea<'a> {
|
||||
.unwrap_or(line.len())
|
||||
}
|
||||
|
||||
fn clamp_cursor_to_buffer(&mut self, cursor: (usize, usize)) -> (usize, usize) {
|
||||
if self.lines.is_empty() {
|
||||
self.lines.push(String::new());
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
let (row, col) = cursor;
|
||||
let row = row.min(self.lines.len() - 1);
|
||||
let col = col.min(self.lines[row].chars().count());
|
||||
(row, col)
|
||||
}
|
||||
|
||||
/// Set the style used for text selection. The default style is light blue.
|
||||
/// ```
|
||||
/// use tui_textarea::TextArea;
|
||||
@@ -1651,7 +1671,7 @@ impl<'a> TextArea<'a> {
|
||||
pub fn undo(&mut self) -> bool {
|
||||
if let Some(cursor) = self.history.undo(&mut self.lines) {
|
||||
self.cancel_selection();
|
||||
self.cursor = cursor;
|
||||
self.cursor = self.clamp_cursor_to_buffer(cursor);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
@@ -1674,7 +1694,7 @@ impl<'a> TextArea<'a> {
|
||||
pub fn redo(&mut self) -> bool {
|
||||
if let Some(cursor) = self.history.redo(&mut self.lines) {
|
||||
self.cancel_selection();
|
||||
self.cursor = cursor;
|
||||
self.cursor = self.clamp_cursor_to_buffer(cursor);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
@@ -2639,4 +2659,33 @@ mod tests {
|
||||
textarea.scroll((-5, 0));
|
||||
assert_eq!(textarea.cursor(), (12, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_clamps_invalid_cursor_from_history() {
|
||||
let mut textarea = TextArea::default();
|
||||
textarea.history.push(Edit::new(
|
||||
EditKind::DeleteStr(String::new()),
|
||||
Pos::new(1, 10, 0),
|
||||
Pos::new(0, 0, 0),
|
||||
));
|
||||
|
||||
assert!(textarea.undo());
|
||||
assert_eq!(textarea.lines(), [""]);
|
||||
assert_eq!(textarea.cursor(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redo_clamps_invalid_cursor_from_history() {
|
||||
let mut textarea = TextArea::default();
|
||||
textarea.history.push(Edit::new(
|
||||
EditKind::InsertStr(String::new()),
|
||||
Pos::new(0, 0, 0),
|
||||
Pos::new(1, 10, 0),
|
||||
));
|
||||
|
||||
assert!(textarea.undo());
|
||||
assert!(textarea.redo());
|
||||
assert_eq!(textarea.lines(), [""]);
|
||||
assert_eq!(textarea.cursor(), (0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,3 +80,38 @@ fn test_insert_multi_code_unit_emoji() {
|
||||
}
|
||||
assert_eq!(t.lines(), ["👨👩👧👦"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ctrl_u_after_delete_str_max_on_empty_buffer() {
|
||||
let mut t = TextArea::default();
|
||||
assert!(t.delete_str(usize::MAX));
|
||||
assert_eq!(t.lines(), [""]);
|
||||
assert_eq!(t.cursor(), (0, 0));
|
||||
|
||||
assert!(t.input(Input {
|
||||
key: Key::Char('u'),
|
||||
ctrl: true,
|
||||
alt: false,
|
||||
shift: false,
|
||||
}));
|
||||
assert_eq!(t.lines(), [""]);
|
||||
assert_eq!(t.cursor(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ctrl_u_after_select_all_then_delete_str_max_on_empty_buffer() {
|
||||
let mut t = TextArea::default();
|
||||
t.select_all();
|
||||
assert!(t.delete_str(usize::MAX));
|
||||
assert_eq!(t.lines(), [""]);
|
||||
assert_eq!(t.cursor(), (0, 0));
|
||||
|
||||
assert!(t.input(Input {
|
||||
key: Key::Char('u'),
|
||||
ctrl: true,
|
||||
alt: false,
|
||||
shift: false,
|
||||
}));
|
||||
assert_eq!(t.lines(), [""]);
|
||||
assert_eq!(t.cursor(), (0, 0));
|
||||
}
|
||||
|
||||
Ссылка в новой задаче
Block a user