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