зеркало из
https://github.com/glebtv/tui-textarea.git
synced 2026-09-03 14:26:17 +03:00
feat: wrapped line cursor navigation (#15)
* feat(cursor): add Up/Down navigation between wrapped visual lines - Extend CursorMove::next_cursor() with optional wrapped rows parameter so Up/Down use visual row indices when wrapping is active - Compute wrapped rows in move_cursor_with_shift() and pass them through - Fix cursor_at_visual_row() boundary clamping for non-last wrapped segments to prevent the cursor landing on the next visual line - Preserve visual column offset when moving between wrapped rows so the cursor moves straight vertically instead of jumping to row start/end * chore(release): bump to v0.10.1 and update changelog
Этот коммит содержится в:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,3 +1,12 @@
|
||||
<a id="v0.10.1"></a>
|
||||
# [v0.10.1](https://github.com/srothgan/tui-textarea/releases/tag/v0.10.1) - 2026-03-05
|
||||
|
||||
- Fix Up/Down arrow keys to navigate between visual (wrapped) lines instead of jumping between logical lines when word wrapping is active.
|
||||
- Preserve visual column offset when moving between wrapped rows so the cursor moves straight vertically.
|
||||
- Fix `cursor_at_visual_row()` boundary clamping for non-last wrapped segments to prevent the cursor landing on the wrong visual line.
|
||||
|
||||
[Changes][v0.10.1]
|
||||
|
||||
<a id="v0.10.0"></a>
|
||||
# [v0.10.0](https://github.com/srothgan/tui-textarea/releases/tag/v0.10.0) - 2026-02-25
|
||||
|
||||
@@ -507,6 +516,7 @@ First release :tada:
|
||||
|
||||
[Changes][v0.1.0]
|
||||
|
||||
[v0.10.1]: https://github.com/srothgan/tui-textarea/compare/v0.10.0...v0.10.1
|
||||
[v0.10.0]: https://github.com/srothgan/tui-textarea/compare/v0.9.2...v0.10.0
|
||||
[v0.9.2]: https://github.com/srothgan/tui-textarea/compare/v0.9.1...v0.9.2
|
||||
[v0.9.1]: https://github.com/srothgan/tui-textarea/compare/v0.9.0...v0.9.1
|
||||
|
||||
@@ -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.10.0"
|
||||
version = "0.10.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.85.0" # for Rust 2024 edition support
|
||||
authors = ["Simon Peter Rothgang <simonrothgang@icloud.com>", "rhysd <lin90162@yahoo.co.jp>"]
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::widget::Viewport;
|
||||
use crate::word::{
|
||||
find_word_inclusive_end_forward, find_word_start_backward, find_word_start_forward,
|
||||
};
|
||||
use crate::wrap::{WrappedLine, cursor_at_visual_row, cursor_visual_row};
|
||||
#[cfg(feature = "arbitrary")]
|
||||
use arbitrary::Arbitrary;
|
||||
#[cfg(feature = "serde")]
|
||||
@@ -263,6 +264,7 @@ impl CursorMove {
|
||||
(row, col): (usize, usize),
|
||||
lines: &[String],
|
||||
viewport: &Viewport,
|
||||
wrapped: Option<&[WrappedLine]>,
|
||||
) -> Option<(usize, usize)> {
|
||||
use CursorMove::*;
|
||||
|
||||
@@ -280,10 +282,26 @@ impl CursorMove {
|
||||
Some((row, lines[row].chars().count()))
|
||||
}
|
||||
Back => Some((row, col - 1)),
|
||||
Up if wrapped.is_some() => {
|
||||
let rows = wrapped.unwrap();
|
||||
let visual = cursor_visual_row(rows, (row, col));
|
||||
if visual == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(cursor_at_visual_row(lines, rows, (row, col), visual - 1))
|
||||
}
|
||||
Up => {
|
||||
let row = row.checked_sub(1)?;
|
||||
Some((row, fit_col(col, &lines[row])))
|
||||
}
|
||||
Down if wrapped.is_some() => {
|
||||
let rows = wrapped.unwrap();
|
||||
let visual = cursor_visual_row(rows, (row, col));
|
||||
if visual >= rows.len() - 1 {
|
||||
return None;
|
||||
}
|
||||
Some(cursor_at_visual_row(lines, rows, (row, col), visual + 1))
|
||||
}
|
||||
Down => Some((row + 1, fit_col(col, lines.get(row + 1)?))),
|
||||
Head => Some((row, 0)),
|
||||
End => Some((row, lines[row].chars().count())),
|
||||
|
||||
@@ -1632,10 +1632,26 @@ impl<'a> TextArea<'a> {
|
||||
}
|
||||
|
||||
fn move_cursor_with_shift(&mut self, m: CursorMove, shift: bool) {
|
||||
let wrapped_storage;
|
||||
let wrapped_ref = if self.wrap_mode != WrapMode::None {
|
||||
let (_, _, width, _) = self.viewport.rect();
|
||||
if width > 0 {
|
||||
let line_number_len = self.line_number_style.map(|_| num_digits(self.lines.len()));
|
||||
let wrap_width = effective_wrap_width(width, line_number_len);
|
||||
wrapped_storage =
|
||||
wrapped_rows(&self.lines, self.wrap_mode, wrap_width, self.tab_len);
|
||||
Some(wrapped_storage.as_slice())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
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)
|
||||
m.next_cursor(self.cursor, &self.lines, &self.viewport, wrapped_ref)
|
||||
};
|
||||
|
||||
if let Some(cursor) = next {
|
||||
|
||||
27
src/wrap.rs
27
src/wrap.rs
@@ -106,11 +106,28 @@ pub(crate) fn cursor_at_visual_row(
|
||||
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)
|
||||
// Compute the visual column offset within the current visual row so we can
|
||||
// preserve it when moving to the target row (straight up/down movement).
|
||||
let current_visual = cursor_visual_row(rows, cursor);
|
||||
let current_wrapped = rows[current_visual.min(rows.len() - 1)];
|
||||
let visual_col_offset = cursor.1.saturating_sub(current_wrapped.start_col);
|
||||
|
||||
let target = rows[visual_row.min(rows.len() - 1)];
|
||||
let line_len = lines[target.row].chars().count();
|
||||
|
||||
// Apply the visual offset to the target row's start column.
|
||||
let target_col = target.start_col + visual_col_offset;
|
||||
|
||||
// For non-last wrapped segments the end boundary is exclusive (end_col is the
|
||||
// start_col of the next visual line), so clamp to end_col - 1 to stay on this
|
||||
// visual row.
|
||||
let max_col = if target.last_in_row {
|
||||
target.end_col
|
||||
} else {
|
||||
target.end_col.saturating_sub(1)
|
||||
};
|
||||
let col = target_col.min(line_len).clamp(target.start_col, max_col);
|
||||
(target.row, col)
|
||||
}
|
||||
|
||||
pub(crate) fn line_ranges(
|
||||
|
||||
211
tests/wrap.rs
211
tests/wrap.rs
@@ -264,3 +264,214 @@ fn selection_does_not_extend_with_synthetic_space_on_wrap_boundary() {
|
||||
assert_eq!(buf[(5, 0)].style().bg, select_style.bg);
|
||||
assert_ne!(buf[(6, 0)].style().bg, select_style.bg);
|
||||
}
|
||||
|
||||
// --- Wrapped line cursor navigation tests ---
|
||||
|
||||
fn render(textarea: &TextArea<'_>, width: u16, height: u16) {
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
let mut buf = Buffer::empty(area);
|
||||
textarea.render(area, &mut buf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_cursor_down_moves_within_same_logical_line() {
|
||||
// "abcdefghij" at width=5 wraps into 2 visual lines:
|
||||
// visual 0: "abcde" (row 0, cols 0..5)
|
||||
// visual 1: "fghij" (row 0, cols 5..10)
|
||||
let mut textarea = TextArea::from(["abcdefghij"]);
|
||||
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
|
||||
render(&textarea, 5, 4);
|
||||
|
||||
// Cursor starts at (0, 0) — top of visual line 0
|
||||
assert_eq!(textarea.cursor(), (0, 0));
|
||||
|
||||
// Down should move to the second visual line within the same logical line
|
||||
textarea.move_cursor(CursorMove::Down);
|
||||
assert_eq!(textarea.cursor(), (0, 5));
|
||||
|
||||
// Down again should not move (already at last visual row)
|
||||
textarea.move_cursor(CursorMove::Down);
|
||||
assert_eq!(textarea.cursor(), (0, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_cursor_up_moves_within_same_logical_line() {
|
||||
// "abcdefghij" at width=5 wraps into 2 visual lines:
|
||||
// visual 0: cols 0..4 (end_col=5 exclusive for non-last)
|
||||
// visual 1: cols 5..10
|
||||
let mut textarea = TextArea::from(["abcdefghij"]);
|
||||
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
|
||||
render(&textarea, 5, 4);
|
||||
|
||||
// Move to end of text (row 0, col 10) which is on visual line 1
|
||||
textarea.move_cursor(CursorMove::End);
|
||||
assert_eq!(textarea.cursor(), (0, 10));
|
||||
|
||||
// Up should move to visual line 0, col clamped to max of visual line 0 (col 4)
|
||||
textarea.move_cursor(CursorMove::Up);
|
||||
assert_eq!(textarea.cursor(), (0, 4));
|
||||
|
||||
// Up again should not move (already at first visual row)
|
||||
textarea.move_cursor(CursorMove::Up);
|
||||
assert_eq!(textarea.cursor(), (0, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_cursor_down_crosses_logical_line_boundary() {
|
||||
// Two logical lines, first one wraps:
|
||||
// visual 0: "abcde" (row 0, cols 0..4)
|
||||
// visual 1: "fghij" (row 0, cols 5..10)
|
||||
// visual 2: "xy" (row 1, cols 0..2)
|
||||
let mut textarea = TextArea::from(["abcdefghij", "xy"]);
|
||||
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
|
||||
render(&textarea, 5, 4);
|
||||
|
||||
assert_eq!(textarea.cursor(), (0, 0));
|
||||
|
||||
// Down: visual 0 → visual 1
|
||||
textarea.move_cursor(CursorMove::Down);
|
||||
assert_eq!(textarea.cursor(), (0, 5));
|
||||
|
||||
// Down: visual 1 → visual 2 (crosses into second logical line, visual offset 0 preserved)
|
||||
textarea.move_cursor(CursorMove::Down);
|
||||
assert_eq!(textarea.cursor(), (1, 0));
|
||||
|
||||
// Down: already at bottom
|
||||
textarea.move_cursor(CursorMove::Down);
|
||||
assert_eq!(textarea.cursor(), (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_cursor_up_crosses_logical_line_boundary() {
|
||||
// visual 0: "abcde" (row 0, cols 0..4)
|
||||
// visual 1: "fghij" (row 0, cols 5..10)
|
||||
// visual 2: "xy" (row 1, cols 0..2)
|
||||
let mut textarea = TextArea::from(["abcdefghij", "xy"]);
|
||||
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
|
||||
render(&textarea, 5, 4);
|
||||
|
||||
// Start at second logical line
|
||||
textarea.move_cursor(CursorMove::Jump(1, 0));
|
||||
assert_eq!(textarea.cursor(), (1, 0));
|
||||
|
||||
// Up: visual 2 → visual 1 (visual offset 0 → start of visual row 1)
|
||||
textarea.move_cursor(CursorMove::Up);
|
||||
assert_eq!(textarea.cursor(), (0, 5));
|
||||
|
||||
// Up: visual 1 → visual 0 (visual offset 0 preserved → col 0)
|
||||
textarea.move_cursor(CursorMove::Up);
|
||||
assert_eq!(textarea.cursor(), (0, 0));
|
||||
|
||||
// Up: already at top
|
||||
textarea.move_cursor(CursorMove::Up);
|
||||
assert_eq!(textarea.cursor(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_cursor_column_preserved_across_visual_lines() {
|
||||
// "abcdefghij" at width=5:
|
||||
// visual 0: "abcde" (cols 0..4)
|
||||
// visual 1: "fghij" (cols 5..10)
|
||||
let mut textarea = TextArea::from(["abcdefghij"]);
|
||||
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
|
||||
render(&textarea, 5, 4);
|
||||
|
||||
// Start at col 2 on visual line 0
|
||||
textarea.move_cursor(CursorMove::Jump(0, 2));
|
||||
assert_eq!(textarea.cursor(), (0, 2));
|
||||
|
||||
// Down: visual offset 2 preserved → target col 5+2 = 7
|
||||
textarea.move_cursor(CursorMove::Down);
|
||||
assert_eq!(textarea.cursor(), (0, 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_cursor_column_clamped_to_shorter_visual_line() {
|
||||
// "abcdefgh" + "xy" at width=5:
|
||||
// visual 0: "abcde" (row 0, cols 0..4)
|
||||
// visual 1: "fgh" (row 0, cols 5..8)
|
||||
// visual 2: "xy" (row 1, cols 0..2)
|
||||
let mut textarea = TextArea::from(["abcdefgh", "xy"]);
|
||||
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
|
||||
render(&textarea, 5, 4);
|
||||
|
||||
// Start at col 4 on visual line 0
|
||||
textarea.move_cursor(CursorMove::Jump(0, 4));
|
||||
assert_eq!(textarea.cursor(), (0, 4));
|
||||
|
||||
// Down: visual offset 4 → target col 5+4 = 9, clamped to line len 8
|
||||
textarea.move_cursor(CursorMove::Down);
|
||||
assert_eq!(textarea.cursor(), (0, 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_cursor_down_up_with_word_wrap_mode() {
|
||||
// "hello world" at width=6 with Word wrap:
|
||||
// visual 0: "hello " (row 0, cols 0..5 for non-last)
|
||||
// visual 1: "world" (row 0, cols 6..11)
|
||||
let mut textarea = TextArea::from(["hello world"]);
|
||||
textarea.set_wrap_mode(WrapMode::Word);
|
||||
render(&textarea, 6, 4);
|
||||
|
||||
assert_eq!(textarea.cursor(), (0, 0));
|
||||
|
||||
textarea.move_cursor(CursorMove::Down);
|
||||
assert_eq!(textarea.cursor(), (0, 6));
|
||||
|
||||
// Up: visual offset 0 preserved → col 0
|
||||
textarea.move_cursor(CursorMove::Up);
|
||||
assert_eq!(textarea.cursor(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_cursor_no_wrap_short_lines_behave_normally() {
|
||||
// Lines fit within width — no wrapping happens, normal Up/Down
|
||||
let mut textarea = TextArea::from(["abc", "def", "ghi"]);
|
||||
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
|
||||
render(&textarea, 10, 4);
|
||||
|
||||
assert_eq!(textarea.cursor(), (0, 0));
|
||||
textarea.move_cursor(CursorMove::Down);
|
||||
assert_eq!(textarea.cursor(), (1, 0));
|
||||
textarea.move_cursor(CursorMove::Down);
|
||||
assert_eq!(textarea.cursor(), (2, 0));
|
||||
textarea.move_cursor(CursorMove::Up);
|
||||
assert_eq!(textarea.cursor(), (1, 0));
|
||||
textarea.move_cursor(CursorMove::Up);
|
||||
assert_eq!(textarea.cursor(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_cursor_three_visual_lines_from_one_logical() {
|
||||
// "abcdefghijklmno" at width=5:
|
||||
// visual 0: "abcde" (cols 0..4)
|
||||
// visual 1: "fghij" (cols 5..9)
|
||||
// visual 2: "klmno" (cols 10..15)
|
||||
let mut textarea = TextArea::from(["abcdefghijklmno"]);
|
||||
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
|
||||
render(&textarea, 5, 4);
|
||||
|
||||
assert_eq!(textarea.cursor(), (0, 0));
|
||||
|
||||
textarea.move_cursor(CursorMove::Down);
|
||||
assert_eq!(textarea.cursor(), (0, 5));
|
||||
|
||||
textarea.move_cursor(CursorMove::Down);
|
||||
assert_eq!(textarea.cursor(), (0, 10));
|
||||
|
||||
textarea.move_cursor(CursorMove::Down);
|
||||
assert_eq!(textarea.cursor(), (0, 10)); // already at bottom
|
||||
|
||||
// Up: visual offset 0 preserved → col 5
|
||||
textarea.move_cursor(CursorMove::Up);
|
||||
assert_eq!(textarea.cursor(), (0, 5));
|
||||
|
||||
// Up: visual offset 0 preserved → col 0
|
||||
textarea.move_cursor(CursorMove::Up);
|
||||
assert_eq!(textarea.cursor(), (0, 0));
|
||||
}
|
||||
|
||||
Ссылка в новой задаче
Block a user