diff --git a/Cargo.lock b/Cargo.lock index e7dac15..9b02be2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -482,6 +482,7 @@ dependencies = [ "tokio", "tokio-test", "toml", + "unicode-width 0.2.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8b0458c..3dcddd9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,5 +50,6 @@ regex = "1.11.1" ratatui = { version = "0.29.0", features = ["crossterm"] } crossterm = "0.28.1" toml = "0.8.20" +unicode-width = "0.2.0" common = { path = "./common" } diff --git a/canvas/Cargo.toml b/canvas/Cargo.toml index 71a219e..422ce22 100644 --- a/canvas/Cargo.toml +++ b/canvas/Cargo.toml @@ -17,6 +17,7 @@ anyhow = { workspace = true } tokio = { workspace = true } toml = { workspace = true } serde = { workspace = true } +unicode-width.workspace = true [dev-dependencies] tokio-test = "0.4.4" diff --git a/canvas/src/gui/autocomplete.rs b/canvas/src/gui/autocomplete.rs new file mode 100644 index 0000000..30706b5 --- /dev/null +++ b/canvas/src/gui/autocomplete.rs @@ -0,0 +1,193 @@ +// canvas/src/gui/autocomplete.rs + +#[cfg(feature = "gui")] +use ratatui::{ + layout::{Alignment, Rect}, + style::{Modifier, Style}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + Frame, +}; + +use crate::autocomplete::AutocompleteState; +use super::theme::CanvasTheme; + +#[cfg(feature = "gui")] +use unicode_width::UnicodeWidthStr; + +/// Render autocomplete dropdown - call this AFTER rendering canvas +#[cfg(feature = "gui")] +pub fn render_autocomplete_dropdown( + f: &mut Frame, + frame_area: Rect, + input_rect: Rect, + theme: &T, + autocomplete_state: &AutocompleteState, +) { + if !autocomplete_state.is_active { + return; + } + + if autocomplete_state.is_loading { + render_loading_indicator(f, frame_area, input_rect, theme); + } else if !autocomplete_state.suggestions.is_empty() { + render_suggestions_dropdown(f, frame_area, input_rect, theme, autocomplete_state); + } +} + +/// Show loading spinner/text +#[cfg(feature = "gui")] +fn render_loading_indicator( + f: &mut Frame, + frame_area: Rect, + input_rect: Rect, + theme: &T, +) { + let loading_text = "Loading suggestions..."; + let loading_width = loading_text.width() as u16 + 4; // +4 for borders and padding + let loading_height = 3; + + let dropdown_area = calculate_dropdown_position( + input_rect, + frame_area, + loading_width, + loading_height, + ); + + let loading_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.accent())) + .style(Style::default().bg(theme.bg())); + + let loading_paragraph = Paragraph::new(loading_text) + .block(loading_block) + .style(Style::default().fg(theme.fg())) + .alignment(Alignment::Center); + + f.render_widget(loading_paragraph, dropdown_area); +} + +/// Show actual suggestions list +#[cfg(feature = "gui")] +fn render_suggestions_dropdown( + f: &mut Frame, + frame_area: Rect, + input_rect: Rect, + theme: &T, + autocomplete_state: &AutocompleteState, +) { + let display_texts: Vec<&str> = autocomplete_state.suggestions + .iter() + .map(|item| item.display_text.as_str()) + .collect(); + + let dropdown_dimensions = calculate_dropdown_dimensions(&display_texts); + let dropdown_area = calculate_dropdown_position( + input_rect, + frame_area, + dropdown_dimensions.width, + dropdown_dimensions.height, + ); + + // Background + let dropdown_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.accent())) + .style(Style::default().bg(theme.bg())); + + // List items + let items = create_suggestion_list_items( + &display_texts, + autocomplete_state.selected_index, + dropdown_dimensions.width, + theme, + ); + + let list = List::new(items).block(dropdown_block); + let mut list_state = ListState::default(); + list_state.select(autocomplete_state.selected_index); + + f.render_stateful_widget(list, dropdown_area, &mut list_state); +} + +/// Calculate dropdown size based on suggestions +#[cfg(feature = "gui")] +fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions { + let max_width = display_texts + .iter() + .map(|text| text.width()) + .max() + .unwrap_or(0) as u16; + + let horizontal_padding = 4; // borders + padding + let width = (max_width + horizontal_padding).max(12); + let height = (display_texts.len() as u16).min(8) + 2; // max 8 visible items + borders + + DropdownDimensions { width, height } +} + +/// Position dropdown to stay in bounds +#[cfg(feature = "gui")] +fn calculate_dropdown_position( + input_rect: Rect, + frame_area: Rect, + dropdown_width: u16, + dropdown_height: u16, +) -> Rect { + let mut dropdown_area = Rect { + x: input_rect.x, + y: input_rect.y + 1, // below input field + width: dropdown_width, + height: dropdown_height, + }; + + // Keep in bounds + if dropdown_area.bottom() > frame_area.height { + dropdown_area.y = input_rect.y.saturating_sub(dropdown_height); + } + if dropdown_area.right() > frame_area.width { + dropdown_area.x = frame_area.width.saturating_sub(dropdown_width); + } + dropdown_area.x = dropdown_area.x.max(0); + dropdown_area.y = dropdown_area.y.max(0); + + dropdown_area +} + +/// Create styled list items +#[cfg(feature = "gui")] +fn create_suggestion_list_items<'a, T: CanvasTheme>( + display_texts: &'a [&'a str], + selected_index: Option, + dropdown_width: u16, + theme: &T, +) -> Vec> { + let horizontal_padding = 4; + let available_width = dropdown_width.saturating_sub(horizontal_padding); + + display_texts + .iter() + .enumerate() + .map(|(i, text)| { + let is_selected = selected_index == Some(i); + let text_width = text.width() as u16; + let padding_needed = available_width.saturating_sub(text_width); + let padded_text = format!("{}{}", text, " ".repeat(padding_needed as usize)); + + ListItem::new(padded_text).style(if is_selected { + Style::default() + .fg(theme.bg()) + .bg(theme.highlight()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.fg()).bg(theme.bg()) + }) + }) + .collect() +} + +/// Helper struct for dropdown dimensions +#[cfg(feature = "gui")] +struct DropdownDimensions { + width: u16, + height: u16, +} diff --git a/canvas/src/gui/canvas.rs b/canvas/src/gui/canvas.rs new file mode 100644 index 0000000..36d060e --- /dev/null +++ b/canvas/src/gui/canvas.rs @@ -0,0 +1,336 @@ +// canvas/src/gui/canvas.rs + +#[cfg(feature = "gui")] +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, BorderType, Paragraph}, + Frame, +}; + +use crate::state::CanvasState; +use crate::modes::HighlightState; +use super::theme::CanvasTheme; + +#[cfg(feature = "gui")] +use std::cmp::{max, min}; + +/// Render ONLY the canvas form fields - no autocomplete +#[cfg(feature = "gui")] +pub fn render_canvas( + f: &mut Frame, + area: Rect, + form_state: &impl CanvasState, + theme: &T, + is_edit_mode: bool, + highlight_state: &HighlightState, +) -> Option { + let fields: Vec<&str> = form_state.fields(); + let current_field_idx = form_state.current_field(); + let inputs: Vec<&String> = form_state.inputs(); + + render_canvas_fields( + f, + area, + &fields, + ¤t_field_idx, + &inputs, + theme, + is_edit_mode, + highlight_state, + form_state.current_cursor_pos(), + form_state.has_unsaved_changes(), + |i| form_state.get_display_value_for_field(i).to_string(), + |i| form_state.has_display_override(i), + ) +} + +/// Core canvas field rendering +#[cfg(feature = "gui")] +fn render_canvas_fields( + f: &mut Frame, + area: Rect, + fields: &[&str], + current_field_idx: &usize, + inputs: &[&String], + theme: &T, + is_edit_mode: bool, + highlight_state: &HighlightState, + current_cursor_pos: usize, + has_unsaved_changes: bool, + get_display_value: F1, + has_display_override: F2, +) -> Option +where + F1: Fn(usize) -> String, + F2: Fn(usize) -> bool, +{ + // 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 { + Style::default().fg(theme.accent()) + } else { + Style::default().fg(theme.secondary()) + }; + + // Input container + let input_container = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(border_style) + .style(Style::default().bg(theme.bg())); + + let input_block = Rect { + x: columns[1].x, + y: columns[1].y, + width: columns[1].width, + height: fields.len() as u16 + 2, + }; + + f.render_widget(&input_container, input_block); + + // Input area layout + let input_area = input_container.inner(input_block); + 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(), // Fix: Convert Rc<[Rect]> to Vec + inputs, + current_field_idx, + theme, + highlight_state, + current_cursor_pos, + get_display_value, + has_display_override, + ) +} + +/// Render field labels +#[cfg(feature = "gui")] +fn render_field_labels( + f: &mut Frame, + label_area: Rect, + input_block: Rect, + fields: &[&str], + theme: &T, +) { + for (i, field) in fields.iter().enumerate() { + let label = Paragraph::new(Line::from(Span::styled( + format!("{}:", field), + Style::default().fg(theme.fg()), + ))); + f.render_widget( + label, + Rect { + x: label_area.x, + y: input_block.y + 1 + i as u16, + width: label_area.width, + height: 1, + }, + ); + } +} + +/// 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, +) -> Option +where + F1: Fn(usize) -> String, + F2: Fn(usize) -> bool, +{ + let mut active_field_input_rect = None; + + for (i, _input) in inputs.iter().enumerate() { + let is_active = i == *current_field_idx; + let text = get_display_value(i); + + // Apply highlighting + let line = apply_highlighting( + &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 + if is_active { + active_field_input_rect = Some(input_rows[i]); + set_cursor_position(f, input_rows[i], &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>( + text: &'a str, + field_index: usize, + current_field_idx: &usize, + current_cursor_pos: usize, + highlight_state: &HighlightState, + theme: &T, + is_active: bool, +) -> Line<'a> { + let text_len = text.chars().count(); + + match highlight_state { + HighlightState::Off => { + Line::from(Span::styled( + text, + if is_active { + Style::default().fg(theme.highlight()) + } else { + 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) + } + HighlightState::Linewise { anchor_line } => { + apply_linewise_highlighting(text, field_index, current_field_idx, anchor_line, theme, is_active) + } + } +} + +/// Apply characterwise highlighting +#[cfg(feature = "gui")] +fn apply_characterwise_highlighting<'a, T: CanvasTheme>( + text: &'a str, + text_len: usize, + field_index: usize, + current_field_idx: &usize, + current_cursor_pos: usize, + anchor: &(usize, usize), + theme: &T, + is_active: bool, +) -> Line<'a> { + let (anchor_field, anchor_char) = *anchor; + let start_field = min(anchor_field, *current_field_idx); + let end_field = max(anchor_field, *current_field_idx); + + let highlight_style = Style::default() + .fg(theme.highlight()) + .bg(theme.highlight_bg()) + .add_modifier(Modifier::BOLD); + let normal_style_in_highlight = Style::default().fg(theme.highlight()); + let normal_style_outside = Style::default().fg(theme.fg()); + + if field_index >= start_field && field_index <= end_field { + if start_field == end_field { + let (start_char, end_char) = if anchor_field == *current_field_idx { + (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 { + (current_cursor_pos, anchor_char) + }; + + let clamped_start = start_char.min(text_len); + let clamped_end = end_char.min(text_len); + + let before: String = text.chars().take(clamped_start).collect(); + 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_in_highlight), + Span::styled(highlighted, highlight_style), + Span::styled(after, normal_style_in_highlight), + ]) + } else { + // Multi-field selection + Line::from(Span::styled(text, highlight_style)) + } + } else { + Line::from(Span::styled( + text, + if is_active { normal_style_in_highlight } else { normal_style_outside } + )) + } +} + +/// Apply linewise highlighting +#[cfg(feature = "gui")] +fn apply_linewise_highlighting<'a, T: CanvasTheme>( + text: &'a str, + field_index: usize, + current_field_idx: &usize, + anchor_line: &usize, + theme: &T, + is_active: bool, +) -> Line<'a> { + let start_field = min(*anchor_line, *current_field_idx); + let end_field = max(*anchor_line, *current_field_idx); + + let highlight_style = Style::default() + .fg(theme.highlight()) + .bg(theme.highlight_bg()) + .add_modifier(Modifier::BOLD); + let normal_style_in_highlight = Style::default().fg(theme.highlight()); + let normal_style_outside = Style::default().fg(theme.fg()); + + if field_index >= start_field && field_index <= end_field { + Line::from(Span::styled(text, highlight_style)) + } else { + Line::from(Span::styled( + text, + if is_active { normal_style_in_highlight } else { normal_style_outside } + )) + } +} + +/// Set cursor position +#[cfg(feature = "gui")] +fn set_cursor_position( + f: &mut Frame, + field_rect: Rect, + text: &str, + current_cursor_pos: usize, + has_display_override: bool, +) { + let cursor_x = if has_display_override { + field_rect.x + text.chars().count() as u16 + } else { + field_rect.x + current_cursor_pos as u16 + }; + let cursor_y = field_rect.y; + f.set_cursor_position((cursor_x, cursor_y)); +} diff --git a/canvas/src/gui/mod.rs b/canvas/src/gui/mod.rs index c66d3a3..f5c833d 100644 --- a/canvas/src/gui/mod.rs +++ b/canvas/src/gui/mod.rs @@ -1,7 +1,20 @@ // canvas/src/gui/mod.rs -pub mod theme; -pub mod render; +#[cfg(feature = "gui")] +pub mod canvas; +#[cfg(feature = "gui")] +pub mod autocomplete; + +#[cfg(feature = "gui")] +pub mod theme; + +// Export the separate rendering functions +#[cfg(feature = "gui")] +pub use canvas::render_canvas; + +#[cfg(feature = "gui")] +pub use autocomplete::render_autocomplete_dropdown; + +#[cfg(feature = "gui")] pub use theme::CanvasTheme; -pub use render::render_canvas; diff --git a/canvas/src/gui/render.rs b/canvas/src/gui/render.rs deleted file mode 100644 index 4c985a1..0000000 --- a/canvas/src/gui/render.rs +++ /dev/null @@ -1,374 +0,0 @@ -// canvas/src/gui/render.rs - -#[cfg(feature = "gui")] -use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, BorderType, List, ListItem, ListState, Paragraph}, - Frame, -}; -use crate::state::CanvasState; -use crate::modes::HighlightState; -#[cfg(feature = "gui")] -use super::theme::CanvasTheme; -#[cfg(feature = "gui")] -use std::cmp::{max, min}; -#[cfg(feature = "gui")] -use unicode_width::UnicodeWidthStr; - -/// Render canvas using the CanvasState trait and CanvasTheme -#[cfg(feature = "gui")] -pub fn render_canvas( - f: &mut Frame, - area: Rect, - form_state: &impl CanvasState, - theme: &T, - is_edit_mode: bool, - highlight_state: &HighlightState, -) -> Option { - let fields: Vec<&str> = form_state.fields(); - let current_field_idx = form_state.current_field(); - let inputs: Vec<&String> = form_state.inputs(); - - let active_field_rect = render_canvas_impl( - f, - area, - &fields, - ¤t_field_idx, - &inputs, - theme, - is_edit_mode, - highlight_state, - form_state.current_cursor_pos(), - form_state.has_unsaved_changes(), - |i| form_state.get_display_value_for_field(i).to_string(), - |i| form_state.has_display_override(i), - ); - - // NEW: Render autocomplete dropdown if active - if let Some(autocomplete_state) = form_state.autocomplete_state() { - if autocomplete_state.is_active { - if let Some(field_rect) = active_field_rect { - render_autocomplete_dropdown(f, area, field_rect, theme, autocomplete_state); - } - } - } - - active_field_rect -} - -/// Render autocomplete dropdown -#[cfg(feature = "gui")] -fn render_autocomplete_dropdown( - f: &mut Frame, - frame_area: Rect, - input_rect: Rect, - theme: &T, - autocomplete_state: &crate::autocomplete::AutocompleteState, -) { - if autocomplete_state.is_loading { - // Show loading indicator - let loading_text = "Loading suggestions..."; - let loading_width = loading_text.width() as u16 + 2; - let loading_height = 3; - - let mut dropdown_area = Rect { - x: input_rect.x, - y: input_rect.y + 1, - width: loading_width, - height: loading_height, - }; - - // Adjust position to stay within frame - if dropdown_area.bottom() > frame_area.height { - dropdown_area.y = input_rect.y.saturating_sub(loading_height); - } - if dropdown_area.right() > frame_area.width { - dropdown_area.x = frame_area.width.saturating_sub(loading_width); - } - - let loading_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.accent())) - .style(Style::default().bg(theme.bg())); - - let loading_paragraph = Paragraph::new(loading_text) - .block(loading_block) - .style(Style::default().fg(theme.fg())) - .alignment(Alignment::Center); - - f.render_widget(loading_paragraph, dropdown_area); - return; - } - - if autocomplete_state.suggestions.is_empty() { - return; - } - - // Calculate dropdown dimensions - let display_texts: Vec<&str> = autocomplete_state.suggestions - .iter() - .map(|item| item.display_text.as_str()) - .collect(); - - let max_width = display_texts - .iter() - .map(|text| text.width()) - .max() - .unwrap_or(0) as u16; - - let horizontal_padding = 4; // 2 for borders + 2 for internal padding - let dropdown_width = (max_width + horizontal_padding).max(12); - let dropdown_height = (autocomplete_state.suggestions.len() as u16).min(8) + 2; // +2 for borders - - let mut dropdown_area = Rect { - x: input_rect.x, - y: input_rect.y + 1, - width: dropdown_width, - height: dropdown_height, - }; - - // Adjust position to stay within frame bounds - if dropdown_area.bottom() > frame_area.height { - dropdown_area.y = input_rect.y.saturating_sub(dropdown_height); - } - if dropdown_area.right() > frame_area.width { - dropdown_area.x = frame_area.width.saturating_sub(dropdown_width); - } - dropdown_area.x = dropdown_area.x.max(0); - dropdown_area.y = dropdown_area.y.max(0); - - // Create dropdown background - let dropdown_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.accent())) - .style(Style::default().bg(theme.bg())); - - // Create list items - let items: Vec = display_texts - .iter() - .enumerate() - .map(|(i, text)| { - let is_selected = autocomplete_state.selected_index == Some(i); - let text_width = text.width() as u16; - let available_width = dropdown_width.saturating_sub(horizontal_padding); - let padding_needed = available_width.saturating_sub(text_width); - let padded_text = format!("{}{}", text, " ".repeat(padding_needed as usize)); - - ListItem::new(padded_text).style(if is_selected { - Style::default() - .fg(theme.bg()) - .bg(theme.highlight()) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(theme.fg()).bg(theme.bg()) - }) - }) - .collect(); - - let list = List::new(items).block(dropdown_block); - let mut list_state = ListState::default(); - list_state.select(autocomplete_state.selected_index); - - f.render_stateful_widget(list, dropdown_area, &mut list_state); -} - -/// Internal implementation of canvas rendering (unchanged from previous version) -#[cfg(feature = "gui")] -fn render_canvas_impl( - f: &mut Frame, - area: Rect, - fields: &[&str], - current_field_idx: &usize, - inputs: &[&String], - theme: &T, - is_edit_mode: bool, - highlight_state: &HighlightState, - current_cursor_pos: usize, - has_unsaved_changes: bool, - get_display_value: F1, - has_display_override: F2, -) -> Option -where - F1: Fn(usize) -> String, - F2: Fn(usize) -> bool, -{ - let columns = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) - .split(area); - - let border_style = if has_unsaved_changes { - Style::default().fg(theme.warning()) - } else if is_edit_mode { - Style::default().fg(theme.accent()) - } else { - Style::default().fg(theme.secondary()) - }; - - let input_container = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(border_style) - .style(Style::default().bg(theme.bg())); - - let input_block = Rect { - x: columns[1].x, - y: columns[1].y, - width: columns[1].width, - height: fields.len() as u16 + 2, - }; - - f.render_widget(&input_container, input_block); - - let input_area = input_container.inner(input_block); - let input_rows = Layout::default() - .direction(Direction::Vertical) - .constraints(vec![Constraint::Length(1); fields.len()]) - .split(input_area); - - let mut active_field_input_rect = None; - - // Render field labels - for (i, field) in fields.iter().enumerate() { - let label = Paragraph::new(Line::from(Span::styled( - format!("{}:", field), - Style::default().fg(theme.fg()), - ))); - f.render_widget( - label, - Rect { - x: columns[0].x, - y: input_block.y + 1 + i as u16, - width: columns[0].width, - height: 1, - }, - ); - } - - // Render field values - for (i, _input) in inputs.iter().enumerate() { - let is_active = i == *current_field_idx; - - // Use the provided closure to get display value - let text = get_display_value(i); - let text_len = text.chars().count(); - let line: Line; - - match highlight_state { - HighlightState::Off => { - line = Line::from(Span::styled( - &text, - if is_active { - Style::default().fg(theme.highlight()) - } else { - Style::default().fg(theme.fg()) - }, - )); - } - HighlightState::Characterwise { anchor } => { - let (anchor_field, anchor_char) = *anchor; - let start_field = min(anchor_field, *current_field_idx); - let end_field = max(anchor_field, *current_field_idx); - - let (start_char, end_char) = if anchor_field == *current_field_idx { - (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 { - (current_cursor_pos, anchor_char) - }; - - let highlight_style = Style::default() - .fg(theme.highlight()) - .bg(theme.highlight_bg()) - .add_modifier(Modifier::BOLD); - let normal_style_in_highlight = Style::default().fg(theme.highlight()); - let normal_style_outside = Style::default().fg(theme.fg()); - - if i >= start_field && i <= end_field { - if start_field == end_field { - let clamped_start = start_char.min(text_len); - let clamped_end = end_char.min(text_len); - - let before: String = text.chars().take(clamped_start).collect(); - 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 = Line::from(vec![ - Span::styled(before, normal_style_in_highlight), - Span::styled(highlighted, highlight_style), - Span::styled(after, normal_style_in_highlight), - ]); - } else if i == start_field { - let safe_start = start_char.min(text_len); - let before: String = text.chars().take(safe_start).collect(); - let highlighted: String = text.chars().skip(safe_start).collect(); - line = Line::from(vec![ - Span::styled(before, normal_style_in_highlight), - Span::styled(highlighted, highlight_style), - ]); - } else if i == end_field { - let safe_end_inclusive = if text_len > 0 { end_char.min(text_len - 1) } else { 0 }; - let highlighted: String = text.chars().take(safe_end_inclusive + 1).collect(); - let after: String = text.chars().skip(safe_end_inclusive + 1).collect(); - line = Line::from(vec![ - Span::styled(highlighted, highlight_style), - Span::styled(after, normal_style_in_highlight), - ]); - } else { - line = Line::from(Span::styled(&text, highlight_style)); - } - } else { - line = Line::from(Span::styled( - &text, - if is_active { normal_style_in_highlight } else { normal_style_outside } - )); - } - } - HighlightState::Linewise { anchor_line } => { - let start_field = min(*anchor_line, *current_field_idx); - let end_field = max(*anchor_line, *current_field_idx); - let highlight_style = Style::default() - .fg(theme.highlight()) - .bg(theme.highlight_bg()) - .add_modifier(Modifier::BOLD); - let normal_style_in_highlight = Style::default().fg(theme.highlight()); - let normal_style_outside = Style::default().fg(theme.fg()); - - if i >= start_field && i <= end_field { - line = Line::from(Span::styled(&text, highlight_style)); - } else { - line = Line::from(Span::styled( - &text, - if is_active { normal_style_in_highlight } else { normal_style_outside } - )); - } - } - } - - let input_display = Paragraph::new(line).alignment(Alignment::Left); - f.render_widget(input_display, input_rows[i]); - - if is_active { - active_field_input_rect = Some(input_rows[i]); - - // Use the provided closure to check for display override - let cursor_x = if has_display_override(i) { - // If an override exists, place the cursor at the end. - input_rows[i].x + text.chars().count() as u16 - } else { - // Otherwise, use the real cursor position. - input_rows[i].x + current_cursor_pos as u16 - }; - let cursor_y = input_rows[i].y; - f.set_cursor_position((cursor_x, cursor_y)); - } - } - - active_field_input_rect -} diff --git a/client/Cargo.toml b/client/Cargo.toml index dad74f6..5aaffd5 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -27,7 +27,7 @@ tracing = "0.1.41" tracing-subscriber = "0.3.19" tui-textarea = { version = "0.7.0", features = ["crossterm", "ratatui", "search"] } unicode-segmentation = "1.12.0" -unicode-width = "0.2.0" +unicode-width.workspace = true [features] default = []