autocomplete now working
This commit is contained in:
@@ -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"
|
||||
|
||||
412
canvas/examples/autocomplete.rs
Normal file
412
canvas/examples/autocomplete.rs
Normal file
@@ -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<String>,
|
||||
field_names: Vec<String>,
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
mode: AppMode,
|
||||
has_changes: bool,
|
||||
debug_message: String,
|
||||
|
||||
// Autocomplete state
|
||||
autocomplete: AutocompleteState<EmailSuggestion>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
// 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<Self::SuggestionData>> {
|
||||
Some(&self.autocomplete)
|
||||
}
|
||||
|
||||
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> {
|
||||
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<B: Backend>(terminal: &mut Terminal<B>, 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<dyn std::error::Error>> {
|
||||
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(())
|
||||
}
|
||||
@@ -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<S: CanvasState + AutocompleteCanvasState>(
|
||||
/// 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<S: CanvasState + AutocompleteCanvasState>(
|
||||
action: CanvasAction,
|
||||
state: &mut S,
|
||||
_ideal_cursor_column: &mut usize, // Keep for compatibility
|
||||
_config: Option<&()>, // Remove CanvasConfig, keep for compatibility
|
||||
) -> Result<ActionResult> {
|
||||
// Check for autocomplete-specific actions first
|
||||
match &action {
|
||||
CanvasAction::InsertChar(_) => {
|
||||
// Character insertion - execute then potentially trigger autocomplete
|
||||
let result = execute(action, state).await?;
|
||||
// === AUTOCOMPLETE-SPECIFIC ACTIONS ===
|
||||
|
||||
// Check if we should trigger autocomplete after character insertion
|
||||
if state.should_trigger_autocomplete() {
|
||||
state.trigger_autocomplete_suggestions().await;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
_ => {
|
||||
// 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<S: CanvasState + AutocompleteCanvasState>(
|
||||
action: CanvasAction,
|
||||
state: &mut S,
|
||||
_context: &ActionContext,
|
||||
) -> Result<ActionResult> {
|
||||
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<S: CanvasState + AutocompleteCanvasState
|
||||
}
|
||||
|
||||
CanvasAction::SuggestionDown => {
|
||||
// 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<S: CanvasState + AutocompleteCanvasState
|
||||
}
|
||||
|
||||
CanvasAction::SelectSuggestion => {
|
||||
// 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<String> {
|
||||
/// // 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<S: CanvasState + AutocompleteCanvasState>(
|
||||
action: &CanvasAction,
|
||||
state: &S,
|
||||
) -> Option<String> {
|
||||
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<S: CanvasState + AutocompleteCanvasState>(
|
||||
action: CanvasAction,
|
||||
state: &mut S,
|
||||
_ideal_cursor_column: &mut usize, // Ignored - new system manages this internally
|
||||
_config: Option<&()>, // Ignored - no more config system
|
||||
) -> Result<ActionResult> {
|
||||
execute_with_autocomplete(action, state).await
|
||||
}
|
||||
|
||||
@@ -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<T: CanvasTheme>(
|
||||
pub fn render_autocomplete_dropdown<T: CanvasTheme, D: Clone + Send + 'static>(
|
||||
f: &mut Frame,
|
||||
frame_area: Rect,
|
||||
input_rect: Rect,
|
||||
theme: &T,
|
||||
autocomplete_state: &AutocompleteState<impl Clone + Send + 'static>,
|
||||
autocomplete_state: &AutocompleteState<D>,
|
||||
) {
|
||||
if !autocomplete_state.is_active {
|
||||
return;
|
||||
@@ -68,12 +69,12 @@ fn render_loading_indicator<T: CanvasTheme>(
|
||||
|
||||
/// Show actual suggestions list
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_suggestions_dropdown<T: CanvasTheme>(
|
||||
fn render_suggestions_dropdown<T: CanvasTheme, D: Clone + Send + 'static>(
|
||||
f: &mut Frame,
|
||||
frame_area: Rect,
|
||||
input_rect: Rect,
|
||||
theme: &T,
|
||||
autocomplete_state: &AutocompleteState<impl Clone + Send + 'static>,
|
||||
autocomplete_state: &AutocompleteState<D>,
|
||||
) {
|
||||
let display_texts: Vec<&str> = autocomplete_state.suggestions
|
||||
.iter()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<crate::autocomplete::SuggestionItem<Self::SuggestionData>>) {
|
||||
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<String> {
|
||||
// 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<crate::autocomplete::SuggestionItem<Self::SuggestionData>> {
|
||||
self.autocomplete_state()?
|
||||
.get_selected()
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Apply the selected suggestion to the current field
|
||||
fn apply_suggestion(&mut self, suggestion: &crate::autocomplete::SuggestionItem<Self::SuggestionData>) {
|
||||
// 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<String> {
|
||||
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<String> {
|
||||
self.apply_selected_suggestion()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user