diff --git a/CHANGELOG.md b/CHANGELOG.md index be90e34..324ba6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ + +# [v0.8.0](https://github.com/srothgan/tui-textarea/releases/tag/v0.8.0) - 2026-02-18 + +- Add `TextArea::clear()` API to clear all text content with undo/redo coverage. (upstream PR [#113](https://github.com/rhysd/tui-textarea/pull/113)) +- Add custom highlight ranges with priority via `TextArea::custom_highlight()` and `TextArea::clear_custom_highlight()`. (upstream PR [#93](https://github.com/rhysd/tui-textarea/pull/93)) +- Fix horizontal scrolling with wide Unicode characters by using display width for cursor column calculations. (upstream PR [#94](https://github.com/rhysd/tui-textarea/pull/94)) +- Treat `_` as part of words for word navigation/edit operations. (upstream PR [#98](https://github.com/rhysd/tui-textarea/pull/98)) +- Fix editor examples to preserve modified state across multiple inputs. (upstream PR [#100](https://github.com/rhysd/tui-textarea/pull/100)) +- Use `portable-atomic` for viewport atomics to improve target compatibility. (upstream PR [#111](https://github.com/rhysd/tui-textarea/pull/111)) +- Fix README wording for line-number default behavior. (upstream PR [#116](https://github.com/rhysd/tui-textarea/pull/116)) + +[Changes][v0.8.0] + # [v0.7.1](https://github.com/srothgan/tui-textarea/releases/tag/v0.7.1) - 2026-02-18 @@ -449,6 +462,7 @@ First release :tada: [Changes][v0.1.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 [v0.7.0]: https://github.com/rhysd/tui-textarea/compare/v0.6.1...v0.7.0 [v0.6.1]: https://github.com/rhysd/tui-textarea/compare/v0.6.0...v0.6.1 diff --git a/Cargo.toml b/Cargo.toml index 284809e..e65c037 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.7.1" +version = "0.8.0" edition = "2021" rust-version = "1.56.1" # for `tui` crate support authors = ["Simon Peter Rothgang ", "rhysd "] @@ -15,7 +15,7 @@ license = "MIT" readme = "README.md" categories = ["text-editors", "text-processing"] keywords = ["tui", "textarea", "editor", "input", "ratatui"] -include = ["/src", "/examples", "/tests", "/README.md", "/LICENSE.txt"] +include = ["/src", "/examples", "/tests", "/README.md", "/LICENSE"] [lib] name = "tui_textarea" @@ -46,6 +46,7 @@ crossterm-028 = { package = "crossterm", version = "0.28", optional = true } crossterm-025 = { package = "crossterm", version = "0.25", optional = true } ratatui-core = { version = "0.1.0", default-features = false, optional = true } ratatui-widgets = { version = "0.3.0", default-features = false, optional = true } +portable-atomic = "1.11.1" regex = { version = "1", optional = true } termion = { version = "4.0", optional = true } termion-15 = { package = "termion", version = "1.5", optional = true } diff --git a/LICENSE.txt b/LICENSE similarity index 98% rename from LICENSE.txt rename to LICENSE index f4ea71e..dec136d 100644 --- a/LICENSE.txt +++ b/LICENSE @@ -1,4 +1,4 @@ -the MIT License +The MIT License Copyright (c) 2022 rhysd diff --git a/README.md b/README.md index afb0182..34417c8 100644 --- a/README.md +++ b/README.md @@ -343,7 +343,7 @@ assert_eq!(textarea.into_lines(), [""]); ### Show line number -By default, `TextArea` does now show line numbers. To enable, set a style for rendering line numbers by +By default, `TextArea` does not show line numbers. To enable, set a style for rendering line numbers by `TextArea::set_line_number_style()`. For example, the following renders line numbers in dark gray background color. @@ -485,6 +485,7 @@ notify how to move the cursor. | `textarea.delete_line_by_head()` | Delete from cursor until the head of line | | `textarea.delete_word()` | Delete one word before cursor | | `textarea.delete_next_word()` | Delete one word next to cursor | +| `textarea.clear()` | Clear all text | | `textarea.undo()` | Undo | | `textarea.redo()` | Redo | | `textarea.copy()` | Copy selected text | @@ -493,6 +494,8 @@ notify how to move the cursor. | `textarea.start_selection()` | Start text selection | | `textarea.cancel_selection()` | Cancel text selection | | `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.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 | @@ -709,7 +712,7 @@ Please read [CONTRIBUTING.md](./CONTRIBUTING.md) before reporting an issue or ma ## License -tui-textarea-2 is distributed under [The MIT License](./LICENSE.txt). +tui-textarea-2 is distributed under [The MIT License](./LICENSE). [crates-io-badge]: https://img.shields.io/crates/v/tui-textarea-2.svg [crate]: https://crates.io/crates/tui-textarea-2 diff --git a/examples/editor.rs b/examples/editor.rs index 309c5cf..c3f4c4c 100644 --- a/examples/editor.rs +++ b/examples/editor.rs @@ -338,7 +338,7 @@ impl Editor<'_> { } input => { let buffer = &mut self.buffers[self.current]; - buffer.modified = buffer.textarea.input(input); + buffer.modified |= buffer.textarea.input(input); } } } diff --git a/examples/tuirs_editor.rs b/examples/tuirs_editor.rs index dc1a6cd..792c2c0 100644 --- a/examples/tuirs_editor.rs +++ b/examples/tuirs_editor.rs @@ -341,7 +341,7 @@ impl Editor<'_> { } input => { let buffer = &mut self.buffers[self.current]; - buffer.modified = buffer.textarea.input(input); + buffer.modified |= buffer.textarea.input(input); } } } diff --git a/src/highlight.rs b/src/highlight.rs index 936859d..d7e15aa 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -15,6 +15,7 @@ enum Boundary { Select(Style), #[cfg(feature = "search")] Search(Style), + Custom(Style, u8), // style, priority End, } @@ -22,10 +23,11 @@ impl Boundary { fn cmp(&self, other: &Boundary) -> Ordering { fn rank(b: &Boundary) -> u8 { match b { - Boundary::Cursor(_) => 3, + Boundary::Cursor(_) => 30, #[cfg(feature = "search")] - Boundary::Search(_) => 2, - Boundary::Select(_) => 1, + Boundary::Search(_) => 20, + Boundary::Select(_) => 10, + Boundary::Custom(_, p) => *p, Boundary::End => 0, } } @@ -38,6 +40,7 @@ impl Boundary { Boundary::Select(s) => Some(*s), #[cfg(feature = "search")] Boundary::Search(s) => Some(*s), + Boundary::Custom(s, _) => Some(*s), Boundary::End => None, } } @@ -156,13 +159,15 @@ impl<'a> LineHighlighter<'a> { } } - pub fn selection( + // Shared code for selection and custom highlights + fn multiline_highlight( &mut self, current_row: usize, start_row: usize, start_off: usize, end_row: usize, end_off: usize, + boundary: Boundary, ) { let (start, end) = if current_row == start_row { if start_row == end_row { @@ -180,12 +185,47 @@ impl<'a> LineHighlighter<'a> { return; }; if start != end { - self.boundaries - .push((Boundary::Select(self.select_style), start)); + self.boundaries.push((boundary, start)); self.boundaries.push((Boundary::End, end)); } } + pub fn selection( + &mut self, + current_row: usize, + start_row: usize, + start_off: usize, + end_row: usize, + end_off: usize, + ) { + self.multiline_highlight( + current_row, + start_row, + start_off, + end_row, + end_off, + Boundary::Select(self.select_style), + ); + } + + pub fn custom( + &mut self, + current_row: usize, + range: ((usize, usize), (usize, usize)), + style: Style, + priority: u8, + ) { + let ((start_row, start_off), (end_row, end_off)) = range; + self.multiline_highlight( + current_row, + start_row, + start_off, + end_row, + end_off, + Boundary::Custom(style, priority), + ); + } + pub fn into_spans(self) -> Line<'a> { let Self { line, diff --git a/src/textarea.rs b/src/textarea.rs index ad559f6..33c95fa 100644 --- a/src/textarea.rs +++ b/src/textarea.rs @@ -55,6 +55,13 @@ impl fmt::Display for YankText { } } +#[derive(Clone, Debug)] +struct CustomHighlight { + range: ((usize, usize), (usize, usize)), + style: Style, + priority: u8, +} + /// A type to manage state of textarea. These are some important methods: /// /// - [`TextArea::default`] creates an empty textarea. @@ -125,6 +132,7 @@ pub struct TextArea<'a> { mask: Option, selection_start: Option<(usize, usize)>, select_style: Style, + custom_highlights: Vec, } /// Convert any iterator whose elements can be converted into [`String`] into [`TextArea`]. Each [`String`] element is @@ -230,6 +238,7 @@ impl<'a> TextArea<'a> { mask: None, selection_start: None, select_style: Style::default().bg(Color::LightBlue), + custom_highlights: Default::default(), } } @@ -1299,6 +1308,37 @@ impl<'a> TextArea<'a> { } } + /// Clears the whole content of the text area. + /// This will result in an empty text area. + /// The position of the cursor does matter for clearing the entire text. + /// + /// This method returns if some text was deleted or not in the textarea. + /// + /// ``` + /// use tui_textarea::TextArea; + /// + /// let mut textarea = TextArea::from(["aaa\nbbb ccc"]); + /// + /// textarea.clear(); + /// assert!(textarea.is_empty()); + /// ``` + pub fn clear(&mut self) -> bool { + if self.is_empty() { + return false; + } + + let all_lines = self.lines(); + let summed_up_chars_new_lines: usize = all_lines + .iter() + .map(|line| line.chars().map(|_| 1usize).sum::()) + .sum(); + let all_chars = all_lines.len() + summed_up_chars_new_lines; + self.move_cursor(CursorMove::Jump(0, 0)); + self.delete_str(all_chars); + + true + } + /// Paste a string previously deleted by [`TextArea::delete_line_by_head`], [`TextArea::delete_line_by_end`], /// [`TextArea::delete_word`], [`TextArea::delete_next_word`]. This method returns if some text was inserted or not /// in the textarea. @@ -1355,6 +1395,23 @@ impl<'a> TextArea<'a> { self.selection_start = None; } + pub fn custom_highlight( + &mut self, + range: ((usize, usize), (usize, usize)), + style: Style, + priority: u8, + ) { + self.custom_highlights.push(CustomHighlight { + range, + style, + priority, + }); + } + + pub fn clear_custom_highlight(&mut self) { + self.custom_highlights.clear(); + } + /// Select the entire text. Cursor moves to the end of the text buffer. When text selection is already ongoing, /// it is canceled. /// ``` @@ -1612,6 +1669,20 @@ impl<'a> TextArea<'a> { hl.selection(row, start.row, start.offset, end.row, end.offset); } + for CustomHighlight { + range: ((start_row, start_offset), (end_row, end_offset)), + style, + priority, + } in &self.custom_highlights + { + hl.custom( + row, + ((*start_row, *start_offset), (*end_row, *end_offset)), + *style, + *priority, + ); + } + hl.into_spans() } diff --git a/src/widget.rs b/src/widget.rs index 463fa19..47e0450 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -4,12 +4,13 @@ use crate::ratatui::text::{Span, Text}; use crate::ratatui::widgets::{Paragraph, Widget}; use crate::textarea::TextArea; use crate::util::num_digits; +use portable_atomic::{AtomicU64, Ordering}; #[cfg(feature = "ratatui")] use ratatui_core::text::Line; use std::cmp; -use std::sync::atomic::{AtomicU64, Ordering}; #[cfg(feature = "tuirs")] use tui::text::Spans as Line; +use unicode_width::UnicodeWidthChar; // &mut 'a (u16, u16, u16, u16) is not available since `render` method takes immutable reference of TextArea // instance. In the case, the TextArea instance cannot be accessed from any other objects since it is mutablly @@ -114,7 +115,15 @@ impl<'a> TextArea<'a> { } fn scroll_top_col(&self, prev_top: u16, width: u16) -> u16 { - let mut cursor = self.cursor().1 as u16; + let (row, col) = self.cursor(); + + // Adujst the cursor position due to the width of non-latine characters. + let mut cursor = self.lines()[row] + .chars() + .take(col) + .map(|c| c.width().unwrap_or(0)) + .sum::() as u16; + // Adjust the cursor position due to the width of line number. if self.line_number_style().is_some() { let lnum = num_digits(self.lines().len()) as u16 + 2; // `+ 2` for margins diff --git a/src/word.rs b/src/word.rs index f64f15c..e0d7b36 100644 --- a/src/word.rs +++ b/src/word.rs @@ -9,6 +9,8 @@ impl CharKind { fn new(c: char) -> Self { if c.is_whitespace() { Self::Space + } else if c == '_' { + Self::Other } else if c.is_ascii_punctuation() { Self::Punct } else { diff --git a/tests/textarea.rs b/tests/textarea.rs index 392f850..a7a5736 100644 --- a/tests/textarea.rs +++ b/tests/textarea.rs @@ -1437,6 +1437,44 @@ fn test_delete_selection_before_insert() { } } +#[test] +fn test_clear_on_empty_text() { + let mut t = TextArea::default(); + + let was_cleared = t.clear(); + assert!(!was_cleared); +} + +#[test] +fn test_clear_text() { + let mut t = TextArea::default(); + let input = " +Hello world + +Again +"; + t.insert_str(input); + let was_cleared = t.clear(); + assert!( + t.lines().join("\n").is_empty(), + "Textarea should be empty after clearing the text" + ); + assert!(was_cleared); + + t.undo(); + assert_eq!( + t.lines().join("\n"), + input, + "Textare should have the previous content before the reset. Orginal input: {input}", + ); + + t.redo(); + assert!( + t.lines().join("\n").is_empty(), + "Textarea should be empty after clearing the text again" + ); +} + #[test] fn test_undo_redo_stop_selection() { fn check(t: &mut TextArea, f: fn(&mut TextArea) -> bool) {