зеркало из
https://github.com/glebtv/tui-textarea.git
synced 2026-09-08 16:35:49 +03:00
Feat/textarea row measurement min max (#14)
* feat(textarea): add row measurement API with configurable min/max bounds - add TextAreaMeasure and TextArea::measure(width_cols) - make measurement wrap-aware and include block chrome rows - add set_min_rows/set_max_rows (+ getters) with invariant normalization - clamp preferred_rows to effective [min_rows, max_rows] - export TextAreaMeasure at crate root - switch examples/variable.rs to measured preferred_rows - add integration tests for measure + min/max behavior * chore(release): bump to v0.10.0 and update changelog
Этот коммит содержится в:
14
CHANGELOG.md
14
CHANGELOG.md
@@ -1,3 +1,15 @@
|
|||||||
|
<a id="v0.10.0"></a>
|
||||||
|
# [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]
|
||||||
|
|
||||||
<a id="v0.9.2"></a>
|
<a id="v0.9.2"></a>
|
||||||
# [v0.9.2](https://github.com/srothgan/tui-textarea/releases/tag/v0.9.2) - 2026-02-19
|
# [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]
|
[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.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.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
|
[v0.8.0]: https://github.com/srothgan/tui-textarea/compare/v0.7.1...v0.8.0
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name = "tui-textarea-2"
|
|||||||
homepage = "https://github.com/srothgan/tui-textarea#readme"
|
homepage = "https://github.com/srothgan/tui-textarea#readme"
|
||||||
repository = "https://github.com/srothgan/tui-textarea"
|
repository = "https://github.com/srothgan/tui-textarea"
|
||||||
documentation = "https://docs.rs/tui-textarea-2/latest/tui_textarea/"
|
documentation = "https://docs.rs/tui-textarea-2/latest/tui_textarea/"
|
||||||
version = "0.9.2"
|
version = "0.10.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85.0" # for Rust 2024 edition support
|
rust-version = "1.85.0" # for Rust 2024 edition support
|
||||||
authors = ["Simon Peter Rothgang <simonrothgang@icloud.com>", "rhysd <lin90162@yahoo.co.jp>"]
|
authors = ["Simon Peter Rothgang <simonrothgang@icloud.com>", "rhysd <lin90162@yahoo.co.jp>"]
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ fn main() -> io::Result<()> {
|
|||||||
loop {
|
loop {
|
||||||
term.draw(|f| {
|
term.draw(|f| {
|
||||||
const MIN_HEIGHT: usize = 3;
|
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()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([Constraint::Length(height), Constraint::Min(0)])
|
.constraints([Constraint::Length(height), Constraint::Min(0)])
|
||||||
|
|||||||
@@ -53,5 +53,5 @@ use termion_15 as termion;
|
|||||||
pub use cursor::CursorMove;
|
pub use cursor::CursorMove;
|
||||||
pub use input::{Input, Key};
|
pub use input::{Input, Key};
|
||||||
pub use scroll::Scrolling;
|
pub use scroll::Scrolling;
|
||||||
pub use textarea::TextArea;
|
pub use textarea::{TextArea, TextAreaMeasure};
|
||||||
pub use wrap::WrapMode;
|
pub use wrap::WrapMode;
|
||||||
|
|||||||
112
src/textarea.rs
112
src/textarea.rs
@@ -2,7 +2,7 @@ use crate::cursor::CursorMove;
|
|||||||
use crate::highlight::LineHighlighter;
|
use crate::highlight::LineHighlighter;
|
||||||
use crate::history::{Edit, EditKind, History};
|
use crate::history::{Edit, EditKind, History};
|
||||||
use crate::input::{Input, Key};
|
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::style::{Color, Modifier, Style};
|
||||||
use crate::ratatui::widgets::{Block, Widget};
|
use crate::ratatui::widgets::{Block, Widget};
|
||||||
use crate::scroll::Scrolling;
|
use crate::scroll::Scrolling;
|
||||||
@@ -66,6 +66,19 @@ struct CustomHighlight {
|
|||||||
priority: u8,
|
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:
|
/// A type to manage state of textarea. These are some important methods:
|
||||||
///
|
///
|
||||||
/// - [`TextArea::default`] creates an empty textarea.
|
/// - [`TextArea::default`] creates an empty textarea.
|
||||||
@@ -132,6 +145,8 @@ pub struct TextArea<'a> {
|
|||||||
search: Search,
|
search: Search,
|
||||||
alignment: Alignment,
|
alignment: Alignment,
|
||||||
wrap_mode: WrapMode,
|
wrap_mode: WrapMode,
|
||||||
|
min_rows: u16,
|
||||||
|
max_rows: u16,
|
||||||
pub(crate) placeholder: String,
|
pub(crate) placeholder: String,
|
||||||
pub(crate) placeholder_style: Style,
|
pub(crate) placeholder_style: Style,
|
||||||
mask: Option<char>,
|
mask: Option<char>,
|
||||||
@@ -239,6 +254,8 @@ impl<'a> TextArea<'a> {
|
|||||||
search: Search::default(),
|
search: Search::default(),
|
||||||
alignment: Alignment::Left,
|
alignment: Alignment::Left,
|
||||||
wrap_mode: WrapMode::None,
|
wrap_mode: WrapMode::None,
|
||||||
|
min_rows: 1,
|
||||||
|
max_rows: u16::MAX,
|
||||||
placeholder: String::new(),
|
placeholder: String::new(),
|
||||||
placeholder_style: Style::default().fg(Color::DarkGray),
|
placeholder_style: Style::default().fg(Color::DarkGray),
|
||||||
mask: None,
|
mask: None,
|
||||||
@@ -2346,6 +2363,99 @@ impl<'a> TextArea<'a> {
|
|||||||
self.wrap_mode
|
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.
|
/// Check if the textarea has a empty content.
|
||||||
/// ```
|
/// ```
|
||||||
/// use tui_textarea::TextArea;
|
/// use tui_textarea::TextArea;
|
||||||
|
|||||||
107
tests/measure.rs
Обычный файл
107
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);
|
||||||
|
}
|
||||||
Ссылка в новой задаче
Block a user