From 57aa0ed8e343c51b76f9e3e5eff302e9c6a08365 Mon Sep 17 00:00:00 2001 From: Priec Date: Mon, 18 Aug 2025 16:45:49 +0200 Subject: [PATCH] trying to fix end line bugs --- canvas/aider.md | 16 ++ canvas/examples/textarea_normal.rs | 9 + canvas/src/canvas/gui.rs | 165 ++++++++++++++--- canvas/src/textarea/state.rs | 283 +++++++++++++++++++++-------- canvas/src/textarea/widget.rs | 252 ++++++++++++++++++------- 5 files changed, 565 insertions(+), 160 deletions(-) create mode 100644 canvas/aider.md diff --git a/canvas/aider.md b/canvas/aider.md new file mode 100644 index 0000000..952a2e0 --- /dev/null +++ b/canvas/aider.md @@ -0,0 +1,16 @@ +# Aider Instructions + +## General Rules +- Only modify files that I explicitly add with `/add`. +- If a prompt mentions multiple files, **ignore all files except the ones I have added**. +- Do not create, edit, or delete any files unless they are explicitly added. +- Keep all other files exactly as they are, even if the prompt suggests changes. +- Never move logic into or out of files that are not explicitly added. +- If a prompt suggests changes to multiple files, apply **only the subset of changes** that belong to the added file(s). +- If a change requires touching other files, ignore them, if they were not manually added. + +## Coding Style +- Follow Rust 2021 edition idioms. +- No logic in `mod.rs` files (only exports/routing). +- Always update or create tests **only if the test file is explicitly added**. +- Do not think, only apply changes from the prompt diff --git a/canvas/examples/textarea_normal.rs b/canvas/examples/textarea_normal.rs index a7ee4e9..be48a7c 100644 --- a/canvas/examples/textarea_normal.rs +++ b/canvas/examples/textarea_normal.rs @@ -254,6 +254,15 @@ fn handle_key_press( editor.set_debug_message("Overflow: wrap ON".to_string()); } + (KeyCode::F(3), _) => { + editor.textarea.set_wrap_indent_cols(3); + editor.set_debug_message("Wrap indent: 3 columns".to_string()); + } + (KeyCode::F(4), _) => { + editor.textarea.set_wrap_indent_cols(0); + editor.set_debug_message("Wrap indent: 0 columns".to_string()); + } + // Debug/info (KeyCode::Char('?'), _) => { editor.set_debug_message(format!( diff --git a/canvas/src/canvas/gui.rs b/canvas/src/canvas/gui.rs index b071937..53fc499 100644 --- a/canvas/src/canvas/gui.rs +++ b/canvas/src/canvas/gui.rs @@ -73,6 +73,108 @@ fn clip_with_indicator_line<'a>(s: &'a str, width: u16, indicator: char) -> Line 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 cols: u16 = 0; + let mut out = String::new(); + let mut taken: u16 = 0; + let mut started = false; + + for ch in s.chars() { + let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16; + let next = cols.saturating_add(w); + + if !started { + if next <= start_cols { + cols = next; + continue; + } else { + started = true; + } + } + + if taken.saturating_add(w) > max_cols { + break; + } + out.push(ch); + taken = taken.saturating_add(w); + cols = next; + } + + out +} + +#[cfg(feature = "gui")] +const RIGHT_PAD: u16 = 3; + +#[cfg(feature = "gui")] +fn compute_h_scroll_with_padding(cursor_cols: u16, width: u16) -> (u16, u16) { + // Returns (h_scroll, left_cols). left_cols = 1 if a left indicator is shown. + // We pre-emptively keep the caret out of the last RIGHT_PAD columns. + let mut h = 0u16; + // Two passes are enough to converge (second pass accounts for left indicator). + for _ in 0..2 { + let left_cols = if h > 0 { 1 } else { 0 }; + let max_x_visible = width.saturating_sub(1 + RIGHT_PAD + left_cols); + let needed = cursor_cols.saturating_sub(max_x_visible); + if needed <= h { + return (h, left_cols); + } + h = needed; + } + let left_cols = if h > 0 { 1 } else { 0 }; + (h, left_cols) +} + +#[cfg(feature = "gui")] +fn active_indicator_viewport( + s: &str, + width: u16, + indicator: char, + cursor_chars: usize, + _right_padding: u16, // kept for signature symmetry; we use RIGHT_PAD constant +) -> (Line<'static>, u16, u16) { + if width == 0 { + return (Line::from(""), 0, 0); + } + + // Total display width of the string and cursor display column + let total_cols = display_width(s); + let mut cursor_cols: u16 = 0; + for (i, ch) in s.chars().enumerate() { + if i >= cursor_chars { + break; + } + cursor_cols = cursor_cols + .saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16); + } + + // Pre-emptive scroll: never let caret enter the last RIGHT_PAD columns + let (h_scroll, left_cols) = compute_h_scroll_with_padding(cursor_cols, width); + + // Right indicator if more content beyond the window start + let content_budget = width.saturating_sub(left_cols); + let show_right = total_cols.saturating_sub(h_scroll) > content_budget; + let right_cols: u16 = if show_right { 1 } else { 0 }; + + let visible_cols = width.saturating_sub(left_cols + right_cols); + let visible = slice_by_display_cols(s, h_scroll, visible_cols); + + let mut spans: Vec = Vec::with_capacity(3); + if left_cols == 1 { + spans.push(Span::raw(indicator.to_string())); + } + spans.push(Span::raw(visible)); + if show_right { + spans.push(Span::raw(indicator.to_string())); + } + + (Line::from(spans), h_scroll, left_cols) +} + /// Default renderer: overflow indicator '$' #[cfg(feature = "gui")] pub fn render_canvas( @@ -268,16 +370,33 @@ where let typed_text = get_display_value(i); let inner_width = input_rows[i].width; + let mut h_scroll_for_cursor: u16 = 0; + let mut left_offset_for_cursor: u16 = 0; + let line = match (opts.overflow, highlight_state) { - // No highlighting, just apply overflow mode (OverflowMode::Indicator(ind), HighlightState::Off) => { - clip_with_indicator_line(&typed_text, inner_width, ind) + if is_active { + let (l, hs, left_cols) = active_indicator_viewport( + &typed_text, + inner_width, + ind, + current_cursor_pos, + RIGHT_PAD, + ); + h_scroll_for_cursor = hs; + left_offset_for_cursor = left_cols; + l + } else { + if display_width(&typed_text) <= inner_width { + Line::from(Span::raw(typed_text.clone())) + } else { + clip_with_indicator_line(&typed_text, inner_width, ind) + } + } } - // Highlighting is active - need to handle both highlighting and overflow + // Existing highlighting paths (unchanged) (OverflowMode::Indicator(_ind), HighlightState::Characterwise { .. }) => { - // For now, prioritize highlighting over clipping to avoid mangling spans - // TODO: Could implement post-processing to clip highlighted spans if needed apply_highlighting( &typed_text, i, @@ -288,10 +407,7 @@ where is_active, ) } - (OverflowMode::Indicator(_ind), HighlightState::Linewise { .. }) => { - // For now, prioritize highlighting over clipping to avoid mangling spans - // TODO: Could implement post-processing to clip highlighted spans if needed apply_highlighting( &typed_text, i, @@ -303,13 +419,9 @@ where ) } - // Wrap mode - just show text and let paragraph handle wrapping - (OverflowMode::Wrap, HighlightState::Off) => { - Line::from(Span::raw(typed_text.clone())) - } - + // Wrap mode unchanged (Paragraph::wrap will handle it) + (OverflowMode::Wrap, HighlightState::Off) => Line::from(Span::raw(typed_text.clone())), (OverflowMode::Wrap, _) => { - // Apply highlighting and let wrapping handle overflow apply_highlighting( &typed_text, i, @@ -332,12 +444,14 @@ where if is_active { active_field_input_rect = Some(input_rows[i]); - set_cursor_position( + set_cursor_position_scrolled( f, input_rows[i], &typed_text, current_cursor_pos, has_display_override(i), + h_scroll_for_cursor, + left_offset_for_cursor, ); } } @@ -544,12 +658,14 @@ fn apply_linewise_highlighting<'a, T: CanvasTheme>( /// Set cursor position (x clamp only; no Y offset with wrap in this version) #[cfg(feature = "gui")] -fn set_cursor_position( +fn set_cursor_position_scrolled( f: &mut Frame, field_rect: Rect, text: &str, current_cursor_pos: usize, _has_display_override: bool, + h_scroll: u16, + left_offset: u16, ) { let mut cols: u16 = 0; for (i, ch) in text.chars().enumerate() { @@ -559,13 +675,18 @@ fn set_cursor_position( cols = cols.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16); } - let cursor_x = field_rect.x.saturating_add(cols); + // Visible x = (cursor columns - scroll) + left indicator column (if any) + let mut visible_x = cols.saturating_sub(h_scroll).saturating_add(left_offset); + + // Hard clamp: keep RIGHT_PAD columns free at the right border + let limit = field_rect.width.saturating_sub(1 + RIGHT_PAD); + if visible_x > limit { + visible_x = limit; + } + + let cursor_x = field_rect.x.saturating_add(visible_x); let cursor_y = field_rect.y; - - let max_cursor_x = field_rect.x + field_rect.width.saturating_sub(1); - let safe_cursor_x = cursor_x.min(max_cursor_x); - - f.set_cursor_position((safe_cursor_x, cursor_y)); + f.set_cursor_position((cursor_x, cursor_y)); } /// Default theme diff --git a/canvas/src/textarea/state.rs b/canvas/src/textarea/state.rs index 3dcd178..0f3b054 100644 --- a/canvas/src/textarea/state.rs +++ b/canvas/src/textarea/state.rs @@ -15,7 +15,7 @@ use ratatui::{layout::Rect, widgets::Block}; use unicode_width::UnicodeWidthChar; #[cfg(feature = "gui")] -fn wrapped_rows(s: &str, width: u16) -> u16 { +pub(crate) fn wrapped_rows(s: &str, width: u16) -> u16 { if width == 0 { return 1; } @@ -33,7 +33,7 @@ fn wrapped_rows(s: &str, width: u16) -> u16 { } #[cfg(feature = "gui")] -fn wrapped_rows_to_cursor(s: &str, width: u16, cursor_chars: usize) -> (u16, u16) { +pub(crate) fn wrapped_rows_to_cursor(s: &str, width: u16, cursor_chars: usize) -> (u16, u16) { if width == 0 { return (0, 0); } @@ -53,6 +53,105 @@ fn wrapped_rows_to_cursor(s: &str, width: u16, cursor_chars: usize) -> (u16, u16 (row, cols) } +#[cfg(feature = "gui")] +pub(crate) const RIGHT_PAD: u16 = 3; + +#[cfg(feature = "gui")] +pub(crate) fn compute_h_scroll_with_padding( + cursor_cols: u16, + width: u16, +) -> (u16, u16) { + // Returns (h_scroll, left_cols). left_cols = 1 if a left indicator is shown. + let mut h = 0u16; + for _ in 0..2 { + let left_cols = if h > 0 { 1 } else { 0 }; + let max_x_visible = width.saturating_sub(1 + RIGHT_PAD + left_cols); + let needed = cursor_cols.saturating_sub(max_x_visible); + if needed <= h { + return (h, left_cols); + } + h = needed; + } + let left_cols = if h > 0 { 1 } else { 0 }; + (h, left_cols) +} + +#[cfg(feature = "gui")] +fn normalize_indent(width: u16, indent: u16) -> u16 { + // Ensure continuation capacity stays >= 1 + indent.min(width.saturating_sub(1)) +} + +// Count visual rows for a single logical line using early-wrap and continuation indent +#[cfg(feature = "gui")] +pub(crate) fn count_wrapped_rows_indented( + s: &str, + width: u16, + indent: u16, +) -> u16 { + if width == 0 { + return 1; + } + let indent = normalize_indent(width, indent); + let cont_cap = width.saturating_sub(indent); + + let mut rows: u16 = 1; + let mut used: u16 = 0; + let mut first = true; + + for ch in s.chars() { + let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16; + let cap = if first { width } else { cont_cap }; + + // Early-wrap: avoid the "one char freeze" at the boundary + if used > 0 && used.saturating_add(w) >= cap { + rows = rows.saturating_add(1); + first = false; + used = indent; // continuation indent occupies leading cells + } + used = used.saturating_add(w); + } + + rows +} + +// Compute caret (subrow, x) for a given cursor index with indent + early-wrap +#[cfg(feature = "gui")] +fn wrapped_rows_to_cursor_indented( + s: &str, + width: u16, + indent: u16, + cursor_chars: usize, +) -> (u16, u16) { + if width == 0 { + return (0, 0); + } + let indent = normalize_indent(width, indent); + let cont_cap = width.saturating_sub(indent); + + let mut row: u16 = 0; + let mut used: u16 = 0; + let mut first = true; + + for (i, ch) in s.chars().enumerate() { + if i >= cursor_chars { + break; + } + let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16; + let cap = if first { width } else { cont_cap }; + + if used > 0 && used.saturating_add(w) >= cap { + row = row.saturating_add(1); + first = false; + used = indent; // place indent on continuation line + } + used = used.saturating_add(w); + } + + // 'used' already includes indent when on continuation rows + (row, used.min(width.saturating_sub(1))) +} + pub type TextAreaEditor = FormEditor; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -67,6 +166,9 @@ pub struct TextAreaState { pub(crate) placeholder: Option, pub(crate) overflow_mode: TextOverflowMode, pub(crate) h_scroll: u16, + // NEW: visual indentation for wrapped continuation rows (Vim-like) + #[cfg(feature = "gui")] + pub(crate) wrap_indent_cols: u16, } impl Default for TextAreaState { @@ -77,6 +179,8 @@ impl Default for TextAreaState { placeholder: None, overflow_mode: TextOverflowMode::Indicator { ch: '$' }, h_scroll: 0, + #[cfg(feature = "gui")] + wrap_indent_cols: 0, // default: no continuation indent } } } @@ -105,6 +209,8 @@ impl TextAreaState { placeholder: None, overflow_mode: TextOverflowMode::Indicator { ch: '$' }, h_scroll: 0, + #[cfg(feature = "gui")] + wrap_indent_cols: 0, } } @@ -133,6 +239,14 @@ impl TextAreaState { self.overflow_mode = TextOverflowMode::Wrap; } + // Optional: set continuation indent for wrap mode (e.g. 3 like Vim) + pub fn set_wrap_indent_cols(&mut self, cols: u16) { + #[cfg(feature = "gui")] + { + self.wrap_indent_cols = cols; + } + } + // Textarea-specific primitive: split at cursor pub fn insert_newline(&mut self) { let line_idx = self.current_field(); @@ -245,7 +359,27 @@ impl TextAreaState { } } + // ----------------------------------------------------------------- // Cursor helpers for GUI + // ----------------------------------------------------------------- + + #[cfg(feature = "gui")] + fn visual_rows_before_line_indented( + &self, + width: u16, + line_idx: usize, + ) -> u16 { + let provider = self.editor.data_provider(); + let mut acc: u16 = 0; + let indent = self.wrap_indent_cols; + + for i in 0..line_idx { + let s = provider.field_value(i); + acc = acc.saturating_add(count_wrapped_rows_indented(s, width, indent)); + } + acc + } + #[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 }; @@ -254,33 +388,38 @@ impl TextAreaState { 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 y_top = inner.y; + let indent = self.wrap_indent_cols; + + if width == 0 { + let prefix = self.visual_rows_before_line_indented(1, line_idx); + let y = y_top.saturating_add(prefix.saturating_sub(self.scroll_y)); + return (inner.x, y); } + let prefix_rows = + self.visual_rows_before_line_indented(width, line_idx); 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); + let (subrow, x_cols) = wrapped_rows_to_cursor_indented( + ¤t_line, + width, + indent, + col_chars, + ); + + let caret_vis_row = prefix_rows.saturating_add(subrow); + let y = y_top.saturating_add(caret_vis_row.saturating_sub(self.scroll_y)); + let x = inner.x.saturating_add(x_cols); (x, y) } TextOverflowMode::Indicator { .. } => { - // existing indicator path (with h_scroll) - let y = inner.y - + (line_idx as u16) - .saturating_sub(self.scroll_y); + 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(); + // Display columns up to caret let mut x_cols: u16 = 0; for (i, ch) in current_line.chars().enumerate() { if i >= col { @@ -289,7 +428,18 @@ impl TextAreaState { 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 left_cols = if self.h_scroll > 0 { 1 } else { 0 }; + + let mut x_off_visible = x_cols + .saturating_sub(self.h_scroll) + .saturating_add(left_cols); + + let limit = inner.width.saturating_sub(1 + RIGHT_PAD); + if x_off_visible > limit { + x_off_visible = limit; + } + let x = inner.x.saturating_add(x_off_visible); (x, y) } @@ -303,34 +453,22 @@ impl TextAreaState { return; } - // 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 { .. } => { + // Logical-line vertical scroll + 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); + } + 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() { @@ -341,59 +479,50 @@ impl TextAreaState { .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); + let (target_h, _left_cols) = + compute_h_scroll_with_padding(cursor_cols, width); - if cursor_cols > self.h_scroll.saturating_add(visible_limit) { - self.h_scroll = cursor_cols.saturating_sub(visible_limit); + if target_h > self.h_scroll { + self.h_scroll = target_h; } 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 { + self.h_scroll = 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, - )); - } + let indent = self.wrap_indent_cols; + let line_idx = self.current_field() as usize; - // Cursor's row within current line - let (row_in_line, _) = wrapped_rows_to_cursor( - &self.current_text(), - width, - self.display_cursor_position(), - ); + let prefix_rows = + self.visual_rows_before_line_indented(width, line_idx); - // 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; + let current_line = self.current_text(); + let col = self.display_cursor_position(); + + let (subrow, _x_cols) = + wrapped_rows_to_cursor_indented(¤t_line, width, indent, col); + + let caret_vis_row = prefix_rows.saturating_add(subrow); + + let top = self.scroll_y; + let height = inner.height; + + if caret_vis_row < top { + self.scroll_y = caret_vis_row; + } else { + let bottom = top.saturating_add(height.saturating_sub(1)); + if caret_vis_row > bottom { + let shift = caret_vis_row.saturating_sub(bottom); + self.scroll_y = top.saturating_add(shift); } } + + self.h_scroll = 0; // no horizontal scroll in wrap mode } } } diff --git a/canvas/src/textarea/widget.rs b/canvas/src/textarea/widget.rs index ca4a1f3..8c4e344 100644 --- a/canvas/src/textarea/widget.rs +++ b/canvas/src/textarea/widget.rs @@ -14,7 +14,12 @@ use ratatui::{ use crate::data_provider::DataProvider; #[cfg(feature = "gui")] -use crate::textarea::state::{TextAreaState, TextOverflowMode}; +use crate::textarea::state::{ + compute_h_scroll_with_padding, + count_wrapped_rows_indented, + TextAreaState, + TextOverflowMode, +}; #[cfg(feature = "gui")] use unicode_width::UnicodeWidthChar; @@ -70,6 +75,18 @@ fn display_width(s: &str) -> u16 { .sum() } +#[cfg(feature = "gui")] +fn display_cols_up_to(s: &str, char_count: usize) -> u16 { + let mut cols: u16 = 0; + for (i, ch) in s.chars().enumerate() { + if i >= char_count { + break; + } + cols = cols.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16); + } + cols +} + #[cfg(feature = "gui")] fn clip_with_indicator(s: &str, width: u16, indicator: char) -> Line<'static> { if width == 0 { @@ -95,86 +112,179 @@ fn clip_with_indicator(s: &str, width: u16, indicator: char) -> Line<'static> { Line::from(vec![Span::raw(out), Span::raw(indicator.to_string())]) } +// anchor: near other helpers #[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 taken: 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 + let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16; + if !started { - if current_cols + char_width <= start_cols { - current_cols += char_width; + if current_cols.saturating_add(w) <= start_cols { + current_cols = current_cols.saturating_add(w); continue; } else { started = true; } } - - // Stop if adding this character would exceed our budget - if output_cols + char_width > max_cols { + + if taken.saturating_add(w) > max_cols { break; } - + output.push(ch); - output_cols += char_width; - current_cols += char_width; + taken = taken.saturating_add(w); + current_cols = current_cols.saturating_add(w); } output } #[cfg(feature = "gui")] -fn clip_window_with_indicator( +fn clip_window_with_indicator_padded( text: &str, - width: u16, + view_width: u16, indicator: char, start_cols: u16, ) -> Line<'static> { - if width == 0 { + if view_width == 0 { return Line::from(""); } - let total_width = display_width(text); + let total = 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 if we scrolled + let show_left = start_cols > 0; + let left_cols: u16 = if show_left { 1 } else { 0 }; - // 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 }; + // Capacity for text if we also need a right indicator + let cap_with_right = view_width.saturating_sub(left_cols + 1); - // 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 }; + // Do we still have content beyond this window? + let remaining = total.saturating_sub(start_cols); + let show_right = remaining > cap_with_right; - // 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); + // Final capacity for visible text + let max_visible = if show_right { + cap_with_right + } else { + view_width.saturating_sub(left_cols) + }; - let mut spans = Vec::new(); - if show_left_indicator { + let visible = slice_by_display_cols(text, start_cols, max_visible); + + let mut spans: Vec = Vec::new(); + if show_left { spans.push(Span::raw(indicator.to_string())); } - spans.push(Span::raw(visible_content)); - if show_right_indicator { + + // Visible text + spans.push(Span::raw(visible.clone())); + + // Place $ flush-right + if show_right { + let used_cols = left_cols + display_width(&visible); + let right_pos = view_width.saturating_sub(1); + let filler = right_pos.saturating_sub(used_cols); + if filler > 0 { + spans.push(Span::raw(" ".repeat(filler as usize))); + } spans.push(Span::raw(indicator.to_string())); } + Line::from(spans) } +#[cfg(feature = "gui")] +fn wrap_segments_with_indent( + s: &str, + width: u16, + indent: u16, +) -> Vec { + let mut segments: Vec = Vec::new(); + if width == 0 { + segments.push(String::new()); + return segments; + } + + let indent = indent.min(width.saturating_sub(1)); + let cont_cap = width.saturating_sub(indent); + let indent_str = " ".repeat(indent as usize); + + let mut buf = String::new(); + let mut used: u16 = 0; + let mut first = true; + + for ch in s.chars() { + let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16; + let cap = if first { width } else { cont_cap }; + + // Early-wrap: wrap before filling the last cell (and avoid empty segment) + if used > 0 && used.saturating_add(w) >= cap { + segments.push(buf); + buf = String::new(); + used = 0; + first = false; + if indent > 0 { + buf.push_str(&indent_str); + used = indent; + } + } + + buf.push(ch); + used = used.saturating_add(w); + } + + segments.push(buf); + segments +} + +// Map visual row offset to (logical line, intra segment) +#[cfg(feature = "gui")] +fn resolve_start_line_and_intra_indented( + state: &TextAreaState, + inner: Rect, +) -> (usize, u16) { + let provider = state.editor.data_provider(); + let total = provider.line_count(); + + if total == 0 { + return (0, 0); + } + + let wrap = matches!(state.overflow_mode, TextOverflowMode::Wrap); + let width = inner.width; + let target_vis = state.scroll_y; + + if !wrap { + let start = (target_vis as usize).min(total); + return (start, 0); + } + + let indent = state.wrap_indent_cols; + + let mut acc: u16 = 0; + for i in 0..total { + let s = provider.field_value(i); + let rows = count_wrapped_rows_indented(s, width, indent); + if acc.saturating_add(rows) > target_vis { + let intra = target_vis.saturating_sub(acc); + return (i, intra); + } + acc = acc.saturating_add(rows); + } + + (total.saturating_sub(1), 0) +} + #[cfg(feature = "gui")] impl<'a> StatefulWidget for TextArea<'a> { type State = TextAreaState; @@ -189,52 +299,72 @@ impl<'a> StatefulWidget for TextArea<'a> { area }; - let total = state.editor.data_provider().line_count(); - let start = state.scroll_y as usize; - let end = start - .saturating_add(inner.height as usize) - .min(total); + let wrap_mode = matches!(state.overflow_mode, TextOverflowMode::Wrap); + let provider = state.editor.data_provider(); + let total = provider.line_count(); - let mut display_lines: Vec = Vec::with_capacity(end - start); + let (start, intra) = resolve_start_line_and_intra_indented(state, inner); - if start >= end { + let mut display_lines: Vec = Vec::new(); + + if total == 0 || start >= total { if let Some(ph) = &state.placeholder { display_lines.push(Line::from(Span::raw(ph.clone()))); } - } else { - for i in start..end { - let s = state.editor.data_provider().field_value(i); - match state.overflow_mode { - TextOverflowMode::Wrap => { - display_lines.push(Line::from(Span::raw(s.to_string()))); + } else if wrap_mode { + // manual pre-wrap path (unchanged) + let mut rows_left = inner.height; + let indent = state.wrap_indent_cols; + let mut i = start; + while i < total && rows_left > 0 { + let s = provider.field_value(i); + let segments = wrap_segments_with_indent(s, inner.width, indent); + let skip = if i == start { intra as usize } else { 0 }; + for seg in segments.into_iter().skip(skip) { + display_lines.push(Line::from(Span::raw(seg))); + rows_left = rows_left.saturating_sub(1); + if rows_left == 0 { + break; } + } + i += 1; + } + } else { + // Indicator mode: full inner width; RIGHT_PAD only affects cursor clamp and h-scroll + let end = (start.saturating_add(inner.height as usize)).min(total); + + for i in start..end { + let s = provider.field_value(i); + match state.overflow_mode { + TextOverflowMode::Wrap => unreachable!(), TextOverflowMode::Indicator { 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 + // Same-frame h-scroll so text shifts immediately + let start_cols = if i == state.current_field() { + let col_idx = state.display_cursor_position(); + let cursor_cols = display_cols_up_to(s, col_idx); + let (target_h, _left_cols) = + compute_h_scroll_with_padding(cursor_cols, inner.width); + target_h.max(state.h_scroll) } else { 0 }; - display_lines.push(clip_window_with_indicator( + display_lines.push(clip_window_with_indicator_padded( s, - inner.width, + inner.width, // full view width ch, - h_scroll_offset, + start_cols, )); } } } } - let mut p = Paragraph::new(display_lines) + let p = Paragraph::new(display_lines) .alignment(Alignment::Left) .style(self.style); - if matches!(state.overflow_mode, TextOverflowMode::Wrap) { - p = p.wrap(Wrap { trim: false }); - } - + // No Paragraph::wrap/scroll in wrap mode — we pre-wrap. p.render(inner, buf); } }