From 5efee3f0440703d08c0f42c9fde39678ab3de81f Mon Sep 17 00:00:00 2001 From: Priec Date: Mon, 18 Aug 2025 09:44:53 +0200 Subject: [PATCH] line wrapping is now working properly well --- canvas/examples/textarea_normal.rs | 11 ++ canvas/examples/textarea_vim.rs | 1 + canvas/src/textarea/state.rs | 200 +++++++++++++++++++++++++---- canvas/src/textarea/widget.rs | 94 +++++++++++++- 4 files changed, 283 insertions(+), 23 deletions(-) diff --git a/canvas/examples/textarea_normal.rs b/canvas/examples/textarea_normal.rs index 91e4d18..a7ee4e9 100644 --- a/canvas/examples/textarea_normal.rs +++ b/canvas/examples/textarea_normal.rs @@ -243,6 +243,17 @@ fn handle_key_press( (KeyCode::Delete, _) => editor.delete_char_forward(), (KeyCode::Backspace, _) => editor.delete_char_backward(), + (KeyCode::F(1), _) => { + // Switch to indicator mode + editor.textarea.use_overflow_indicator('$'); + editor.set_debug_message("Overflow: indicator '$' (wrap OFF)".to_string()); + } + (KeyCode::F(2), _) => { + // Switch to wrap mode + editor.textarea.use_wrap(); + editor.set_debug_message("Overflow: wrap ON".to_string()); + } + // Debug/info (KeyCode::Char('?'), _) => { editor.set_debug_message(format!( diff --git a/canvas/examples/textarea_vim.rs b/canvas/examples/textarea_vim.rs index 16ecbdf..8478a98 100644 --- a/canvas/examples/textarea_vim.rs +++ b/canvas/examples/textarea_vim.rs @@ -77,6 +77,7 @@ Press ? for help, F1/F2 for manual cursor control demo."; let mut textarea = TextAreaState::from_text(initial_text); textarea.set_placeholder("Start typing..."); + textarea.use_wrap(); Self { textarea, diff --git a/canvas/src/textarea/state.rs b/canvas/src/textarea/state.rs index f601539..3dcd178 100644 --- a/canvas/src/textarea/state.rs +++ b/canvas/src/textarea/state.rs @@ -6,6 +6,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crate::editor::FormEditor; use crate::textarea::provider::TextAreaProvider; +use crate::data_provider::DataProvider; #[cfg(feature = "gui")] use ratatui::{layout::Rect, widgets::Block}; @@ -13,6 +14,45 @@ use ratatui::{layout::Rect, widgets::Block}; #[cfg(feature = "gui")] use unicode_width::UnicodeWidthChar; +#[cfg(feature = "gui")] +fn wrapped_rows(s: &str, width: u16) -> u16 { + if width == 0 { + return 1; + } + let mut rows: u16 = 1; + let mut cols: u16 = 0; + for ch in s.chars() { + let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16; + if cols.saturating_add(w) > width { + rows = rows.saturating_add(1); + cols = 0; + } + cols = cols.saturating_add(w); + } + rows +} + +#[cfg(feature = "gui")] +fn wrapped_rows_to_cursor(s: &str, width: u16, cursor_chars: usize) -> (u16, u16) { + if width == 0 { + return (0, 0); + } + let mut row: u16 = 0; + let mut cols: u16 = 0; + for (i, ch) in s.chars().enumerate() { + if i >= cursor_chars { + break; + } + let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16; + if cols.saturating_add(w) > width { + row = row.saturating_add(1); + cols = 0; + } + cols = cols.saturating_add(w); + } + (row, cols) +} + pub type TextAreaEditor = FormEditor; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -26,6 +66,7 @@ pub struct TextAreaState { pub(crate) scroll_y: u16, pub(crate) placeholder: Option, pub(crate) overflow_mode: TextOverflowMode, + pub(crate) h_scroll: u16, } impl Default for TextAreaState { @@ -35,6 +76,7 @@ impl Default for TextAreaState { scroll_y: 0, placeholder: None, overflow_mode: TextOverflowMode::Indicator { ch: '$' }, + h_scroll: 0, } } } @@ -62,6 +104,7 @@ impl TextAreaState { scroll_y: 0, placeholder: None, overflow_mode: TextOverflowMode::Indicator { ch: '$' }, + h_scroll: 0, } } @@ -206,39 +249,152 @@ impl TextAreaState { #[cfg(feature = "gui")] pub fn cursor(&self, area: Rect, block: Option<&Block<'_>>) -> (u16, u16) { let inner = if let Some(b) = block { b.inner(area) } else { area }; - let line_idx = self.current_field() as u16; - let y = inner.y + line_idx.saturating_sub(self.scroll_y); + let line_idx = self.current_field() as usize; - let current_line = self.current_text(); - let col = self.display_cursor_position(); + match self.overflow_mode { + TextOverflowMode::Wrap => { + let width = inner.width; + // Visual rows above the current line (from the first visible line) + let mut rows_above: u16 = 0; + for i in (self.scroll_y as usize)..line_idx { + rows_above = rows_above + .saturating_add(wrapped_rows( + self.editor.data_provider().field_value(i), + width, + )); + } - let mut x_off: u16 = 0; - for (i, ch) in current_line.chars().enumerate() { - if i >= col { - break; + let current_line = self.current_text(); + let col_chars = self.display_cursor_position(); + let (row_in_line, col_in_row) = + wrapped_rows_to_cursor(¤t_line, width, col_chars); + + let y = inner.y.saturating_add(rows_above).saturating_add(row_in_line); + let x = inner.x.saturating_add(col_in_row); + (x, y) + } + TextOverflowMode::Indicator { .. } => { + // existing indicator path (with h_scroll) + let y = inner.y + + (line_idx as u16) + .saturating_sub(self.scroll_y); + let current_line = self.current_text(); + let col = self.display_cursor_position(); + + let mut x_cols: u16 = 0; + for (i, ch) in current_line.chars().enumerate() { + if i >= col { + break; + } + x_cols = x_cols + .saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16); + } + let x_off_visible = x_cols.saturating_sub(self.h_scroll); + let x = inner.x.saturating_add(x_off_visible); + (x, y) } - x_off = x_off - .saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16); } - let x = inner.x.saturating_add(x_off); - (x, y) } #[cfg(feature = "gui")] - pub(crate) fn ensure_visible( - &mut self, - area: Rect, - block: Option<&Block<'_>>, - ) { + pub(crate) fn ensure_visible(&mut self, area: Rect, block: Option<&Block<'_>>) { let inner = if let Some(b) = block { b.inner(area) } else { area }; if inner.height == 0 { return; } - let line_idx = self.current_field() as u16; - if line_idx < self.scroll_y { - self.scroll_y = line_idx; - } else if line_idx >= self.scroll_y + inner.height { - self.scroll_y = line_idx.saturating_sub(inner.height - 1); + + // Keep logical line within vertical window (coarse guard) + let line_idx_u16 = self.current_field() as u16; + if line_idx_u16 < self.scroll_y { + self.scroll_y = line_idx_u16; + } else if line_idx_u16 >= self.scroll_y + inner.height { + self.scroll_y = line_idx_u16.saturating_sub(inner.height - 1); + } + + match self.overflow_mode { + TextOverflowMode::Indicator { .. } => { + let width = inner.width; + if width == 0 { + return; + } + + // If the line fits, drop any horizontal scroll + let current_line = self.current_text(); + let mut total_cols: u16 = 0; + for ch in current_line.chars() { + total_cols = total_cols + .saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16); + } + if total_cols <= width { + self.h_scroll = 0; + return; + } + + // Follow caret with right padding reserved + let col = self.display_cursor_position(); + let mut cursor_cols: u16 = 0; + for (i, ch) in current_line.chars().enumerate() { + if i >= col { + break; + } + cursor_cols = cursor_cols + .saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16); + } + + let right_padding: u16 = 3; + // reserve 1 column for a potential right indicator + let visible_limit = width.saturating_sub(1 + right_padding); + + if cursor_cols > self.h_scroll.saturating_add(visible_limit) { + self.h_scroll = cursor_cols.saturating_sub(visible_limit); + } else if cursor_cols < self.h_scroll { + self.h_scroll = cursor_cols; + } + } + TextOverflowMode::Wrap => { + self.h_scroll = 0; // no horizontal scroll in wrap + + let width = inner.width; + if width == 0 { + return; + } + + // Ensure the cursor's wrapped row is on screen + let current_idx = self.current_field(); + // Visual rows above current line from current scroll_y + let mut rows_above: u16 = 0; + for i in (self.scroll_y as usize)..current_idx { + rows_above = rows_above + .saturating_add(wrapped_rows( + self.editor.data_provider().field_value(i), + width, + )); + } + + // Cursor's row within current line + let (row_in_line, _) = wrapped_rows_to_cursor( + &self.current_text(), + width, + self.display_cursor_position(), + ); + + // Scroll down if cursor row is below the visible window + while rows_above.saturating_add(row_in_line) >= inner.height { + if self.scroll_y < current_idx as u16 { + // subtract the rows of the line we're dropping from the top + let dropped = wrapped_rows( + self.editor + .data_provider() + .field_value(self.scroll_y as usize), + width, + ); + self.scroll_y = self.scroll_y.saturating_add(1); + rows_above = rows_above.saturating_sub(dropped); + } else { + break; + } + } + } } } } diff --git a/canvas/src/textarea/widget.rs b/canvas/src/textarea/widget.rs index 75b58f2..ca4a1f3 100644 --- a/canvas/src/textarea/widget.rs +++ b/canvas/src/textarea/widget.rs @@ -95,6 +95,86 @@ fn clip_with_indicator(s: &str, width: u16, indicator: char) -> Line<'static> { Line::from(vec![Span::raw(out), Span::raw(indicator.to_string())]) } +#[cfg(feature = "gui")] +fn slice_by_display_cols(s: &str, start_cols: u16, max_cols: u16) -> String { + if max_cols == 0 { + return String::new(); + } + + let mut current_cols: u16 = 0; + let mut output = String::new(); + let mut output_cols: u16 = 0; + let mut started = false; + + for ch in s.chars() { + let char_width = UnicodeWidthChar::width(ch).unwrap_or(0) as u16; + + // Skip characters until we reach the start position + if !started { + if current_cols + char_width <= start_cols { + current_cols += char_width; + continue; + } else { + started = true; + } + } + + // Stop if adding this character would exceed our budget + if output_cols + char_width > max_cols { + break; + } + + output.push(ch); + output_cols += char_width; + current_cols += char_width; + } + + output +} + +#[cfg(feature = "gui")] +fn clip_window_with_indicator( + text: &str, + width: u16, + indicator: char, + start_cols: u16, +) -> Line<'static> { + if width == 0 { + return Line::from(""); + } + + let total_width = display_width(text); + + // If the line fits entirely, show it as-is (no indicators, no windowing) + if total_width <= width { + return Line::from(Span::raw(text.to_string())); + } + + // Left indicator only if there is real overflow and the window is shifted + let show_left_indicator = start_cols > 0 && total_width > width; + let left_indicator_width = if show_left_indicator { 1 } else { 0 }; + + // Will we overflow to the right from this start? + let remaining_after_start = total_width.saturating_sub(start_cols); + let content_budget = width.saturating_sub(left_indicator_width); + let show_right_indicator = remaining_after_start > content_budget; + let right_indicator_width = if show_right_indicator { 1 } else { 0 }; + + // Compute visible slice budget + let actual_content_width = content_budget.saturating_sub(right_indicator_width); + let visible_content = slice_by_display_cols(text, start_cols, actual_content_width); + + let mut spans = Vec::new(); + if show_left_indicator { + spans.push(Span::raw(indicator.to_string())); + } + spans.push(Span::raw(visible_content)); + if show_right_indicator { + spans.push(Span::raw(indicator.to_string())); + } + Line::from(spans) +} + #[cfg(feature = "gui")] impl<'a> StatefulWidget for TextArea<'a> { type State = TextAreaState; @@ -129,7 +209,19 @@ impl<'a> StatefulWidget for TextArea<'a> { display_lines.push(Line::from(Span::raw(s.to_string()))); } TextOverflowMode::Indicator { ch } => { - display_lines.push(clip_with_indicator(s, inner.width, ch)); + // Use horizontal scroll for the active line, show full text for others + let h_scroll_offset = if i == state.current_field() { + state.h_scroll + } else { + 0 + }; + + display_lines.push(clip_window_with_indicator( + s, + inner.width, + ch, + h_scroll_offset, + )); } } }