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)) {