diff --git a/canvas/src/canvas/gui.rs b/canvas/src/canvas/gui.rs index 0ee989d..b071937 100644 --- a/canvas/src/canvas/gui.rs +++ b/canvas/src/canvas/gui.rs @@ -6,7 +6,7 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, BorderType, Paragraph}, + widgets::{Block, Borders, BorderType, Paragraph, Wrap}, Frame, }; @@ -20,8 +20,60 @@ use unicode_width::UnicodeWidthChar; #[cfg(feature = "gui")] use std::cmp::{max, min}; -/// Render ONLY the canvas form fields - no suggestions rendering here -/// Updated to work with FormEditor instead of CanvasState trait +#[cfg(feature = "gui")] +#[derive(Debug, Clone, Copy)] +pub enum OverflowMode { + Indicator(char), // default '$' + Wrap, +} + +#[cfg(feature = "gui")] +#[derive(Debug, Clone, Copy)] +pub struct CanvasDisplayOptions { + pub overflow: OverflowMode, +} + +#[cfg(feature = "gui")] +impl Default for CanvasDisplayOptions { + fn default() -> Self { + Self { + overflow: OverflowMode::Indicator('$'), + } + } +} + +/// Utility: measure display width of a string +#[cfg(feature = "gui")] +fn display_width(s: &str) -> u16 { + s.chars() + .map(|c| UnicodeWidthChar::width(c).unwrap_or(0) as u16) + .sum() +} + +/// Utility: clip a string to fit width, append indicator if overflow +#[cfg(feature = "gui")] +fn clip_with_indicator_line<'a>(s: &'a str, width: u16, indicator: char) -> Line<'a> { + if width == 0 { + return Line::from(""); + } + if display_width(s) <= width { + return Line::from(Span::raw(s)); + } + let budget = width.saturating_sub(1); + let mut out = String::new(); + let mut used: u16 = 0; + for ch in s.chars() { + let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16; + if used + w > budget { + break; + } + out.push(ch); + used = used.saturating_add(w); + } + Line::from(vec![Span::raw(out), Span::raw(indicator.to_string())]) +} + +/// Default renderer: overflow indicator '$' #[cfg(feature = "gui")] pub fn render_canvas( f: &mut Frame, @@ -29,32 +81,43 @@ pub fn render_canvas( editor: &FormEditor, theme: &T, ) -> Option { - // Convert SelectionState to HighlightState - let highlight_state = convert_selection_to_highlight(editor.ui_state().selection_state()); - render_canvas_with_highlight(f, area, editor, theme, &highlight_state) + let opts = CanvasDisplayOptions::default(); + render_canvas_with_options(f, area, editor, theme, opts) } -/// Render canvas with explicit highlight state (for advanced use) +/// Wrapped variant: opt into soft wrap instead of overflow indicator #[cfg(feature = "gui")] -pub fn render_canvas_with_highlight( +pub fn render_canvas_with_options( + f: &mut Frame, + area: Rect, + editor: &FormEditor, + theme: &T, + opts: CanvasDisplayOptions, +) -> Option { + let highlight_state = + convert_selection_to_highlight(editor.ui_state().selection_state()); + render_canvas_with_highlight_and_options(f, area, editor, theme, &highlight_state, opts) +} + +/// Render canvas with explicit highlight state (with options) +#[cfg(feature = "gui")] +fn render_canvas_with_highlight_and_options( f: &mut Frame, area: Rect, editor: &FormEditor, theme: &T, highlight_state: &HighlightState, + opts: CanvasDisplayOptions, ) -> Option { let ui_state = editor.ui_state(); let data_provider = editor.data_provider(); - // Build field information let field_count = data_provider.field_count(); let mut fields: Vec<&str> = Vec::with_capacity(field_count); let mut inputs: Vec = Vec::with_capacity(field_count); for i in 0..field_count { fields.push(data_provider.field_name(i)); - - // Use editor-provided effective display text per field (Feature 4/mask aware) #[cfg(feature = "validation")] { inputs.push(editor.display_text_for_field(i)); @@ -68,7 +131,6 @@ pub fn render_canvas_with_highlight( let current_field_idx = ui_state.current_field(); let is_edit_mode = matches!(ui_state.mode(), crate::canvas::modes::AppMode::Edit); - // Precompute completion for active field #[cfg(feature = "suggestions")] let active_completion = if ui_state.is_suggestions_active() && ui_state.suggestions.active_field == Some(current_field_idx) @@ -77,11 +139,10 @@ pub fn render_canvas_with_highlight( } else { None }; - #[cfg(not(feature = "suggestions"))] let active_completion: Option = None; - render_canvas_fields( + render_canvas_fields_with_options( f, area, &fields, @@ -90,27 +151,23 @@ pub fn render_canvas_with_highlight( theme, is_edit_mode, highlight_state, - editor.display_cursor_position(), // Use display cursor position for masks - false, // TODO: track unsaved changes in editor - // Closures for getting display values and overrides + editor.display_cursor_position(), + false, #[cfg(feature = "validation")] |field_idx| editor.display_text_for_field(field_idx), #[cfg(not(feature = "validation"))] |field_idx| data_provider.field_value(field_idx).to_string(), - // Closure for checking display overrides #[cfg(feature = "validation")] |field_idx| { - editor.ui_state().validation_state().get_field_config(field_idx) - .map(|cfg| { - let has_formatter = cfg.custom_formatter.is_some(); - let has_mask = cfg.display_mask.is_some(); - has_formatter || has_mask - }) + editor + .ui_state() + .validation_state() + .get_field_config(field_idx) + .map(|cfg| cfg.custom_formatter.is_some() || cfg.display_mask.is_some()) .unwrap_or(false) }, #[cfg(not(feature = "validation"))] |_field_idx| false, - // Closure for providing completion |field_idx| { if field_idx == current_field_idx { active_completion.clone() @@ -118,24 +175,32 @@ pub fn render_canvas_with_highlight( None } }, + opts, ) } -/// Convert SelectionState to HighlightState for rendering #[cfg(feature = "gui")] -fn convert_selection_to_highlight(selection: &crate::canvas::state::SelectionState) -> HighlightState { +fn convert_selection_to_highlight( + selection: &crate::canvas::state::SelectionState, +) -> HighlightState { use crate::canvas::state::SelectionState; match selection { SelectionState::None => HighlightState::Off, - SelectionState::Characterwise { anchor } => HighlightState::Characterwise { anchor: *anchor }, - SelectionState::Linewise { anchor_field } => HighlightState::Linewise { anchor_line: *anchor_field }, + SelectionState::Characterwise { anchor } => { + HighlightState::Characterwise { anchor: *anchor } + } + SelectionState::Linewise { anchor_field } => { + HighlightState::Linewise { + anchor_line: *anchor_field, + } + } } } -/// Core canvas field rendering +/// Core canvas field rendering with options #[cfg(feature = "gui")] -fn render_canvas_fields( +fn render_canvas_fields_with_options( f: &mut Frame, area: Rect, fields: &[&str], @@ -149,19 +214,18 @@ fn render_canvas_fields( get_display_value: F1, has_display_override: F2, get_completion: F3, + opts: CanvasDisplayOptions, ) -> Option where F1: Fn(usize) -> String, F2: Fn(usize) -> bool, F3: Fn(usize) -> Option, { - // Create layout let columns = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) .split(area); - // Border style based on state let border_style = if has_unsaved_changes { Style::default().fg(theme.warning()) } else if is_edit_mode { @@ -170,7 +234,6 @@ where Style::default().fg(theme.secondary()) }; - // Input container let input_container = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) @@ -186,29 +249,100 @@ where f.render_widget(&input_container, input_block); - // Input area layout let input_area = input_container.inner(input_block); + + // NOTE: We keep one visual row per field; Wrap mode renders wrapped content + // visually within that row (ratatui handles visual wrapping). To fully + // expand rows by wrapped height, we'd convert to per-field dynamic heights. let input_rows = Layout::default() .direction(Direction::Vertical) .constraints(vec![Constraint::Length(1); fields.len()]) .split(input_area); - // Render field labels render_field_labels(f, columns[0], input_block, fields, theme); - // Render field values and return active field rect - render_field_values( - f, - input_rows.to_vec(), - inputs, - current_field_idx, - theme, - highlight_state, - current_cursor_pos, - get_display_value, - has_display_override, - get_completion, - ) + let mut active_field_input_rect = None; + + for i in 0..inputs.len() { + let is_active = i == *current_field_idx; + let typed_text = get_display_value(i); + let inner_width = input_rows[i].width; + + 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) + } + + // Highlighting is active - need to handle both highlighting and overflow + (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, + current_field_idx, + current_cursor_pos, + highlight_state, + theme, + 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, + current_field_idx, + current_cursor_pos, + highlight_state, + theme, + is_active, + ) + } + + // Wrap mode - just show text and let paragraph handle wrapping + (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, + current_field_idx, + current_cursor_pos, + highlight_state, + theme, + is_active, + ) + } + }; + + let mut p = Paragraph::new(line).alignment(Alignment::Left); + + if matches!(opts.overflow, OverflowMode::Wrap) { + p = p.wrap(Wrap { trim: false }); + } + + f.render_widget(p, input_rows[i]); + + if is_active { + active_field_input_rect = Some(input_rows[i]); + set_cursor_position( + f, + input_rows[i], + &typed_text, + current_cursor_pos, + has_display_override(i), + ); + } + } + + active_field_input_rect } /// Render field labels @@ -237,73 +371,6 @@ fn render_field_labels( } } -/// Render field values with highlighting -#[cfg(feature = "gui")] -fn render_field_values( - f: &mut Frame, - input_rows: Vec, - inputs: &[String], - current_field_idx: &usize, - theme: &T, - highlight_state: &HighlightState, - current_cursor_pos: usize, - get_display_value: F1, - has_display_override: F2, - get_completion: F3, -) -> Option -where - F1: Fn(usize) -> String, - F2: Fn(usize) -> bool, - F3: Fn(usize) -> Option, -{ - let mut active_field_input_rect = None; - - // FIX: Iterate over indices only since we never use the input values directly - for i in 0..inputs.len() { - let is_active = i == *current_field_idx; - let typed_text = get_display_value(i); - - let line = if is_active { - // Compose typed + gray completion for the active field - let normal_style = Style::default().fg(theme.fg()); - let gray_style = Style::default().fg(theme.suggestion_gray()); - - let mut spans: Vec = Vec::new(); - spans.push(Span::styled(typed_text.clone(), normal_style)); - - if let Some(completion) = get_completion(i) { - if !completion.is_empty() { - spans.push(Span::styled(completion, gray_style)); - } - } - - Line::from(spans) - } else { - // Non-active fields: keep existing highlighting logic - apply_highlighting( - &typed_text, - i, - current_field_idx, - current_cursor_pos, - highlight_state, - theme, - is_active, - ) - }; - - let input_display = Paragraph::new(line).alignment(Alignment::Left); - f.render_widget(input_display, input_rows[i]); - - // Set cursor for active field at end of typed text (not after completion) - if is_active { - active_field_input_rect = Some(input_rows[i]); - set_cursor_position(f, input_rows[i], &typed_text, current_cursor_pos, has_display_override(i)); - } - } - - active_field_input_rect -} - /// Apply highlighting based on highlight state #[cfg(feature = "gui")] fn apply_highlighting<'a, T: CanvasTheme>( @@ -319,21 +386,34 @@ fn apply_highlighting<'a, T: CanvasTheme>( match highlight_state { HighlightState::Off => { - Line::from(Span::styled( - text, - Style::default().fg(theme.fg()) - )) + Line::from(Span::styled(text, Style::default().fg(theme.fg()))) } HighlightState::Characterwise { anchor } => { - apply_characterwise_highlighting(text, text_len, field_index, current_field_idx, current_cursor_pos, anchor, theme, is_active) + apply_characterwise_highlighting( + text, + text_len, + field_index, + current_field_idx, + current_cursor_pos, + anchor, + theme, + is_active, + ) } HighlightState::Linewise { anchor_line } => { - apply_linewise_highlighting(text, field_index, current_field_idx, anchor_line, theme, is_active) + apply_linewise_highlighting( + text, + field_index, + current_field_idx, + anchor_line, + theme, + is_active, + ) } } } -/// Apply characterwise highlighting - PROPER VIM-LIKE VERSION +/// Apply characterwise highlighting (unchanged) #[cfg(feature = "gui")] fn apply_characterwise_highlighting<'a, T: CanvasTheme>( text: &'a str, @@ -349,21 +429,20 @@ fn apply_characterwise_highlighting<'a, T: CanvasTheme>( let start_field = min(anchor_field, *current_field_idx); let end_field = max(anchor_field, *current_field_idx); - // Vim-like styling: - // - Selected text: contrasting color + background (like vim visual selection) - // - All other text: normal color (no special colors for active fields, etc.) let highlight_style = Style::default() - .fg(theme.highlight()) // ✅ Contrasting text color for selected text - .bg(theme.highlight_bg()) // ✅ Background for selected text + .fg(theme.highlight()) + .bg(theme.highlight_bg()) .add_modifier(Modifier::BOLD); - let normal_style = Style::default().fg(theme.fg()); // ✅ Normal text color everywhere else + let normal_style = Style::default().fg(theme.fg()); if field_index >= start_field && field_index <= end_field { if start_field == end_field { - // Single field selection let (start_char, end_char) = if anchor_field == *current_field_idx { - (min(anchor_char, current_cursor_pos), max(anchor_char, current_cursor_pos)) + ( + min(anchor_char, current_cursor_pos), + max(anchor_char, current_cursor_pos), + ) } else if anchor_field < *current_field_idx { (anchor_char, current_cursor_pos) } else { @@ -374,19 +453,19 @@ fn apply_characterwise_highlighting<'a, T: CanvasTheme>( let clamped_end = end_char.min(text_len); let before: String = text.chars().take(clamped_start).collect(); - let highlighted: String = text.chars() + let highlighted: String = text + .chars() .skip(clamped_start) .take(clamped_end.saturating_sub(clamped_start) + 1) .collect(); let after: String = text.chars().skip(clamped_end + 1).collect(); Line::from(vec![ - Span::styled(before, normal_style), // Normal text color - Span::styled(highlighted, highlight_style), // Contrasting color + background - Span::styled(after, normal_style), // Normal text color + Span::styled(before, normal_style), + Span::styled(highlighted, highlight_style), + Span::styled(after, normal_style), ]) } else { - // Multi-field selection if field_index == anchor_field { if anchor_field < *current_field_idx { let clamped_start = anchor_char.min(text_len); @@ -428,17 +507,15 @@ fn apply_characterwise_highlighting<'a, T: CanvasTheme>( ]) } } else { - // Middle field: highlight entire field Line::from(Span::styled(text, highlight_style)) } } } else { - // Outside selection: always normal text color (no special active field color) Line::from(Span::styled(text, normal_style)) } } -/// Apply linewise highlighting - PROPER VIM-LIKE VERSION +/// Apply linewise highlighting (unchanged) #[cfg(feature = "gui")] fn apply_linewise_highlighting<'a, T: CanvasTheme>( text: &'a str, @@ -451,26 +528,21 @@ fn apply_linewise_highlighting<'a, T: CanvasTheme>( let start_field = min(*anchor_line, *current_field_idx); let end_field = max(*anchor_line, *current_field_idx); - // Vim-like styling: - // - Selected lines: contrasting text color + background - // - All other lines: normal text color (no special active field color) let highlight_style = Style::default() - .fg(theme.highlight()) // ✅ Contrasting text color for selected text - .bg(theme.highlight_bg()) // ✅ Background for selected text + .fg(theme.highlight()) + .bg(theme.highlight_bg()) .add_modifier(Modifier::BOLD); - let normal_style = Style::default().fg(theme.fg()); // ✅ Normal text color everywhere else + let normal_style = Style::default().fg(theme.fg()); if field_index >= start_field && field_index <= end_field { - // Selected line: contrasting text color + background Line::from(Span::styled(text, highlight_style)) } else { - // Normal line: normal text color (no special active field color) Line::from(Span::styled(text, normal_style)) } } -/// Set cursor position +/// Set cursor position (x clamp only; no Y offset with wrap in this version) #[cfg(feature = "gui")] fn set_cursor_position( f: &mut Frame, @@ -479,7 +551,6 @@ fn set_cursor_position( current_cursor_pos: usize, _has_display_override: bool, ) { - // Sum display widths of the first current_cursor_pos characters let mut cols: u16 = 0; for (i, ch) in text.chars().enumerate() { if i >= current_cursor_pos { @@ -491,14 +562,13 @@ fn set_cursor_position( let cursor_x = field_rect.x.saturating_add(cols); let cursor_y = field_rect.y; - // Clamp to field bounds 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)); } -/// Set default theme if custom not specified +/// Default theme #[cfg(feature = "gui")] pub fn render_canvas_default( f: &mut Frame, diff --git a/canvas/src/lib.rs b/canvas/src/lib.rs index 99347c4..bfb3a60 100644 --- a/canvas/src/lib.rs +++ b/canvas/src/lib.rs @@ -12,6 +12,10 @@ pub mod suggestions; #[cfg(feature = "validation")] pub mod validation; +// First-class textarea module and exports +#[cfg(feature = "textarea")] +pub mod textarea; + // Only include computed module if feature is enabled #[cfg(feature = "computed")] pub mod computed; @@ -56,18 +60,17 @@ pub use computed::{ComputedProvider, ComputedContext, ComputedState}; pub use canvas::theme::{CanvasTheme, DefaultCanvasTheme}; #[cfg(feature = "gui")] -pub use canvas::gui::render_canvas; +pub use canvas::gui::{render_canvas, render_canvas_default}; #[cfg(feature = "gui")] -pub use canvas::gui::render_canvas_default; +pub use canvas::gui::render_canvas_with_options; + +#[cfg(feature = "gui")] +pub use canvas::gui::{CanvasDisplayOptions, OverflowMode}; #[cfg(all(feature = "gui", feature = "suggestions"))] pub use suggestions::gui::render_suggestions_dropdown; -// First-class textarea module and exports -#[cfg(feature = "textarea")] -pub mod textarea; - #[cfg(feature = "textarea")] pub use textarea::{TextArea, TextAreaProvider, TextAreaState, TextAreaEditor}; diff --git a/canvas/src/textarea/mod.rs b/canvas/src/textarea/mod.rs index efde53f..bdee3eb 100644 --- a/canvas/src/textarea/mod.rs +++ b/canvas/src/textarea/mod.rs @@ -1,14 +1,15 @@ // src/textarea/mod.rs -// Module routing and re-exports only. No logic here. - pub mod provider; pub mod state; #[cfg(feature = "gui")] pub mod widget; +#[cfg(feature = "keymaps")] +pub mod commands_impl; + pub use provider::TextAreaProvider; -pub use state::{TextAreaEditor, TextAreaState}; +pub use state::{TextAreaEditor, TextAreaState, TextOverflowMode}; #[cfg(feature = "gui")] pub use widget::TextArea; diff --git a/canvas/src/textarea/state.rs b/canvas/src/textarea/state.rs index d02b8b2..f601539 100644 --- a/canvas/src/textarea/state.rs +++ b/canvas/src/textarea/state.rs @@ -15,11 +15,17 @@ use unicode_width::UnicodeWidthChar; pub type TextAreaEditor = FormEditor; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TextOverflowMode { + Indicator { ch: char }, // show trailing indicator (default '$') + Wrap, // soft wrap lines +} + pub struct TextAreaState { pub(crate) editor: TextAreaEditor, pub(crate) scroll_y: u16, - pub(crate) wrap: bool, pub(crate) placeholder: Option, + pub(crate) overflow_mode: TextOverflowMode, } impl Default for TextAreaState { @@ -27,8 +33,8 @@ impl Default for TextAreaState { Self { editor: FormEditor::new(TextAreaProvider::default()), scroll_y: 0, - wrap: false, placeholder: None, + overflow_mode: TextOverflowMode::Indicator { ch: '$' }, } } } @@ -54,8 +60,8 @@ impl TextAreaState { Self { editor: FormEditor::new(provider), scroll_y: 0, - wrap: false, placeholder: None, + overflow_mode: TextOverflowMode::Indicator { ch: '$' }, } } @@ -70,14 +76,20 @@ impl TextAreaState { self.editor.ui_state.ideal_cursor_column = 0; } - pub fn set_wrap(&mut self, wrap: bool) { - self.wrap = wrap; - } - pub fn set_placeholder>(&mut self, s: S) { self.placeholder = Some(s.into()); } + // RUNTIME TOGGLES ---------------------------------------------------- + + pub fn use_overflow_indicator(&mut self, ch: char) { + self.overflow_mode = TextOverflowMode::Indicator { ch }; + } + + pub fn use_wrap(&mut self) { + self.overflow_mode = TextOverflowMode::Wrap; + } + // Textarea-specific primitive: split at cursor pub fn insert_newline(&mut self) { let line_idx = self.current_field(); @@ -106,10 +118,8 @@ impl TextAreaState { return; } - if let Some((prev_idx, new_col)) = self - .editor - .data_provider_mut() - .join_with_prev(line_idx) + if let Some((prev_idx, new_col)) = + self.editor.data_provider_mut().join_with_prev(line_idx) { let _ = self.transition_to_field(prev_idx); self.set_cursor_position(new_col); @@ -128,44 +138,14 @@ impl TextAreaState { return; } - if let Some(new_col) = self - .editor - .data_provider_mut() - .join_with_next(line_idx) + if let Some(new_col) = + self.editor.data_provider_mut().join_with_next(line_idx) { self.set_cursor_position(new_col); self.enter_edit_mode(); } } - // Override for multiline: insert new blank line below and enter insert mode. - pub fn open_line_below(&mut self) -> Result<()> { - let line_idx = self.current_field(); - let new_idx = self - .editor - .data_provider_mut() - .insert_blank_line_after(line_idx); - - self.transition_to_field(new_idx)?; - self.move_line_start(); - self.enter_edit_mode(); - Ok(()) - } - - // Override for multiline: insert new blank line above and enter insert mode. - pub fn open_line_above(&mut self) -> Result<()> { - let line_idx = self.current_field(); - let new_idx = self - .editor - .data_provider_mut() - .insert_blank_line_before(line_idx); - - self.transition_to_field(new_idx)?; - self.move_line_start(); - self.enter_edit_mode(); - Ok(()) - } - // Drive from KeyEvent; you can still call all FormEditor methods directly pub fn input(&mut self, key: KeyEvent) { if key.kind != KeyEventKind::Press { @@ -199,7 +179,7 @@ impl TextAreaState { self.move_line_end(); } - // Optional: word motions + // Optional: word motions (kept) (KeyCode::Char('b'), KeyModifiers::ALT) => self.move_word_prev(), (KeyCode::Char('f'), KeyModifiers::ALT) => self.move_word_next(), (KeyCode::Char('e'), KeyModifiers::ALT) => self.move_word_end(), diff --git a/canvas/src/textarea/widget.rs b/canvas/src/textarea/widget.rs index 1e341f6..75b58f2 100644 --- a/canvas/src/textarea/widget.rs +++ b/canvas/src/textarea/widget.rs @@ -11,10 +11,13 @@ use ratatui::{ }; #[cfg(feature = "gui")] -use crate::data_provider::DataProvider; // bring trait into scope +use crate::data_provider::DataProvider; #[cfg(feature = "gui")] -use crate::textarea::state::TextAreaState; +use crate::textarea::state::{TextAreaState, TextOverflowMode}; + +#[cfg(feature = "gui")] +use unicode_width::UnicodeWidthChar; #[cfg(feature = "gui")] #[derive(Debug, Clone)] @@ -60,6 +63,38 @@ impl<'a> TextArea<'a> { } } +#[cfg(feature = "gui")] +fn display_width(s: &str) -> u16 { + s.chars() + .map(|c| UnicodeWidthChar::width(c).unwrap_or(0) as u16) + .sum() +} + +#[cfg(feature = "gui")] +fn clip_with_indicator(s: &str, width: u16, indicator: char) -> Line<'static> { + if width == 0 { + return Line::from(""); + } + + if display_width(s) <= width { + return Line::from(Span::raw(s.to_string())); + } + + let budget = width.saturating_sub(1); + let mut out = String::new(); + let mut used: u16 = 0; + for ch in s.chars() { + let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16; + if used + w > budget { + break; + } + out.push(ch); + used = used.saturating_add(w); + } + + Line::from(vec![Span::raw(out), Span::raw(indicator.to_string())]) +} + #[cfg(feature = "gui")] impl<'a> StatefulWidget for TextArea<'a> { type State = TextAreaState; @@ -89,7 +124,14 @@ impl<'a> StatefulWidget for TextArea<'a> { } else { for i in start..end { let s = state.editor.data_provider().field_value(i); - display_lines.push(Line::from(Span::raw(s.to_string()))); + match state.overflow_mode { + TextOverflowMode::Wrap => { + display_lines.push(Line::from(Span::raw(s.to_string()))); + } + TextOverflowMode::Indicator { ch } => { + display_lines.push(clip_with_indicator(s, inner.width, ch)); + } + } } } @@ -97,7 +139,7 @@ impl<'a> StatefulWidget for TextArea<'a> { .alignment(Alignment::Left) .style(self.style); - if state.wrap { + if matches!(state.overflow_mode, TextOverflowMode::Wrap) { p = p.wrap(Wrap { trim: false }); }