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)); +}