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