diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a9ba29..e654aa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ + +# [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] + # [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 diff --git a/Cargo.toml b/Cargo.toml index e0a12b1..6cd9e68 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.10.0" +version = "0.10.1" edition = "2024" rust-version = "1.85.0" # for Rust 2024 edition support authors = ["Simon Peter Rothgang ", "rhysd "] diff --git a/src/cursor.rs b/src/cursor.rs index 5333b64..ac9afa3 100644 --- a/src/cursor.rs +++ b/src/cursor.rs @@ -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())), diff --git a/src/textarea.rs b/src/textarea.rs index 2e44d82..cba8d6a 100644 --- a/src/textarea.rs +++ b/src/textarea.rs @@ -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 { diff --git a/src/wrap.rs b/src/wrap.rs index b6bae85..0e746e4 100644 --- a/src/wrap.rs +++ b/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( diff --git a/tests/wrap.rs b/tests/wrap.rs index f31a2cf..f3312d9 100644 --- a/tests/wrap.rs +++ b/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)); +}