diff --git a/CHANGELOG.md b/CHANGELOG.md index 324ba6e..01fbf8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ + +# [v0.9.0](https://github.com/srothgan/tui-textarea/releases/tag/v0.9.0) - 2026-02-18 + +- Add opt-in soft-wrap modes via `WrapMode::{None, Word, Glyph, WordOrGlyph}` and `TextArea::set_wrap_mode`. +- Add Unicode-aware wrapped rendering with logical-to-visual row mapping. +- Keep backward-compatible behavior in `WrapMode::None` (existing horizontal scrolling path). +- Make wrapped width calculations tab-aware to prevent hidden text on lines containing `\t`. +- Fix wrapped continuation rendering for cursor-line style and selection boundaries. +- Add comprehensive wrap regression tests for long text, newlines, tabs, tiny widths, and wrapped highlight behavior. + +[Changes][v0.9.0] + # [v0.8.0](https://github.com/srothgan/tui-textarea/releases/tag/v0.8.0) - 2026-02-18 @@ -462,6 +474,7 @@ First release :tada: [Changes][v0.1.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.7.1]: https://github.com/srothgan/tui-textarea/compare/v0.7.0...v0.7.1 [v0.7.0]: https://github.com/rhysd/tui-textarea/compare/v0.6.1...v0.7.0 diff --git a/Cargo.toml b/Cargo.toml index e65c037..c5ad12b 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.8.0" +version = "0.9.0" edition = "2021" rust-version = "1.56.1" # for `tui` crate support authors = ["Simon Peter Rothgang ", "rhysd "] @@ -53,6 +53,7 @@ termion-15 = { package = "termion", version = "1.5", optional = true } termwiz = { version = "0.23.0", optional = true } tui = { version = "0.19", default-features = false, optional = true } unicode-width = "0.2.0" +unicode-segmentation = "1.10.1" serde = { version = "1", optional = true , features = ["derive"] } [[example]] diff --git a/README.md b/README.md index dbe57dd..c1669aa 100644 --- a/README.md +++ b/README.md @@ -385,6 +385,24 @@ Typing tab key inserts 2 spaces. textarea.set_tab_length(2); ``` +### Configure soft wrap mode + +By default, soft wrapping is disabled and long lines are handled by horizontal scrolling. To enable soft wrapping, set +`TextArea::set_wrap_mode()` with one of the supported modes. + +```rust,ignore +use tui_textarea::WrapMode; + +textarea.set_wrap_mode(WrapMode::WordOrGlyph); +``` + +Supported modes: + +- `WrapMode::None`: Disable soft wrap (default behavior). +- `WrapMode::Word`: Wrap only at word boundaries. +- `WrapMode::Glyph`: Wrap at grapheme boundaries. +- `WrapMode::WordOrGlyph`: Wrap at word boundaries with grapheme fallback for long words. + ### 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 diff --git a/src/highlight.rs b/src/highlight.rs index d7e15aa..ad8c686 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -138,6 +138,10 @@ impl<'a> LineHighlighter<'a> { .push(Span::styled(format!("{}{} ", pad, row + 1), style)); } + pub fn line_number_placeholder(&mut self, lnum_len: u8, style: Style) { + self.spans.push(Span::styled(spaces(lnum_len + 2), style)); + } + pub fn cursor_line(&mut self, cursor_col: usize, style: Style) { if let Some((start, c)) = self.line.char_indices().nth(cursor_col) { self.boundaries @@ -149,6 +153,10 @@ impl<'a> LineHighlighter<'a> { self.style_begin = style; } + pub fn set_line_style(&mut self, style: Style) { + self.style_begin = style; + } + #[cfg(feature = "search")] pub fn search(&mut self, matches: impl Iterator, style: Style) { for (start, end) in matches { @@ -208,6 +216,17 @@ impl<'a> LineHighlighter<'a> { ); } + pub fn selection_segment(&mut self, start_off: usize, end_off: usize, select_at_end: bool) { + if start_off < end_off { + self.boundaries + .push((Boundary::Select(self.select_style), start_off)); + self.boundaries.push((Boundary::End, end_off)); + } + if select_at_end { + self.select_at_end = true; + } + } + pub fn custom( &mut self, current_row: usize, diff --git a/src/lib.rs b/src/lib.rs index ad56da6..2c6172f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ mod textarea; mod util; mod widget; mod word; +mod wrap; #[cfg(feature = "ratatui")] #[allow(clippy::single_component_path_imports)] @@ -51,3 +52,4 @@ pub use cursor::CursorMove; pub use input::{Input, Key}; pub use scroll::Scrolling; pub use textarea::TextArea; +pub use wrap::WrapMode; diff --git a/src/textarea.rs b/src/textarea.rs index 33c95fa..7ee5e7b 100644 --- a/src/textarea.rs +++ b/src/textarea.rs @@ -8,12 +8,16 @@ use crate::ratatui::widgets::{Block, Widget}; use crate::scroll::Scrolling; #[cfg(feature = "search")] use crate::search::Search; -use crate::util::{spaces, Pos}; +use crate::util::{num_digits, spaces, Pos}; use crate::widget::Viewport; use crate::word::{find_word_exclusive_end_forward, find_word_start_backward}; +use crate::wrap::{ + cursor_at_visual_row, cursor_visual_row, effective_wrap_width, wrapped_rows, WrapMode, + WrappedLine, +}; #[cfg(feature = "ratatui")] use ratatui_core::text::Line; -use std::cmp::Ordering; +use std::cmp::{self, Ordering}; use std::fmt; #[cfg(feature = "tuirs")] use tui::text::Spans as Line; @@ -127,6 +131,7 @@ pub struct TextArea<'a> { #[cfg(feature = "search")] search: Search, alignment: Alignment, + wrap_mode: WrapMode, pub(crate) placeholder: String, pub(crate) placeholder_style: Style, mask: Option, @@ -233,6 +238,7 @@ impl<'a> TextArea<'a> { #[cfg(feature = "search")] search: Search::default(), alignment: Alignment::Left, + wrap_mode: WrapMode::None, placeholder: String::new(), placeholder_style: Style::default().fg(Color::DarkGray), mask: None, @@ -1587,7 +1593,13 @@ impl<'a> TextArea<'a> { } fn move_cursor_with_shift(&mut self, m: CursorMove, shift: bool) { - if let Some(cursor) = m.next_cursor(self.cursor, &self.lines, &self.viewport) { + let next = if m == CursorMove::InViewport && self.wrap_mode != WrapMode::None { + self.cursor_in_wrapped_viewport() + } else { + m.next_cursor(self.cursor, &self.lines, &self.viewport) + }; + + if let Some(cursor) = next { if shift { if self.selection_start.is_none() { self.start_selection(); @@ -1599,6 +1611,32 @@ impl<'a> TextArea<'a> { } } + fn cursor_in_wrapped_viewport(&self) -> Option<(usize, usize)> { + let (row_top, _, width, height) = self.viewport.rect(); + if height == 0 { + return Some(self.cursor); + } + + let line_number_len = self.line_number_style.map(|_| num_digits(self.lines.len())); + let wrap_width = effective_wrap_width(width, line_number_len); + let rows = wrapped_rows(&self.lines, self.wrap_mode, wrap_width, self.tab_len); + if rows.is_empty() { + return Some(self.cursor); + } + + let cursor_visual = cursor_visual_row(&rows, self.cursor); + let row_top = row_top as usize; + let row_bottom = row_top + height.saturating_sub(1) as usize; + let target_visual = cursor_visual.clamp(row_top, row_bottom); + + Some(cursor_at_visual_row( + &self.lines, + &rows, + self.cursor, + target_visual, + )) + } + /// Undo the last modification. This method returns if the undo modified text contents or not in the textarea. /// ``` /// use tui_textarea::{TextArea, CursorMove}; @@ -1644,8 +1682,27 @@ impl<'a> TextArea<'a> { } pub(crate) fn line_spans<'b>(&'b self, line: &'b str, row: usize, lnum_len: u8) -> Line<'b> { + let wrapped = WrappedLine { + row, + start_byte: 0, + end_byte: line.len(), + start_col: 0, + end_col: line.chars().count(), + first_in_row: true, + last_in_row: true, + }; + self.line_spans_segment(line, &wrapped, lnum_len) + } + + pub(crate) fn line_spans_segment<'b>( + &'b self, + line: &'b str, + wrapped: &WrappedLine, + lnum_len: u8, + ) -> Line<'b> { + let fragment = &line[wrapped.start_byte..wrapped.end_byte]; let mut hl = LineHighlighter::new( - line, + fragment, self.cursor_style, self.tab_len, self.mask, @@ -1653,20 +1710,64 @@ impl<'a> TextArea<'a> { ); if let Some(style) = self.line_number_style { - hl.line_number(row, lnum_len, style); + if wrapped.first_in_row { + hl.line_number(wrapped.row, lnum_len, style); + } else { + hl.line_number_placeholder(lnum_len, style); + } } - if row == self.cursor.0 { - hl.cursor_line(self.cursor.1, self.cursor_line_style); + if wrapped.row == self.cursor.0 { + hl.set_line_style(self.cursor_line_style); + let cursor_col = self.cursor.1; + let in_segment = if wrapped.last_in_row { + wrapped.start_col <= cursor_col && cursor_col <= wrapped.end_col + } else { + wrapped.start_col <= cursor_col && cursor_col < wrapped.end_col + }; + if in_segment { + hl.cursor_line(cursor_col - wrapped.start_col, self.cursor_line_style); + } } #[cfg(feature = "search")] if let Some(matches) = self.search.matches(line) { - hl.search(matches, self.search.style); + let clipped = matches + .filter_map(|(start, end)| { + let start = cmp::max(start, wrapped.start_byte); + let end = cmp::min(end, wrapped.end_byte); + (start < end).then_some((start - wrapped.start_byte, end - wrapped.start_byte)) + }) + .collect::>(); + if !clipped.is_empty() { + hl.search(clipped.into_iter(), self.search.style); + } } if let Some((start, end)) = self.selection_positions() { - hl.selection(row, start.row, start.offset, end.row, end.offset); + if wrapped.first_in_row && wrapped.last_in_row { + hl.selection(wrapped.row, start.row, start.offset, end.row, end.offset); + } else if start.row <= wrapped.row && wrapped.row <= end.row { + let start_off = if start.row == wrapped.row { + start.offset + } else { + 0 + }; + let end_off = if end.row == wrapped.row { + end.offset + } else { + line.len() + }; + let clipped_start = cmp::max(start_off, wrapped.start_byte); + let clipped_end = cmp::min(end_off, wrapped.end_byte); + let select_at_end = + wrapped.last_in_row && clipped_end == wrapped.end_byte && wrapped.row < end.row; + hl.selection_segment( + clipped_start.saturating_sub(wrapped.start_byte), + clipped_end.saturating_sub(wrapped.start_byte), + select_at_end, + ); + } } for CustomHighlight { @@ -1675,12 +1776,31 @@ impl<'a> TextArea<'a> { priority, } in &self.custom_highlights { - hl.custom( - row, - ((*start_row, *start_offset), (*end_row, *end_offset)), - *style, - *priority, - ); + if *start_row <= wrapped.row && wrapped.row <= *end_row { + let start_off = if *start_row == wrapped.row { + *start_offset + } else { + 0 + }; + let end_off = if *end_row == wrapped.row { + *end_offset + } else { + line.len() + }; + let clipped_start = cmp::max(start_off, wrapped.start_byte); + let clipped_end = cmp::min(end_off, wrapped.end_byte); + if clipped_start < clipped_end { + hl.custom( + wrapped.row, + ( + (wrapped.row, clipped_start - wrapped.start_byte), + (wrapped.row, clipped_end - wrapped.start_byte), + ), + *style, + *priority, + ); + } + } } hl.into_spans() @@ -2186,6 +2306,24 @@ impl<'a> TextArea<'a> { self.alignment } + /// Set soft-wrap mode of rendering. + /// By default, wrapping is disabled and horizontal scrolling is used. + /// ``` + /// use tui_textarea::{TextArea, WrapMode}; + /// + /// let mut textarea = TextArea::default(); + /// textarea.set_wrap_mode(WrapMode::WordOrGlyph); + /// assert_eq!(textarea.wrap_mode(), WrapMode::WordOrGlyph); + /// ``` + pub fn set_wrap_mode(&mut self, mode: WrapMode) { + self.wrap_mode = mode; + } + + /// Get the current soft-wrap mode. + pub fn wrap_mode(&self) -> WrapMode { + self.wrap_mode + } + /// Check if the textarea has a empty content. /// ``` /// use tui_textarea::TextArea; diff --git a/src/widget.rs b/src/widget.rs index 47e0450..cd9f492 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -4,6 +4,7 @@ use crate::ratatui::text::{Span, Text}; use crate::ratatui::widgets::{Paragraph, Widget}; use crate::textarea::TextArea; use crate::util::num_digits; +use crate::wrap::{cursor_visual_row, effective_wrap_width, wrapped_rows, WrapMode}; use portable_atomic::{AtomicU64, Ordering}; #[cfg(feature = "ratatui")] use ratatui_core::text::Line; @@ -110,6 +111,44 @@ impl<'a> TextArea<'a> { Text::from(Line::from(vec![cursor, text])) } + fn wrapped_text_widget( + &'a self, + prev_top_row: u16, + width: u16, + height: u16, + ) -> (Text<'a>, u16) { + if height == 0 { + return (Text::default(), prev_top_row); + } + + let lines_len = self.lines().len(); + let lnum_len = num_digits(lines_len); + let line_number_len = self.line_number_style().map(|_| lnum_len); + let wrap_width = effective_wrap_width(width, line_number_len); + let wrapped = wrapped_rows( + self.lines(), + self.wrap_mode(), + wrap_width, + self.tab_length(), + ); + if wrapped.is_empty() { + return (Text::default(), 0); + } + + let cursor_visual = cursor_visual_row(&wrapped, self.cursor()); + let top_row = next_scroll_top(prev_top_row, cursor_visual as u16, height); + let top = top_row as usize; + let bottom = cmp::min(top + height as usize, wrapped.len()); + + let mut lines = Vec::with_capacity(bottom.saturating_sub(top)); + for row in &wrapped[top..bottom] { + let line = &self.lines()[row.row]; + lines.push(self.line_spans_segment(line, row, lnum_len)); + } + + (Text::from(lines), top_row) + } + fn scroll_top_row(&self, prev_top: u16, height: u16) -> u16 { next_scroll_top(prev_top, self.cursor().0 as u16, height) } @@ -145,14 +184,21 @@ impl Widget for &TextArea<'_> { area }; - let (top_row, top_col) = self.viewport.scroll_top(); - let top_row = self.scroll_top_row(top_row, height); - let top_col = self.scroll_top_col(top_col, width); - - let (text, style) = if !self.placeholder.is_empty() && self.is_empty() { - (self.placeholder_widget(), self.placeholder_style) + let (prev_top_row, prev_top_col) = self.viewport.scroll_top(); + let (text, style, top_row, top_col) = if !self.placeholder.is_empty() && self.is_empty() { + (self.placeholder_widget(), self.placeholder_style, 0, 0) + } else if self.wrap_mode() == WrapMode::None { + let top_row = self.scroll_top_row(prev_top_row, height); + let top_col = self.scroll_top_col(prev_top_col, width); + ( + self.text_widget(top_row as _, height as _), + self.style(), + top_row, + top_col, + ) } else { - (self.text_widget(top_row as _, height as _), self.style()) + let (text, top_row) = self.wrapped_text_widget(prev_top_row, width, height); + (text, self.style(), top_row, 0) }; // To get fine control over the text color and the surrrounding block they have to be rendered separately diff --git a/src/wrap.rs b/src/wrap.rs new file mode 100644 index 0000000..b6bae85 --- /dev/null +++ b/src/wrap.rs @@ -0,0 +1,316 @@ +#[cfg(feature = "arbitrary")] +use arbitrary::Arbitrary; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthChar; + +/// Specify how logical lines are soft-wrapped at render time. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum WrapMode { + /// Disable soft wrapping and keep horizontal scrolling behavior. + None, + /// Wrap only at word boundaries. Words wider than viewport are not split. + Word, + /// Wrap at grapheme boundaries. + Glyph, + /// Wrap at word boundaries, and fall back to grapheme wrapping for long words. + WordOrGlyph, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct WrappedLine { + pub row: usize, + pub start_byte: usize, + pub end_byte: usize, + pub start_col: usize, + pub end_col: usize, + pub first_in_row: bool, + pub last_in_row: bool, +} + +#[derive(Clone, Copy)] +struct Chunk { + start: usize, + end: usize, +} + +pub(crate) fn effective_wrap_width(total_width: u16, line_number_len: Option) -> usize { + let total_width = total_width as usize; + let reserved = line_number_len.map(|len| len as usize + 2).unwrap_or(0); + if total_width > reserved { + total_width - reserved + } else { + 1 + } +} + +pub(crate) fn wrapped_rows( + lines: &[String], + mode: WrapMode, + width: usize, + tab_len: u8, +) -> Vec { + let mut rows = Vec::new(); + + for (row, line) in lines.iter().enumerate() { + let ranges = line_ranges(line, mode, width, tab_len); + let mut start_col = 0usize; + for (i, (start_byte, end_byte)) in ranges.iter().copied().enumerate() { + let end_col = start_col + line[start_byte..end_byte].chars().count(); + rows.push(WrappedLine { + row, + start_byte, + end_byte, + start_col, + end_col, + first_in_row: i == 0, + last_in_row: i + 1 == ranges.len(), + }); + start_col = end_col; + } + } + + rows +} + +pub(crate) fn cursor_visual_row(rows: &[WrappedLine], cursor: (usize, usize)) -> usize { + let (cursor_row, cursor_col) = cursor; + let mut fallback = 0usize; + for (vrow, wrapped) in rows.iter().copied().enumerate() { + if wrapped.row != cursor_row { + continue; + } + fallback = vrow; + let contains = if wrapped.last_in_row { + wrapped.start_col <= cursor_col && cursor_col <= wrapped.end_col + } else { + wrapped.start_col <= cursor_col && cursor_col < wrapped.end_col + }; + if contains { + return vrow; + } + } + fallback +} + +pub(crate) fn cursor_at_visual_row( + lines: &[String], + rows: &[WrappedLine], + cursor: (usize, usize), + visual_row: usize, +) -> (usize, usize) { + if rows.is_empty() { + return cursor; + } + + let wrapped = rows[visual_row.min(rows.len() - 1)]; + let line_len = lines[wrapped.row].chars().count(); + let mut col = cursor.1.min(line_len); + col = col.clamp(wrapped.start_col, wrapped.end_col); + (wrapped.row, col) +} + +pub(crate) fn line_ranges( + line: &str, + mode: WrapMode, + width: usize, + tab_len: u8, +) -> Vec<(usize, usize)> { + if mode == WrapMode::None { + return vec![(0, line.len())]; + } + + let width = width.max(1); + let mut out = match mode { + WrapMode::None => vec![(0, line.len())], + WrapMode::Glyph => { + let mut chunks = Vec::new(); + split_range_by_grapheme_width(line, 0, line.len(), width, tab_len, &mut chunks); + chunks + } + WrapMode::Word => wrap_word_chunks(line, width, tab_len, false), + WrapMode::WordOrGlyph => wrap_word_chunks(line, width, tab_len, true), + }; + + if out.is_empty() { + out.push((0, 0)); + } + out +} + +fn wrap_word_chunks( + line: &str, + width: usize, + tab_len: u8, + fallback_to_glyph: bool, +) -> Vec<(usize, usize)> { + let chunks: Vec<_> = UnicodeSegmentation::split_word_bound_indices(line) + .map(|(start, text)| Chunk { + start, + end: start + text.len(), + }) + .collect(); + + if chunks.is_empty() { + return vec![(0, 0)]; + } + + let mut out = Vec::new(); + let mut i = 0usize; + let mut seg_start = chunks[0].start; + let mut seg_end = seg_start; + let mut seg_width = 0usize; + + while i < chunks.len() { + let chunk = chunks[i]; + if seg_end == seg_start { + seg_start = chunk.start; + } + + let chunk_width = display_width_from(chunk_text(line, chunk), seg_width, tab_len); + if seg_width + chunk_width <= width { + seg_end = chunk.end; + seg_width += chunk_width; + i += 1; + continue; + } + + if seg_end > seg_start { + out.push((seg_start, seg_end)); + seg_start = seg_end; + seg_width = 0; + continue; + } + + if fallback_to_glyph { + split_range_by_grapheme_width(line, chunk.start, chunk.end, width, tab_len, &mut out); + } else { + out.push((chunk.start, chunk.end)); + } + + i += 1; + seg_start = chunk.end; + seg_end = chunk.end; + seg_width = 0; + } + + if seg_end > seg_start { + out.push((seg_start, seg_end)); + } + + out +} + +fn split_range_by_grapheme_width( + line: &str, + start: usize, + end: usize, + width: usize, + tab_len: u8, + out: &mut Vec<(usize, usize)>, +) { + let mut segment_start = start; + while segment_start < end { + let mut segment_end = segment_start; + let mut segment_width = 0usize; + + for (offset, grapheme) in + UnicodeSegmentation::grapheme_indices(&line[segment_start..end], true) + { + let grapheme_start = segment_start + offset; + let grapheme_end = grapheme_start + grapheme.len(); + let next_width = display_width_to(grapheme, segment_width, tab_len); + let grapheme_width = next_width.saturating_sub(segment_width); + + if segment_end != segment_start && segment_width + grapheme_width > width { + break; + } + + segment_end = grapheme_end; + segment_width = next_width; + if segment_width > width { + break; + } + } + + if segment_end == segment_start { + if let Some(ch) = line[segment_start..end].chars().next() { + segment_end = segment_start + ch.len_utf8(); + } else { + break; + } + } + + out.push((segment_start, segment_end)); + segment_start = segment_end; + } +} + +#[inline] +fn chunk_text(line: &str, chunk: Chunk) -> &str { + &line[chunk.start..chunk.end] +} + +fn display_width_from(text: &str, start_width: usize, tab_len: u8) -> usize { + display_width_to(text, start_width, tab_len).saturating_sub(start_width) +} + +fn display_width_to(text: &str, mut width: usize, tab_len: u8) -> usize { + for c in text.chars() { + if c == '\t' { + if tab_len > 0 { + let tab = tab_len as usize; + let pad = tab - (width % tab); + width += pad; + } + } else { + width += c.width().unwrap_or(0); + } + } + width +} + +#[cfg(test)] +mod tests { + use super::*; + + fn segments(line: &str, mode: WrapMode, width: usize) -> Vec<&str> { + line_ranges(line, mode, width, 4) + .into_iter() + .map(|(s, e)| &line[s..e]) + .collect() + } + + #[test] + fn word_wrap_keeps_long_word() { + let have = segments("helloworld", WrapMode::Word, 4); + assert_eq!(have, vec!["helloworld"]); + } + + #[test] + fn word_or_glyph_wrap_splits_long_word() { + let have = segments("helloworld", WrapMode::WordOrGlyph, 4); + assert_eq!(have, vec!["hell", "owor", "ld"]); + } + + #[test] + fn glyph_wrap_handles_wide_chars() { + let have = segments("ab犬猫", WrapMode::Glyph, 4); + assert_eq!(have, vec!["ab犬", "猫"]); + } + + #[test] + fn glyph_wrap_keeps_combining_grapheme_cluster() { + let have = segments("e\u{301}x", WrapMode::Glyph, 1); + assert_eq!(have, vec!["e\u{301}", "x"]); + } + + #[test] + fn tab_width_is_accounted_for_in_wrap() { + let have = segments("\tX", WrapMode::WordOrGlyph, 2); + assert_eq!(have, vec!["\t", "X"]); + } +} diff --git a/tests/wrap.rs b/tests/wrap.rs new file mode 100644 index 0000000..f31a2cf --- /dev/null +++ b/tests/wrap.rs @@ -0,0 +1,266 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::widgets::Widget as _; +use tui_textarea::{CursorMove, TextArea, WrapMode}; + +fn render_lines(textarea: &TextArea<'_>, width: u16, height: u16) -> Vec { + let area = Rect { + x: 0, + y: 0, + width, + height, + }; + let mut buf = Buffer::empty(area); + textarea.render(area, &mut buf); + + (0..height) + .map(|y| { + let mut line = String::new(); + for x in 0..width { + line.push_str(buf[(x, y)].symbol()); + } + line + }) + .collect() +} + +fn render_buffer(textarea: &TextArea<'_>, width: u16, height: u16) -> Buffer { + let area = Rect { + x: 0, + y: 0, + width, + height, + }; + let mut buf = Buffer::empty(area); + textarea.render(area, &mut buf); + buf +} + +#[test] +fn wrap_mode_default_is_none() { + let textarea = TextArea::default(); + assert_eq!(textarea.wrap_mode(), WrapMode::None); +} + +#[test] +fn none_mode_keeps_horizontal_behavior() { + let textarea = TextArea::from(["abcdef"]); + let lines = render_lines(&textarea, 5, 2); + assert_eq!(lines, vec!["abcde".to_string(), " ".to_string()]); +} + +#[test] +fn word_or_glyph_mode_soft_wraps() { + let mut textarea = TextArea::from(["abcdef"]); + textarea.set_wrap_mode(WrapMode::WordOrGlyph); + let lines = render_lines(&textarea, 5, 2); + assert_eq!(lines, vec!["abcde".to_string(), "f ".to_string()]); +} + +#[test] +fn word_mode_does_not_split_long_word() { + let mut textarea = TextArea::from(["abcdefgh"]); + textarea.set_wrap_mode(WrapMode::Word); + let lines = render_lines(&textarea, 5, 2); + assert_eq!(lines, vec!["abcde".to_string(), " ".to_string()]); +} + +#[test] +fn word_mode_wraps_with_newlines() { + let mut textarea = TextArea::from(["hello world", "again here"]); + textarea.set_wrap_mode(WrapMode::Word); + + let lines = render_lines(&textarea, 6, 4); + assert_eq!( + lines, + vec![ + "hello ".to_string(), + "world ".to_string(), + "again ".to_string(), + "here ".to_string(), + ] + ); +} + +#[test] +fn none_mode_long_multiline_text_stays_unwrapped() { + let textarea = TextArea::from(["first very long line", "second very long line"]); + let lines = render_lines(&textarea, 8, 4); + assert_eq!( + lines, + vec![ + "first ve".to_string(), + "second v".to_string(), + " ".to_string(), + " ".to_string(), + ] + ); +} + +#[test] +fn word_mode_long_paragraph_wraps_by_words() { + let mut textarea = TextArea::from(["lorem ipsum dolor sit amet consectetur adipiscing elit"]); + textarea.set_wrap_mode(WrapMode::Word); + let lines = render_lines(&textarea, 12, 6); + assert_eq!( + lines, + vec![ + "lorem ipsum ".to_string(), + "dolor sit ".to_string(), + "amet ".to_string(), + "consectetur ".to_string(), + "adipiscing ".to_string(), + "elit ".to_string(), + ] + ); +} + +#[test] +fn glyph_mode_splits_long_token() { + let mut textarea = TextArea::from(["0123456789abcdefghijklmnopqrstuvwxyz"]); + textarea.set_wrap_mode(WrapMode::Glyph); + let lines = render_lines(&textarea, 10, 4); + assert_eq!( + lines, + vec![ + "0123456789".to_string(), + "abcdefghij".to_string(), + "klmnopqrst".to_string(), + "uvwxyz ".to_string(), + ] + ); +} + +#[test] +fn word_and_word_or_glyph_differ_for_long_words() { + let text = "alpha supercalifragilisticexpialidocious omega"; + + let mut word = TextArea::from([text]); + word.set_wrap_mode(WrapMode::Word); + let word_lines = render_lines(&word, 10, 4); + assert_eq!( + word_lines, + vec![ + "alpha ".to_string(), + "supercalif".to_string(), + " omega ".to_string(), + " ".to_string(), + ] + ); + + let mut word_or_glyph = TextArea::from([text]); + word_or_glyph.set_wrap_mode(WrapMode::WordOrGlyph); + let word_or_glyph_lines = render_lines(&word_or_glyph, 10, 6); + assert_eq!( + word_or_glyph_lines, + vec![ + "alpha ".to_string(), + "supercalif".to_string(), + "ragilistic".to_string(), + "expialidoc".to_string(), + "ious ".to_string(), + " omega ".to_string(), + ] + ); +} + +#[test] +fn wrapped_mode_line_numbers_on_continuation_rows() { + let mut textarea = TextArea::from(["abcdefghijk", "xy"]); + textarea.set_wrap_mode(WrapMode::WordOrGlyph); + textarea.set_line_number_style(Style::default()); + + let lines = render_lines(&textarea, 8, 4); + assert_eq!( + lines, + vec![ + " 1 abcde".to_string(), + " fghij".to_string(), + " k ".to_string(), + " 2 xy ".to_string(), + ] + ); +} + +#[test] +fn wrapped_mode_handles_tiny_width() { + let mut textarea = TextArea::from(["abcd"]); + textarea.set_wrap_mode(WrapMode::WordOrGlyph); + + let lines = render_lines(&textarea, 1, 4); + assert_eq!( + lines, + vec![ + "a".to_string(), + "b".to_string(), + "c".to_string(), + "d".to_string(), + ] + ); +} + +#[test] +fn wrapped_mode_preserves_empty_logical_lines() { + let mut textarea = TextArea::from(["ab", "", "cd"]); + textarea.set_wrap_mode(WrapMode::WordOrGlyph); + + let lines = render_lines(&textarea, 2, 3); + assert_eq!( + lines, + vec!["ab".to_string(), " ".to_string(), "cd".to_string(),] + ); +} + +#[test] +fn glyph_mode_combining_grapheme_renders_in_two_rows() { + let mut textarea = TextArea::from(["e\u{301}x"]); + textarea.set_wrap_mode(WrapMode::Glyph); + + let lines = render_lines(&textarea, 1, 2); + assert_eq!(lines, vec!["e".to_string(), "x".to_string()]); +} + +#[test] +fn wrapped_mode_scrolls_to_cursor_visual_row() { + let mut textarea = TextArea::from(["abcdefghijklmnopqrstuvwxyz"]); + textarea.set_wrap_mode(WrapMode::WordOrGlyph); + textarea.move_cursor(CursorMove::End); + + let lines = render_lines(&textarea, 5, 2); + assert_eq!(lines, vec!["uvwxy".to_string(), "z ".to_string()]); +} + +#[test] +fn wrapped_mode_with_tab_does_not_hide_following_text() { + let mut textarea = TextArea::from(["\tX"]); + textarea.set_wrap_mode(WrapMode::WordOrGlyph); + let lines = render_lines(&textarea, 2, 2); + assert_eq!(lines, vec![" ".to_string(), "X ".to_string()]); +} + +#[test] +fn cursor_line_style_applies_to_wrapped_continuation_rows() { + let mut textarea = TextArea::from(["abcdefghij"]); + textarea.set_wrap_mode(WrapMode::WordOrGlyph); + let style = Style::default().bg(Color::Red); + textarea.set_cursor_line_style(style); + + let buf = render_buffer(&textarea, 5, 2); + assert_eq!(buf[(1, 1)].style().bg, style.bg); +} + +#[test] +fn selection_does_not_extend_with_synthetic_space_on_wrap_boundary() { + let mut textarea = TextArea::from(["hello world again"]); + textarea.set_wrap_mode(WrapMode::Word); + textarea.set_cursor_line_style(Style::default()); + let select_style = Style::default().bg(Color::Blue); + textarea.set_selection_style(select_style); + textarea.start_selection(); + textarea.move_cursor(CursorMove::End); + + let buf = render_buffer(&textarea, 10, 3); + assert_eq!(buf[(5, 0)].style().bg, select_style.bg); + assert_ne!(buf[(6, 0)].style().bg, select_style.bg); +}