diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e460ae..5a9ba29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ + +# [v0.10.0](https://github.com/srothgan/tui-textarea/releases/tag/v0.10.0) - 2026-02-25 + +- Add row-based measurement API via `TextArea::measure(width_cols)` and `TextAreaMeasure`. +- Include content rows and preferred rows (with block chrome such as borders/padding) in measurement results. +- Add configurable row bounds via `TextArea::set_min_rows` / `set_max_rows` with getters. +- Clamp `preferred_rows` to effective `[min_rows, max_rows]` while keeping wrapping and viewport behavior unchanged. +- Update the variable-height example to size from `measure().preferred_rows` instead of raw line count. +- Add integration tests covering measurement semantics, wrapping/line-number effects, and min/max normalization. + +[Changes][v0.10.0] + # [v0.9.2](https://github.com/srothgan/tui-textarea/releases/tag/v0.9.2) - 2026-02-19 @@ -495,6 +507,8 @@ First release :tada: [Changes][v0.1.0] +[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 [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 diff --git a/Cargo.toml b/Cargo.toml index 0c7405f..e0a12b1 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.2" +version = "0.10.0" edition = "2024" rust-version = "1.85.0" # for Rust 2024 edition support authors = ["Simon Peter Rothgang ", "rhysd "] diff --git a/examples/variable.rs b/examples/variable.rs index e112b63..d95fa4b 100644 --- a/examples/variable.rs +++ b/examples/variable.rs @@ -29,7 +29,8 @@ fn main() -> io::Result<()> { loop { term.draw(|f| { const MIN_HEIGHT: usize = 3; - let height = cmp::max(textarea.lines().len(), MIN_HEIGHT) as u16 + 2; // + 2 for borders + let measure = textarea.measure(f.area().width); + let height = cmp::max(measure.preferred_rows as usize, MIN_HEIGHT) as u16; let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(height), Constraint::Min(0)]) diff --git a/src/lib.rs b/src/lib.rs index b993075..803083a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -53,5 +53,5 @@ use termion_15 as termion; pub use cursor::CursorMove; pub use input::{Input, Key}; pub use scroll::Scrolling; -pub use textarea::TextArea; +pub use textarea::{TextArea, TextAreaMeasure}; pub use wrap::WrapMode; diff --git a/src/textarea.rs b/src/textarea.rs index 1ac10fb..2e44d82 100644 --- a/src/textarea.rs +++ b/src/textarea.rs @@ -2,7 +2,7 @@ use crate::cursor::CursorMove; use crate::highlight::LineHighlighter; use crate::history::{Edit, EditKind, History}; use crate::input::{Input, Key}; -use crate::ratatui::layout::Alignment; +use crate::ratatui::layout::{Alignment, Rect}; use crate::ratatui::style::{Color, Modifier, Style}; use crate::ratatui::widgets::{Block, Widget}; use crate::scroll::Scrolling; @@ -66,6 +66,19 @@ struct CustomHighlight { priority: u8, } +/// Measured textarea height information for a given width. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TextAreaMeasure { + /// Rows required by text content inside the block's inner area. + pub content_rows: u16, + /// Preferred total rows including internal chrome such as block borders and padding. + pub preferred_rows: u16, + /// Minimum meaningful rows for this configuration. + pub min_rows: u16, + /// Maximum rows allowed by the widget itself. Currently unbounded (`u16::MAX`). + pub max_rows: u16, +} + /// A type to manage state of textarea. These are some important methods: /// /// - [`TextArea::default`] creates an empty textarea. @@ -132,6 +145,8 @@ pub struct TextArea<'a> { search: Search, alignment: Alignment, wrap_mode: WrapMode, + min_rows: u16, + max_rows: u16, pub(crate) placeholder: String, pub(crate) placeholder_style: Style, mask: Option, @@ -239,6 +254,8 @@ impl<'a> TextArea<'a> { search: Search::default(), alignment: Alignment::Left, wrap_mode: WrapMode::None, + min_rows: 1, + max_rows: u16::MAX, placeholder: String::new(), placeholder_style: Style::default().fg(Color::DarkGray), mask: None, @@ -2346,6 +2363,99 @@ impl<'a> TextArea<'a> { self.wrap_mode } + /// Set the minimum preferred height in rows. + /// + /// The value is applied to measured outer rows (content + block chrome). Setting 0 is + /// normalized to 1. When the new minimum exceeds current maximum, maximum is raised to keep + /// `min_rows <= max_rows`. + pub fn set_min_rows(&mut self, rows: u16) { + self.min_rows = rows.max(1); + if self.max_rows < self.min_rows { + self.max_rows = self.min_rows; + } + } + + /// Get the configured minimum preferred height in rows. + pub fn min_rows(&self) -> u16 { + self.min_rows + } + + /// Set the maximum preferred height in rows. + /// + /// The value is applied to measured outer rows (content + block chrome). Setting 0 is + /// normalized to 1. When the new maximum is smaller than current minimum, maximum is raised + /// to the current minimum to keep `min_rows <= max_rows`. + pub fn set_max_rows(&mut self, rows: u16) { + self.max_rows = rows.max(1); + if self.max_rows < self.min_rows { + self.max_rows = self.min_rows; + } + } + + /// Get the configured maximum preferred height in rows. + pub fn max_rows(&self) -> u16 { + self.max_rows + } + + /// Measure textarea height in terminal rows for a given available width. + /// + /// `width_cols` is the outer width where the widget will be rendered. The returned + /// `preferred_rows` includes internal chrome (block borders/padding when configured). + /// + /// ``` + /// use tui_textarea::{TextArea, WrapMode}; + /// + /// let mut textarea = TextArea::from(["abcdef"]); + /// textarea.set_wrap_mode(WrapMode::WordOrGlyph); + /// + /// let m = textarea.measure(5); + /// assert_eq!(m.content_rows, 2); + /// assert_eq!(m.preferred_rows, 2); + /// ``` + pub fn measure(&mut self, width_cols: u16) -> TextAreaMeasure { + let area = Rect { + x: 0, + y: 0, + width: width_cols, + height: u16::MAX, + }; + let inner = if let Some(block) = self.block() { + block.inner(area) + } else { + area + }; + let chrome_rows = area.height.saturating_sub(inner.height); + let content_rows = self.measure_content_rows(inner.width); + let intrinsic_min_rows = chrome_rows.saturating_add(1); + let min_rows = self.min_rows.max(intrinsic_min_rows); + let max_rows = self.max_rows.max(min_rows); + let unclamped_preferred_rows = content_rows.saturating_add(chrome_rows); + let preferred_rows = unclamped_preferred_rows.clamp(min_rows, max_rows); + + TextAreaMeasure { + content_rows, + preferred_rows, + min_rows, + max_rows, + } + } + + fn measure_content_rows(&self, width_cols: u16) -> u16 { + if !self.placeholder.is_empty() && self.is_empty() { + return 1; + } + + let rows = if self.wrap_mode == WrapMode::None { + self.lines.len() + } else { + let line_number_len = self.line_number_style.map(|_| num_digits(self.lines.len())); + let wrap_width = effective_wrap_width(width_cols, line_number_len); + wrapped_rows(&self.lines, self.wrap_mode, wrap_width, self.tab_len).len() + }; + + rows.min(u16::MAX as usize) as u16 + } + /// Check if the textarea has a empty content. /// ``` /// use tui_textarea::TextArea; diff --git a/tests/measure.rs b/tests/measure.rs new file mode 100644 index 0000000..5a85da4 --- /dev/null +++ b/tests/measure.rs @@ -0,0 +1,107 @@ +use ratatui::style::Style; +use ratatui::widgets::{Block, Borders}; +use tui_textarea::{TextArea, TextAreaMeasure, WrapMode}; + +#[test] +fn measure_without_wrap_uses_logical_line_count() { + let mut textarea = TextArea::from(["hello", "world"]); + let have = textarea.measure(20); + let want = TextAreaMeasure { + content_rows: 2, + preferred_rows: 2, + min_rows: 1, + max_rows: u16::MAX, + }; + assert_eq!(have, want); +} + +#[test] +fn measure_with_wrap_counts_visual_rows() { + let mut textarea = TextArea::from(["abcdef"]); + textarea.set_wrap_mode(WrapMode::WordOrGlyph); + + let have = textarea.measure(5); + assert_eq!(have.content_rows, 2); + assert_eq!(have.preferred_rows, 2); + assert_eq!(have.min_rows, 1); +} + +#[test] +fn measure_accounts_for_line_number_width_when_wrapping() { + 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 measure_adds_block_chrome_rows_to_preferred_and_min() { + let mut textarea = TextArea::from(["line"]); + textarea.set_block(Block::default().borders(Borders::ALL)); + + let have = textarea.measure(12); + assert_eq!(have.content_rows, 1); + assert_eq!(have.preferred_rows, 3); + assert_eq!(have.min_rows, 3); + assert_eq!(have.max_rows, u16::MAX); +} + +#[test] +fn measure_wraps_even_at_zero_width() { + let mut textarea = TextArea::from(["ab"]); + textarea.set_wrap_mode(WrapMode::WordOrGlyph); + + let have = textarea.measure(0); + assert_eq!(have.content_rows, 2); + assert_eq!(have.preferred_rows, 2); +} + +#[test] +fn measure_respects_configured_min_rows() { + let mut textarea = TextArea::from(["line"]); + textarea.set_min_rows(4); + + let have = textarea.measure(12); + assert_eq!(have.content_rows, 1); + assert_eq!(have.preferred_rows, 4); + assert_eq!(have.min_rows, 4); + assert_eq!(have.max_rows, u16::MAX); +} + +#[test] +fn measure_respects_configured_max_rows() { + let mut textarea: TextArea<'_> = (0..10).map(|n| n.to_string()).collect(); + textarea.set_max_rows(3); + + let have = textarea.measure(12); + assert_eq!(have.content_rows, 10); + assert_eq!(have.preferred_rows, 3); + assert_eq!(have.min_rows, 1); + assert_eq!(have.max_rows, 3); +} + +#[test] +fn min_max_rows_setters_normalize_and_keep_order() { + let mut textarea = TextArea::default(); + textarea.set_max_rows(0); + assert_eq!(textarea.max_rows(), 1); + + textarea.set_min_rows(5); + assert_eq!(textarea.min_rows(), 5); + assert_eq!(textarea.max_rows(), 5); +} + +#[test] +fn block_intrinsic_min_rows_overrides_configured_bounds() { + let mut textarea = TextArea::from(["line"]); + textarea.set_block(Block::default().borders(Borders::ALL)); + textarea.set_min_rows(1); + textarea.set_max_rows(2); + + let have = textarea.measure(12); + assert_eq!(have.preferred_rows, 3); + assert_eq!(have.min_rows, 3); + assert_eq!(have.max_rows, 3); +}