working autocomplete now, with backwards deprecation

This commit is contained in:
Priec
2025-07-31 22:44:21 +02:00
parent c594c35b37
commit 8f99aa79ec
5 changed files with 59 additions and 45 deletions

1
Cargo.lock generated
View File

@@ -475,6 +475,7 @@ name = "canvas"
version = "0.4.2" version = "0.4.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait",
"common", "common",
"crossterm", "crossterm",
"ratatui", "ratatui",

View File

@@ -22,6 +22,7 @@ thiserror = { workspace = true }
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = "0.3.19" tracing-subscriber = "0.3.19"
async-trait = { workspace = true, optional = true }
[dev-dependencies] [dev-dependencies]
tokio-test = "0.4.4" tokio-test = "0.4.4"
@@ -29,7 +30,7 @@ tokio-test = "0.4.4"
[features] [features]
default = [] default = []
gui = ["ratatui"] gui = ["ratatui"]
autocomplete = ["tokio"] autocomplete = ["tokio", "async-trait"]
[[example]] [[example]]
name = "autocomplete" name = "autocomplete"

View File

@@ -24,8 +24,8 @@ use canvas::{
theme::CanvasTheme, theme::CanvasTheme,
}, },
autocomplete::{ autocomplete::{
AutocompleteCanvasState, AutocompleteCanvasState,
AutocompleteState, AutocompleteState,
SuggestionItem, SuggestionItem,
execute_with_autocomplete, execute_with_autocomplete,
handle_autocomplete_feature_action, handle_autocomplete_feature_action,
@@ -33,6 +33,9 @@ use canvas::{
CanvasAction, CanvasAction,
}; };
// Add the async_trait import
use async_trait::async_trait;
// Simple theme implementation // Simple theme implementation
#[derive(Clone)] #[derive(Clone)]
struct DemoTheme; struct DemoTheme;
@@ -64,7 +67,7 @@ struct AutocompleteFormState {
mode: AppMode, mode: AppMode,
has_changes: bool, has_changes: bool,
debug_message: String, debug_message: String,
// Autocomplete state // Autocomplete state
autocomplete: AutocompleteState<EmailSuggestion>, autocomplete: AutocompleteState<EmailSuggestion>,
} }
@@ -97,14 +100,14 @@ impl AutocompleteFormState {
impl CanvasState for AutocompleteFormState { impl CanvasState for AutocompleteFormState {
fn current_field(&self) -> usize { self.current_field } fn current_field(&self) -> usize { self.current_field }
fn current_cursor_pos(&self) -> usize { self.cursor_pos } fn current_cursor_pos(&self) -> usize { self.cursor_pos }
fn set_current_field(&mut self, index: usize) { fn set_current_field(&mut self, index: usize) {
self.current_field = index.min(self.fields.len().saturating_sub(1)); self.current_field = index.min(self.fields.len().saturating_sub(1));
// Clear autocomplete when changing fields // Clear autocomplete when changing fields
if self.is_autocomplete_active() { if self.is_autocomplete_active() {
self.clear_autocomplete_suggestions(); self.clear_autocomplete_suggestions();
} }
} }
fn set_current_cursor_pos(&mut self, pos: usize) { fn set_current_cursor_pos(&mut self, pos: usize) {
let max_pos = if self.mode == AppMode::Edit { let max_pos = if self.mode == AppMode::Edit {
self.fields[self.current_field].len() self.fields[self.current_field].len()
} else { } else {
@@ -146,6 +149,8 @@ impl CanvasState for AutocompleteFormState {
} }
} }
// Add the #[async_trait] attribute to the implementation
#[async_trait]
impl AutocompleteCanvasState for AutocompleteFormState { impl AutocompleteCanvasState for AutocompleteFormState {
type SuggestionData = EmailSuggestion; type SuggestionData = EmailSuggestion;
@@ -165,9 +170,9 @@ impl AutocompleteCanvasState for AutocompleteFormState {
fn should_trigger_autocomplete(&self) -> bool { fn should_trigger_autocomplete(&self) -> bool {
let current_input = self.get_current_input(); let current_input = self.get_current_input();
let current_field = self.current_field(); let current_field = self.current_field();
// Trigger for email field when we have "@" and at least 1 more character // Trigger for email field when we have "@" and at least 1 more character
self.supports_autocomplete(current_field) && self.supports_autocomplete(current_field) &&
current_input.contains('@') && current_input.contains('@') &&
current_input.len() > current_input.find('@').unwrap_or(0) + 1 && current_input.len() > current_input.find('@').unwrap_or(0) + 1 &&
!self.is_autocomplete_active() !self.is_autocomplete_active()
@@ -181,7 +186,7 @@ impl AutocompleteCanvasState for AutocompleteFormState {
// 2. Get current input for querying // 2. Get current input for querying
let query = self.get_current_input().to_string(); let query = self.get_current_input().to_string();
// 3. Extract domain part from email // 3. Extract domain part from email
let domain_part = if let Some(at_pos) = query.find('@') { let domain_part = if let Some(at_pos) = query.find('@') {
query[at_pos + 1..].to_string() query[at_pos + 1..].to_string()
@@ -195,19 +200,19 @@ impl AutocompleteCanvasState for AutocompleteFormState {
let suggestions = tokio::task::spawn_blocking(move || { let suggestions = tokio::task::spawn_blocking(move || {
// Simulate network delay // Simulate network delay
std::thread::sleep(std::time::Duration::from_millis(200)); std::thread::sleep(std::time::Duration::from_millis(200));
// Create mock suggestions based on domain input // Create mock suggestions based on domain input
let popular_domains = vec![ let popular_domains = vec![
("gmail.com", "Gmail"), ("gmail.com", "Gmail"),
("yahoo.com", "Yahoo Mail"), ("yahoo.com", "Yahoo Mail"),
("outlook.com", "Outlook"), ("outlook.com", "Outlook"),
("hotmail.com", "Hotmail"), ("hotmail.com", "Hotmail"),
("company.com", "Company Email"), ("company.com", "Company Email"),
("university.edu", "University"), ("university.edu", "University"),
]; ];
let mut results = Vec::new(); let mut results = Vec::new();
for (domain, provider) in popular_domains { for (domain, provider) in popular_domains {
if domain.starts_with(&domain_part) || domain_part.is_empty() { if domain.starts_with(&domain_part) || domain_part.is_empty() {
let full_email = format!("{}@{}", email_prefix, domain); let full_email = format!("{}@{}", email_prefix, domain);
@@ -221,7 +226,7 @@ impl AutocompleteCanvasState for AutocompleteFormState {
)); ));
} }
} }
results results
}).await.unwrap_or_default(); }).await.unwrap_or_default();
@@ -246,7 +251,7 @@ async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut Aut
Some(CanvasAction::NextField) // Normal tab Some(CanvasAction::NextField) // Normal tab
} }
} }
KeyCode::BackTab => { KeyCode::BackTab => {
if state.is_autocomplete_active() { if state.is_autocomplete_active() {
Some(CanvasAction::SuggestionUp) Some(CanvasAction::SuggestionUp)
@@ -254,7 +259,7 @@ async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut Aut
Some(CanvasAction::PrevField) Some(CanvasAction::PrevField)
} }
} }
KeyCode::Enter => { KeyCode::Enter => {
if state.is_autocomplete_active() { if state.is_autocomplete_active() {
Some(CanvasAction::SelectSuggestion) // Apply suggestion Some(CanvasAction::SelectSuggestion) // Apply suggestion
@@ -262,7 +267,7 @@ async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut Aut
Some(CanvasAction::NextField) Some(CanvasAction::NextField)
} }
} }
KeyCode::Esc => { KeyCode::Esc => {
if state.is_autocomplete_active() { if state.is_autocomplete_active() {
Some(CanvasAction::ExitSuggestions) // Close autocomplete Some(CanvasAction::ExitSuggestions) // Close autocomplete
@@ -280,12 +285,12 @@ async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut Aut
KeyCode::End => Some(CanvasAction::MoveLineEnd), KeyCode::End => Some(CanvasAction::MoveLineEnd),
KeyCode::Backspace => Some(CanvasAction::DeleteBackward), KeyCode::Backspace => Some(CanvasAction::DeleteBackward),
KeyCode::Delete => Some(CanvasAction::DeleteForward), KeyCode::Delete => Some(CanvasAction::DeleteForward),
// Character input // Character input
KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => { KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => {
Some(CanvasAction::InsertChar(c)) Some(CanvasAction::InsertChar(c))
} }
_ => None, _ => None,
}; };
@@ -371,7 +376,7 @@ fn ui(f: &mut Frame, state: &AutocompleteFormState, theme: &DemoTheme) {
}; };
let status_lines = vec![ let status_lines = vec![
Line::from(Span::raw(format!("Mode: {:?} | Field: {}/{} | Cursor: {}", Line::from(Span::raw(format!("Mode: {:?} | Field: {}/{} | Cursor: {}",
state.mode, state.current_field + 1, state.fields.len(), state.cursor_pos))), state.mode, state.current_field + 1, state.fields.len(), state.cursor_pos))),
Line::from(Span::raw(format!("Autocomplete: {}", autocomplete_status))), Line::from(Span::raw(format!("Autocomplete: {}", autocomplete_status))),
Line::from(Span::raw(state.debug_message.clone())), Line::from(Span::raw(state.debug_message.clone())),

View File

@@ -8,18 +8,18 @@ use anyhow::Result;
/// Enhanced execute function for states that support autocomplete /// Enhanced execute function for states that support autocomplete
/// This is the main entry point for autocomplete-aware canvas execution /// This is the main entry point for autocomplete-aware canvas execution
/// ///
/// Use this instead of canvas::execute() if you want autocomplete behavior: /// Use this instead of canvas::execute() if you want autocomplete behavior:
/// ```rust /// ```rust
/// execute_with_autocomplete(action, &mut state).await?; /// execute_with_autocomplete(action, &mut state).await?;
/// ``` /// ```
pub async fn execute_with_autocomplete<S: CanvasState + AutocompleteCanvasState>( pub async fn execute_with_autocomplete<S: CanvasState + AutocompleteCanvasState + Send>(
action: CanvasAction, action: CanvasAction,
state: &mut S, state: &mut S,
) -> Result<ActionResult> { ) -> Result<ActionResult> {
match &action { match &action {
// === AUTOCOMPLETE-SPECIFIC ACTIONS === // === AUTOCOMPLETE-SPECIFIC ACTIONS ===
CanvasAction::TriggerAutocomplete => { CanvasAction::TriggerAutocomplete => {
if state.supports_autocomplete(state.current_field()) { if state.supports_autocomplete(state.current_field()) {
state.trigger_autocomplete_suggestions().await; state.trigger_autocomplete_suggestions().await;
@@ -61,7 +61,7 @@ pub async fn execute_with_autocomplete<S: CanvasState + AutocompleteCanvasState>
} }
// === TEXT INSERTION WITH AUTO-TRIGGER === // === TEXT INSERTION WITH AUTO-TRIGGER ===
CanvasAction::InsertChar(_) => { CanvasAction::InsertChar(_) => {
// First, execute the character insertion normally // First, execute the character insertion normally
let result = execute(action, state).await?; let result = execute(action, state).await?;
@@ -75,7 +75,7 @@ pub async fn execute_with_autocomplete<S: CanvasState + AutocompleteCanvasState>
} }
// === NAVIGATION/EDITING ACTIONS (clear autocomplete first) === // === NAVIGATION/EDITING ACTIONS (clear autocomplete first) ===
CanvasAction::MoveLeft | CanvasAction::MoveRight | CanvasAction::MoveLeft | CanvasAction::MoveRight |
CanvasAction::MoveUp | CanvasAction::MoveDown | CanvasAction::MoveUp | CanvasAction::MoveDown |
CanvasAction::NextField | CanvasAction::PrevField | CanvasAction::NextField | CanvasAction::PrevField |
@@ -84,13 +84,13 @@ pub async fn execute_with_autocomplete<S: CanvasState + AutocompleteCanvasState>
if state.is_autocomplete_active() { if state.is_autocomplete_active() {
state.clear_autocomplete_suggestions(); state.clear_autocomplete_suggestions();
} }
// Execute the action normally // Execute the action normally
execute(action, state).await execute(action, state).await
} }
// === ALL OTHER ACTIONS (normal execution) === // === ALL OTHER ACTIONS (normal execution) ===
_ => { _ => {
// For all other actions, just execute normally // For all other actions, just execute normally
execute(action, state).await execute(action, state).await
@@ -99,7 +99,7 @@ pub async fn execute_with_autocomplete<S: CanvasState + AutocompleteCanvasState>
} }
/// Helper function to integrate autocomplete actions with CanvasState.handle_feature_action() /// Helper function to integrate autocomplete actions with CanvasState.handle_feature_action()
/// ///
/// Use this in your CanvasState implementation like this: /// Use this in your CanvasState implementation like this:
/// ```rust /// ```rust
/// fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> { /// fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
@@ -107,12 +107,12 @@ pub async fn execute_with_autocomplete<S: CanvasState + AutocompleteCanvasState>
/// if let Some(result) = handle_autocomplete_feature_action(action, self) { /// if let Some(result) = handle_autocomplete_feature_action(action, self) {
/// return Some(result); /// return Some(result);
/// } /// }
/// ///
/// // Handle your other custom actions... /// // Handle your other custom actions...
/// None /// None
/// } /// }
/// ``` /// ```
pub fn handle_autocomplete_feature_action<S: CanvasState + AutocompleteCanvasState>( pub fn handle_autocomplete_feature_action<S: CanvasState + AutocompleteCanvasState + Send>(
action: &CanvasAction, action: &CanvasAction,
state: &S, state: &S,
) -> Option<String> { ) -> Option<String> {
@@ -160,7 +160,7 @@ pub fn handle_autocomplete_feature_action<S: CanvasState + AutocompleteCanvasSta
/// Legacy compatibility function - kept for backward compatibility /// Legacy compatibility function - kept for backward compatibility
/// This is the old function signature, now it just wraps the new system /// This is the old function signature, now it just wraps the new system
#[deprecated(note = "Use execute_with_autocomplete instead")] #[deprecated(note = "Use execute_with_autocomplete instead")]
pub async fn execute_canvas_action_with_autocomplete<S: CanvasState + AutocompleteCanvasState>( pub async fn execute_canvas_action_with_autocomplete<S: CanvasState + AutocompleteCanvasState + Send>(
action: CanvasAction, action: CanvasAction,
state: &mut S, state: &mut S,
_ideal_cursor_column: &mut usize, // Ignored - new system manages this internally _ideal_cursor_column: &mut usize, // Ignored - new system manages this internally

View File

@@ -1,17 +1,19 @@
// src/autocomplete/state.rs // src/autocomplete/state.rs
use crate::canvas::state::CanvasState; use crate::canvas::state::CanvasState;
use async_trait::async_trait;
/// OPTIONAL extension trait for states that want rich autocomplete functionality. /// OPTIONAL extension trait for states that want rich autocomplete functionality.
/// Only implement this if you need the new autocomplete features. /// Only implement this if you need the new autocomplete features.
/// ///
/// # User Workflow: /// # User Workflow:
/// 1. User presses trigger key (Tab, Ctrl+K, etc.) /// 1. User presses trigger key (Tab, Ctrl+K, etc.)
/// 2. User's key mapping calls CanvasAction::TriggerAutocomplete /// 2. User's key mapping calls CanvasAction::TriggerAutocomplete
/// 3. Library calls your trigger_autocomplete_suggestions() method /// 3. Library calls your trigger_autocomplete_suggestions() method
/// 4. You implement async fetching logic in that method /// 4. You implement async fetching logic in that method
/// 5. You call set_autocomplete_suggestions() with results /// 5. You call set_autocomplete_suggestions() with results
/// 6. Library manages UI state and navigation /// 6. Library manages UI state and navigation
#[async_trait]
pub trait AutocompleteCanvasState: CanvasState { pub trait AutocompleteCanvasState: CanvasState {
/// Associated type for suggestion data (e.g., Hit, String, CustomType) /// Associated type for suggestion data (e.g., Hit, String, CustomType)
type SuggestionData: Clone + Send + 'static; type SuggestionData: Clone + Send + 'static;
@@ -92,34 +94,39 @@ pub trait AutocompleteCanvasState: CanvasState {
fn should_trigger_autocomplete(&self) -> bool { fn should_trigger_autocomplete(&self) -> bool {
let current_input = self.get_current_input(); let current_input = self.get_current_input();
let current_field = self.current_field(); let current_field = self.current_field();
self.supports_autocomplete(current_field) && self.supports_autocomplete(current_field) &&
current_input.len() >= 2 && // Default: trigger after 2 chars current_input.len() >= 2 && // Default: trigger after 2 chars
!self.is_autocomplete_active() !self.is_autocomplete_active()
} }
/// **USER MUST IMPLEMENT**: Trigger autocomplete suggestions (async) /// **USER MUST IMPLEMENT**: Trigger autocomplete suggestions (async)
/// This is where you implement your API calls, caching, etc. /// This is where you implement your API calls, caching, etc.
/// ///
/// # Example Implementation: /// # Example Implementation:
/// ```rust /// ```rust
/// async fn trigger_autocomplete_suggestions(&mut self) { /// #[async_trait]
/// self.activate_autocomplete(); // Show loading state /// impl AutocompleteCanvasState for MyState {
/// type SuggestionData = MyData;
/// ///
/// let query = self.get_current_input().to_string(); /// async fn trigger_autocomplete_suggestions(&mut self) {
/// let suggestions = my_api.search(&query).await.unwrap_or_default(); /// self.activate_autocomplete(); // Show loading state
/// ///
/// self.set_autocomplete_suggestions(suggestions); /// 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) { async fn trigger_autocomplete_suggestions(&mut self) {
// Activate autocomplete UI // Activate autocomplete UI
self.activate_autocomplete(); self.activate_autocomplete();
// Default: just show loading state // Default: just show loading state
// User should override this to do actual async fetching // User should override this to do actual async fetching
self.set_autocomplete_loading(true); self.set_autocomplete_loading(true);
// In a real implementation, you'd: // In a real implementation, you'd:
// 1. Get current input: let query = self.get_current_input(); // 1. Get current input: let query = self.get_current_input();
// 2. Make API call: let results = api.search(query).await; // 2. Make API call: let results = api.search(query).await;
@@ -157,7 +164,7 @@ pub trait AutocompleteCanvasState: CanvasState {
// Apply the value to current field // Apply the value to current field
*self.get_current_input_mut() = suggestion.value_to_store.clone(); *self.get_current_input_mut() = suggestion.value_to_store.clone();
self.set_has_unsaved_changes(true); self.set_has_unsaved_changes(true);
// Clear autocomplete // Clear autocomplete
self.clear_autocomplete_suggestions(); self.clear_autocomplete_suggestions();
} }