From f03a7b9ec1674b98ae9dc8151a1fde04ceaef8cd Mon Sep 17 00:00:00 2001
From: srothgan <141313976+srothgan@users.noreply.github.com>
Date: Wed, 18 Feb 2026 22:37:00 +0100
Subject: [PATCH] fix(textarea): clamp undo/redo cursor after oversized
delete_str (#9)
---
CHANGELOG.md | 11 ++++++++++
Cargo.toml | 2 +-
src/textarea.rs | 53 +++++++++++++++++++++++++++++++++++++++++++++++--
tests/input.rs | 35 ++++++++++++++++++++++++++++++++
4 files changed, 98 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 01fbf8b..77234c9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,13 @@
+
+# [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]
+
# [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
diff --git a/Cargo.toml b/Cargo.toml
index c5ad12b..d801607 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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 ", "rhysd "]
diff --git a/src/textarea.rs b/src/textarea.rs
index 7ee5e7b..8892ef3 100644
--- a/src/textarea.rs
+++ b/src/textarea.rs
@@ -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));
+ }
}
diff --git a/tests/input.rs b/tests/input.rs
index 4dafeb0..06bc6ba 100644
--- a/tests/input.rs
+++ b/tests/input.rs
@@ -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));
+}