diff --git a/canvas/Cargo.toml b/canvas/Cargo.toml index 3f1549d..df1d04f 100644 --- a/canvas/Cargo.toml +++ b/canvas/Cargo.toml @@ -13,7 +13,7 @@ categories.workspace = true common = { path = "../common" } ratatui = { workspace = true, optional = true } crossterm = { workspace = true } -anyhow = { workspace = true } +anyhow.workspace = true tokio = { workspace = true, optional = true } toml = { workspace = true } serde = { workspace = true } @@ -22,7 +22,7 @@ thiserror = { workspace = true } tracing = "0.1.41" tracing-subscriber = "0.3.19" -async-trait = { workspace = true, optional = true } +async-trait.workspace = true [dev-dependencies] tokio-test = "0.4.4" @@ -30,7 +30,7 @@ tokio-test = "0.4.4" [features] default = [] gui = ["ratatui"] -autocomplete = ["tokio", "async-trait"] +autocomplete = ["tokio"] [[example]] name = "autocomplete" diff --git a/canvas/examples/autocomplete.rs b/canvas/examples/autocomplete.rs index 955638f..0237524 100644 --- a/canvas/examples/autocomplete.rs +++ b/canvas/examples/autocomplete.rs @@ -20,21 +20,14 @@ use canvas::{ canvas::{ gui::render_canvas, modes::AppMode, - state::{ActionContext, CanvasState}, theme::CanvasTheme, }, - autocomplete::{ - AutocompleteCanvasState, - AutocompleteState, - SuggestionItem, - execute_with_autocomplete, - handle_autocomplete_feature_action, - }, - CanvasAction, + autocomplete::gui::render_autocomplete_dropdown, + FormEditor, DataProvider, AutocompleteProvider, SuggestionItem, }; -// Add the async_trait import use async_trait::async_trait; +use anyhow::Result; // Simple theme implementation #[derive(Clone)] @@ -58,150 +51,94 @@ struct EmailSuggestion { provider: String, } -// Demo form state with autocomplete -struct AutocompleteFormState { - fields: Vec, - field_names: Vec, - current_field: usize, - cursor_pos: usize, - mode: AppMode, - has_changes: bool, - debug_message: String, +// =================================================================== +// SIMPLE DATA PROVIDER - Only business data, no UI concerns! +// =================================================================== - // Autocomplete state - autocomplete: AutocompleteState, +struct ContactForm { + // Only business data - no UI state! + name: String, + email: String, + phone: String, + city: String, } -impl AutocompleteFormState { +impl ContactForm { fn new() -> Self { Self { - fields: vec![ - "John Doe".to_string(), - "john@".to_string(), // Partial email to demonstrate autocomplete - "+1 234 567 8900".to_string(), - "San Francisco".to_string(), - ], - field_names: vec![ - "Name".to_string(), - "Email".to_string(), - "Phone".to_string(), - "City".to_string(), - ], - current_field: 1, // Start on email field - cursor_pos: 5, // Position after "john@" - mode: AppMode::Edit, - has_changes: false, - debug_message: "Type in email field, Tab to trigger autocomplete, Enter to select, Esc to cancel".to_string(), - autocomplete: AutocompleteState::new(), + name: "John Doe".to_string(), + email: "john@".to_string(), // Partial email for demo + phone: "+1 234 567 8900".to_string(), + city: "San Francisco".to_string(), } } } -impl CanvasState for AutocompleteFormState { - fn current_field(&self) -> usize { self.current_field } - fn current_cursor_pos(&self) -> usize { self.cursor_pos } - fn set_current_field(&mut self, index: usize) { - self.current_field = index.min(self.fields.len().saturating_sub(1)); - // Clear autocomplete when changing fields - if self.is_autocomplete_active() { - self.clear_autocomplete_suggestions(); +// Simple trait implementation - only 4 methods! +impl DataProvider for ContactForm { + fn field_count(&self) -> usize { 4 } + + fn field_name(&self, index: usize) -> &str { + match index { + 0 => "Name", + 1 => "Email", + 2 => "Phone", + 3 => "City", + _ => "", } } - fn set_current_cursor_pos(&mut self, pos: usize) { - let max_pos = if self.mode == AppMode::Edit { - self.fields[self.current_field].len() - } else { - self.fields[self.current_field].len().saturating_sub(1) - }; - self.cursor_pos = pos.min(max_pos); - } - fn current_mode(&self) -> AppMode { self.mode } - fn get_current_input(&self) -> &str { &self.fields[self.current_field] } - fn get_current_input_mut(&mut self) -> &mut String { &mut self.fields[self.current_field] } - fn inputs(&self) -> Vec<&String> { self.fields.iter().collect() } - fn fields(&self) -> Vec<&str> { self.field_names.iter().map(|s| s.as_str()).collect() } - fn has_unsaved_changes(&self) -> bool { self.has_changes } - fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; } - - fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option { - // Handle autocomplete actions first - if let Some(result) = handle_autocomplete_feature_action(action, self) { - return Some(result); - } - - // Handle other custom actions - match action { - CanvasAction::Custom(cmd) => { - match cmd.as_str() { - "toggle_mode" => { - self.mode = match self.mode { - AppMode::Edit => AppMode::ReadOnly, - AppMode::ReadOnly => AppMode::Edit, - _ => AppMode::Edit, - }; - Some(format!("Switched to {:?} mode", self.mode)) - } - _ => None, - } - } - _ => None, + + fn field_value(&self, index: usize) -> &str { + match index { + 0 => &self.name, + 1 => &self.email, + 2 => &self.phone, + 3 => &self.city, + _ => "", } } -} - -// Add the #[async_trait] attribute to the implementation -#[async_trait] -impl AutocompleteCanvasState for AutocompleteFormState { - type SuggestionData = EmailSuggestion; - + + fn set_field_value(&mut self, index: usize, value: String) { + match index { + 0 => self.name = value, + 1 => self.email = value, + 2 => self.phone = value, + 3 => self.city = value, + _ => {} + } + } + fn supports_autocomplete(&self, field_index: usize) -> bool { - // Only enable autocomplete for email field (index 1) - field_index == 1 + field_index == 1 // Only email field } +} - fn autocomplete_state(&self) -> Option<&AutocompleteState> { - Some(&self.autocomplete) - } +// =================================================================== +// SIMPLE AUTOCOMPLETE PROVIDER - Only data fetching! +// =================================================================== - fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState> { - Some(&mut self.autocomplete) - } +struct EmailAutocomplete; - fn should_trigger_autocomplete(&self) -> bool { - let current_input = self.get_current_input(); - let current_field = self.current_field(); - - // Trigger for email field when we have "@" and at least 1 more character - self.supports_autocomplete(current_field) && - current_input.contains('@') && - current_input.len() > current_input.find('@').unwrap_or(0) + 1 && - !self.is_autocomplete_active() - } - - /// This is where the magic happens - user implements their own async fetching - async fn trigger_autocomplete_suggestions(&mut self) { - // 1. Activate UI (shows loading spinner) - self.activate_autocomplete(); - self.set_autocomplete_loading(true); - - // 2. Get current input for querying - let query = self.get_current_input().to_string(); - - // 3. Extract domain part from email - let domain_part = if let Some(at_pos) = query.find('@') { - query[at_pos + 1..].to_string() +#[async_trait] +impl AutocompleteProvider for EmailAutocomplete { + type SuggestionData = EmailSuggestion; + + async fn fetch_suggestions(&mut self, _field_index: usize, query: &str) + -> Result>> + { + // Extract domain part from email + let (email_prefix, domain_part) = if let Some(at_pos) = query.find('@') { + (query[..at_pos].to_string(), query[at_pos + 1..].to_string()) } else { - self.set_autocomplete_loading(false); - return; // No @ symbol, can't suggest + return Ok(Vec::new()); // No @ symbol }; - // 4. SIMULATE ASYNC API CALL (in real code, this would be HTTP request) - let email_prefix = query[..query.find('@').unwrap()].to_string(); + // Simulate async API call let suggestions = tokio::task::spawn_blocking(move || { // Simulate network delay std::thread::sleep(std::time::Duration::from_millis(200)); - // Create mock suggestions based on domain input + // Mock email suggestions let popular_domains = vec![ ("gmail.com", "Gmail"), ("yahoo.com", "Yahoo Mail"), @@ -212,110 +149,148 @@ impl AutocompleteCanvasState for AutocompleteFormState { ]; let mut results = Vec::new(); - for (domain, provider) in popular_domains { if domain.starts_with(&domain_part) || domain_part.is_empty() { let full_email = format!("{}@{}", email_prefix, domain); - results.push(SuggestionItem::new( - EmailSuggestion { + results.push(SuggestionItem { + data: EmailSuggestion { email: full_email.clone(), provider: provider.to_string(), }, - format!("{} ({})", full_email, provider), // display text - full_email, // value to store - )); + display_text: format!("{} ({})", full_email, provider), + value_to_store: full_email, + }); } } - results }).await.unwrap_or_default(); - // 5. Provide suggestions back to library - self.set_autocomplete_suggestions(suggestions); + Ok(suggestions) } } -async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut AutocompleteFormState) -> bool { +// =================================================================== +// APPLICATION STATE - Much simpler! +// =================================================================== + +struct AppState { + editor: FormEditor, + autocomplete: EmailAutocomplete, + debug_message: String, +} + +impl AppState { + fn new() -> Self { + let contact_form = ContactForm::new(); + let mut editor = FormEditor::new(contact_form); + + // Start on email field (index 1) at end of existing text + editor.set_mode(AppMode::Edit); + // TODO: Add method to set initial field/cursor position + + Self { + editor, + autocomplete: EmailAutocomplete, + debug_message: "Type in email field, Tab to trigger autocomplete, Enter to select, Esc to cancel".to_string(), + } + } +} + +// =================================================================== +// INPUT HANDLING - Much cleaner! +// =================================================================== + +async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut AppState) -> bool { if key == KeyCode::F(10) || (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) { return false; // Quit } - let action = match key { + // Handle input based on key + let result = match key { // === AUTOCOMPLETE KEYS === KeyCode::Tab => { - if state.is_autocomplete_active() { - Some(CanvasAction::SuggestionDown) // Navigate suggestions - } else if state.supports_autocomplete(state.current_field()) { - Some(CanvasAction::TriggerAutocomplete) // Manual trigger + if state.editor.is_autocomplete_active() { + state.editor.autocomplete_next(); + Ok("Navigated to next suggestion".to_string()) + } else if state.editor.data_provider().supports_autocomplete(state.editor.current_field()) { + state.editor.trigger_autocomplete(&mut state.autocomplete).await + .map(|_| "Triggered autocomplete".to_string()) } else { - Some(CanvasAction::NextField) // Normal tab - } - } - - KeyCode::BackTab => { - if state.is_autocomplete_active() { - Some(CanvasAction::SuggestionUp) - } else { - Some(CanvasAction::PrevField) + state.editor.move_to_next_field(); + Ok("Moved to next field".to_string()) } } KeyCode::Enter => { - if state.is_autocomplete_active() { - Some(CanvasAction::SelectSuggestion) // Apply suggestion + if state.editor.is_autocomplete_active() { + if let Some(applied) = state.editor.apply_autocomplete() { + Ok(format!("Applied: {}", applied)) + } else { + Ok("No suggestion to apply".to_string()) + } } else { - Some(CanvasAction::NextField) + state.editor.move_to_next_field(); + Ok("Moved to next field".to_string()) } } KeyCode::Esc => { - if state.is_autocomplete_active() { - Some(CanvasAction::ExitSuggestions) // Close autocomplete + if state.editor.is_autocomplete_active() { + // Autocomplete will be cleared automatically by mode change + Ok("Cancelled autocomplete".to_string()) } else { - Some(CanvasAction::Custom("toggle_mode".to_string())) + // Toggle between edit and readonly mode + let new_mode = match state.editor.mode() { + AppMode::Edit => AppMode::ReadOnly, + _ => AppMode::Edit, + }; + state.editor.set_mode(new_mode); + Ok(format!("Switched to {:?} mode", new_mode)) } } - // === STANDARD CANVAS KEYS === - KeyCode::Left => Some(CanvasAction::MoveLeft), - KeyCode::Right => Some(CanvasAction::MoveRight), - KeyCode::Up => Some(CanvasAction::MoveUp), - KeyCode::Down => Some(CanvasAction::MoveDown), - KeyCode::Home => Some(CanvasAction::MoveLineStart), - KeyCode::End => Some(CanvasAction::MoveLineEnd), - KeyCode::Backspace => Some(CanvasAction::DeleteBackward), - KeyCode::Delete => Some(CanvasAction::DeleteForward), - - // Character input - KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => { - Some(CanvasAction::InsertChar(c)) + // === MOVEMENT KEYS === + KeyCode::Left => { + state.editor.move_left(); + Ok("Moved left".to_string()) + } + KeyCode::Right => { + state.editor.move_right(); + Ok("Moved right".to_string()) + } + KeyCode::Up => { + state.editor.move_to_next_field(); // TODO: Add move_up method + Ok("Moved up".to_string()) + } + KeyCode::Down => { + state.editor.move_to_next_field(); // TODO: Add move_down method + Ok("Moved down".to_string()) } - _ => None, + // === TEXT INPUT === + KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => { + state.editor.insert_char(c) + .map(|_| format!("Inserted '{}'", c)) + } + + KeyCode::Backspace => { + // TODO: Add delete_backward method to FormEditor + Ok("Backspace (not implemented yet)".to_string()) + } + + _ => Ok(format!("Unhandled key: {:?}", key)), }; - if let Some(action) = action { - match execute_with_autocomplete(action.clone(), state).await { - Ok(result) => { - if let Some(msg) = result.message() { - state.debug_message = msg.to_string(); - } else { - state.debug_message = format!("Executed: {:?}", action); - } - true - } - Err(e) => { - state.debug_message = format!("Error: {}", e); - true - } - } - } else { - state.debug_message = format!("Unhandled key: {:?}", key); - true + // Update debug message + match result { + Ok(msg) => state.debug_message = msg, + Err(e) => state.debug_message = format!("Error: {}", e), } + + true } -async fn run_app(terminal: &mut Terminal, mut state: AutocompleteFormState) -> io::Result<()> { +async fn run_app(terminal: &mut Terminal, mut state: AppState) -> io::Result<()> { let theme = DemoTheme; loop { @@ -332,7 +307,7 @@ async fn run_app(terminal: &mut Terminal, mut state: Autocomplete Ok(()) } -fn ui(f: &mut Frame, state: &AutocompleteFormState, theme: &DemoTheme) { +fn ui(f: &mut Frame, state: &AppState, theme: &DemoTheme) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -341,33 +316,31 @@ fn ui(f: &mut Frame, state: &AutocompleteFormState, theme: &DemoTheme) { ]) .split(f.area()); - // Render the canvas form + // Render the canvas form - much simpler! let active_field_rect = render_canvas( f, chunks[0], - state, + &state.editor, theme, - state.mode == AppMode::Edit, - &canvas::HighlightState::Off, ); - // Render autocomplete dropdown on top if active + // Render autocomplete dropdown if active if let Some(input_rect) = active_field_rect { - canvas::render_autocomplete_dropdown( + render_autocomplete_dropdown( f, chunks[0], input_rect, theme, - &state.autocomplete, + &state.editor, ); } // Status info - let autocomplete_status = if state.is_autocomplete_active() { - if state.autocomplete.is_loading { + let autocomplete_status = if state.editor.is_autocomplete_active() { + if state.editor.ui_state().is_autocomplete_loading() { "Loading suggestions..." - } else if state.has_autocomplete_suggestions() { - "Use Tab/Shift+Tab to navigate, Enter to select, Esc to cancel" + } else if !state.editor.suggestions().is_empty() { + "Use Tab to navigate, Enter to select, Esc to cancel" } else { "No suggestions found" } @@ -377,7 +350,10 @@ fn ui(f: &mut Frame, state: &AutocompleteFormState, theme: &DemoTheme) { let status_lines = vec![ Line::from(Span::raw(format!("Mode: {:?} | Field: {}/{} | Cursor: {}", - state.mode, state.current_field + 1, state.fields.len(), state.cursor_pos))), + state.editor.mode(), + state.editor.current_field() + 1, + state.editor.data_provider().field_count(), + state.editor.cursor_position()))), Line::from(Span::raw(format!("Autocomplete: {}", autocomplete_status))), Line::from(Span::raw(state.debug_message.clone())), Line::from(Span::raw("F10: Quit | Tab: Trigger/Navigate autocomplete | Enter: Select | Esc: Cancel/Toggle mode")), @@ -397,8 +373,7 @@ async fn main() -> Result<(), Box> { let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - let state = AutocompleteFormState::new(); - + let state = AppState::new(); let res = run_app(&mut terminal, state).await; disable_raw_mode()?; diff --git a/canvas/src/autocomplete/actions.rs b/canvas/src/autocomplete/actions.rs index 4c86e6c..1fa130d 100644 --- a/canvas/src/autocomplete/actions.rs +++ b/canvas/src/autocomplete/actions.rs @@ -1,170 +1,47 @@ // src/autocomplete/actions.rs +//! Legacy autocomplete actions - deprecated in favor of FormEditor -use crate::canvas::state::CanvasState; -use crate::autocomplete::state::AutocompleteCanvasState; use crate::canvas::actions::types::{CanvasAction, ActionResult}; -use crate::canvas::actions::execute; use anyhow::Result; -/// Enhanced execute function for states that support autocomplete -/// This is the main entry point for autocomplete-aware canvas execution -/// -/// Use this instead of canvas::execute() if you want autocomplete behavior: -/// ```rust +/// Legacy function - use FormEditor.trigger_autocomplete() instead +/// +/// # Migration Guide +/// +/// **Old way:** +/// ```rust,ignore /// execute_with_autocomplete(action, &mut state).await?; /// ``` -pub async fn execute_with_autocomplete( - action: CanvasAction, - state: &mut S, -) -> Result { - match &action { - // === AUTOCOMPLETE-SPECIFIC ACTIONS === - - CanvasAction::TriggerAutocomplete => { - if state.supports_autocomplete(state.current_field()) { - state.trigger_autocomplete_suggestions().await; - Ok(ActionResult::success_with_message("Triggered autocomplete")) - } else { - Ok(ActionResult::success_with_message("Autocomplete not supported for this field")) - } - } - - CanvasAction::SuggestionUp => { - if state.has_autocomplete_suggestions() { - state.move_suggestion_selection(-1); - Ok(ActionResult::success()) - } else { - Ok(ActionResult::success_with_message("No suggestions available")) - } - } - - CanvasAction::SuggestionDown => { - if state.has_autocomplete_suggestions() { - state.move_suggestion_selection(1); - Ok(ActionResult::success()) - } else { - Ok(ActionResult::success_with_message("No suggestions available")) - } - } - - CanvasAction::SelectSuggestion => { - if let Some(message) = state.apply_selected_suggestion() { - Ok(ActionResult::success_with_message(&message)) - } else { - Ok(ActionResult::success_with_message("No suggestion to select")) - } - } - - CanvasAction::ExitSuggestions => { - state.clear_autocomplete_suggestions(); - Ok(ActionResult::success_with_message("Closed autocomplete")) - } - - // === TEXT INSERTION WITH AUTO-TRIGGER === - - CanvasAction::InsertChar(_) => { - // First, execute the character insertion normally - let result = execute(action, state).await?; - - // After successful insertion, check if we should auto-trigger autocomplete - if result.is_success() && state.should_trigger_autocomplete() { - state.trigger_autocomplete_suggestions().await; - } - - Ok(result) - } - - // === NAVIGATION/EDITING ACTIONS (clear autocomplete first) === - - CanvasAction::MoveLeft | CanvasAction::MoveRight | - CanvasAction::MoveUp | CanvasAction::MoveDown | - CanvasAction::NextField | CanvasAction::PrevField | - CanvasAction::DeleteBackward | CanvasAction::DeleteForward => { - // Clear autocomplete when navigating/editing - if state.is_autocomplete_active() { - state.clear_autocomplete_suggestions(); - } - - // Execute the action normally - execute(action, state).await - } - - // === ALL OTHER ACTIONS (normal execution) === - - _ => { - // For all other actions, just execute normally - execute(action, state).await - } - } -} - -/// Helper function to integrate autocomplete actions with CanvasState.handle_feature_action() -/// -/// Use this in your CanvasState implementation like this: -/// ```rust -/// fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option { -/// // Try autocomplete first -/// if let Some(result) = handle_autocomplete_feature_action(action, self) { -/// return Some(result); +/// +/// **New way:** +/// ```rust,ignore +/// let mut editor = FormEditor::new(your_data_provider); +/// match action { +/// CanvasAction::TriggerAutocomplete => { +/// editor.trigger_autocomplete(&mut autocomplete_provider).await?; /// } -/// -/// // Handle your other custom actions... -/// None +/// CanvasAction::InsertChar(c) => { +/// editor.insert_char(c)?; +/// } +/// // ... etc /// } /// ``` -pub fn handle_autocomplete_feature_action( - action: &CanvasAction, - state: &S, -) -> Option { - match action { - CanvasAction::TriggerAutocomplete => { - if state.supports_autocomplete(state.current_field()) { - if state.is_autocomplete_active() { - Some("Autocomplete already active".to_string()) - } else { - None // Let execute_with_autocomplete handle it - } - } else { - Some("Autocomplete not available for this field".to_string()) - } - } - - CanvasAction::SuggestionUp | CanvasAction::SuggestionDown => { - if state.is_autocomplete_active() { - None // Let execute_with_autocomplete handle navigation - } else { - Some("No autocomplete suggestions to navigate".to_string()) - } - } - - CanvasAction::SelectSuggestion => { - if state.has_autocomplete_suggestions() { - None // Let execute_with_autocomplete handle selection - } else { - Some("No suggestion to select".to_string()) - } - } - - CanvasAction::ExitSuggestions => { - if state.is_autocomplete_active() { - None // Let execute_with_autocomplete handle exit - } else { - Some("No autocomplete to close".to_string()) - } - } - - _ => None // Not an autocomplete action - } -} - -/// Legacy compatibility function - kept for backward compatibility -/// This is the old function signature, now it just wraps the new system -#[deprecated(note = "Use execute_with_autocomplete instead")] -pub async fn execute_canvas_action_with_autocomplete( - action: CanvasAction, - state: &mut S, - _ideal_cursor_column: &mut usize, // Ignored - new system manages this internally - _config: Option<&()>, // Ignored - no more config system +#[deprecated(note = "Use FormEditor.trigger_autocomplete() and related methods instead")] +pub async fn execute_with_autocomplete( + _action: CanvasAction, + _state: &mut T, ) -> Result { - execute_with_autocomplete(action, state).await + Err(anyhow::anyhow!( + "execute_with_autocomplete is deprecated. Use FormEditor API instead.\n\ + Migration: Replace CanvasState trait with DataProvider trait and use FormEditor." + )) +} + +/// Legacy function - use FormEditor methods instead +#[deprecated(note = "Use FormEditor methods instead")] +pub fn handle_autocomplete_feature_action( + _action: &CanvasAction, + _state: &T, +) -> Option { + Some("handle_autocomplete_feature_action is deprecated. Use FormEditor API instead.".to_string()) } diff --git a/canvas/src/autocomplete/gui.rs b/canvas/src/autocomplete/gui.rs index 52ffbbd..0f74cb0 100644 --- a/canvas/src/autocomplete/gui.rs +++ b/canvas/src/autocomplete/gui.rs @@ -1,4 +1,5 @@ // src/autocomplete/gui.rs +//! Autocomplete GUI updated to work with FormEditor #[cfg(feature = "gui")] use ratatui::{ @@ -8,32 +9,33 @@ use ratatui::{ Frame, }; -// Use the correct import from our types module -use crate::autocomplete::types::AutocompleteState; - #[cfg(feature = "gui")] use crate::canvas::theme::CanvasTheme; +use crate::data_provider::{DataProvider, SuggestionItem}; +use crate::editor::FormEditor; #[cfg(feature = "gui")] use unicode_width::UnicodeWidthStr; -/// Render autocomplete dropdown - call this AFTER rendering canvas +/// Render autocomplete dropdown for FormEditor - call this AFTER rendering canvas #[cfg(feature = "gui")] -pub fn render_autocomplete_dropdown( +pub fn render_autocomplete_dropdown( f: &mut Frame, frame_area: Rect, input_rect: Rect, theme: &T, - autocomplete_state: &AutocompleteState, + editor: &FormEditor, ) { - if !autocomplete_state.is_active { + let ui_state = editor.ui_state(); + + if !ui_state.is_autocomplete_active() { return; } - if autocomplete_state.is_loading { + if ui_state.autocomplete.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); + } else if !editor.suggestions().is_empty() { + render_suggestions_dropdown(f, frame_area, input_rect, theme, editor.suggestions(), ui_state.autocomplete.selected_index); } } @@ -69,14 +71,15 @@ fn render_loading_indicator( /// Show actual suggestions list #[cfg(feature = "gui")] -fn render_suggestions_dropdown( +fn render_suggestions_dropdown( f: &mut Frame, frame_area: Rect, input_rect: Rect, theme: &T, - autocomplete_state: &AutocompleteState, + suggestions: &[SuggestionItem], + selected_index: Option, ) { - let display_texts: Vec<&str> = autocomplete_state.suggestions + let display_texts: Vec<&str> = suggestions .iter() .map(|item| item.display_text.as_str()) .collect(); @@ -96,19 +99,19 @@ fn render_suggestions_dropdown( // List items let items = create_suggestion_list_items( &display_texts, - autocomplete_state.selected_index, + 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); + list_state.select(selected_index); f.render_stateful_widget(list, dropdown_area, &mut list_state); } -/// Calculate dropdown size based on suggestions - updated to match client dimensions +/// Calculate dropdown size based on suggestions #[cfg(feature = "gui")] fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions { let max_width = display_texts @@ -117,9 +120,9 @@ fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions { .max() .unwrap_or(0) as u16; - let horizontal_padding = 2; // Changed from 4 to 2 to match client - let width = (max_width + horizontal_padding).max(10); // Changed from 12 to 10 to match client - let height = (display_texts.len() as u16).min(5); // Removed +2 since no borders + let horizontal_padding = 2; + let width = (max_width + horizontal_padding).max(10); + let height = (display_texts.len() as u16).min(5); DropdownDimensions { width, height } } @@ -152,7 +155,7 @@ fn calculate_dropdown_position( dropdown_area } -/// Create styled list items - updated to match client spacing +/// Create styled list items #[cfg(feature = "gui")] fn create_suggestion_list_items<'a, T: CanvasTheme>( display_texts: &'a [&'a str], @@ -160,8 +163,7 @@ fn create_suggestion_list_items<'a, T: CanvasTheme>( dropdown_width: u16, theme: &T, ) -> Vec> { - let horizontal_padding = 2; // Changed from 4 to 2 to match client - let available_width = dropdown_width; // No border padding needed + let available_width = dropdown_width; display_texts .iter() diff --git a/canvas/src/autocomplete/mod.rs b/canvas/src/autocomplete/mod.rs index 31bcb4a..0ef3b62 100644 --- a/canvas/src/autocomplete/mod.rs +++ b/canvas/src/autocomplete/mod.rs @@ -9,7 +9,6 @@ pub mod gui; // Re-export the main autocomplete API pub use types::{SuggestionItem, AutocompleteState}; -pub use state::AutocompleteCanvasState; // Re-export the new action functions pub use actions::{ diff --git a/canvas/src/autocomplete/state.rs b/canvas/src/autocomplete/state.rs index 46b0905..6b4fe11 100644 --- a/canvas/src/autocomplete/state.rs +++ b/canvas/src/autocomplete/state.rs @@ -1,189 +1,9 @@ // src/autocomplete/state.rs +//! Simple autocomplete provider pattern - replaces complex trait -use crate::canvas::state::CanvasState; -use async_trait::async_trait; +// Re-export the main types from data_provider for backward compatibility +pub use crate::data_provider::{AutocompleteProvider, SuggestionItem}; -/// OPTIONAL extension trait for states that want rich autocomplete functionality. -/// Only implement this if you need the new autocomplete features. -/// -/// # User Workflow: -/// 1. User presses trigger key (Tab, Ctrl+K, etc.) -/// 2. User's key mapping calls CanvasAction::TriggerAutocomplete -/// 3. Library calls your trigger_autocomplete_suggestions() method -/// 4. You implement async fetching logic in that method -/// 5. You call set_autocomplete_suggestions() with results -/// 6. Library manages UI state and navigation -#[async_trait] -pub trait AutocompleteCanvasState: CanvasState { - /// Associated type for suggestion data (e.g., Hit, String, CustomType) - type SuggestionData: Clone + Send + 'static; - - /// Check if a field supports autocomplete (user decides which fields) - fn supports_autocomplete(&self, _field_index: usize) -> bool { - false // Default: no autocomplete support - } - - /// Get autocomplete state (read-only) - fn autocomplete_state(&self) -> Option<&crate::autocomplete::AutocompleteState> { - None // Default: no autocomplete state - } - - /// Get autocomplete state (mutable) - fn autocomplete_state_mut(&mut self) -> Option<&mut crate::autocomplete::AutocompleteState> { - None // Default: no autocomplete state - } - - // === PUBLIC API METHODS (called by library) === - - /// Activate autocomplete for current field (shows loading spinner) - fn activate_autocomplete(&mut self) { - let current_field = self.current_field(); - if let Some(state) = self.autocomplete_state_mut() { - state.activate(current_field); - } - } - - /// Deactivate autocomplete (hides dropdown) - fn deactivate_autocomplete(&mut self) { - if let Some(state) = self.autocomplete_state_mut() { - state.deactivate(); - } - } - - /// Set suggestions (called after your async fetch completes) - fn set_autocomplete_suggestions(&mut self, suggestions: Vec>) { - if let Some(state) = self.autocomplete_state_mut() { - state.set_suggestions(suggestions); - } - } - - /// Set loading state (show/hide spinner) - fn set_autocomplete_loading(&mut self, loading: bool) { - if let Some(state) = self.autocomplete_state_mut() { - state.is_loading = loading; - } - } - - // === QUERY METHODS === - - /// Check if autocomplete is currently active/visible - fn is_autocomplete_active(&self) -> bool { - self.autocomplete_state() - .map(|state| state.is_active) - .unwrap_or(false) - } - - /// Check if autocomplete has suggestions ready for navigation - fn is_autocomplete_ready(&self) -> bool { - self.autocomplete_state() - .map(|state| state.is_ready()) - .unwrap_or(false) - } - - /// Check if there are available suggestions - fn has_autocomplete_suggestions(&self) -> bool { - self.autocomplete_state() - .map(|state| !state.suggestions.is_empty()) - .unwrap_or(false) - } - - // === USER-IMPLEMENTABLE METHODS === - - /// Check if autocomplete should be triggered automatically (e.g., after typing 2+ chars) - /// Override this to implement your own trigger logic - fn should_trigger_autocomplete(&self) -> bool { - let current_input = self.get_current_input(); - let current_field = self.current_field(); - - self.supports_autocomplete(current_field) && - current_input.len() >= 2 && // Default: trigger after 2 chars - !self.is_autocomplete_active() - } - - /// **USER MUST IMPLEMENT**: Trigger autocomplete suggestions (async) - /// This is where you implement your API calls, caching, etc. - /// - /// # Example Implementation: - /// ```rust - /// #[async_trait] - /// impl AutocompleteCanvasState for MyState { - /// type SuggestionData = MyData; - /// - /// async fn trigger_autocomplete_suggestions(&mut self) { - /// self.activate_autocomplete(); // Show loading state - /// - /// let query = self.get_current_input().to_string(); - /// let suggestions = my_api.search(&query).await.unwrap_or_default(); - /// - /// self.set_autocomplete_suggestions(suggestions); - /// } - /// } - /// ``` - async fn trigger_autocomplete_suggestions(&mut self) { - // Activate autocomplete UI - self.activate_autocomplete(); - - // Default: just show loading state - // User should override this to do actual async fetching - self.set_autocomplete_loading(true); - - // In a real implementation, you'd: - // 1. Get current input: let query = self.get_current_input(); - // 2. Make API call: let results = api.search(query).await; - // 3. Convert to suggestions: let suggestions = results.into_suggestions(); - // 4. Set suggestions: self.set_autocomplete_suggestions(suggestions); - } - - // === INTERNAL NAVIGATION METHODS (called by library actions) === - - /// Clear autocomplete suggestions and hide dropdown - fn clear_autocomplete_suggestions(&mut self) { - self.deactivate_autocomplete(); - } - - /// Move selection up/down in suggestions list - fn move_suggestion_selection(&mut self, direction: i32) { - if let Some(state) = self.autocomplete_state_mut() { - if direction > 0 { - state.select_next(); - } else { - state.select_previous(); - } - } - } - - /// Get currently selected suggestion for display/application - fn get_selected_suggestion(&self) -> Option> { - self.autocomplete_state()? - .get_selected() - .cloned() - } - - /// Apply the selected suggestion to the current field - fn apply_suggestion(&mut self, suggestion: &crate::autocomplete::SuggestionItem) { - // Apply the value to current field - *self.get_current_input_mut() = suggestion.value_to_store.clone(); - self.set_has_unsaved_changes(true); - - // Clear autocomplete - self.clear_autocomplete_suggestions(); - } - - /// Apply the currently selected suggestion (convenience method) - fn apply_selected_suggestion(&mut self) -> Option { - if let Some(suggestion) = self.get_selected_suggestion() { - let display_text = suggestion.display_text.clone(); - self.apply_suggestion(&suggestion); - Some(format!("Applied: {}", display_text)) - } else { - None - } - } - - // === LEGACY COMPATIBILITY === - - /// INTERNAL: Apply selected autocomplete value to current field (legacy method) - fn apply_autocomplete_selection(&mut self) -> Option { - self.apply_selected_suggestion() - } -} +// Legacy compatibility - empty trait for migration +#[deprecated(note = "Use AutocompleteProvider instead")] +pub trait AutocompleteCanvasState {} diff --git a/canvas/src/autocomplete/types.rs b/canvas/src/autocomplete/types.rs index e9f96b2..dfaea81 100644 --- a/canvas/src/autocomplete/types.rs +++ b/canvas/src/autocomplete/types.rs @@ -1,126 +1,21 @@ -// canvas/src/autocomplete.rs +// src/autocomplete/types.rs +//! Legacy autocomplete types - deprecated -/// Generic suggestion item that clients push to canvas -#[derive(Debug, Clone)] -pub struct SuggestionItem { - /// The underlying data (client-specific, e.g., Hit, String, etc.) - pub data: T, - /// Text to display in the dropdown - pub display_text: String, - /// Value to store in the form field when selected - pub value_to_store: String, -} +// Re-export the new simplified types +pub use crate::data_provider::SuggestionItem; -impl SuggestionItem { - pub fn new(data: T, display_text: String, value_to_store: String) -> Self { - Self { - data, - display_text, - value_to_store, - } - } - - /// Convenience constructor for simple string suggestions - pub fn simple(data: T, text: String) -> Self { - Self { - data, - display_text: text.clone(), - value_to_store: text, - } - } -} - -/// Autocomplete state managed by canvas +/// Legacy type - use FormEditor instead +#[deprecated(note = "Use FormEditor instead")] #[derive(Debug, Clone)] pub struct AutocompleteState { - /// Whether autocomplete is currently active/visible - pub is_active: bool, - /// Whether suggestions are being loaded (for spinner/loading indicator) - pub is_loading: bool, - /// Current suggestions to display - pub suggestions: Vec>, - /// Currently selected suggestion index - pub selected_index: Option, - /// Field index that triggered autocomplete (for context) - pub active_field: Option, -} - -impl Default for AutocompleteState { - fn default() -> Self { - Self { - is_active: false, - is_loading: false, - suggestions: Vec::new(), - selected_index: None, - active_field: None, - } - } + _phantom: std::marker::PhantomData, } +#[allow(dead_code)] impl AutocompleteState { - pub fn new() -> Self { - Self::default() - } - - /// Activate autocomplete for a specific field - pub fn activate(&mut self, field_index: usize) { - self.is_active = true; - self.active_field = Some(field_index); - self.selected_index = None; - self.suggestions.clear(); - self.is_loading = true; - } - - /// Deactivate autocomplete and clear state - pub fn deactivate(&mut self) { - self.is_active = false; - self.is_loading = false; - self.suggestions.clear(); - self.selected_index = None; - self.active_field = None; - } - - /// Set suggestions and stop loading - pub fn set_suggestions(&mut self, suggestions: Vec>) { - self.suggestions = suggestions; - self.is_loading = false; - self.selected_index = if self.suggestions.is_empty() { - None - } else { - Some(0) - }; - } - - /// Move selection down - pub fn select_next(&mut self) { - if !self.suggestions.is_empty() { - let current = self.selected_index.unwrap_or(0); - self.selected_index = Some((current + 1) % self.suggestions.len()); - } - } - - /// Move selection up - pub fn select_previous(&mut self) { - if !self.suggestions.is_empty() { - let current = self.selected_index.unwrap_or(0); - self.selected_index = Some( - if current == 0 { - self.suggestions.len() - 1 - } else { - current - 1 - } - ); - } - } - - /// Get currently selected suggestion - pub fn get_selected(&self) -> Option<&SuggestionItem> { - self.selected_index - .and_then(|idx| self.suggestions.get(idx)) - } - - /// Check if autocomplete is ready for interaction (active and has suggestions) - pub fn is_ready(&self) -> bool { - self.is_active && !self.suggestions.is_empty() && !self.is_loading + /// Legacy method - use FormEditor.is_autocomplete_active() instead + #[deprecated(note = "Use FormEditor.is_autocomplete_active() instead")] + pub fn is_active(&self) -> bool { + false } } diff --git a/canvas/src/canvas/actions/handlers/dispatcher.rs b/canvas/src/canvas/actions/handlers/dispatcher.rs index 31dc0d3..6636574 100644 --- a/canvas/src/canvas/actions/handlers/dispatcher.rs +++ b/canvas/src/canvas/actions/handlers/dispatcher.rs @@ -1,43 +1,30 @@ // src/canvas/actions/handlers/dispatcher.rs -use crate::canvas::state::{CanvasState, ActionContext}; +use crate::canvas::state::EditorState; use crate::canvas::actions::{CanvasAction, ActionResult}; use crate::canvas::modes::AppMode; -use anyhow::Result; use super::{handle_edit_action, handle_readonly_action, handle_highlight_action}; -/// Main action dispatcher - routes actions to mode-specific handlers -pub async fn dispatch_action( +/// Internal action dispatcher - routes actions to mode-specific handlers +pub(crate) fn dispatch_action_internal( action: CanvasAction, - state: &mut S, - ideal_cursor_column: &mut usize, -) -> Result { - // Check if the application wants to handle this action first - let context = ActionContext { - key_code: None, - ideal_cursor_column: *ideal_cursor_column, - current_input: state.get_current_input().to_string(), - current_field: state.current_field(), - }; - - if let Some(result) = state.handle_feature_action(&action, &context) { - return Ok(ActionResult::HandledByFeature(result)); - } - - // Route to mode-specific handler - match state.current_mode() { + editor_state: &mut EditorState, + current_text: &str, +) -> ActionResult { + // Route to mode-specific handler based on current mode + match editor_state.current_mode { AppMode::Edit => { - handle_edit_action(action, state, ideal_cursor_column).await + handle_edit_action(action, editor_state, current_text) } AppMode::ReadOnly => { - handle_readonly_action(action, state, ideal_cursor_column).await + handle_readonly_action(action, editor_state, current_text) } AppMode::Highlight => { - handle_highlight_action(action, state, ideal_cursor_column).await + handle_highlight_action(action, editor_state, current_text) } AppMode::General | AppMode::Command => { - Ok(ActionResult::success_with_message("Mode does not handle canvas actions directly")) + ActionResult::success_with_message("Mode does not handle canvas actions directly") } } } diff --git a/canvas/src/canvas/actions/handlers/edit.rs b/canvas/src/canvas/actions/handlers/edit.rs index b86a0ba..c2706da 100644 --- a/canvas/src/canvas/actions/handlers/edit.rs +++ b/canvas/src/canvas/actions/handlers/edit.rs @@ -1,213 +1,143 @@ // src/canvas/actions/handlers/edit.rs -//! Edit mode action handler -//! -//! Handles user input when in edit mode, supporting text entry, deletion, -//! and cursor movement with edit-specific behavior (cursor can go past end of text). +//! Edit mode action handler with EditorState use crate::canvas::actions::types::{CanvasAction, ActionResult}; use crate::canvas::actions::movement::*; -use crate::canvas::state::CanvasState; -use anyhow::Result; +use crate::canvas::state::EditorState; /// Edit mode uses cursor-past-end behavior for text insertion const FOR_EDIT_MODE: bool = true; /// Handle actions in edit mode with edit-specific cursor behavior -/// -/// Edit mode allows text modification and uses cursor positioning that can -/// go past the end of existing text to facilitate insertion. -/// -/// # Arguments -/// * `action` - The action to perform -/// * `state` - Mutable canvas state -/// * `ideal_cursor_column` - Desired column for vertical movement (maintained across line changes) -pub async fn handle_edit_action( +pub(crate) fn handle_edit_action( action: CanvasAction, - state: &mut S, - ideal_cursor_column: &mut usize, -) -> Result { + editor_state: &mut EditorState, + current_text: &str, +) -> ActionResult { match action { - CanvasAction::InsertChar(c) => { - // Insert character at cursor position and advance cursor - let cursor_pos = state.current_cursor_pos(); - let input = state.get_current_input_mut(); - input.insert(cursor_pos, c); - state.set_current_cursor_pos(cursor_pos + 1); - state.set_has_unsaved_changes(true); - *ideal_cursor_column = cursor_pos + 1; - Ok(ActionResult::success()) - } - - CanvasAction::DeleteBackward => { - // Delete character before cursor (Backspace behavior) - let cursor_pos = state.current_cursor_pos(); - if cursor_pos > 0 { - let input = state.get_current_input_mut(); - input.remove(cursor_pos - 1); - state.set_current_cursor_pos(cursor_pos - 1); - state.set_has_unsaved_changes(true); - *ideal_cursor_column = cursor_pos - 1; - } - Ok(ActionResult::success()) - } - - CanvasAction::DeleteForward => { - // Delete character at cursor position (Delete key behavior) - let cursor_pos = state.current_cursor_pos(); - let input = state.get_current_input_mut(); - if cursor_pos < input.len() { - input.remove(cursor_pos); - state.set_has_unsaved_changes(true); - } - Ok(ActionResult::success()) - } - + // Note: Text insertion is handled at the FormEditor level + // These handlers only deal with cursor movement and navigation + // Cursor movement actions CanvasAction::MoveLeft => { - let new_pos = move_left(state.current_cursor_pos()); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; - Ok(ActionResult::success()) + let new_pos = move_left(editor_state.cursor_pos); + editor_state.cursor_pos = new_pos; + editor_state.ideal_cursor_column = new_pos; + ActionResult::success() } CanvasAction::MoveRight => { - let current_input = state.get_current_input(); - let current_pos = state.current_cursor_pos(); - let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; - Ok(ActionResult::success()) + let new_pos = move_right(editor_state.cursor_pos, current_text, FOR_EDIT_MODE); + editor_state.cursor_pos = new_pos; + editor_state.ideal_cursor_column = new_pos; + ActionResult::success() } // Field navigation (treating single-line fields as "lines") CanvasAction::MoveUp => { - let current_field = state.current_field(); - if current_field > 0 { - state.set_current_field(current_field - 1); - let current_input = state.get_current_input(); - let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE); - state.set_current_cursor_pos(new_pos); + if editor_state.current_field > 0 { + editor_state.current_field -= 1; + let new_pos = safe_cursor_position(current_text, editor_state.ideal_cursor_column, FOR_EDIT_MODE); + editor_state.cursor_pos = new_pos; } - Ok(ActionResult::success()) + ActionResult::success() } CanvasAction::MoveDown => { - let current_field = state.current_field(); - let total_fields = state.fields().len(); - if current_field < total_fields - 1 { - state.set_current_field(current_field + 1); - let current_input = state.get_current_input(); - let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE); - state.set_current_cursor_pos(new_pos); - } - Ok(ActionResult::success()) + // Note: field count validation happens at FormEditor level + editor_state.current_field += 1; + let new_pos = safe_cursor_position(current_text, editor_state.ideal_cursor_column, FOR_EDIT_MODE); + editor_state.cursor_pos = new_pos; + ActionResult::success() } // Line-based movement CanvasAction::MoveLineStart => { let new_pos = line_start_position(); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; - Ok(ActionResult::success()) + editor_state.cursor_pos = new_pos; + editor_state.ideal_cursor_column = new_pos; + ActionResult::success() } CanvasAction::MoveLineEnd => { - let current_input = state.get_current_input(); - let new_pos = line_end_position(current_input, FOR_EDIT_MODE); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; - Ok(ActionResult::success()) + let new_pos = line_end_position(current_text, FOR_EDIT_MODE); + editor_state.cursor_pos = new_pos; + editor_state.ideal_cursor_column = new_pos; + ActionResult::success() } // Document-level movement (first/last field) CanvasAction::MoveFirstLine => { - state.set_current_field(0); - let current_input = state.get_current_input(); - let new_pos = safe_cursor_position(current_input, 0, FOR_EDIT_MODE); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; - Ok(ActionResult::success()) + editor_state.current_field = 0; + let new_pos = safe_cursor_position(current_text, 0, FOR_EDIT_MODE); + editor_state.cursor_pos = new_pos; + editor_state.ideal_cursor_column = new_pos; + ActionResult::success() } CanvasAction::MoveLastLine => { - let last_field = state.fields().len() - 1; - state.set_current_field(last_field); - let current_input = state.get_current_input(); - let new_pos = line_end_position(current_input, FOR_EDIT_MODE); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; - Ok(ActionResult::success()) + // Note: field count validation happens at FormEditor level + let new_pos = line_end_position(current_text, FOR_EDIT_MODE); + editor_state.cursor_pos = new_pos; + editor_state.ideal_cursor_column = new_pos; + ActionResult::success() } // Word-based movement CanvasAction::MoveWordNext => { - let current_input = state.get_current_input(); - if !current_input.is_empty() { - let new_pos = find_next_word_start(current_input, state.current_cursor_pos()); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; + if !current_text.is_empty() { + let new_pos = find_next_word_start(current_text, editor_state.cursor_pos); + editor_state.cursor_pos = new_pos; + editor_state.ideal_cursor_column = new_pos; } - Ok(ActionResult::success()) + ActionResult::success() } CanvasAction::MoveWordEnd => { - let current_input = state.get_current_input(); - if !current_input.is_empty() { - let new_pos = find_word_end(current_input, state.current_cursor_pos()); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; + if !current_text.is_empty() { + let new_pos = find_word_end(current_text, editor_state.cursor_pos); + editor_state.cursor_pos = new_pos; + editor_state.ideal_cursor_column = new_pos; } - Ok(ActionResult::success()) + ActionResult::success() } CanvasAction::MoveWordPrev => { - let current_input = state.get_current_input(); - if !current_input.is_empty() { - let new_pos = find_prev_word_start(current_input, state.current_cursor_pos()); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; + if !current_text.is_empty() { + let new_pos = find_prev_word_start(current_text, editor_state.cursor_pos); + editor_state.cursor_pos = new_pos; + editor_state.ideal_cursor_column = new_pos; } - Ok(ActionResult::success()) + ActionResult::success() } CanvasAction::MoveWordEndPrev => { - let current_input = state.get_current_input(); - if !current_input.is_empty() { - let new_pos = find_prev_word_end(current_input, state.current_cursor_pos()); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; + if !current_text.is_empty() { + let new_pos = find_prev_word_end(current_text, editor_state.cursor_pos); + editor_state.cursor_pos = new_pos; + editor_state.ideal_cursor_column = new_pos; } - Ok(ActionResult::success()) + ActionResult::success() } - // Field navigation with simple wrapping behavior + // Field navigation - handled at FormEditor level for bounds checking CanvasAction::NextField | CanvasAction::PrevField => { - let current_field = state.current_field(); - let total_fields = state.fields().len(); + ActionResult::success_with_message("Field navigation handled by FormEditor") + } - let new_field = match action { - CanvasAction::NextField => { - (current_field + 1) % total_fields // Simple wrap - } - CanvasAction::PrevField => { - if current_field == 0 { total_fields - 1 } else { current_field - 1 } // Simple wrap - } - _ => unreachable!(), - }; - - state.set_current_field(new_field); - let current_input = state.get_current_input(); - let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE); - state.set_current_cursor_pos(new_pos); - Ok(ActionResult::success()) + // Text editing actions - handled at FormEditor level + CanvasAction::InsertChar(_) | + CanvasAction::DeleteBackward | + CanvasAction::DeleteForward => { + ActionResult::success_with_message("Text editing handled by FormEditor") } CanvasAction::Custom(action_str) => { - Ok(ActionResult::success_with_message(&format!("Custom edit action: {}", action_str))) + ActionResult::success_with_message(&format!("Custom edit action: {}", action_str)) } _ => { - Ok(ActionResult::success_with_message("Action not implemented for edit mode")) + ActionResult::success_with_message("Action not implemented for edit mode") } } } diff --git a/canvas/src/canvas/actions/handlers/highlight.rs b/canvas/src/canvas/actions/handlers/highlight.rs index 66b8604..77314f5 100644 --- a/canvas/src/canvas/actions/handlers/highlight.rs +++ b/canvas/src/canvas/actions/handlers/highlight.rs @@ -1,104 +1,97 @@ // src/canvas/actions/handlers/highlight.rs +//! Highlight mode action handler with EditorState use crate::canvas::actions::types::{CanvasAction, ActionResult}; use crate::canvas::actions::movement::*; -use crate::canvas::state::CanvasState; -use anyhow::Result; +use crate::canvas::state::EditorState; const FOR_EDIT_MODE: bool = false; // Highlight mode uses read-only cursor behavior /// Handle actions in highlight/visual mode -/// TODO: Implement selection logic and highlight-specific behaviors -pub async fn handle_highlight_action( +pub(crate) fn handle_highlight_action( action: CanvasAction, - state: &mut S, - ideal_cursor_column: &mut usize, -) -> Result { + editor_state: &mut EditorState, + current_text: &str, +) -> ActionResult { match action { // Movement actions work similar to read-only mode but with selection CanvasAction::MoveLeft => { - let new_pos = move_left(state.current_cursor_pos()); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; + let new_pos = move_left(editor_state.cursor_pos); + editor_state.cursor_pos = new_pos; + editor_state.ideal_cursor_column = new_pos; // TODO: Update selection range - Ok(ActionResult::success()) + ActionResult::success() } CanvasAction::MoveRight => { - let current_input = state.get_current_input(); - let current_pos = state.current_cursor_pos(); - let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; + let new_pos = move_right(editor_state.cursor_pos, current_text, FOR_EDIT_MODE); + editor_state.cursor_pos = new_pos; + editor_state.ideal_cursor_column = new_pos; // TODO: Update selection range - Ok(ActionResult::success()) + ActionResult::success() } CanvasAction::MoveWordNext => { - let current_input = state.get_current_input(); - if !current_input.is_empty() { - let new_pos = find_next_word_start(current_input, state.current_cursor_pos()); - let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE); - state.set_current_cursor_pos(final_pos); - *ideal_cursor_column = final_pos; + if !current_text.is_empty() { + let new_pos = find_next_word_start(current_text, editor_state.cursor_pos); + let final_pos = clamp_cursor_position(new_pos, current_text, FOR_EDIT_MODE); + editor_state.cursor_pos = final_pos; + editor_state.ideal_cursor_column = final_pos; // TODO: Update selection range } - Ok(ActionResult::success()) + ActionResult::success() } CanvasAction::MoveWordEnd => { - let current_input = state.get_current_input(); - if !current_input.is_empty() { - let new_pos = find_word_end(current_input, state.current_cursor_pos()); - let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE); - state.set_current_cursor_pos(final_pos); - *ideal_cursor_column = final_pos; + if !current_text.is_empty() { + let new_pos = find_word_end(current_text, editor_state.cursor_pos); + let final_pos = clamp_cursor_position(new_pos, current_text, FOR_EDIT_MODE); + editor_state.cursor_pos = final_pos; + editor_state.ideal_cursor_column = final_pos; // TODO: Update selection range } - Ok(ActionResult::success()) + ActionResult::success() } CanvasAction::MoveWordPrev => { - let current_input = state.get_current_input(); - if !current_input.is_empty() { - let new_pos = find_prev_word_start(current_input, state.current_cursor_pos()); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; + if !current_text.is_empty() { + let new_pos = find_prev_word_start(current_text, editor_state.cursor_pos); + editor_state.cursor_pos = new_pos; + editor_state.ideal_cursor_column = new_pos; // TODO: Update selection range } - Ok(ActionResult::success()) + ActionResult::success() } CanvasAction::MoveLineStart => { let new_pos = line_start_position(); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; + editor_state.cursor_pos = new_pos; + editor_state.ideal_cursor_column = new_pos; // TODO: Update selection range - Ok(ActionResult::success()) + ActionResult::success() } CanvasAction::MoveLineEnd => { - let current_input = state.get_current_input(); - let new_pos = line_end_position(current_input, FOR_EDIT_MODE); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; + let new_pos = line_end_position(current_text, FOR_EDIT_MODE); + editor_state.cursor_pos = new_pos; + editor_state.ideal_cursor_column = new_pos; // TODO: Update selection range - Ok(ActionResult::success()) + ActionResult::success() } // Highlight mode doesn't handle editing actions CanvasAction::InsertChar(_) | CanvasAction::DeleteBackward | CanvasAction::DeleteForward => { - Ok(ActionResult::success_with_message("Action not available in highlight mode")) + ActionResult::success_with_message("Action not available in highlight mode") } CanvasAction::Custom(action_str) => { - Ok(ActionResult::success_with_message(&format!("Custom highlight action: {}", action_str))) + ActionResult::success_with_message(&format!("Custom highlight action: {}", action_str)) } _ => { - Ok(ActionResult::success_with_message("Action not implemented for highlight mode")) + ActionResult::success_with_message("Action not implemented for highlight mode") } } } diff --git a/canvas/src/canvas/actions/handlers/mod.rs b/canvas/src/canvas/actions/handlers/mod.rs index 3bbbe8f..1810a00 100644 --- a/canvas/src/canvas/actions/handlers/mod.rs +++ b/canvas/src/canvas/actions/handlers/mod.rs @@ -5,7 +5,7 @@ pub mod readonly; pub mod highlight; pub mod dispatcher; -pub use edit::handle_edit_action; -pub use readonly::handle_readonly_action; -pub use highlight::handle_highlight_action; -pub use dispatcher::dispatch_action; +pub use edit::*; +pub use readonly::*; +pub use highlight::*; +pub use dispatcher::*; diff --git a/canvas/src/canvas/actions/handlers/readonly.rs b/canvas/src/canvas/actions/handlers/readonly.rs index 19201bd..551457d 100644 --- a/canvas/src/canvas/actions/handlers/readonly.rs +++ b/canvas/src/canvas/actions/handlers/readonly.rs @@ -1,183 +1,136 @@ // src/canvas/actions/handlers/readonly.rs +//! ReadOnly mode action handler with EditorState use crate::canvas::actions::types::{CanvasAction, ActionResult}; use crate::canvas::actions::movement::*; -use crate::canvas::state::CanvasState; -use anyhow::Result; +use crate::canvas::state::EditorState; const FOR_EDIT_MODE: bool = false; // Read-only mode flag /// Handle actions in read-only mode with read-only specific cursor behavior -pub async fn handle_readonly_action( +pub(crate) fn handle_readonly_action( action: CanvasAction, - state: &mut S, - ideal_cursor_column: &mut usize, -) -> Result { + editor_state: &mut EditorState, + current_text: &str, +) -> ActionResult { match action { CanvasAction::MoveLeft => { - let new_pos = move_left(state.current_cursor_pos()); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; - Ok(ActionResult::success()) + let new_pos = move_left(editor_state.cursor_pos); + editor_state.cursor_pos = new_pos; + editor_state.ideal_cursor_column = new_pos; + ActionResult::success() } CanvasAction::MoveRight => { - let current_input = state.get_current_input(); - let current_pos = state.current_cursor_pos(); - let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; - Ok(ActionResult::success()) + let new_pos = move_right(editor_state.cursor_pos, current_text, FOR_EDIT_MODE); + editor_state.cursor_pos = new_pos; + editor_state.ideal_cursor_column = new_pos; + ActionResult::success() } CanvasAction::MoveUp => { - let current_field = state.current_field(); - let new_field = current_field.saturating_sub(1); - state.set_current_field(new_field); - - // Apply ideal cursor column with read-only bounds - let current_input = state.get_current_input(); - let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE); - state.set_current_cursor_pos(new_pos); - Ok(ActionResult::success()) + if editor_state.current_field > 0 { + editor_state.current_field -= 1; + let new_pos = safe_cursor_position(current_text, editor_state.ideal_cursor_column, FOR_EDIT_MODE); + editor_state.cursor_pos = new_pos; + } + ActionResult::success() } CanvasAction::MoveDown => { - let current_field = state.current_field(); - let total_fields = state.fields().len(); - if total_fields == 0 { - return Ok(ActionResult::success_with_message("No fields to navigate")); - } - - let new_field = (current_field + 1).min(total_fields - 1); - state.set_current_field(new_field); - - // Apply ideal cursor column with read-only bounds - let current_input = state.get_current_input(); - let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE); - state.set_current_cursor_pos(new_pos); - Ok(ActionResult::success()) + // Note: bounds checking happens at FormEditor level + editor_state.current_field += 1; + let new_pos = safe_cursor_position(current_text, editor_state.ideal_cursor_column, FOR_EDIT_MODE); + editor_state.cursor_pos = new_pos; + ActionResult::success() } CanvasAction::MoveFirstLine => { - let total_fields = state.fields().len(); - if total_fields == 0 { - return Ok(ActionResult::success_with_message("No fields to navigate")); - } - - state.set_current_field(0); - let current_input = state.get_current_input(); - let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; - Ok(ActionResult::success()) + editor_state.current_field = 0; + let new_pos = safe_cursor_position(current_text, editor_state.ideal_cursor_column, FOR_EDIT_MODE); + editor_state.cursor_pos = new_pos; + editor_state.ideal_cursor_column = new_pos; + ActionResult::success() } CanvasAction::MoveLastLine => { - let total_fields = state.fields().len(); - if total_fields == 0 { - return Ok(ActionResult::success_with_message("No fields to navigate")); - } - - let last_field = total_fields - 1; - state.set_current_field(last_field); - let current_input = state.get_current_input(); - let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; - Ok(ActionResult::success()) + // Note: field count validation happens at FormEditor level + let new_pos = safe_cursor_position(current_text, editor_state.ideal_cursor_column, FOR_EDIT_MODE); + editor_state.cursor_pos = new_pos; + editor_state.ideal_cursor_column = new_pos; + ActionResult::success() } CanvasAction::MoveLineStart => { let new_pos = line_start_position(); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; - Ok(ActionResult::success()) + editor_state.cursor_pos = new_pos; + editor_state.ideal_cursor_column = new_pos; + ActionResult::success() } CanvasAction::MoveLineEnd => { - let current_input = state.get_current_input(); - let new_pos = line_end_position(current_input, FOR_EDIT_MODE); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; - Ok(ActionResult::success()) + let new_pos = line_end_position(current_text, FOR_EDIT_MODE); + editor_state.cursor_pos = new_pos; + editor_state.ideal_cursor_column = new_pos; + ActionResult::success() } CanvasAction::MoveWordNext => { - let current_input = state.get_current_input(); - if !current_input.is_empty() { - let new_pos = find_next_word_start(current_input, state.current_cursor_pos()); - let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE); - state.set_current_cursor_pos(final_pos); - *ideal_cursor_column = final_pos; + if !current_text.is_empty() { + let new_pos = find_next_word_start(current_text, editor_state.cursor_pos); + let final_pos = clamp_cursor_position(new_pos, current_text, FOR_EDIT_MODE); + editor_state.cursor_pos = final_pos; + editor_state.ideal_cursor_column = final_pos; } - Ok(ActionResult::success()) + ActionResult::success() } CanvasAction::MoveWordEnd => { - let current_input = state.get_current_input(); - if !current_input.is_empty() { - let current_pos = state.current_cursor_pos(); - let new_pos = find_word_end(current_input, current_pos); - let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE); - state.set_current_cursor_pos(final_pos); - *ideal_cursor_column = final_pos; + if !current_text.is_empty() { + let new_pos = find_word_end(current_text, editor_state.cursor_pos); + let final_pos = clamp_cursor_position(new_pos, current_text, FOR_EDIT_MODE); + editor_state.cursor_pos = final_pos; + editor_state.ideal_cursor_column = final_pos; } - Ok(ActionResult::success()) + ActionResult::success() } CanvasAction::MoveWordPrev => { - let current_input = state.get_current_input(); - if !current_input.is_empty() { - let new_pos = find_prev_word_start(current_input, state.current_cursor_pos()); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; + if !current_text.is_empty() { + let new_pos = find_prev_word_start(current_text, editor_state.cursor_pos); + editor_state.cursor_pos = new_pos; + editor_state.ideal_cursor_column = new_pos; } - Ok(ActionResult::success()) + ActionResult::success() } CanvasAction::MoveWordEndPrev => { - let current_input = state.get_current_input(); - if !current_input.is_empty() { - let new_pos = find_prev_word_end(current_input, state.current_cursor_pos()); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; + if !current_text.is_empty() { + let new_pos = find_prev_word_end(current_text, editor_state.cursor_pos); + editor_state.cursor_pos = new_pos; + editor_state.ideal_cursor_column = new_pos; } - Ok(ActionResult::success()) + ActionResult::success() } + // Field navigation - handled at FormEditor level CanvasAction::NextField | CanvasAction::PrevField => { - let current_field = state.current_field(); - let total_fields = state.fields().len(); - - let new_field = match action { - CanvasAction::NextField => { - (current_field + 1) % total_fields // Simple wrap - } - CanvasAction::PrevField => { - if current_field == 0 { total_fields - 1 } else { current_field - 1 } // Simple wrap - } - _ => unreachable!(), - }; - - state.set_current_field(new_field); - *ideal_cursor_column = state.current_cursor_pos(); - Ok(ActionResult::success()) + ActionResult::success_with_message("Field navigation handled by FormEditor") } // Read-only mode doesn't handle editing actions CanvasAction::InsertChar(_) | CanvasAction::DeleteBackward | CanvasAction::DeleteForward => { - Ok(ActionResult::success_with_message("Action not available in read-only mode")) + ActionResult::success_with_message("Action not available in read-only mode") } CanvasAction::Custom(action_str) => { - Ok(ActionResult::success_with_message(&format!("Custom readonly action: {}", action_str))) + ActionResult::success_with_message(&format!("Custom readonly action: {}", action_str)) } _ => { - Ok(ActionResult::success_with_message("Action not implemented for read-only mode")) + ActionResult::success_with_message("Action not implemented for read-only mode") } } } diff --git a/canvas/src/canvas/actions/mod.rs b/canvas/src/canvas/actions/mod.rs index 803d179..21a80f8 100644 --- a/canvas/src/canvas/actions/mod.rs +++ b/canvas/src/canvas/actions/mod.rs @@ -5,4 +5,4 @@ pub mod handlers; pub mod movement; // Re-export the main API -pub use types::{CanvasAction, ActionResult, execute}; +pub use types::{CanvasAction, ActionResult}; diff --git a/canvas/src/canvas/actions/types.rs b/canvas/src/canvas/actions/types.rs index 4f4d163..d5a2de4 100644 --- a/canvas/src/canvas/actions/types.rs +++ b/canvas/src/canvas/actions/types.rs @@ -1,7 +1,6 @@ // src/canvas/actions/types.rs -use crate::canvas::state::CanvasState; -use anyhow::Result; +use crate::canvas::state::EditorState; /// All available canvas actions #[derive(Debug, Clone, PartialEq)] @@ -11,35 +10,35 @@ pub enum CanvasAction { MoveRight, MoveUp, MoveDown, - + // Word movement MoveWordNext, MoveWordPrev, MoveWordEnd, MoveWordEndPrev, - + // Line movement MoveLineStart, MoveLineEnd, - + // Field movement NextField, PrevField, MoveFirstLine, MoveLastLine, - + // Editing actions InsertChar(char), DeleteBackward, DeleteForward, - + // Autocomplete actions TriggerAutocomplete, SuggestionUp, SuggestionDown, SelectSuggestion, ExitSuggestions, - + // Custom actions Custom(String), } @@ -58,23 +57,23 @@ impl ActionResult { pub fn success() -> Self { Self::Success } - + pub fn success_with_message(msg: &str) -> Self { Self::Message(msg.to_string()) } - + pub fn handled_by_app(msg: &str) -> Self { Self::HandledByApp(msg.to_string()) } - + pub fn error(msg: &str) -> Self { Self::Error(msg.to_string()) } - + pub fn is_success(&self) -> bool { matches!(self, Self::Success | Self::Message(_) | Self::HandledByApp(_) | Self::HandledByFeature(_)) } - + pub fn message(&self) -> Option<&str> { match self { Self::Message(msg) | Self::HandledByApp(msg) | Self::HandledByFeature(msg) | Self::Error(msg) => Some(msg), @@ -83,17 +82,13 @@ impl ActionResult { } } -/// Execute a canvas action on the given state -pub async fn execute( - action: CanvasAction, - state: &mut S, -) -> Result { - let mut ideal_cursor_column = 0; - - super::handlers::dispatch_action(action, state, &mut ideal_cursor_column).await -} - impl CanvasAction { + /// Internal method used by FormEditor + pub(crate) fn apply_to_editor_state(self, editor_state: &mut EditorState, current_text: &str) -> ActionResult { + // Internal method used by FormEditor + crate::canvas::actions::handlers::dispatch_action_internal(self, editor_state, current_text) + } + /// Get a human-readable description of this action pub fn description(&self) -> &'static str { match self { @@ -111,7 +106,7 @@ impl CanvasAction { Self::PrevField => "previous field", Self::MoveFirstLine => "first field", Self::MoveLastLine => "last field", - Self::InsertChar(c) => "insert character", + Self::InsertChar(_c) => "insert character", Self::DeleteBackward => "delete backward", Self::DeleteForward => "delete forward", Self::TriggerAutocomplete => "trigger autocomplete", @@ -119,7 +114,7 @@ impl CanvasAction { Self::SuggestionDown => "suggestion down", Self::SelectSuggestion => "select suggestion", Self::ExitSuggestions => "exit suggestions", - Self::Custom(name) => "custom action", + Self::Custom(_name) => "custom action", } } diff --git a/canvas/src/canvas/gui.rs b/canvas/src/canvas/gui.rs index 4e06985..af7131a 100644 --- a/canvas/src/canvas/gui.rs +++ b/canvas/src/canvas/gui.rs @@ -1,4 +1,5 @@ -// canvas/src/canvas/gui.rs +// src/canvas/gui.rs +//! Canvas GUI updated to work with FormEditor #[cfg(feature = "gui")] use ratatui::{ @@ -9,29 +10,43 @@ use ratatui::{ Frame, }; -use crate::canvas::state::CanvasState; -use crate::canvas::modes::HighlightState; - #[cfg(feature = "gui")] use crate::canvas::theme::CanvasTheme; +use crate::canvas::modes::HighlightState; +use crate::data_provider::DataProvider; +use crate::editor::FormEditor; #[cfg(feature = "gui")] use std::cmp::{max, min}; /// Render ONLY the canvas form fields - no autocomplete +/// Updated to work with FormEditor instead of CanvasState trait #[cfg(feature = "gui")] -pub fn render_canvas( +pub fn render_canvas( f: &mut Frame, area: Rect, - form_state: &impl CanvasState, + editor: &FormEditor, 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 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)); + inputs.push(data_provider.field_value(i).to_string()); + } + + let current_field_idx = ui_state.current_field(); + let is_edit_mode = matches!(ui_state.mode(), crate::canvas::modes::AppMode::Edit); + + // For now, create a default highlight state (TODO: get from editor state) + let highlight_state = HighlightState::Off; + render_canvas_fields( f, area, @@ -40,11 +55,13 @@ pub fn render_canvas( &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), + &highlight_state, + ui_state.cursor_position(), + false, // TODO: track unsaved changes in editor + |i| { + data_provider.display_value(i).unwrap_or(data_provider.field_value(i)).to_string() + }, + |i| data_provider.display_value(i).is_some(), ) } @@ -55,7 +72,7 @@ fn render_canvas_fields( area: Rect, fields: &[&str], current_field_idx: &usize, - inputs: &[&String], + inputs: &[String], theme: &T, is_edit_mode: bool, highlight_state: &HighlightState, @@ -112,7 +129,7 @@ where // Render field values and return active field rect render_field_values( f, - input_rows.to_vec(), // Fix: Convert Rc<[Rect]> to Vec + input_rows.to_vec(), inputs, current_field_idx, theme, @@ -154,7 +171,7 @@ fn render_field_labels( fn render_field_values( f: &mut Frame, input_rows: Vec, - inputs: &[&String], + inputs: &[String], current_field_idx: &usize, theme: &T, highlight_state: &HighlightState, @@ -171,7 +188,7 @@ where 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, @@ -301,7 +318,7 @@ fn apply_linewise_highlighting<'a, T: CanvasTheme>( ) -> 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()) diff --git a/canvas/src/canvas/mod.rs b/canvas/src/canvas/mod.rs index aa72003..85a3e51 100644 --- a/canvas/src/canvas/mod.rs +++ b/canvas/src/canvas/mod.rs @@ -1,18 +1,14 @@ // src/canvas/mod.rs pub mod actions; -pub mod gui; -pub mod modes; pub mod state; +pub mod modes; + +#[cfg(feature = "gui")] +pub mod gui; + +#[cfg(feature = "gui")] pub mod theme; -// Re-export main types for convenience -pub use actions::{CanvasAction, ActionResult}; +// Keep these exports for current functionality pub use modes::{AppMode, ModeManager, HighlightState}; -pub use state::{CanvasState, ActionContext}; - -#[cfg(feature = "gui")] -pub use theme::CanvasTheme; - -#[cfg(feature = "gui")] -pub use gui::render_canvas; diff --git a/canvas/src/canvas/state.rs b/canvas/src/canvas/state.rs index f8a3e9b..fe59ac3 100644 --- a/canvas/src/canvas/state.rs +++ b/canvas/src/canvas/state.rs @@ -1,117 +1,132 @@ // src/canvas/state.rs -//! Canvas state trait and related types -//! -//! This module defines the core trait that any form or input system must implement -//! to work with the canvas library. +//! Library-owned UI state - user never directly modifies this -use crate::canvas::actions::CanvasAction; use crate::canvas::modes::AppMode; -/// Context information passed to feature-specific action handlers -#[derive(Debug)] -pub struct ActionContext { - /// Original key code that triggered this action (for backwards compatibility) - pub key_code: Option, - /// Current ideal cursor column for vertical movement - pub ideal_cursor_column: usize, - /// Current input text - pub current_input: String, - /// Current field index - pub current_field: usize, +/// Library-owned UI state - user never directly modifies this +#[derive(Debug, Clone)] +pub struct EditorState { + // Navigation state + pub(crate) current_field: usize, + pub(crate) cursor_pos: usize, + pub(crate) ideal_cursor_column: usize, + + // Mode state + pub(crate) current_mode: AppMode, + + // Autocomplete state + pub(crate) autocomplete: AutocompleteUIState, + + // Selection state (for vim visual mode) + pub(crate) selection: SelectionState, } -/// Core trait that any form-like state must implement to work with canvas -/// -/// This trait enables the same mode behaviors (edit, read-only, highlight) to work -/// across any implementation - login forms, data entry forms, configuration screens, etc. -/// -/// # Required Implementation -/// -/// Your struct needs to track: -/// - Current field index and cursor position -/// - All input field values -/// - Current interaction mode -/// - Whether there are unsaved changes -/// -/// # Example Implementation -/// -/// ```rust -/// struct MyForm { -/// fields: Vec, -/// current_field: usize, -/// cursor_pos: usize, -/// mode: AppMode, -/// dirty: bool, -/// } -/// -/// impl CanvasState for MyForm { -/// fn current_field(&self) -> usize { self.current_field } -/// fn current_cursor_pos(&self) -> usize { self.cursor_pos } -/// // ... implement other required methods -/// } -/// ``` -pub trait CanvasState { - // --- Core Navigation --- - - /// Get current field index (0-based) - fn current_field(&self) -> usize; - - /// Get current cursor position within the current field - fn current_cursor_pos(&self) -> usize; - - /// Set current field index (should clamp to valid range) - fn set_current_field(&mut self, index: usize); - - /// Set cursor position within current field (should clamp to valid range) - fn set_current_cursor_pos(&mut self, pos: usize); +#[derive(Debug, Clone)] +pub struct AutocompleteUIState { + pub(crate) is_active: bool, + pub(crate) is_loading: bool, + pub(crate) selected_index: Option, + pub(crate) active_field: Option, +} - // --- Mode Information --- - - /// Get current interaction mode (edit, read-only, highlight, etc.) - fn current_mode(&self) -> AppMode; +#[derive(Debug, Clone)] +pub enum SelectionState { + None, + Characterwise { anchor: (usize, usize) }, + Linewise { anchor_field: usize }, +} - // --- Data Access --- - - /// Get immutable reference to current field's text - fn get_current_input(&self) -> &str; - - /// Get mutable reference to current field's text - fn get_current_input_mut(&mut self) -> &mut String; - - /// Get all input values as immutable references - fn inputs(&self) -> Vec<&String>; - - /// Get all field names/labels - fn fields(&self) -> Vec<&str>; - - // --- State Management --- - - /// Check if there are unsaved changes - fn has_unsaved_changes(&self) -> bool; - - /// Mark whether there are unsaved changes - fn set_has_unsaved_changes(&mut self, changed: bool); - - // --- Optional Overrides --- - - /// Handle application-specific actions not covered by standard handlers - /// Return Some(message) if the action was handled, None to use standard handling - fn handle_feature_action(&mut self, _action: &CanvasAction, _context: &ActionContext) -> Option { - None // Default: no custom handling +impl EditorState { + pub fn new() -> Self { + Self { + current_field: 0, + cursor_pos: 0, + ideal_cursor_column: 0, + current_mode: AppMode::Edit, + autocomplete: AutocompleteUIState { + is_active: false, + is_loading: false, + selected_index: None, + active_field: None, + }, + selection: SelectionState::None, + } } - - /// Get display value for a field (may differ from actual value) - /// Used for things like password masking or computed display values - fn get_display_value_for_field(&self, index: usize) -> &str { - self.inputs() - .get(index) - .map(|s| s.as_str()) - .unwrap_or("") + + // =================================================================== + // READ-ONLY ACCESS: User can fetch UI state for compatibility + // =================================================================== + + /// Get current field index (for user's business logic) + pub fn current_field(&self) -> usize { + self.current_field } - - /// Check if a field has a custom display value - /// Return true if get_display_value_for_field returns something different than the actual value - fn has_display_override(&self, _index: usize) -> bool { - false + + /// Get current cursor position (for user's business logic) + pub fn cursor_position(&self) -> usize { + self.cursor_pos + } + + /// Get current mode (for user's business logic) + pub fn mode(&self) -> AppMode { + self.current_mode + } + + /// Check if autocomplete is active (for user's business logic) + pub fn is_autocomplete_active(&self) -> bool { + self.autocomplete.is_active + } + + /// Check if autocomplete is loading (for user's business logic) + pub fn is_autocomplete_loading(&self) -> bool { + self.autocomplete.is_loading + } + + /// Get selection state (for user's business logic) + pub fn selection_state(&self) -> &SelectionState { + &self.selection + } + + // =================================================================== + // INTERNAL MUTATIONS: Only library modifies these + // =================================================================== + + pub(crate) fn move_to_field(&mut self, field_index: usize, field_count: usize) { + if field_index < field_count { + self.current_field = field_index; + // Reset cursor to safe position - will be clamped by movement logic + self.cursor_pos = 0; + } + } + + pub(crate) fn set_cursor(&mut self, position: usize, max_position: usize, for_edit_mode: bool) { + if for_edit_mode { + // Edit mode: can go past end for insertion + self.cursor_pos = position.min(max_position); + } else { + // ReadOnly/Highlight: stay within text bounds + self.cursor_pos = position.min(max_position.saturating_sub(1)); + } + self.ideal_cursor_column = self.cursor_pos; + } + + pub(crate) fn activate_autocomplete(&mut self, field_index: usize) { + self.autocomplete.is_active = true; + self.autocomplete.is_loading = true; + self.autocomplete.active_field = Some(field_index); + self.autocomplete.selected_index = None; + } + + pub(crate) fn deactivate_autocomplete(&mut self) { + self.autocomplete.is_active = false; + self.autocomplete.is_loading = false; + self.autocomplete.active_field = None; + self.autocomplete.selected_index = None; + } +} + +impl Default for EditorState { + fn default() -> Self { + Self::new() } } diff --git a/canvas/src/data_provider.rs b/canvas/src/data_provider.rs new file mode 100644 index 0000000..3b737f1 --- /dev/null +++ b/canvas/src/data_provider.rs @@ -0,0 +1,47 @@ +// src/data_provider.rs +//! Simplified user interface - only business data, no UI state + +use anyhow::Result; +use async_trait::async_trait; + +/// User implements this - only business data, no UI state +pub trait DataProvider { + /// How many fields in the form + fn field_count(&self) -> usize; + + /// Get field label/name + fn field_name(&self, index: usize) -> &str; + + /// Get field value + fn field_value(&self, index: usize) -> &str; + + /// Set field value (library calls this when text changes) + fn set_field_value(&mut self, index: usize, value: String); + + /// Check if field supports autocomplete (optional) + fn supports_autocomplete(&self, _field_index: usize) -> bool { + false + } + + /// Get display value (for password masking, etc.) - optional + fn display_value(&self, index: usize) -> Option<&str> { + None // Default: use actual value + } +} + +/// Optional: User implements this for autocomplete data +#[async_trait] +pub trait AutocompleteProvider { + type SuggestionData: Clone + Send + 'static; + + /// Fetch autocomplete suggestions (user's business logic) + async fn fetch_suggestions(&mut self, field_index: usize, query: &str) + -> Result>>; +} + +#[derive(Debug, Clone)] +pub struct SuggestionItem { + pub data: T, + pub display_text: String, + pub value_to_store: String, +} diff --git a/canvas/src/editor.rs b/canvas/src/editor.rs new file mode 100644 index 0000000..81dc16a --- /dev/null +++ b/canvas/src/editor.rs @@ -0,0 +1,234 @@ +// src/editor.rs +//! Main API for the canvas library - FormEditor with library-owned state + +use anyhow::Result; +use async_trait::async_trait; +use crate::canvas::state::EditorState; +use crate::data_provider::{DataProvider, AutocompleteProvider, SuggestionItem}; +use crate::canvas::modes::AppMode; + +/// Main editor that manages UI state internally and delegates data to user +pub struct FormEditor { + // Library owns all UI state + ui_state: EditorState, + + // User owns business data + data_provider: D, + + // Autocomplete suggestions (library manages UI, user provides data) + pub(crate) suggestions: Vec>, +} + +impl FormEditor { + pub fn new(data_provider: D) -> Self { + Self { + ui_state: EditorState::new(), + data_provider, + suggestions: Vec::new(), + } + } + + // =================================================================== + // READ-ONLY ACCESS: User can fetch UI state + // =================================================================== + + /// Get current field index (for user's compatibility) + pub fn current_field(&self) -> usize { + self.ui_state.current_field() + } + + /// Get current cursor position (for user's compatibility) + pub fn cursor_position(&self) -> usize { + self.ui_state.cursor_position() + } + + /// Get current mode (for user's mode-dependent logic) + pub fn mode(&self) -> AppMode { + self.ui_state.mode() + } + + /// Check if autocomplete is active (for user's logic) + pub fn is_autocomplete_active(&self) -> bool { + self.ui_state.is_autocomplete_active() + } + + /// Get current field text (convenience method) + pub fn current_text(&self) -> &str { + let field_index = self.ui_state.current_field; + if field_index < self.data_provider.field_count() { + self.data_provider.field_value(field_index) + } else { + "" + } + } + + /// Get reference to UI state for rendering + pub fn ui_state(&self) -> &EditorState { + &self.ui_state + } + + /// Get reference to data provider for rendering + pub fn data_provider(&self) -> &D { + &self.data_provider + } + + /// Get autocomplete suggestions for rendering (read-only) + pub fn suggestions(&self) -> &[SuggestionItem] { + &self.suggestions + } + + // =================================================================== + // SYNC OPERATIONS: No async needed for basic editing + // =================================================================== + + /// Handle character insertion + pub fn insert_char(&mut self, ch: char) -> Result<()> { + if self.ui_state.current_mode != AppMode::Edit { + return Ok(()); // Ignore in non-edit modes + } + + let field_index = self.ui_state.current_field; + let cursor_pos = self.ui_state.cursor_pos; + + // Get current text from user + let mut current_text = self.data_provider.field_value(field_index).to_string(); + + // Insert character + current_text.insert(cursor_pos, ch); + + // Update user's data + self.data_provider.set_field_value(field_index, current_text); + + // Update library's UI state + self.ui_state.cursor_pos += 1; + self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos; + + Ok(()) + } + + /// Handle cursor movement + pub fn move_left(&mut self) { + if self.ui_state.cursor_pos > 0 { + self.ui_state.cursor_pos -= 1; + self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos; + } + } + + pub fn move_right(&mut self) { + let current_text = self.current_text(); + let max_pos = if self.ui_state.current_mode == AppMode::Edit { + current_text.len() // Edit mode: can go past end + } else { + current_text.len().saturating_sub(1) // ReadOnly: stay in bounds + }; + + if self.ui_state.cursor_pos < max_pos { + self.ui_state.cursor_pos += 1; + self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos; + } + } + + /// Handle field navigation + pub fn move_to_next_field(&mut self) { + let field_count = self.data_provider.field_count(); + let next_field = (self.ui_state.current_field + 1) % field_count; + self.ui_state.move_to_field(next_field, field_count); + + // Clamp cursor to new field + let current_text = self.current_text(); + let max_pos = current_text.len(); + self.ui_state.set_cursor( + self.ui_state.ideal_cursor_column, + max_pos, + self.ui_state.current_mode == AppMode::Edit + ); + } + + /// Change mode (for vim compatibility) + pub fn set_mode(&mut self, mode: AppMode) { + self.ui_state.current_mode = mode; + + // Clear autocomplete when changing modes + if mode != AppMode::Edit { + self.ui_state.deactivate_autocomplete(); + } + } + + // =================================================================== + // ASYNC OPERATIONS: Only autocomplete needs async + // =================================================================== + + /// Trigger autocomplete (async because it fetches data) + pub async fn trigger_autocomplete(&mut self, provider: &mut A) -> Result<()> + where + A: AutocompleteProvider, + A::SuggestionData: std::fmt::Debug, // Change from Display to Debug + { + let field_index = self.ui_state.current_field; + + if !self.data_provider.supports_autocomplete(field_index) { + return Ok(()); + } + + // Activate autocomplete UI + self.ui_state.activate_autocomplete(field_index); + + // Fetch suggestions from user + let query = self.current_text(); + let suggestions = provider.fetch_suggestions(field_index, query).await?; + + // Convert to library's format (could be avoided with better generics) + self.suggestions = suggestions.into_iter() + .map(|item| SuggestionItem { + data: format!("{:?}", item.data), // Use Debug formatting instead + display_text: item.display_text, + value_to_store: item.value_to_store, + }) + .collect(); + + // Update UI state + self.ui_state.autocomplete.is_loading = false; + if !self.suggestions.is_empty() { + self.ui_state.autocomplete.selected_index = Some(0); + } + + Ok(()) + } + + /// Navigate autocomplete suggestions + pub fn autocomplete_next(&mut self) { + if !self.ui_state.autocomplete.is_active || self.suggestions.is_empty() { + return; + } + + let current = self.ui_state.autocomplete.selected_index.unwrap_or(0); + let next = (current + 1) % self.suggestions.len(); + self.ui_state.autocomplete.selected_index = Some(next); + } + + /// Apply selected autocomplete suggestion + pub fn apply_autocomplete(&mut self) -> Option { + if let Some(selected_index) = self.ui_state.autocomplete.selected_index { + if let Some(suggestion) = self.suggestions.get(selected_index).cloned() { + let field_index = self.ui_state.current_field; + + // Apply to user's data + self.data_provider.set_field_value( + field_index, + suggestion.value_to_store.clone() + ); + + // Update cursor position + self.ui_state.cursor_pos = suggestion.value_to_store.len(); + self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos; + + // Close autocomplete + self.ui_state.deactivate_autocomplete(); + self.suggestions.clear(); + + return Some(suggestion.display_text); + } + } + None + } +} diff --git a/canvas/src/lib.rs b/canvas/src/lib.rs index b0630ec..31084f6 100644 --- a/canvas/src/lib.rs +++ b/canvas/src/lib.rs @@ -1,31 +1,47 @@ // src/lib.rs pub mod canvas; +pub mod editor; +pub mod data_provider; // Only include autocomplete module if feature is enabled #[cfg(feature = "autocomplete")] pub mod autocomplete; -// Re-export the main API for easy access -pub use canvas::actions::{CanvasAction, ActionResult, execute}; -pub use canvas::state::{CanvasState, ActionContext}; -pub use canvas::modes::{AppMode, ModeManager, HighlightState}; +// =================================================================== +// NEW API: Library-owned state pattern +// =================================================================== +// Main API exports +pub use editor::FormEditor; +pub use data_provider::{DataProvider, AutocompleteProvider, SuggestionItem}; + +// UI state (read-only access for users) +pub use canvas::state::EditorState; +pub use canvas::modes::AppMode; + +// Actions and results (for users who want to handle actions manually) +pub use canvas::actions::{CanvasAction, ActionResult}; + +// Theming and GUI #[cfg(feature = "gui")] pub use canvas::theme::CanvasTheme; #[cfg(feature = "gui")] pub use canvas::gui::render_canvas; -// Re-export autocomplete API if feature is enabled -#[cfg(feature = "autocomplete")] -pub use autocomplete::{ - AutocompleteCanvasState, - AutocompleteState, - SuggestionItem, - execute_with_autocomplete, - handle_autocomplete_feature_action, -}; - #[cfg(all(feature = "gui", feature = "autocomplete"))] -pub use autocomplete::render_autocomplete_dropdown; +pub use autocomplete::gui::render_autocomplete_dropdown; + +// =================================================================== +// LEGACY COMPATIBILITY: Old trait-based API (deprecated) +// =================================================================== + +// Legacy exports for backward compatibility - mark as deprecated + +#[deprecated(note = "Use FormEditor and AutocompleteProvider instead")] +#[cfg(feature = "autocomplete")] +pub use crate::autocomplete::state::AutocompleteCanvasState; + +// Mode management (still used) +pub use canvas::modes::{ModeManager, HighlightState};