1
0
зеркало из https://github.com/glebtv/tui-textarea.git synced 2026-09-03 14:26:17 +03:00

feat: add unicode-aware soft-wrap modes and wrapped rendering (#8)

* feat(textarea): add unicode-aware soft-wrap modes and wrapped rendering

- add WrapMode API/state: None, Word, Glyph, WordOrGlyph
- keep None behavior unchanged (horizontal scroll)
- implement wrapped render path with logical-to-visual row mapping
- add Unicode-aware wrapping via unicode-segmentation + unicode-width
- make wrap width tab-aware to prevent hidden text
- apply cursor-line style on wrapped continuation rows
- fix selection at soft-wrap boundaries (no synthetic highlighted space)
- add wrap-focused tests for long text, newlines, tabs, tiny widths, and line-number
    continuations

* chore(release): bump to v0.9.0 and update README/CHANGELOG
Этот коммит содержится в:
srothgan
2026-02-18 18:26:48 +01:00
коммит произвёл GitHub
родитель cc783097ae
Коммит 1740facd24
9 изменённых файлов: 842 добавлений и 23 удалений

Просмотреть файл

@@ -1,3 +1,15 @@
<a id="v0.9.0"></a>
# [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]
<a id="v0.8.0"></a>
# [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

Просмотреть файл

@@ -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 <simonrothgang@icloud.com>", "rhysd <lin90162@yahoo.co.jp>"]
@@ -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]]

Просмотреть файл

@@ -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

Просмотреть файл

@@ -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<Item = (usize, usize)>, 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,

Просмотреть файл

@@ -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;

Просмотреть файл

@@ -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<char>,
@@ -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::<Vec<_>>();
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;

Просмотреть файл

@@ -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

316
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<u8>) -> 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<WrappedLine> {
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"]);
}
}

266
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<String> {
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);
}