зеркало из
https://github.com/glebtv/tui-textarea.git
synced 2026-09-08 16:35:49 +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
Этот коммит содержится в:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -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>
|
<a id="v0.8.0"></a>
|
||||||
# [v0.8.0](https://github.com/srothgan/tui-textarea/releases/tag/v0.8.0) - 2026-02-18
|
# [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]
|
[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.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.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
|
[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"
|
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.8.0"
|
version = "0.9.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.56.1" # for `tui` crate support
|
rust-version = "1.56.1" # for `tui` crate 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>"]
|
||||||
@@ -53,6 +53,7 @@ termion-15 = { package = "termion", version = "1.5", optional = true }
|
|||||||
termwiz = { version = "0.23.0", optional = true }
|
termwiz = { version = "0.23.0", optional = true }
|
||||||
tui = { version = "0.19", default-features = false, optional = true }
|
tui = { version = "0.19", default-features = false, optional = true }
|
||||||
unicode-width = "0.2.0"
|
unicode-width = "0.2.0"
|
||||||
|
unicode-segmentation = "1.10.1"
|
||||||
serde = { version = "1", optional = true , features = ["derive"] }
|
serde = { version = "1", optional = true , features = ["derive"] }
|
||||||
|
|
||||||
[[example]]
|
[[example]]
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -385,6 +385,24 @@ Typing tab key inserts 2 spaces.
|
|||||||
textarea.set_tab_length(2);
|
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
|
### 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
|
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));
|
.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) {
|
pub fn cursor_line(&mut self, cursor_col: usize, style: Style) {
|
||||||
if let Some((start, c)) = self.line.char_indices().nth(cursor_col) {
|
if let Some((start, c)) = self.line.char_indices().nth(cursor_col) {
|
||||||
self.boundaries
|
self.boundaries
|
||||||
@@ -149,6 +153,10 @@ impl<'a> LineHighlighter<'a> {
|
|||||||
self.style_begin = style;
|
self.style_begin = style;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_line_style(&mut self, style: Style) {
|
||||||
|
self.style_begin = style;
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "search")]
|
#[cfg(feature = "search")]
|
||||||
pub fn search(&mut self, matches: impl Iterator<Item = (usize, usize)>, style: Style) {
|
pub fn search(&mut self, matches: impl Iterator<Item = (usize, usize)>, style: Style) {
|
||||||
for (start, end) in matches {
|
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(
|
pub fn custom(
|
||||||
&mut self,
|
&mut self,
|
||||||
current_row: usize,
|
current_row: usize,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ mod textarea;
|
|||||||
mod util;
|
mod util;
|
||||||
mod widget;
|
mod widget;
|
||||||
mod word;
|
mod word;
|
||||||
|
mod wrap;
|
||||||
|
|
||||||
#[cfg(feature = "ratatui")]
|
#[cfg(feature = "ratatui")]
|
||||||
#[allow(clippy::single_component_path_imports)]
|
#[allow(clippy::single_component_path_imports)]
|
||||||
@@ -51,3 +52,4 @@ 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;
|
||||||
|
pub use wrap::WrapMode;
|
||||||
|
|||||||
160
src/textarea.rs
160
src/textarea.rs
@@ -8,12 +8,16 @@ use crate::ratatui::widgets::{Block, Widget};
|
|||||||
use crate::scroll::Scrolling;
|
use crate::scroll::Scrolling;
|
||||||
#[cfg(feature = "search")]
|
#[cfg(feature = "search")]
|
||||||
use crate::search::Search;
|
use crate::search::Search;
|
||||||
use crate::util::{spaces, Pos};
|
use crate::util::{num_digits, spaces, Pos};
|
||||||
use crate::widget::Viewport;
|
use crate::widget::Viewport;
|
||||||
use crate::word::{find_word_exclusive_end_forward, find_word_start_backward};
|
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")]
|
#[cfg(feature = "ratatui")]
|
||||||
use ratatui_core::text::Line;
|
use ratatui_core::text::Line;
|
||||||
use std::cmp::Ordering;
|
use std::cmp::{self, Ordering};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
#[cfg(feature = "tuirs")]
|
#[cfg(feature = "tuirs")]
|
||||||
use tui::text::Spans as Line;
|
use tui::text::Spans as Line;
|
||||||
@@ -127,6 +131,7 @@ pub struct TextArea<'a> {
|
|||||||
#[cfg(feature = "search")]
|
#[cfg(feature = "search")]
|
||||||
search: Search,
|
search: Search,
|
||||||
alignment: Alignment,
|
alignment: Alignment,
|
||||||
|
wrap_mode: WrapMode,
|
||||||
pub(crate) placeholder: String,
|
pub(crate) placeholder: String,
|
||||||
pub(crate) placeholder_style: Style,
|
pub(crate) placeholder_style: Style,
|
||||||
mask: Option<char>,
|
mask: Option<char>,
|
||||||
@@ -233,6 +238,7 @@ impl<'a> TextArea<'a> {
|
|||||||
#[cfg(feature = "search")]
|
#[cfg(feature = "search")]
|
||||||
search: Search::default(),
|
search: Search::default(),
|
||||||
alignment: Alignment::Left,
|
alignment: Alignment::Left,
|
||||||
|
wrap_mode: WrapMode::None,
|
||||||
placeholder: String::new(),
|
placeholder: String::new(),
|
||||||
placeholder_style: Style::default().fg(Color::DarkGray),
|
placeholder_style: Style::default().fg(Color::DarkGray),
|
||||||
mask: None,
|
mask: None,
|
||||||
@@ -1587,7 +1593,13 @@ impl<'a> TextArea<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn move_cursor_with_shift(&mut self, m: CursorMove, shift: bool) {
|
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 shift {
|
||||||
if self.selection_start.is_none() {
|
if self.selection_start.is_none() {
|
||||||
self.start_selection();
|
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.
|
/// Undo the last modification. This method returns if the undo modified text contents or not in the textarea.
|
||||||
/// ```
|
/// ```
|
||||||
/// use tui_textarea::{TextArea, CursorMove};
|
/// 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> {
|
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(
|
let mut hl = LineHighlighter::new(
|
||||||
line,
|
fragment,
|
||||||
self.cursor_style,
|
self.cursor_style,
|
||||||
self.tab_len,
|
self.tab_len,
|
||||||
self.mask,
|
self.mask,
|
||||||
@@ -1653,20 +1710,64 @@ impl<'a> TextArea<'a> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if let Some(style) = self.line_number_style {
|
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 {
|
if wrapped.row == self.cursor.0 {
|
||||||
hl.cursor_line(self.cursor.1, self.cursor_line_style);
|
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")]
|
#[cfg(feature = "search")]
|
||||||
if let Some(matches) = self.search.matches(line) {
|
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() {
|
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 {
|
for CustomHighlight {
|
||||||
@@ -1675,13 +1776,32 @@ impl<'a> TextArea<'a> {
|
|||||||
priority,
|
priority,
|
||||||
} in &self.custom_highlights
|
} in &self.custom_highlights
|
||||||
{
|
{
|
||||||
|
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(
|
hl.custom(
|
||||||
row,
|
wrapped.row,
|
||||||
((*start_row, *start_offset), (*end_row, *end_offset)),
|
(
|
||||||
|
(wrapped.row, clipped_start - wrapped.start_byte),
|
||||||
|
(wrapped.row, clipped_end - wrapped.start_byte),
|
||||||
|
),
|
||||||
*style,
|
*style,
|
||||||
*priority,
|
*priority,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
hl.into_spans()
|
hl.into_spans()
|
||||||
}
|
}
|
||||||
@@ -2186,6 +2306,24 @@ impl<'a> TextArea<'a> {
|
|||||||
self.alignment
|
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.
|
/// Check if the textarea has a empty content.
|
||||||
/// ```
|
/// ```
|
||||||
/// use tui_textarea::TextArea;
|
/// use tui_textarea::TextArea;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use crate::ratatui::text::{Span, Text};
|
|||||||
use crate::ratatui::widgets::{Paragraph, Widget};
|
use crate::ratatui::widgets::{Paragraph, Widget};
|
||||||
use crate::textarea::TextArea;
|
use crate::textarea::TextArea;
|
||||||
use crate::util::num_digits;
|
use crate::util::num_digits;
|
||||||
|
use crate::wrap::{cursor_visual_row, effective_wrap_width, wrapped_rows, WrapMode};
|
||||||
use portable_atomic::{AtomicU64, Ordering};
|
use portable_atomic::{AtomicU64, Ordering};
|
||||||
#[cfg(feature = "ratatui")]
|
#[cfg(feature = "ratatui")]
|
||||||
use ratatui_core::text::Line;
|
use ratatui_core::text::Line;
|
||||||
@@ -110,6 +111,44 @@ impl<'a> TextArea<'a> {
|
|||||||
Text::from(Line::from(vec![cursor, text]))
|
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 {
|
fn scroll_top_row(&self, prev_top: u16, height: u16) -> u16 {
|
||||||
next_scroll_top(prev_top, self.cursor().0 as u16, height)
|
next_scroll_top(prev_top, self.cursor().0 as u16, height)
|
||||||
}
|
}
|
||||||
@@ -145,14 +184,21 @@ impl Widget for &TextArea<'_> {
|
|||||||
area
|
area
|
||||||
};
|
};
|
||||||
|
|
||||||
let (top_row, top_col) = self.viewport.scroll_top();
|
let (prev_top_row, prev_top_col) = self.viewport.scroll_top();
|
||||||
let top_row = self.scroll_top_row(top_row, height);
|
let (text, style, top_row, top_col) = if !self.placeholder.is_empty() && self.is_empty() {
|
||||||
let top_col = self.scroll_top_col(top_col, width);
|
(self.placeholder_widget(), self.placeholder_style, 0, 0)
|
||||||
|
} else if self.wrap_mode() == WrapMode::None {
|
||||||
let (text, style) = if !self.placeholder.is_empty() && self.is_empty() {
|
let top_row = self.scroll_top_row(prev_top_row, height);
|
||||||
(self.placeholder_widget(), self.placeholder_style)
|
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 {
|
} 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
|
// To get fine control over the text color and the surrrounding block they have to be rendered separately
|
||||||
|
|||||||
316
src/wrap.rs
Обычный файл
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
Обычный файл
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);
|
||||||
|
}
|
||||||
Ссылка в новой задаче
Block a user