From c594c35b37746dc1c67370613ce628eaa13c97c7 Mon Sep 17 00:00:00 2001 From: Priec Date: Thu, 31 Jul 2025 22:25:43 +0200 Subject: [PATCH] autocomplete now working --- canvas/Cargo.toml | 13 +- canvas/examples/autocomplete.rs | 412 +++++++++++++++++++++++++++++ canvas/src/autocomplete/actions.rs | 182 +++++++++---- canvas/src/autocomplete/gui.rs | 11 +- canvas/src/autocomplete/mod.rs | 18 +- canvas/src/autocomplete/state.rs | 144 ++++++++-- canvas/src/lib.rs | 7 +- 7 files changed, 686 insertions(+), 101 deletions(-) create mode 100644 canvas/examples/autocomplete.rs diff --git a/canvas/Cargo.toml b/canvas/Cargo.toml index 9a16fe2..4a5bc51 100644 --- a/canvas/Cargo.toml +++ b/canvas/Cargo.toml @@ -14,7 +14,7 @@ common = { path = "../common" } ratatui = { workspace = true, optional = true } crossterm = { workspace = true } anyhow = { workspace = true } -tokio = { workspace = true } +tokio = { workspace = true, optional = true } toml = { workspace = true } serde = { workspace = true } unicode-width.workspace = true @@ -29,7 +29,14 @@ tokio-test = "0.4.4" [features] default = [] gui = ["ratatui"] +autocomplete = ["tokio"] [[example]] -name = "ratatui_demo" -path = "examples/ratatui_demo.rs" +name = "autocomplete" +required-features = ["autocomplete", "gui"] +path = "examples/autocomplete.rs" + +[[example]] +name = "canvas_gui_demo" +required-features = ["gui"] +path = "examples/canvas_gui_demo.rs" diff --git a/canvas/examples/autocomplete.rs b/canvas/examples/autocomplete.rs new file mode 100644 index 0000000..7cc927d --- /dev/null +++ b/canvas/examples/autocomplete.rs @@ -0,0 +1,412 @@ +// examples/autocomplete.rs +// Run with: cargo run --example autocomplete --features "autocomplete,gui" + +use std::io; +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::{Backend, CrosstermBackend}, + layout::{Constraint, Direction, Layout}, + style::Color, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, Terminal, +}; + +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, +}; + +// Simple theme implementation +#[derive(Clone)] +struct DemoTheme; + +impl CanvasTheme for DemoTheme { + fn bg(&self) -> Color { Color::Reset } + fn fg(&self) -> Color { Color::White } + fn accent(&self) -> Color { Color::Cyan } + fn secondary(&self) -> Color { Color::Gray } + fn highlight(&self) -> Color { Color::Yellow } + fn highlight_bg(&self) -> Color { Color::DarkGray } + fn warning(&self) -> Color { Color::Red } + fn border(&self) -> Color { Color::Gray } +} + +// Custom suggestion data type +#[derive(Clone, Debug)] +struct EmailSuggestion { + email: String, + 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, + + // Autocomplete state + autocomplete: AutocompleteState, +} + +impl AutocompleteFormState { + 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(), + } + } +} + +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(); + } + } + 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, + } + } +} + +impl AutocompleteCanvasState for AutocompleteFormState { + type SuggestionData = EmailSuggestion; + + fn supports_autocomplete(&self, field_index: usize) -> bool { + // Only enable autocomplete for email field (index 1) + field_index == 1 + } + + fn autocomplete_state(&self) -> Option<&AutocompleteState> { + Some(&self.autocomplete) + } + + fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState> { + Some(&mut self.autocomplete) + } + + 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() + } else { + self.set_autocomplete_loading(false); + return; // No @ symbol, can't suggest + }; + + // 4. SIMULATE ASYNC API CALL (in real code, this would be HTTP request) + let email_prefix = query[..query.find('@').unwrap()].to_string(); + 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 + let popular_domains = vec![ + ("gmail.com", "Gmail"), + ("yahoo.com", "Yahoo Mail"), + ("outlook.com", "Outlook"), + ("hotmail.com", "Hotmail"), + ("company.com", "Company Email"), + ("university.edu", "University"), + ]; + + 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 { + email: full_email.clone(), + provider: provider.to_string(), + }, + format!("{} ({})", full_email, provider), // display text + full_email, // value to store + )); + } + } + + results + }).await.unwrap_or_default(); + + // 5. Provide suggestions back to library + self.set_autocomplete_suggestions(suggestions); + } +} + +async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut AutocompleteFormState) -> bool { + if key == KeyCode::F(10) || (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) { + return false; // Quit + } + + let action = 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 + } else { + Some(CanvasAction::NextField) // Normal tab + } + } + + KeyCode::BackTab => { + if state.is_autocomplete_active() { + Some(CanvasAction::SuggestionUp) + } else { + Some(CanvasAction::PrevField) + } + } + + KeyCode::Enter => { + if state.is_autocomplete_active() { + Some(CanvasAction::SelectSuggestion) // Apply suggestion + } else { + Some(CanvasAction::NextField) + } + } + + KeyCode::Esc => { + if state.is_autocomplete_active() { + Some(CanvasAction::ExitSuggestions) // Close autocomplete + } else { + Some(CanvasAction::Custom("toggle_mode".to_string())) + } + } + + // === 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)) + } + + _ => None, + }; + + 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 + } +} + +async fn run_app(terminal: &mut Terminal, mut state: AutocompleteFormState) -> io::Result<()> { + let theme = DemoTheme; + + loop { + terminal.draw(|f| ui(f, &state, &theme))?; + + if let Event::Key(key) = event::read()? { + let should_continue = handle_key_press(key.code, key.modifiers, &mut state).await; + if !should_continue { + break; + } + } + } + + Ok(()) +} + +fn ui(f: &mut Frame, state: &AutocompleteFormState, theme: &DemoTheme) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(8), + Constraint::Length(5), + ]) + .split(f.area()); + + // Render the canvas form + let active_field_rect = render_canvas( + f, + chunks[0], + state, + theme, + state.mode == AppMode::Edit, + &canvas::HighlightState::Off, + ); + + // Render autocomplete dropdown on top if active + if let Some(input_rect) = active_field_rect { + canvas::render_autocomplete_dropdown( + f, + chunks[0], + input_rect, + theme, + &state.autocomplete, + ); + } + + // Status info + let autocomplete_status = if state.is_autocomplete_active() { + if state.autocomplete.is_loading { + "Loading suggestions..." + } else if state.has_autocomplete_suggestions() { + "Use Tab/Shift+Tab to navigate, Enter to select, Esc to cancel" + } else { + "No suggestions found" + } + } else { + "Tab to trigger autocomplete" + }; + + let status_lines = vec![ + Line::from(Span::raw(format!("Mode: {:?} | Field: {}/{} | Cursor: {}", + state.mode, state.current_field + 1, state.fields.len(), state.cursor_pos))), + 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")), + ]; + + let status = Paragraph::new(status_lines) + .block(Block::default().borders(Borders::ALL).title("Status & Help")); + + f.render_widget(status, chunks[1]); +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let state = AutocompleteFormState::new(); + + let res = run_app(&mut terminal, state).await; + + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + if let Err(err) = res { + println!("{:?}", err); + } + + Ok(()) +} diff --git a/canvas/src/autocomplete/actions.rs b/canvas/src/autocomplete/actions.rs index 2467268..223535b 100644 --- a/canvas/src/autocomplete/actions.rs +++ b/canvas/src/autocomplete/actions.rs @@ -1,66 +1,35 @@ // src/autocomplete/actions.rs -use crate::canvas::state::{CanvasState, ActionContext}; +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; -/// Version for states that implement rich autocomplete -pub async fn execute_canvas_action_with_autocomplete( +/// 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 +/// execute_with_autocomplete(action, &mut state).await?; +/// ``` +pub async fn execute_with_autocomplete( action: CanvasAction, state: &mut S, - _ideal_cursor_column: &mut usize, // Keep for compatibility - _config: Option<&()>, // Remove CanvasConfig, keep for compatibility ) -> Result { - // Check for autocomplete-specific actions first match &action { - CanvasAction::InsertChar(_) => { - // Character insertion - execute then potentially trigger autocomplete - let result = execute(action, state).await?; - - // Check if we should trigger autocomplete after character insertion - if state.should_trigger_autocomplete() { - state.trigger_autocomplete_suggestions().await; - } - - Ok(result) - } + // === AUTOCOMPLETE-SPECIFIC ACTIONS === - _ => { - // For other actions, clear suggestions and execute - let result = execute(action, state).await?; - - // Clear autocomplete on navigation/other actions - match action { - CanvasAction::MoveLeft | CanvasAction::MoveRight | - CanvasAction::MoveUp | CanvasAction::MoveDown | - CanvasAction::NextField | CanvasAction::PrevField => { - state.clear_autocomplete_suggestions(); - } - _ => {} - } - - Ok(result) - } - } -} - -/// Handle autocomplete-specific actions (called from handle_feature_action) -pub async fn handle_autocomplete_action( - action: CanvasAction, - state: &mut S, - _context: &ActionContext, -) -> Result { - match action { CanvasAction::TriggerAutocomplete => { - // Manual trigger of autocomplete - state.trigger_autocomplete_suggestions().await; - Ok(ActionResult::success_with_message("Triggered autocomplete")) + 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 => { - // Navigate up in suggestions if state.has_autocomplete_suggestions() { state.move_suggestion_selection(-1); Ok(ActionResult::success()) @@ -70,7 +39,6 @@ pub async fn handle_autocomplete_action { - // Navigate down in suggestions if state.has_autocomplete_suggestions() { state.move_suggestion_selection(1); Ok(ActionResult::success()) @@ -80,25 +48,123 @@ pub async fn handle_autocomplete_action { - // Accept the selected suggestion - if let Some(suggestion) = state.get_selected_suggestion() { - state.apply_suggestion(&suggestion); - state.clear_autocomplete_suggestions(); - Ok(ActionResult::success_with_message("Applied suggestion")) + if let Some(message) = state.apply_selected_suggestion() { + Ok(ActionResult::success_with_message(&message)) } else { - Ok(ActionResult::success_with_message("No suggestion selected")) + Ok(ActionResult::success_with_message("No suggestion to select")) } } CanvasAction::ExitSuggestions => { - // Cancel autocomplete state.clear_autocomplete_suggestions(); - Ok(ActionResult::success_with_message("Cleared 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) === + _ => { - // Not an autocomplete action - Ok(ActionResult::success_with_message("Not an autocomplete action")) + // 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); +/// } +/// +/// // Handle your other custom actions... +/// None +/// } +/// ``` +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 +) -> Result { + execute_with_autocomplete(action, state).await +} diff --git a/canvas/src/autocomplete/gui.rs b/canvas/src/autocomplete/gui.rs index dab3dbc..52ffbbd 100644 --- a/canvas/src/autocomplete/gui.rs +++ b/canvas/src/autocomplete/gui.rs @@ -1,4 +1,4 @@ -// canvas/src/autocomplete/gui.rs +// src/autocomplete/gui.rs #[cfg(feature = "gui")] use ratatui::{ @@ -8,6 +8,7 @@ use ratatui::{ Frame, }; +// Use the correct import from our types module use crate::autocomplete::types::AutocompleteState; #[cfg(feature = "gui")] @@ -18,12 +19,12 @@ use unicode_width::UnicodeWidthStr; /// Render autocomplete dropdown - 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, + autocomplete_state: &AutocompleteState, ) { if !autocomplete_state.is_active { return; @@ -68,12 +69,12 @@ 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, + autocomplete_state: &AutocompleteState, ) { let display_texts: Vec<&str> = autocomplete_state.suggestions .iter() diff --git a/canvas/src/autocomplete/mod.rs b/canvas/src/autocomplete/mod.rs index 26583b9..31bcb4a 100644 --- a/canvas/src/autocomplete/mod.rs +++ b/canvas/src/autocomplete/mod.rs @@ -1,10 +1,22 @@ // src/autocomplete/mod.rs + pub mod types; -pub mod gui; pub mod state; pub mod actions; -// Re-export autocomplete types +#[cfg(feature = "gui")] +pub mod gui; + +// Re-export the main autocomplete API pub use types::{SuggestionItem, AutocompleteState}; pub use state::AutocompleteCanvasState; -pub use actions::execute_canvas_action_with_autocomplete; + +// Re-export the new action functions +pub use actions::{ + execute_with_autocomplete, + handle_autocomplete_feature_action, +}; + +// Re-export GUI functions if available +#[cfg(feature = "gui")] +pub use gui::render_autocomplete_dropdown; diff --git a/canvas/src/autocomplete/state.rs b/canvas/src/autocomplete/state.rs index 90f31e3..2cb6b73 100644 --- a/canvas/src/autocomplete/state.rs +++ b/canvas/src/autocomplete/state.rs @@ -1,14 +1,22 @@ -// canvas/src/state.rs +// src/autocomplete/state.rs use crate::canvas::state::CanvasState; /// 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 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 + /// Check if a field supports autocomplete (user decides which fields) fn supports_autocomplete(&self, _field_index: usize) -> bool { false // Default: no autocomplete support } @@ -23,74 +31,152 @@ pub trait AutocompleteCanvasState: CanvasState { None // Default: no autocomplete state } - /// CLIENT API: Activate autocomplete for current field + // === 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(); // Get field first + let current_field = self.current_field(); if let Some(state) = self.autocomplete_state_mut() { - state.activate(current_field); // Then use it + state.activate(current_field); } } - /// CLIENT API: Deactivate autocomplete + /// Deactivate autocomplete (hides dropdown) fn deactivate_autocomplete(&mut self) { if let Some(state) = self.autocomplete_state_mut() { state.deactivate(); } } - /// CLIENT API: Set suggestions (called after async fetch completes) + /// 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); } } - /// CLIENT API: Set loading state + /// 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; } } - /// Check if autocomplete is currently active + // === 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 is ready for interaction + /// 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) } - /// INTERNAL: Apply selected autocomplete value to current field - fn apply_autocomplete_selection(&mut self) -> Option { - // First, get the selected value and display text (if any) - let selection_info = if let Some(state) = self.autocomplete_state() { - state.get_selected().map(|selected| { - (selected.value_to_store.clone(), selected.display_text.clone()) - }) - } else { - None - }; + /// Check if there are available suggestions + fn has_autocomplete_suggestions(&self) -> bool { + self.autocomplete_state() + .map(|state| !state.suggestions.is_empty()) + .unwrap_or(false) + } - // Apply the selection if we have one - if let Some((value, display)) = selection_info { - // Apply the value to current field - *self.get_current_input_mut() = value; - self.set_has_unsaved_changes(true); + // === USER-IMPLEMENTABLE METHODS === - // Deactivate autocomplete - if let Some(state_mut) = self.autocomplete_state_mut() { - state_mut.deactivate(); + /// 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 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(); } + } + } - Some(format!("Selected: {}", display)) + /// 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() + } } diff --git a/canvas/src/lib.rs b/canvas/src/lib.rs index 6b2fd59..b0630ec 100644 --- a/canvas/src/lib.rs +++ b/canvas/src/lib.rs @@ -1,4 +1,4 @@ -// src/lib.rs - Updated to conditionally include autocomplete +// src/lib.rs pub mod canvas; @@ -23,8 +23,9 @@ pub use autocomplete::{ AutocompleteCanvasState, AutocompleteState, SuggestionItem, - actions::execute_with_autocomplete, + execute_with_autocomplete, + handle_autocomplete_feature_action, }; #[cfg(all(feature = "gui", feature = "autocomplete"))] -pub use autocomplete::gui::render_autocomplete_dropdown; +pub use autocomplete::render_autocomplete_dropdown;