From f20df6b9651a960bf3495b28521e92eb5a6d8ae3 Mon Sep 17 00:00:00 2001 From: srothgan <141313976+srothgan@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:58:29 +0100 Subject: [PATCH] feat: set lines measure cache (#16) * feat(textarea): add bulk replace API and transparent measure() caching - add TextArea::set_lines(lines, cursor) for direct whole-buffer replacement - preserve widget configuration while resetting history, selection, highlights, viewport scroll, and cached measurement state - cache measure(width_cols) results by outer width and reuse them until dependencies change - invalidate cached measurement on content edits, undo/redo, and measure-affecting setters - add focused tests for set_lines behavior and cache correctness * chore(release): bump to v0.10.2 and refresh docs - prepare the v0.10.2 release with updated crate version and changelog entry - refresh README coverage for fork-added APIs including wrapping, measurement, bulk replace, and custom highlights --- CHANGELOG.md | 11 ++++ Cargo.toml | 2 +- README.md | 120 +++++++++++++++++++++++++++++++++++++++-- src/textarea.rs | 132 +++++++++++++++++++++++++++++++++++++++++++++- tests/measure.rs | 117 ++++++++++++++++++++++++++++++++++++++++ tests/textarea.rs | 78 +++++++++++++++++++++++++++ 6 files changed, 453 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e654aa4..c582fb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ + +# [v0.10.2](https://github.com/srothgan/tui-textarea/releases/tag/v0.10.2) - 2026-03-13 + +- Add `TextArea::set_lines(lines, cursor)` for direct whole-buffer replacement without rebuilding widget configuration. +- Preserve textarea configuration across `set_lines()` while resetting history, active selection, custom highlights, viewport scroll, and cached measurement state. +- Cache `TextArea::measure(width_cols)` results by width and invalidate the cache on content edits, `undo()` / `redo()`, and measure-affecting setters. +- Add tests covering `set_lines()` semantics, cursor clamping, and `measure()` cache correctness. + +[Changes][v0.10.2] + # [v0.10.1](https://github.com/srothgan/tui-textarea/releases/tag/v0.10.1) - 2026-03-05 @@ -516,6 +526,7 @@ First release :tada: [Changes][v0.1.0] +[v0.10.2]: https://github.com/srothgan/tui-textarea/compare/v0.10.1...v0.10.2 [v0.10.1]: https://github.com/srothgan/tui-textarea/compare/v0.10.0...v0.10.1 [v0.10.0]: https://github.com/srothgan/tui-textarea/compare/v0.9.2...v0.10.0 [v0.9.2]: https://github.com/srothgan/tui-textarea/compare/v0.9.1...v0.9.2 diff --git a/Cargo.toml b/Cargo.toml index 6cd9e68..b4f677a 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.10.1" +version = "0.10.2" edition = "2024" rust-version = "1.85.0" # for Rust 2024 edition support authors = ["Simon Peter Rothgang ", "rhysd "] diff --git a/README.md b/README.md index c1669aa..16aff2c 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,15 @@ Multi-line text editor can be easily put as part of your TUI application. > Maintained fork notice: this repository is maintained under `srothgan/tui-textarea` to keep compatibility updates moving (including ratatui 0.30+ support and maintenance fixes). +Since this fork was created, it has added or integrated: + +- Compatibility updates for current ratatui releases plus Rust 2024 / `rust-version = 1.85.0` +- `TextArea::clear()` and custom highlight APIs +- Unicode-aware soft-wrap modes via `WrapMode::{None, Word, Glyph, WordOrGlyph}` +- Wrapped-line Up/Down cursor navigation that follows visual rows +- Row measurement via `TextAreaMeasure`, `TextArea::measure()`, `set_min_rows()`, and `set_max_rows()` +- Bulk whole-buffer replacement via `TextArea::set_lines()` + **Features:** - Multi-line text editor widget with basic operations (insert/delete characters, auto scrolling, ...) @@ -18,8 +27,13 @@ Multi-line text editor can be easily put as part of your TUI application. - Undo/Redo - Line number - Cursor line highlight +- Unicode-aware soft wrap with visual-line cursor navigation +- Dynamic row measurement for auto-sizing layouts +- Bulk content replacement without rebuilding widget configuration - Search with regular expressions - Text selection +- Custom highlighted ranges +- Placeholder and masking support - Mouse scrolling - Yank support. Paste text deleted with `C-k`, `C-j`, ... - Backend agnostic. [crossterm][], [termion][], [termwiz][], and your own backend are all supported @@ -78,7 +92,9 @@ Two split textareas in a screen and switch them. An example for multiple textare cargo run --example variable ``` -Simple textarea with variable height following the number of lines. +Simple textarea with variable height driven by measured preferred rows. + +This example now sizes itself from `textarea.measure(width).preferred_rows`. ### [`vim`](./examples/vim.rs) @@ -264,7 +280,7 @@ Default key mappings are as follows: | `Ctrl+P`, `↑` | Move cursor up by one line | | `Ctrl+N`, `↓` | Move cursor down by one line | | `Alt+F`, `Ctrl+→` | Move cursor forward by word | -| `Atl+B`, `Ctrl+←` | Move cursor backward by word | +| `Alt+B`, `Ctrl+←` | Move cursor backward by word | | `Alt+]`, `Alt+P`, `Ctrl+↑` | Move cursor up by paragraph | | `Alt+[`, `Alt+N`, `Ctrl+↓` | Move cursor down by paragraph | | `Ctrl+E`, `End`, `Ctrl+Alt+F`, `Ctrl+Alt+→` | Move cursor to the end of line | @@ -342,6 +358,27 @@ let textarea = TextArea::default(); assert_eq!(textarea.into_lines(), [""]); ``` +### Replace all text while preserving configuration + +When you want to replace the whole buffer without rebuilding `TextArea` and reapplying styles/configuration, use +`TextArea::set_lines()`. + +```rust,ignore +let mut textarea = TextArea::default(); + +textarea.set_placeholder_text("Type here"); +textarea.set_line_number_style(ratatui::style::Style::default()); + +textarea.set_lines( + vec!["hello".to_string(), "world".to_string()], + (1, 5), +); +``` + +`set_lines()` preserves widget configuration such as styles, wrapping, placeholder, and history capacity, while resetting +content-specific state such as undo/redo contents, active selection, custom highlights, viewport scroll, and cached +measurement results. + ### Show line number By default, `TextArea` does not show line numbers. To enable, set a style for rendering line numbers by @@ -403,6 +440,47 @@ Supported modes: - `WrapMode::Glyph`: Wrap at grapheme boundaries. - `WrapMode::WordOrGlyph`: Wrap at word boundaries with grapheme fallback for long words. +When wrapping is enabled, `CursorMove::Up` and `CursorMove::Down` follow visual rows instead of jumping only between +logical lines. + +### Measure preferred height + +`TextArea::measure(width_cols)` returns a `TextAreaMeasure` with row counts for the current content and layout. This is +useful when your textarea should grow and shrink with wrapped content. + +```rust,ignore +use tui_textarea::{TextArea, WrapMode}; + +let mut textarea = TextArea::from(["hello world"]); +textarea.set_wrap_mode(WrapMode::WordOrGlyph); +textarea.set_min_rows(3); +textarea.set_max_rows(10); + +let measured = textarea.measure(12); +let content_rows = measured.content_rows; +let height = measured.preferred_rows; +``` + +`content_rows` counts the rows needed by the inner content area. `preferred_rows` includes block chrome such as borders +and respects the configured `min_rows` and `max_rows`. + +### Add custom highlighted ranges + +You can draw your own highlighted ranges on top of the content with `TextArea::custom_highlight()`. This is useful for +syntax annotations, diffs, or app-specific match highlighting. + +```rust,ignore +use ratatui::style::{Color, Style}; + +textarea.custom_highlight( + ((0, 0), (0, 5)), + Style::default().bg(Color::Yellow), + 10, +); +``` + +Call `TextArea::clear_custom_highlight()` to remove all custom highlighted ranges. + ### 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 @@ -480,7 +558,7 @@ loop { Input { key: Key::Char('m'), ctrl: true, alt: false } | Input { key: Key::Enter, .. } => continue, input => { - textarea.input(key); + textarea.input(input); } } } @@ -510,6 +588,10 @@ notify how to move the cursor. | `textarea.copy()` | Copy selected text | | `textarea.cut()` | Cut selected text | | `textarea.paste()` | Paste yanked text | +| `textarea.insert_char(c)` | Insert one character | +| `textarea.insert_str(text)` | Insert a string | +| `textarea.insert_tab()` | Insert indentation / tab text | +| `textarea.delete_str(chars)` | Delete multiple characters | | `textarea.start_selection()` | Start text selection | | `textarea.cancel_selection()` | Cancel text selection | | `textarea.select_all()` | Select entire text | @@ -541,6 +623,32 @@ notify how to move the cursor. To define your own key mappings, simply call the above methods in your code instead of `TextArea::input()` method. +Useful state/configuration helpers: + +| Method | Purpose | +|------------------------------------------|-------------------------------------------------------------| +| `textarea.cursor()` | Get current `(row, col)` cursor position | +| `textarea.selection_range()` | Get the current selected range if selection is active | +| `textarea.is_selecting()` | Check whether selection is active | +| `textarea.lines()` | Borrow the current text lines | +| `textarea.set_lines(lines, cursor)` | Replace the entire buffer while preserving widget settings | +| `textarea.set_wrap_mode(mode)` | Configure soft wrapping | +| `textarea.wrap_mode()` | Read the current wrap mode | +| `textarea.set_min_rows(rows)` | Set the minimum preferred measured height | +| `textarea.min_rows()` | Read the configured minimum preferred height | +| `textarea.set_max_rows(rows)` | Set the maximum preferred measured height | +| `textarea.max_rows()` | Read the configured maximum preferred height | +| `textarea.measure(width_cols)` | Measure content and preferred outer height | +| `textarea.set_block(block)` | Configure block chrome used for rendering and measurement | +| `textarea.remove_block()` | Remove block chrome | +| `textarea.set_line_number_style(style)` | Enable or restyle line numbers | +| `textarea.remove_line_number()` | Disable line numbers | +| `textarea.set_placeholder_text(text)` | Set or disable placeholder text | +| `textarea.set_placeholder_style(style)` | Change placeholder style | +| `textarea.set_mask_char(ch)` | Enable character masking | +| `textarea.clear_mask_char()` | Disable character masking | +| `textarea.clear()` | Clear the full buffer | + See the [`vim` example](./examples/vim.rs) for working example. It implements more Vim-like key modal mappings. If you don't want to use default key mappings, `TextArea::input_without_shortcuts()` method can be used instead of @@ -682,6 +790,7 @@ Values of the following types can be serialized/deserialized: - `Input` - `CursorMove` - `Scrolling` +- `WrapMode` Here is an example for deserializing key input from JSON using [serde_json][]. @@ -709,7 +818,10 @@ println!("{input:?}"); ## Minimum Supported Rust Version -MSRV of this crate is depending on `tui` crate. Currently MSRV is 1.56.1. Note that `ratatui` crate requires more recent Rust version. +MSRV of this crate is Rust 1.85.0 because the crate uses Rust 2024 edition. + +If you enable older `tui-rs` integration features, you still need dependency versions compatible with that backend stack, +but the crate itself now targets Rust 1.85.0. ## Versioning diff --git a/src/textarea.rs b/src/textarea.rs index cba8d6a..fcdd110 100644 --- a/src/textarea.rs +++ b/src/textarea.rs @@ -153,6 +153,7 @@ pub struct TextArea<'a> { selection_start: Option<(usize, usize)>, select_style: Style, custom_highlights: Vec, + measure_cache: Option<(u16, TextAreaMeasure)>, } /// Convert any iterator whose elements can be converted into [`String`] into [`TextArea`]. Each [`String`] element is @@ -262,6 +263,7 @@ impl<'a> TextArea<'a> { selection_start: None, select_style: Style::default().bg(Color::LightBlue), custom_highlights: Default::default(), + measure_cache: None, } } @@ -777,6 +779,7 @@ impl<'a> TextArea<'a> { let after = Pos::new(row, col, after_offset); let edit = Edit::new(kind, before, after); self.history.push(edit); + self.reset_measure_cache(); } /// Insert a single character at current cursor position. @@ -1504,6 +1507,10 @@ impl<'a> TextArea<'a> { (row, col) } + fn reset_measure_cache(&mut self) { + self.measure_cache = None; + } + /// Set the style used for text selection. The default style is light blue. /// ``` /// use tui_textarea::TextArea; @@ -1707,6 +1714,7 @@ impl<'a> TextArea<'a> { if let Some(cursor) = self.history.undo(&mut self.lines) { self.cancel_selection(); self.cursor = self.clamp_cursor_to_buffer(cursor); + self.reset_measure_cache(); true } else { false @@ -1730,6 +1738,7 @@ impl<'a> TextArea<'a> { if let Some(cursor) = self.history.redo(&mut self.lines) { self.cancel_selection(); self.cursor = self.clamp_cursor_to_buffer(cursor); + self.reset_measure_cache(); true } else { false @@ -1931,6 +1940,7 @@ impl<'a> TextArea<'a> { /// ``` pub fn set_block(&mut self, block: Block<'a>) { self.block = Some(block); + self.reset_measure_cache(); } /// Remove the block of textarea which was set by [`TextArea::set_block`]. @@ -1946,6 +1956,7 @@ impl<'a> TextArea<'a> { /// ``` pub fn remove_block(&mut self) { self.block = None; + self.reset_measure_cache(); } /// Get the block of textarea if exists. @@ -1970,6 +1981,7 @@ impl<'a> TextArea<'a> { /// ``` pub fn set_tab_length(&mut self, len: u8) { self.tab_len = len; + self.reset_measure_cache(); } /// Get how many spaces are used for representing tab character. The default value is 4. @@ -2076,6 +2088,7 @@ impl<'a> TextArea<'a> { /// ``` pub fn set_line_number_style(&mut self, style: Style) { self.line_number_style = Some(style); + self.reset_measure_cache(); } /// Remove the style of line number which was set by [`TextArea::set_line_number_style`]. After calling this @@ -2092,6 +2105,7 @@ impl<'a> TextArea<'a> { /// ``` pub fn remove_line_number(&mut self) { self.line_number_style = None; + self.reset_measure_cache(); } /// Get the style of line number if set. @@ -2115,6 +2129,7 @@ impl<'a> TextArea<'a> { /// ``` pub fn set_placeholder_text(&mut self, placeholder: impl Into) { self.placeholder = placeholder.into(); + self.reset_measure_cache(); } /// Set the style of the placeholder text. The default style is a dark gray text. @@ -2253,6 +2268,25 @@ impl<'a> TextArea<'a> { &self.lines } + /// Replace the entire text buffer without rebuilding widget configuration. + /// + /// The provided cursor is clamped to the new buffer, edit history is cleared, active + /// selection and custom highlights are removed, and the viewport scroll is reset. + pub fn set_lines(&mut self, lines: Vec, cursor: (usize, usize)) { + assert!( + !lines.is_empty(), + "lines must not be empty; use vec![String::new()] for empty content" + ); + + self.lines = lines; + self.cursor = self.clamp_cursor_to_buffer(cursor); + self.history = History::new(self.history.max_items()); + self.selection_start = None; + self.custom_highlights.clear(); + self.viewport = Viewport::default(); + self.reset_measure_cache(); + } + /// Convert [`TextArea`] instance into line texts. /// ``` /// use tui_textarea::TextArea; @@ -2344,6 +2378,7 @@ impl<'a> TextArea<'a> { pub fn set_alignment(&mut self, alignment: Alignment) { if let Alignment::Center | Alignment::Right = alignment { self.line_number_style = None; + self.reset_measure_cache(); } self.alignment = alignment; } @@ -2372,6 +2407,7 @@ impl<'a> TextArea<'a> { /// ``` pub fn set_wrap_mode(&mut self, mode: WrapMode) { self.wrap_mode = mode; + self.reset_measure_cache(); } /// Get the current soft-wrap mode. @@ -2389,6 +2425,7 @@ impl<'a> TextArea<'a> { if self.max_rows < self.min_rows { self.max_rows = self.min_rows; } + self.reset_measure_cache(); } /// Get the configured minimum preferred height in rows. @@ -2406,6 +2443,7 @@ impl<'a> TextArea<'a> { if self.max_rows < self.min_rows { self.max_rows = self.min_rows; } + self.reset_measure_cache(); } /// Get the configured maximum preferred height in rows. @@ -2429,6 +2467,12 @@ impl<'a> TextArea<'a> { /// assert_eq!(m.preferred_rows, 2); /// ``` pub fn measure(&mut self, width_cols: u16) -> TextAreaMeasure { + if let Some((cached_width, cached_result)) = self.measure_cache { + if cached_width == width_cols { + return cached_result; + } + } + let area = Rect { x: 0, y: 0, @@ -2448,12 +2492,14 @@ impl<'a> TextArea<'a> { let unclamped_preferred_rows = content_rows.saturating_add(chrome_rows); let preferred_rows = unclamped_preferred_rows.clamp(min_rows, max_rows); - TextAreaMeasure { + let result = TextAreaMeasure { content_rows, preferred_rows, min_rows, max_rows, - } + }; + self.measure_cache = Some((width_cols, result)); + result } fn measure_content_rows(&self, width_cols: u16) -> u16 { @@ -2762,6 +2808,7 @@ impl<'a> TextArea<'a> { #[cfg(test)] mod tests { use super::*; + use ratatui::widgets::Borders; // Separate tests for tui-rs support #[test] @@ -2816,4 +2863,85 @@ mod tests { assert_eq!(textarea.lines(), [""]); assert_eq!(textarea.cursor(), (0, 0)); } + + #[test] + fn set_lines_resets_ephemeral_state() { + let mut textarea = TextArea::from(["hello"]); + let style = Style::default().fg(Color::Green); + let cursor_style = Style::default().bg(Color::Red); + let placeholder_style = Style::default().fg(Color::Cyan); + + textarea.set_wrap_mode(WrapMode::WordOrGlyph); + textarea.set_block(Block::default().borders(Borders::ALL).title("Input")); + textarea.set_style(style); + textarea.set_cursor_style(cursor_style); + textarea.set_placeholder_text("placeholder"); + textarea.set_placeholder_style(placeholder_style); + textarea.set_yank_text("copied"); + textarea.set_min_rows(2); + textarea.set_max_rows(6); + textarea.set_max_histories(3); + textarea.start_selection(); + textarea.move_cursor(CursorMove::End); + textarea.custom_highlight(((0, 0), (0, 5)), Style::default(), 1); + textarea.viewport.scroll(3, 4); + textarea.measure(5); + + assert!(textarea.measure_cache.is_some()); + assert!(textarea.is_selecting()); + assert!(!textarea.custom_highlights.is_empty()); + assert_ne!(textarea.viewport.scroll_top(), (0, 0)); + + textarea.set_lines(vec!["abc".to_string()], (0, 99)); + + assert_eq!(textarea.lines(), ["abc"]); + assert_eq!(textarea.cursor(), (0, 3)); + assert_eq!(textarea.style(), style); + assert_eq!(textarea.cursor_style(), cursor_style); + assert_eq!(textarea.placeholder_text(), "placeholder"); + assert_eq!(textarea.placeholder_style(), Some(placeholder_style)); + assert_eq!(textarea.yank_text(), "copied"); + assert_eq!(textarea.min_rows(), 2); + assert_eq!(textarea.max_rows(), 6); + assert_eq!(textarea.max_histories(), 3); + assert!(textarea.block().is_some()); + assert!(!textarea.is_selecting()); + assert!(textarea.custom_highlights.is_empty()); + assert_eq!(textarea.viewport.scroll_top(), (0, 0)); + assert_eq!(textarea.measure_cache, None); + assert!(!textarea.undo()); + } + + #[test] + fn measure_cache_tracks_last_width() { + let mut textarea = TextArea::from(["abcdef"]); + textarea.set_wrap_mode(WrapMode::WordOrGlyph); + + let first = textarea.measure(4); + assert_eq!(textarea.measure_cache, Some((4, first))); + + let second = textarea.measure(8); + assert_eq!(textarea.measure_cache, Some((8, second))); + } + + #[test] + fn set_placeholder_text_resets_measure_cache() { + let mut textarea = TextArea::default(); + textarea.measure(4); + assert!(textarea.measure_cache.is_some()); + + textarea.set_placeholder_text("placeholder"); + assert_eq!(textarea.measure_cache, None); + } + + #[test] + fn set_lines_panics_on_empty_buffer() { + let mut textarea = TextArea::default(); + + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + textarea.set_lines(vec![], (0, 0)); + })); + + assert!(panic.is_err()); + } } diff --git a/tests/measure.rs b/tests/measure.rs index 5a85da4..b20acf9 100644 --- a/tests/measure.rs +++ b/tests/measure.rs @@ -1,3 +1,4 @@ +use ratatui::layout::Alignment; use ratatui::style::Style; use ratatui::widgets::{Block, Borders}; use tui_textarea::{TextArea, TextAreaMeasure, WrapMode}; @@ -105,3 +106,119 @@ fn block_intrinsic_min_rows_overrides_configured_bounds() { assert_eq!(have.min_rows, 3); assert_eq!(have.max_rows, 3); } + +#[test] +fn repeated_measure_at_same_width_returns_same_result() { + let mut textarea = TextArea::from(["abcdef"]); + textarea.set_wrap_mode(WrapMode::WordOrGlyph); + + let first = textarea.measure(4); + let second = textarea.measure(4); + assert_eq!(first, second); +} + +#[test] +fn measuring_at_a_different_width_recomputes() { + let mut textarea = TextArea::from(["abcdef"]); + textarea.set_wrap_mode(WrapMode::WordOrGlyph); + + assert_eq!(textarea.measure(4).content_rows, 2); + assert_eq!(textarea.measure(8).content_rows, 1); +} + +#[test] +fn content_insertion_invalidates_measure_cache() { + let mut textarea = TextArea::from([""]); + textarea.set_wrap_mode(WrapMode::WordOrGlyph); + + assert_eq!(textarea.measure(4).content_rows, 1); + assert!(textarea.insert_str("abcdef")); + assert_eq!(textarea.measure(4).content_rows, 2); +} + +#[test] +fn undo_and_redo_invalidate_measure_cache() { + let mut textarea = TextArea::from([""]); + textarea.set_wrap_mode(WrapMode::WordOrGlyph); + + textarea.measure(4); + assert!(textarea.insert_str("abcdef")); + assert_eq!(textarea.measure(4).content_rows, 2); + + assert!(textarea.undo()); + assert_eq!(textarea.measure(4).content_rows, 1); + + assert!(textarea.redo()); + assert_eq!(textarea.measure(4).content_rows, 2); +} + +#[test] +fn set_wrap_mode_invalidates_measure_cache() { + let mut textarea = TextArea::from(["abcdef"]); + + assert_eq!(textarea.measure(4).content_rows, 1); + textarea.set_wrap_mode(WrapMode::WordOrGlyph); + assert_eq!(textarea.measure(4).content_rows, 2); +} + +#[test] +fn set_line_number_style_invalidates_measure_cache() { + let mut textarea = TextArea::from(["abcdefg"]); + textarea.set_wrap_mode(WrapMode::WordOrGlyph); + + assert_eq!(textarea.measure(8).content_rows, 1); + textarea.set_line_number_style(Style::default()); + assert_eq!(textarea.measure(8).content_rows, 2); +} + +#[test] +fn set_block_and_remove_block_invalidate_measure_cache() { + let mut textarea = TextArea::from(["line"]); + + assert_eq!(textarea.measure(12).preferred_rows, 1); + textarea.set_block(Block::default().borders(Borders::ALL)); + assert_eq!(textarea.measure(12).preferred_rows, 3); + + textarea.remove_block(); + assert_eq!(textarea.measure(12).preferred_rows, 1); +} + +#[test] +fn min_and_max_row_setters_invalidate_measure_cache() { + let mut textarea = TextArea::from(["line"]); + + assert_eq!(textarea.measure(12).preferred_rows, 1); + + textarea.set_min_rows(4); + let min_measured = textarea.measure(12); + assert_eq!(min_measured.preferred_rows, 4); + assert_eq!(min_measured.min_rows, 4); + + let mut textarea: TextArea<'_> = (0..10).map(|n| n.to_string()).collect(); + textarea.measure(12); + textarea.set_max_rows(2); + let max_measured = textarea.measure(12); + assert_eq!(max_measured.preferred_rows, 2); + assert_eq!(max_measured.max_rows, 2); +} + +#[test] +fn set_alignment_invalidates_when_it_disables_line_numbers() { + let mut textarea = TextArea::from(["abcdefg"]); + textarea.set_wrap_mode(WrapMode::WordOrGlyph); + textarea.set_line_number_style(Style::default()); + assert_eq!(textarea.measure(8).content_rows, 2); + + textarea.set_alignment(Alignment::Right); + assert_eq!(textarea.measure(8).content_rows, 1); +} + +#[test] +fn set_lines_updates_measurement_immediately() { + let mut textarea = TextArea::from(["line"]); + textarea.set_wrap_mode(WrapMode::WordOrGlyph); + + assert_eq!(textarea.measure(4).content_rows, 1); + textarea.set_lines(vec!["abcdef".to_string()], (0, 6)); + assert_eq!(textarea.measure(4).content_rows, 2); +} diff --git a/tests/textarea.rs b/tests/textarea.rs index a7a5736..1537203 100644 --- a/tests/textarea.rs +++ b/tests/textarea.rs @@ -1,3 +1,6 @@ +use ratatui::layout::Alignment; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::widgets::{Block, Borders}; use std::cmp; use std::fmt::Debug; use tui_textarea::{CursorMove, TextArea}; @@ -1604,6 +1607,81 @@ fn test_selection_range() { } } +#[test] +fn test_set_lines_preserves_non_content_configuration() { + let mut t = TextArea::from(["hello"]); + let base_style = Style::default().fg(Color::Green); + let cursor_style = Style::default().bg(Color::Red); + let cursor_line_style = Style::default().add_modifier(Modifier::BOLD); + let selection_style = Style::default().bg(Color::Blue); + let line_number_style = Style::default().fg(Color::Yellow); + let placeholder_style = Style::default().fg(Color::Cyan); + + t.set_wrap_mode(tui_textarea::WrapMode::WordOrGlyph); + t.set_block(Block::default().borders(Borders::ALL).title("Input")); + t.set_style(base_style); + t.set_cursor_style(cursor_style); + t.set_cursor_line_style(cursor_line_style); + t.set_selection_style(selection_style); + t.set_tab_length(8); + t.set_alignment(Alignment::Right); + t.set_line_number_style(line_number_style); + t.set_placeholder_text("placeholder"); + t.set_placeholder_style(placeholder_style); + t.set_mask_char('*'); + t.set_yank_text("copied"); + t.set_min_rows(2); + t.set_max_rows(6); + t.set_max_histories(3); + + t.set_lines(vec!["abc".to_string()], (0, 1)); + + assert_eq!(t.lines(), ["abc"]); + assert_eq!(t.cursor(), (0, 1)); + assert_eq!(t.wrap_mode(), tui_textarea::WrapMode::WordOrGlyph); + assert!(t.block().is_some()); + assert_eq!(t.style(), base_style); + assert_eq!(t.cursor_style(), cursor_style); + assert_eq!(t.cursor_line_style(), cursor_line_style); + assert_eq!(t.selection_style(), selection_style); + assert_eq!(t.tab_length(), 8); + assert_eq!(t.alignment(), Alignment::Right); + assert_eq!(t.line_number_style(), Some(line_number_style)); + assert_eq!(t.placeholder_text(), "placeholder"); + assert_eq!(t.placeholder_style(), Some(placeholder_style)); + assert_eq!(t.mask_char(), Some('*')); + assert_eq!(t.yank_text(), "copied"); + assert_eq!(t.min_rows(), 2); + assert_eq!(t.max_rows(), 6); + assert_eq!(t.max_histories(), 3); +} + +#[test] +fn test_set_lines_clamps_cursor_and_clears_selection_and_history() { + let mut t = TextArea::from(["hello", "world"]); + assert!(t.insert_str("!")); + assert!(t.undo()); + assert!(t.redo()); + t.move_cursor(CursorMove::Jump(0, 1)); + t.start_selection(); + t.move_cursor(CursorMove::Jump(1, 3)); + assert!(t.is_selecting()); + + t.set_lines(vec!["ab".to_string(), "c".to_string()], (9, 9)); + + assert_eq!(t.lines(), ["ab", "c"]); + assert_eq!(t.cursor(), (1, 1)); + assert!(!t.is_selecting()); + assert!(!t.undo()); +} + +#[test] +#[should_panic(expected = "lines must not be empty; use vec![String::new()] for empty content")] +fn test_set_lines_panics_on_empty_lines() { + let mut t = TextArea::default(); + t.set_lines(vec![], (0, 0)); +} + struct DeleteTester(&'static [&'static str], fn(&mut TextArea) -> bool); impl DeleteTester { fn test(&self, before: (usize, usize), after: (usize, usize, &[&str], &str)) {