diff --git a/Cargo.lock b/Cargo.lock index e7341d8..3271206 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -475,6 +475,7 @@ name = "canvas" version = "0.4.2" dependencies = [ "anyhow", + "async-trait", "common", "crossterm", "ratatui", diff --git a/canvas/Cargo.toml b/canvas/Cargo.toml index 1d70881..c046767 100644 --- a/canvas/Cargo.toml +++ b/canvas/Cargo.toml @@ -12,9 +12,9 @@ categories.workspace = true [dependencies] common = { path = "../common" } ratatui = { workspace = true, optional = true } -crossterm = { workspace = true } -anyhow = { workspace = true } -tokio = { workspace = true } +crossterm = { workspace = true, optional = true } +anyhow.workspace = true +tokio = { workspace = true, optional = true } toml = { workspace = true } serde = { workspace = true } unicode-width.workspace = true @@ -22,6 +22,7 @@ thiserror = { workspace = true } tracing = "0.1.41" tracing-subscriber = "0.3.19" +async-trait.workspace = true [dev-dependencies] tokio-test = "0.4.4" @@ -29,3 +30,15 @@ tokio-test = "0.4.4" [features] default = [] gui = ["ratatui"] +autocomplete = ["tokio"] +cursor-style = ["crossterm"] + +[[example]] +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/docs/new_function_to_config.txt b/canvas/docs/new_function_to_config.txt new file mode 100644 index 0000000..92aa4ad --- /dev/null +++ b/canvas/docs/new_function_to_config.txt @@ -0,0 +1,77 @@ +❯ git status +On branch main +Your branch is ahead of 'origin/main' by 1 commit. + (use "git push" to publish your local commits) + +Changes not staged for commit: + (use "git add ..." to update what will be committed) + (use "git restore ..." to discard changes in working directory) + modified: src/canvas/actions/handlers/edit.rs + modified: src/canvas/actions/types.rs + +no changes added to commit (use "git add" and/or "git commit -a") +❯ git --no-pager diff +diff --git a/canvas/src/canvas/actions/handlers/edit.rs b/canvas/src/canvas/actions/handlers/edit.rs +index a26fe6f..fa1becb 100644 +--- a/canvas/src/canvas/actions/handlers/edit.rs ++++ b/canvas/src/canvas/actions/handlers/edit.rs +@@ -29,6 +29,21 @@ pub async fn handle_edit_action( + Ok(ActionResult::success()) + } + ++ CanvasAction::SelectAll => { ++ // Select all text in current field ++ let current_input = state.get_current_input(); ++ let text_length = current_input.len(); ++ ++ // Set cursor to start and select all ++ state.set_current_cursor_pos(0); ++ // TODO: You'd need to add selection state to CanvasState trait ++ // For now, just move cursor to end to "select" all ++ state.set_current_cursor_pos(text_length); ++ *ideal_cursor_column = text_length; ++ ++ Ok(ActionResult::success_with_message(&format!("Selected all {} characters", text_length))) ++ } ++ + CanvasAction::DeleteBackward => { + let cursor_pos = state.current_cursor_pos(); + if cursor_pos > 0 { +@@ -323,6 +338,13 @@ impl ActionHandlerIntrospection for EditHandler { + is_required: false, + }); + ++ actions.push(ActionSpec { ++ name: "select_all".to_string(), ++ description: "Select all text in current field".to_string(), ++ examples: vec!["Ctrl+a".to_string()], ++ is_required: false, // Optional action ++ }); ++ + HandlerCapabilities { + mode_name: "edit".to_string(), + actions, +diff --git a/canvas/src/canvas/actions/types.rs b/canvas/src/canvas/actions/types.rs +index 433a4d5..3794596 100644 +--- a/canvas/src/canvas/actions/types.rs ++++ b/canvas/src/canvas/actions/types.rs +@@ -31,6 +31,8 @@ pub enum CanvasAction { + NextField, + PrevField, + ++ SelectAll, ++ + // Autocomplete actions + TriggerAutocomplete, + SuggestionUp, +@@ -62,6 +64,7 @@ impl CanvasAction { + "move_word_end_prev" => Self::MoveWordEndPrev, + "next_field" => Self::NextField, + "prev_field" => Self::PrevField, ++ "select_all" => Self::SelectAll, + "trigger_autocomplete" => Self::TriggerAutocomplete, + "suggestion_up" => Self::SuggestionUp, + "suggestion_down" => Self::SuggestionDown, +╭─    ~/Doc/p/komp_ac/canvas  on   main ⇡1 !2  +╰─ + diff --git a/canvas/examples/autocomplete.rs b/canvas/examples/autocomplete.rs new file mode 100644 index 0000000..0237524 --- /dev/null +++ b/canvas/examples/autocomplete.rs @@ -0,0 +1,392 @@ +// 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, + theme::CanvasTheme, + }, + autocomplete::gui::render_autocomplete_dropdown, + FormEditor, DataProvider, AutocompleteProvider, SuggestionItem, +}; + +use async_trait::async_trait; +use anyhow::Result; + +// 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, +} + +// =================================================================== +// SIMPLE DATA PROVIDER - Only business data, no UI concerns! +// =================================================================== + +struct ContactForm { + // Only business data - no UI state! + name: String, + email: String, + phone: String, + city: String, +} + +impl ContactForm { + fn new() -> Self { + Self { + 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(), + } + } +} + +// 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 field_value(&self, index: usize) -> &str { + match index { + 0 => &self.name, + 1 => &self.email, + 2 => &self.phone, + 3 => &self.city, + _ => "", + } + } + + 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 { + field_index == 1 // Only email field + } +} + +// =================================================================== +// SIMPLE AUTOCOMPLETE PROVIDER - Only data fetching! +// =================================================================== + +struct EmailAutocomplete; + +#[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 { + return Ok(Vec::new()); // No @ symbol + }; + + // Simulate async API call + let suggestions = tokio::task::spawn_blocking(move || { + // Simulate network delay + std::thread::sleep(std::time::Duration::from_millis(200)); + + // Mock email suggestions + 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 { + data: EmailSuggestion { + email: full_email.clone(), + provider: provider.to_string(), + }, + display_text: format!("{} ({})", full_email, provider), + value_to_store: full_email, + }); + } + } + results + }).await.unwrap_or_default(); + + Ok(suggestions) + } +} + +// =================================================================== +// 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 + } + + // Handle input based on key + let result = match key { + // === AUTOCOMPLETE KEYS === + KeyCode::Tab => { + 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 { + state.editor.move_to_next_field(); + Ok("Moved to next field".to_string()) + } + } + + KeyCode::Enter => { + 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 { + state.editor.move_to_next_field(); + Ok("Moved to next field".to_string()) + } + } + + KeyCode::Esc => { + if state.editor.is_autocomplete_active() { + // Autocomplete will be cleared automatically by mode change + Ok("Cancelled autocomplete".to_string()) + } else { + // 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)) + } + } + + // === 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()) + } + + // === 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)), + }; + + // 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: AppState) -> 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: &AppState, theme: &DemoTheme) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(8), + Constraint::Length(5), + ]) + .split(f.area()); + + // Render the canvas form - much simpler! + let active_field_rect = render_canvas( + f, + chunks[0], + &state.editor, + theme, + ); + + // Render autocomplete dropdown if active + if let Some(input_rect) = active_field_rect { + render_autocomplete_dropdown( + f, + chunks[0], + input_rect, + theme, + &state.editor, + ); + } + + // Status info + let autocomplete_status = if state.editor.is_autocomplete_active() { + if state.editor.ui_state().is_autocomplete_loading() { + "Loading suggestions..." + } else if !state.editor.suggestions().is_empty() { + "Use 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.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")), + ]; + + 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 = AppState::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/examples/full_canvas_demo.rs b/canvas/examples/full_canvas_demo.rs new file mode 100644 index 0000000..5487e55 --- /dev/null +++ b/canvas/examples/full_canvas_demo.rs @@ -0,0 +1,724 @@ +// examples/full_canvas_demo.rs +//! Demonstrates the FULL potential of the canvas library using the native API + +use std::io; +use crossterm::{ + cursor::SetCursorStyle, + 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, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, Terminal, +}; + +use canvas::{ + canvas::{ + gui::render_canvas_default, + modes::{AppMode, ModeManager, HighlightState}, + }, + DataProvider, FormEditor, +}; + +/// Update cursor style based on current AppMode +fn update_cursor_for_mode(mode: AppMode) -> io::Result<()> { + let style = match mode { + AppMode::Edit => SetCursorStyle::SteadyBar, // Thin line for insert mode + AppMode::ReadOnly => SetCursorStyle::SteadyBlock, // Block for normal mode + AppMode::Highlight => SetCursorStyle::BlinkingBlock, // Blinking block for visual mode + AppMode::General => SetCursorStyle::SteadyBlock, // Block for general mode + AppMode::Command => SetCursorStyle::SteadyUnderScore, // Underscore for command mode + }; + + execute!(io::stdout(), style) +} + +// Enhanced FormEditor that adds visual mode and status tracking +struct EnhancedFormEditor { + editor: FormEditor, + highlight_state: HighlightState, + has_unsaved_changes: bool, + debug_message: String, + command_buffer: String, // For multi-key vim commands like "gg" +} + +impl EnhancedFormEditor { + fn new(data_provider: D) -> Self { + Self { + editor: FormEditor::new(data_provider), + highlight_state: HighlightState::Off, + has_unsaved_changes: false, + debug_message: "Full Canvas Demo - All features enabled".to_string(), + command_buffer: String::new(), + } + } + + // === COMMAND BUFFER HANDLING === + + fn clear_command_buffer(&mut self) { + self.command_buffer.clear(); + } + + fn add_to_command_buffer(&mut self, ch: char) { + self.command_buffer.push(ch); + } + + fn get_command_buffer(&self) -> &str { + &self.command_buffer + } + + fn has_pending_command(&self) -> bool { + !self.command_buffer.is_empty() + } + + // === VISUAL/HIGHLIGHT MODE SUPPORT === + + fn enter_visual_mode(&mut self) { + if ModeManager::can_enter_highlight_mode(self.editor.mode()) { + self.editor.set_mode(AppMode::Highlight); + self.highlight_state = HighlightState::Characterwise { + anchor: ( + self.editor.current_field(), + self.editor.cursor_position(), + ), + }; + self.debug_message = "-- VISUAL --".to_string(); + } + } + + fn enter_visual_line_mode(&mut self) { + if ModeManager::can_enter_highlight_mode(self.editor.mode()) { + self.editor.set_mode(AppMode::Highlight); + self.highlight_state = + HighlightState::Linewise { anchor_line: self.editor.current_field() }; + self.debug_message = "-- VISUAL LINE --".to_string(); + } + } + + fn exit_visual_mode(&mut self) { + self.highlight_state = HighlightState::Off; + if self.editor.mode() == AppMode::Highlight { + self.editor.set_mode(AppMode::ReadOnly); + self.debug_message = "Visual mode exited".to_string(); + } + } + + fn update_visual_selection(&mut self) { + if self.editor.mode() == AppMode::Highlight { + match &self.highlight_state { + HighlightState::Characterwise { anchor: _ } => { + self.debug_message = format!( + "Visual selection: char {} in field {}", + self.editor.cursor_position(), + self.editor.current_field() + ); + } + HighlightState::Linewise { anchor_line: _ } => { + self.debug_message = format!( + "Visual line selection: field {}", + self.editor.current_field() + ); + } + _ => {} + } + } + } + + // === ENHANCED MOVEMENT WITH VISUAL UPDATES === + + fn move_left(&mut self) { + self.editor.move_left(); + self.update_visual_selection(); + } + + fn move_right(&mut self) { + self.editor.move_right(); + self.update_visual_selection(); + } + + fn move_up(&mut self) { + self.editor.move_up(); + self.update_visual_selection(); + } + + fn move_down(&mut self) { + self.editor.move_down(); + self.update_visual_selection(); + } + + fn move_word_next(&mut self) { + self.editor.move_word_next(); + self.update_visual_selection(); + } + + fn move_word_prev(&mut self) { + self.editor.move_word_prev(); + self.update_visual_selection(); + } + + fn move_word_end(&mut self) { + self.editor.move_word_end(); + self.update_visual_selection(); + } + + fn move_word_end_prev(&mut self) { + self.editor.move_word_end_prev(); + self.update_visual_selection(); + } + + fn move_line_start(&mut self) { + self.editor.move_line_start(); + self.update_visual_selection(); + } + + fn move_line_end(&mut self) { + self.editor.move_line_end(); + self.update_visual_selection(); + } + + fn move_first_line(&mut self) { + self.editor.move_first_line(); + self.update_visual_selection(); + } + + fn move_last_line(&mut self) { + self.editor.move_last_line(); + self.update_visual_selection(); + } + + fn prev_field(&mut self) { + self.editor.prev_field(); + self.update_visual_selection(); + } + + fn next_field(&mut self) { + self.editor.next_field(); + self.update_visual_selection(); + } + + // === DELETE OPERATIONS === + + fn delete_backward(&mut self) -> anyhow::Result<()> { + let result = self.editor.delete_backward(); + if result.is_ok() { + self.has_unsaved_changes = true; + self.debug_message = "Deleted character backward".to_string(); + } + Ok(result?) + } + + fn delete_forward(&mut self) -> anyhow::Result<()> { + let result = self.editor.delete_forward(); + if result.is_ok() { + self.has_unsaved_changes = true; + self.debug_message = "Deleted character forward".to_string(); + } + Ok(result?) + } + + // === MODE TRANSITIONS === + + fn enter_edit_mode(&mut self) { + self.editor.enter_edit_mode(); + self.debug_message = "-- INSERT --".to_string(); + } + + fn exit_edit_mode(&mut self) { + self.editor.exit_edit_mode(); + self.exit_visual_mode(); + self.debug_message = "".to_string(); + } + + fn insert_char(&mut self, ch: char) -> anyhow::Result<()> { + let result = self.editor.insert_char(ch); + if result.is_ok() { + self.has_unsaved_changes = true; + } + Ok(result?) + } + + // === DELEGATE TO ORIGINAL EDITOR === + + fn current_field(&self) -> usize { + self.editor.current_field() + } + + fn cursor_position(&self) -> usize { + self.editor.cursor_position() + } + + fn mode(&self) -> AppMode { + self.editor.mode() + } + + fn current_text(&self) -> &str { + self.editor.current_text() + } + + fn data_provider(&self) -> &D { + self.editor.data_provider() + } + + fn ui_state(&self) -> &canvas::EditorState { + self.editor.ui_state() + } + + fn set_mode(&mut self, mode: AppMode) { + self.editor.set_mode(mode); + if mode != AppMode::Highlight { + self.exit_visual_mode(); + } + } + + // === STATUS AND DEBUG === + + fn set_debug_message(&mut self, msg: String) { + self.debug_message = msg; + } + + fn debug_message(&self) -> &str { + &self.debug_message + } + + fn highlight_state(&self) -> &HighlightState { + &self.highlight_state + } + + fn has_unsaved_changes(&self) -> bool { + self.has_unsaved_changes + } +} + +// Demo form data with interesting text for word movement +struct FullDemoData { + fields: Vec<(String, String)>, +} + +impl FullDemoData { + fn new() -> Self { + Self { + fields: vec![ + ("Name".to_string(), "John-Paul McDonald".to_string()), + ( + "Email".to_string(), + "user@example-domain.com".to_string(), + ), + ("Phone".to_string(), "+1 (555) 123-4567".to_string()), + ("Address".to_string(), "123 Main St, Apt 4B".to_string()), + ( + "Tags".to_string(), + "urgent,important,follow-up".to_string(), + ), + ( + "Notes".to_string(), + "This is a sample note with multiple words, punctuation! And symbols @#$" + .to_string(), + ), + ], + } + } +} + +impl DataProvider for FullDemoData { + fn field_count(&self) -> usize { + self.fields.len() + } + + fn field_name(&self, index: usize) -> &str { + &self.fields[index].0 + } + + fn field_value(&self, index: usize) -> &str { + &self.fields[index].1 + } + + fn set_field_value(&mut self, index: usize, value: String) { + self.fields[index].1 = value; + } + + fn supports_autocomplete(&self, _field_index: usize) -> bool { + false + } + + fn display_value(&self, _index: usize) -> Option<&str> { + None + } +} + +/// Full vim-like key handling using the native FormEditor API +fn handle_key_press( + key: KeyCode, + modifiers: KeyModifiers, + editor: &mut EnhancedFormEditor, +) -> anyhow::Result { + let old_mode = editor.mode(); // Store mode before processing + + // Quit handling + if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL)) + || (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) + || key == KeyCode::F(10) + { + return Ok(false); + } + + match (old_mode, key, modifiers) { + // === MODE TRANSITIONS === + (AppMode::ReadOnly, KeyCode::Char('i'), _) => { + editor.enter_edit_mode(); + editor.clear_command_buffer(); + } + (AppMode::ReadOnly, KeyCode::Char('a'), _) => { + editor.move_right(); // Move after current character + editor.enter_edit_mode(); + editor.set_debug_message("-- INSERT -- (append)".to_string()); + editor.clear_command_buffer(); + } + (AppMode::ReadOnly, KeyCode::Char('A'), _) => { + editor.move_line_end(); + editor.enter_edit_mode(); + editor.set_debug_message("-- INSERT -- (end of line)".to_string()); + editor.clear_command_buffer(); + } + (AppMode::ReadOnly, KeyCode::Char('o'), _) => { + editor.move_line_end(); + editor.enter_edit_mode(); + editor.set_debug_message("-- INSERT -- (open line)".to_string()); + editor.clear_command_buffer(); + } + (AppMode::ReadOnly, KeyCode::Char('v'), _) => { + editor.enter_visual_mode(); + editor.clear_command_buffer(); + } + (AppMode::ReadOnly, KeyCode::Char('V'), _) => { + editor.enter_visual_line_mode(); + editor.clear_command_buffer(); + } + (_, KeyCode::Esc, _) => { + editor.exit_edit_mode(); + editor.clear_command_buffer(); + } + + // === MOVEMENT: VIM-STYLE NAVIGATION === + + // Basic movement (hjkl and arrows) + (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('h'), _) + | (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Left, _) => { + editor.move_left(); + editor.set_debug_message("← left".to_string()); + editor.clear_command_buffer(); + } + (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('l'), _) + | (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Right, _) => { + editor.move_right(); + editor.set_debug_message("→ right".to_string()); + editor.clear_command_buffer(); + } + (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('j'), _) + | (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Down, _) => { + editor.move_down(); + editor.set_debug_message("↓ next field".to_string()); + editor.clear_command_buffer(); + } + (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('k'), _) + | (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Up, _) => { + editor.move_up(); + editor.set_debug_message("↑ previous field".to_string()); + editor.clear_command_buffer(); + } + + // Word movement - Full vim word navigation + (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('w'), _) => { + editor.move_word_next(); + editor.set_debug_message("w: next word start".to_string()); + editor.clear_command_buffer(); + } + (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('b'), _) => { + editor.move_word_prev(); + editor.set_debug_message("b: previous word start".to_string()); + editor.clear_command_buffer(); + } + (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('e'), _) => { + editor.move_word_end(); + editor.set_debug_message("e: word end".to_string()); + editor.clear_command_buffer(); + } + (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('W'), _) => { + editor.move_word_end_prev(); + editor.set_debug_message("W: previous word end".to_string()); + editor.clear_command_buffer(); + } + + // Line movement + (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('0'), _) + | (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Home, _) => { + editor.move_line_start(); + editor.set_debug_message("0: line start".to_string()); + } + (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('$'), _) + | (AppMode::ReadOnly | AppMode::Highlight, KeyCode::End, _) => { + editor.move_line_end(); + editor.set_debug_message("$: line end".to_string()); + } + + // Field/document movement + (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('g'), _) => { + if editor.get_command_buffer() == "g" { + // Second 'g' - execute "gg" command + editor.move_first_line(); + editor.set_debug_message("gg: first field".to_string()); + editor.clear_command_buffer(); + } else { + // First 'g' - start command buffer + editor.clear_command_buffer(); + editor.add_to_command_buffer('g'); + editor.set_debug_message("g".to_string()); + } + } + (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('G'), _) => { + editor.move_last_line(); + editor.set_debug_message("G: last field".to_string()); + editor.clear_command_buffer(); + } + + // === EDIT MODE MOVEMENT === + (AppMode::Edit, KeyCode::Left, m) if m.contains(KeyModifiers::CONTROL) => { + editor.move_word_prev(); + editor.set_debug_message("Ctrl+← word back".to_string()); + } + (AppMode::Edit, KeyCode::Right, m) if m.contains(KeyModifiers::CONTROL) => { + editor.move_word_next(); + editor.set_debug_message("Ctrl+→ word forward".to_string()); + } + (AppMode::Edit, KeyCode::Left, _) => { + editor.move_left(); + } + (AppMode::Edit, KeyCode::Right, _) => { + editor.move_right(); + } + (AppMode::Edit, KeyCode::Up, _) => { + editor.move_up(); + } + (AppMode::Edit, KeyCode::Down, _) => { + editor.move_down(); + } + (AppMode::Edit, KeyCode::Home, _) => { + editor.move_line_start(); + } + (AppMode::Edit, KeyCode::End, _) => { + editor.move_line_end(); + } + + // === DELETE OPERATIONS === + (AppMode::Edit, KeyCode::Backspace, _) => { + editor.delete_backward()?; + } + (AppMode::Edit, KeyCode::Delete, _) => { + editor.delete_forward()?; + } + + // Delete operations in normal mode (vim x) + (AppMode::ReadOnly, KeyCode::Char('x'), _) => { + editor.delete_forward()?; + editor.set_debug_message("x: deleted character".to_string()); + } + (AppMode::ReadOnly, KeyCode::Char('X'), _) => { + editor.delete_backward()?; + editor.set_debug_message("X: deleted character backward".to_string()); + } + + // === TAB NAVIGATION === + (_, KeyCode::Tab, _) => { + editor.next_field(); + editor.set_debug_message("Tab: next field".to_string()); + } + (_, KeyCode::BackTab, _) => { + editor.prev_field(); + editor.set_debug_message("Shift+Tab: previous field".to_string()); + } + + // === CHARACTER INPUT === + (AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => { + editor.insert_char(c)?; + } + + // === DEBUG/INFO COMMANDS === + (AppMode::ReadOnly, KeyCode::Char('?'), _) => { + editor.set_debug_message(format!( + "Field {}/{}, Pos {}, Mode: {:?}", + editor.current_field() + 1, + editor.data_provider().field_count(), + editor.cursor_position(), + editor.mode() + )); + } + + _ => { + // If we have a pending command and this key doesn't complete it, clear the buffer + if editor.has_pending_command() { + editor.clear_command_buffer(); + editor.set_debug_message("Invalid command sequence".to_string()); + } else { + editor.set_debug_message(format!( + "Unhandled: {:?} + {:?} in {:?} mode", + key, modifiers, old_mode + )); + } + } + } + + // Update cursor if mode changed + let new_mode = editor.mode(); + if old_mode != new_mode { + update_cursor_for_mode(new_mode)?; + } + + Ok(true) +} + +fn run_app( + terminal: &mut Terminal, + mut editor: EnhancedFormEditor, +) -> io::Result<()> { + loop { + terminal.draw(|f| ui(f, &editor))?; + + if let Event::Key(key) = event::read()? { + match handle_key_press(key.code, key.modifiers, &mut editor) { + Ok(should_continue) => { + if !should_continue { + break; + } + } + Err(e) => { + editor.set_debug_message(format!("Error: {}", e)); + } + } + } + } + + Ok(()) +} + +fn ui(f: &mut Frame, editor: &EnhancedFormEditor) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(8), Constraint::Length(8)]) + .split(f.area()); + + render_enhanced_canvas(f, chunks[0], editor); + render_status_and_help(f, chunks[1], editor); +} + +fn render_enhanced_canvas( + f: &mut Frame, + area: ratatui::layout::Rect, + editor: &EnhancedFormEditor, +) { + render_canvas_default(f, area, &editor.editor); +} + +fn render_status_and_help( + f: &mut Frame, + area: ratatui::layout::Rect, + editor: &EnhancedFormEditor, +) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Length(5)]) + .split(area); + + // Status bar + let mode_text = match editor.mode() { + AppMode::Edit => "INSERT", + AppMode::ReadOnly => "NORMAL", + AppMode::Highlight => match editor.highlight_state() { + HighlightState::Characterwise { .. } => "VISUAL", + HighlightState::Linewise { .. } => "VISUAL LINE", + _ => "VISUAL", + }, + _ => "NORMAL", + }; + + let status_text = if editor.has_pending_command() { + format!("-- {} -- {} [{}]", mode_text, editor.debug_message(), editor.get_command_buffer()) + } else if editor.has_unsaved_changes() { + format!("-- {} -- [Modified] {}", mode_text, editor.debug_message()) + } else { + format!("-- {} -- {}", mode_text, editor.debug_message()) + }; + + let status = Paragraph::new(Line::from(Span::raw(status_text))) + .block(Block::default().borders(Borders::ALL).title("Status")); + + f.render_widget(status, chunks[0]); + + // Help text + let help_text = match editor.mode() { + AppMode::ReadOnly => { + if editor.has_pending_command() { + match editor.get_command_buffer() { + "g" => "Press 'g' again for first field, or any other key to cancel", + _ => "Pending command... (Esc to cancel)" + } + } else { + "Normal: hjkl/arrows=move, w/b/e=words, 0/$=line, gg/G=first/last, i/a/A=insert, v/V=visual, x/X=delete, ?=info" + } + } + AppMode::Edit => { + "Insert: arrows=move, Ctrl+arrows=words, Backspace/Del=delete, Esc=normal, Tab/Shift+Tab=fields" + } + AppMode::Highlight => { + "Visual: hjkl/arrows=extend selection, w/b/e=word selection, Esc=normal" + } + _ => "Press ? for help" + }; + + let help = Paragraph::new(Line::from(Span::raw(help_text))) + .block(Block::default().borders(Borders::ALL).title("Commands")) + .style(Style::default().fg(Color::Gray)); + + f.render_widget(help, chunks[1]); +} + +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 data = FullDemoData::new(); + let mut editor = EnhancedFormEditor::new(data); + editor.set_mode(AppMode::ReadOnly); // Start in normal mode + + // Set initial cursor style + update_cursor_for_mode(editor.mode())?; + + let res = run_app(&mut terminal, editor); + + // Reset cursor style on exit + execute!(io::stdout(), SetCursorStyle::DefaultUserShape)?; + + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + if let Err(err) = res { + println!("{:?}", err); + } + + Ok(()) +} diff --git a/canvas/examples/generate_template.rs b/canvas/examples/generate_template.rs deleted file mode 100644 index 5b585c1..0000000 --- a/canvas/examples/generate_template.rs +++ /dev/null @@ -1,21 +0,0 @@ -// examples/generate_template.rs -use canvas::config::CanvasConfig; -use std::env; - -fn main() { - let args: Vec = env::args().collect(); - - if args.len() > 1 && args[1] == "clean" { - // Generate clean template with 80% active code - let template = CanvasConfig::generate_clean_template(); - println!("{}", template); - } else { - // Generate verbose template with descriptions (default) - let template = CanvasConfig::generate_template(); - println!("{}", template); - } -} - -// Usage: -// cargo run --example generate_template > canvas_config.toml -// cargo run --example generate_template clean > canvas_config_clean.toml diff --git a/canvas/src/autocomplete/actions.rs b/canvas/src/autocomplete/actions.rs deleted file mode 100644 index b7e3c51..0000000 --- a/canvas/src/autocomplete/actions.rs +++ /dev/null @@ -1,133 +0,0 @@ -// src/autocomplete/actions.rs - -use crate::canvas::state::{CanvasState, ActionContext}; -use crate::autocomplete::state::AutocompleteCanvasState; -use crate::canvas::actions::types::{CanvasAction, ActionResult}; -use crate::dispatcher::ActionDispatcher; // NEW: Use dispatcher directly -use crate::config::CanvasConfig; -use anyhow::Result; - -/// Version for states that implement rich autocomplete -pub async fn execute_canvas_action_with_autocomplete( - action: CanvasAction, - state: &mut S, - ideal_cursor_column: &mut usize, - config: Option<&CanvasConfig>, -) -> Result { - // 1. Try feature-specific handler 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) = handle_rich_autocomplete_action(action.clone(), state, &context) { - return Ok(result); - } - - // 2. Handle generic actions using the new dispatcher directly - let result = ActionDispatcher::dispatch_with_config(action.clone(), state, ideal_cursor_column, config).await?; - - // 3. AUTO-TRIGGER LOGIC: Check if we should activate/deactivate autocomplete - if let Some(cfg) = config { - println!("{:?}, {}", action, cfg.should_auto_trigger_autocomplete()); - if cfg.should_auto_trigger_autocomplete() { - println!("AUTO-TRIGGER"); - match action { - CanvasAction::InsertChar(_) => { - println!("AUTO-T on Ins"); - let current_field = state.current_field(); - let current_input = state.get_current_input(); - - if state.supports_autocomplete(current_field) - && !state.is_autocomplete_active() - && current_input.len() >= 1 - { - println!("ACT AUTOC"); - state.activate_autocomplete(); - } - } - - CanvasAction::NextField | CanvasAction::PrevField => { - println!("AUTO-T on nav"); - let current_field = state.current_field(); - - if state.supports_autocomplete(current_field) && !state.is_autocomplete_active() { - state.activate_autocomplete(); - } else if !state.supports_autocomplete(current_field) && state.is_autocomplete_active() { - state.deactivate_autocomplete(); - } - } - - _ => {} // No auto-trigger for other actions - } - } - } - - Ok(result) -} - -/// Handle rich autocomplete actions for AutocompleteCanvasState -fn handle_rich_autocomplete_action( - action: CanvasAction, - state: &mut S, - _context: &ActionContext, -) -> Option { - match action { - CanvasAction::TriggerAutocomplete => { - let current_field = state.current_field(); - if state.supports_autocomplete(current_field) { - state.activate_autocomplete(); - Some(ActionResult::success_with_message("Autocomplete activated")) - } else { - Some(ActionResult::success_with_message("Autocomplete not supported for this field")) - } - } - - CanvasAction::SuggestionUp => { - if state.is_autocomplete_ready() { - if let Some(autocomplete_state) = state.autocomplete_state_mut() { - autocomplete_state.select_previous(); - } - Some(ActionResult::success()) - } else { - Some(ActionResult::success_with_message("No suggestions available")) - } - } - - CanvasAction::SuggestionDown => { - if state.is_autocomplete_ready() { - if let Some(autocomplete_state) = state.autocomplete_state_mut() { - autocomplete_state.select_next(); - } - Some(ActionResult::success()) - } else { - Some(ActionResult::success_with_message("No suggestions available")) - } - } - - CanvasAction::SelectSuggestion => { - if state.is_autocomplete_ready() { - if let Some(msg) = state.apply_autocomplete_selection() { - Some(ActionResult::success_with_message(&msg)) - } else { - Some(ActionResult::success_with_message("No suggestion selected")) - } - } else { - Some(ActionResult::success_with_message("No suggestions available")) - } - } - - CanvasAction::ExitSuggestions => { - if state.is_autocomplete_active() { - state.deactivate_autocomplete(); - Some(ActionResult::success_with_message("Exited autocomplete")) - } else { - Some(ActionResult::success()) - } - } - - _ => None, // Not a rich autocomplete action - } -} diff --git a/canvas/src/autocomplete/gui.rs b/canvas/src/autocomplete/gui.rs index dab3dbc..70fb894 100644 --- a/canvas/src/autocomplete/gui.rs +++ b/canvas/src/autocomplete/gui.rs @@ -1,38 +1,41 @@ -// canvas/src/autocomplete/gui.rs +// src/autocomplete/gui.rs +//! Autocomplete GUI updated to work with FormEditor #[cfg(feature = "gui")] use ratatui::{ layout::{Alignment, Rect}, style::{Modifier, Style}, - widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + widgets::{Block, List, ListItem, ListState, Paragraph}, // Removed Borders Frame, }; -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); } } @@ -73,9 +76,10 @@ fn render_suggestions_dropdown( frame_area: Rect, input_rect: Rect, theme: &T, - autocomplete_state: &AutocompleteState, + suggestions: &[SuggestionItem], // Fixed: Removed generic parameter + 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(); @@ -95,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 @@ -116,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 } } @@ -151,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], @@ -159,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 26583b9..2027914 100644 --- a/canvas/src/autocomplete/mod.rs +++ b/canvas/src/autocomplete/mod.rs @@ -1,10 +1,12 @@ // src/autocomplete/mod.rs -pub mod types; -pub mod gui; -pub mod state; -pub mod actions; -// Re-export autocomplete types -pub use types::{SuggestionItem, AutocompleteState}; -pub use state::AutocompleteCanvasState; -pub use actions::execute_canvas_action_with_autocomplete; +pub mod state; +#[cfg(feature = "gui")] +pub mod gui; + +// Re-export the main autocomplete types +pub use state::{AutocompleteProvider, SuggestionItem}; + +// 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..11c03fa 100644 --- a/canvas/src/autocomplete/state.rs +++ b/canvas/src/autocomplete/state.rs @@ -1,96 +1,5 @@ -// canvas/src/state.rs +// src/autocomplete/state.rs +//! Autocomplete provider types -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. -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 - 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 - } - - /// CLIENT API: Activate autocomplete for current field - fn activate_autocomplete(&mut self) { - let current_field = self.current_field(); // Get field first - if let Some(state) = self.autocomplete_state_mut() { - state.activate(current_field); // Then use it - } - } - - /// CLIENT API: Deactivate autocomplete - fn deactivate_autocomplete(&mut self) { - if let Some(state) = self.autocomplete_state_mut() { - state.deactivate(); - } - } - - /// CLIENT API: Set suggestions (called after 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 - 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 - fn is_autocomplete_active(&self) -> bool { - self.autocomplete_state() - .map(|state| state.is_active) - .unwrap_or(false) - } - - /// Check if autocomplete is ready for interaction - 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 - }; - - // 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); - - // Deactivate autocomplete - if let Some(state_mut) = self.autocomplete_state_mut() { - state_mut.deactivate(); - } - - Some(format!("Selected: {}", display)) - } else { - None - } - } -} +// Re-export the main types from data_provider +pub use crate::data_provider::{AutocompleteProvider, SuggestionItem}; diff --git a/canvas/src/autocomplete/types.rs b/canvas/src/autocomplete/types.rs deleted file mode 100644 index e9f96b2..0000000 --- a/canvas/src/autocomplete/types.rs +++ /dev/null @@ -1,126 +0,0 @@ -// canvas/src/autocomplete.rs - -/// 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, -} - -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 -#[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, - } - } -} - -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 - } -} diff --git a/canvas/src/canvas/actions/handlers/edit.rs b/canvas/src/canvas/actions/handlers/edit.rs deleted file mode 100644 index cd65e7d..0000000 --- a/canvas/src/canvas/actions/handlers/edit.rs +++ /dev/null @@ -1,203 +0,0 @@ -// src/canvas/actions/handlers/edit.rs - -use crate::canvas::actions::types::{CanvasAction, ActionResult}; -use crate::canvas::actions::movement::*; -use crate::canvas::state::CanvasState; -use crate::config::CanvasConfig; -use anyhow::Result; - -const FOR_EDIT_MODE: bool = true; // Edit mode flag - -/// Handle actions in edit mode with edit-specific cursor behavior -pub async fn handle_edit_action( - action: CanvasAction, - state: &mut S, - ideal_cursor_column: &mut usize, - config: Option<&CanvasConfig>, -) -> Result { - match action { - CanvasAction::InsertChar(c) => { - 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 => { - 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 => { - 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()) - } - - 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()) - } - - 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()) - } - - CanvasAction::MoveUp => { - // For single-line fields, move to previous field - 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); - } - Ok(ActionResult::success()) - } - - CanvasAction::MoveDown => { - // For single-line fields, move to next field - 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()) - } - - CanvasAction::MoveLineStart => { - let new_pos = line_start_position(); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; - Ok(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()) - } - - 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()) - } - - 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()) - } - - 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; - } - Ok(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; - } - Ok(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; - } - Ok(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; - } - Ok(ActionResult::success()) - } - - CanvasAction::NextField | CanvasAction::PrevField => { - let current_field = state.current_field(); - let total_fields = state.fields().len(); - - let new_field = match action { - CanvasAction::NextField => { - if config.map_or(true, |c| c.behavior.wrap_around_fields) { - (current_field + 1) % total_fields - } else { - (current_field + 1).min(total_fields - 1) - } - } - CanvasAction::PrevField => { - if config.map_or(true, |c| c.behavior.wrap_around_fields) { - if current_field == 0 { total_fields - 1 } else { current_field - 1 } - } else { - current_field.saturating_sub(1) - } - } - _ => 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()) - } - - CanvasAction::Custom(action_str) => { - Ok(ActionResult::success_with_message(&format!("Custom edit action: {}", action_str))) - } - - _ => { - Ok(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 deleted file mode 100644 index 1c850e5..0000000 --- a/canvas/src/canvas/actions/handlers/highlight.rs +++ /dev/null @@ -1,106 +0,0 @@ -// src/canvas/actions/handlers/highlight.rs - -use crate::canvas::actions::types::{CanvasAction, ActionResult}; -use crate::canvas::actions::movement::*; -use crate::canvas::state::CanvasState; -use crate::config::CanvasConfig; -use anyhow::Result; - -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( - action: CanvasAction, - state: &mut S, - ideal_cursor_column: &mut usize, - config: Option<&CanvasConfig>, -) -> Result { - 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; - // TODO: Update selection range - Ok(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; - // TODO: Update selection range - Ok(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; - // TODO: Update selection range - } - Ok(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; - // TODO: Update selection range - } - Ok(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; - // TODO: Update selection range - } - Ok(ActionResult::success()) - } - - CanvasAction::MoveLineStart => { - let new_pos = line_start_position(); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; - // TODO: Update selection range - Ok(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; - // TODO: Update selection range - Ok(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")) - } - - CanvasAction::Custom(action_str) => { - Ok(ActionResult::success_with_message(&format!("Custom highlight action: {}", action_str))) - } - - _ => { - Ok(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 deleted file mode 100644 index 91c0b75..0000000 --- a/canvas/src/canvas/actions/handlers/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -// src/canvas/actions/handlers/mod.rs - -pub mod edit; -pub mod readonly; -pub mod highlight; - -// Re-export handler functions -pub use edit::handle_edit_action; -pub use readonly::handle_readonly_action; -pub use highlight::handle_highlight_action; diff --git a/canvas/src/canvas/actions/handlers/readonly.rs b/canvas/src/canvas/actions/handlers/readonly.rs deleted file mode 100644 index d7d6c4d..0000000 --- a/canvas/src/canvas/actions/handlers/readonly.rs +++ /dev/null @@ -1,193 +0,0 @@ -// src/canvas/actions/handlers/readonly.rs - -use crate::canvas::actions::types::{CanvasAction, ActionResult}; -use crate::canvas::actions::movement::*; -use crate::canvas::state::CanvasState; -use crate::config::CanvasConfig; -use anyhow::Result; - -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( - action: CanvasAction, - state: &mut S, - ideal_cursor_column: &mut usize, - config: Option<&CanvasConfig>, -) -> Result { - 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()) - } - - 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()) - } - - 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()) - } - - 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()) - } - - 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()) - } - - 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()) - } - - CanvasAction::MoveLineStart => { - let new_pos = line_start_position(); - state.set_current_cursor_pos(new_pos); - *ideal_cursor_column = new_pos; - Ok(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()) - } - - 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; - } - Ok(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; - } - Ok(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; - } - Ok(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; - } - Ok(ActionResult::success()) - } - - CanvasAction::NextField | CanvasAction::PrevField => { - let current_field = state.current_field(); - let total_fields = state.fields().len(); - - let new_field = match action { - CanvasAction::NextField => { - if config.map_or(true, |c| c.behavior.wrap_around_fields) { - (current_field + 1) % total_fields - } else { - (current_field + 1).min(total_fields - 1) - } - } - CanvasAction::PrevField => { - if config.map_or(true, |c| c.behavior.wrap_around_fields) { - if current_field == 0 { total_fields - 1 } else { current_field - 1 } - } else { - current_field.saturating_sub(1) - } - } - _ => unreachable!(), - }; - - state.set_current_field(new_field); - *ideal_cursor_column = state.current_cursor_pos(); - Ok(ActionResult::success()) - } - - // 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")) - } - - CanvasAction::Custom(action_str) => { - Ok(ActionResult::success_with_message(&format!("Custom readonly action: {}", action_str))) - } - - _ => { - Ok(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 6beba1b..412d758 100644 --- a/canvas/src/canvas/actions/mod.rs +++ b/canvas/src/canvas/actions/mod.rs @@ -2,7 +2,6 @@ pub mod types; pub mod movement; -pub mod handlers; -// Re-export the main types +// Re-export the main API pub use types::{CanvasAction, ActionResult}; diff --git a/canvas/src/canvas/actions/types.rs b/canvas/src/canvas/actions/types.rs index 433a4d5..fe9ae9c 100644 --- a/canvas/src/canvas/actions/types.rs +++ b/canvas/src/canvas/actions/types.rs @@ -1,35 +1,34 @@ // src/canvas/actions/types.rs +/// All available canvas actions #[derive(Debug, Clone, PartialEq)] pub enum CanvasAction { - // Character input - InsertChar(char), - - // Deletion - DeleteBackward, - DeleteForward, - - // Basic cursor movement + // Movement actions MoveLeft, MoveRight, MoveUp, MoveDown, + // Word movement + MoveWordNext, + MoveWordPrev, + MoveWordEnd, + MoveWordEndPrev, + // Line movement MoveLineStart, MoveLineEnd, + + // Field movement + NextField, + PrevField, MoveFirstLine, MoveLastLine, - // Word movement - MoveWordNext, - MoveWordEnd, - MoveWordPrev, - MoveWordEndPrev, - - // Field navigation - NextField, - PrevField, + // Editing actions + InsertChar(char), + DeleteBackward, + DeleteForward, // Autocomplete actions TriggerAutocomplete, @@ -42,67 +41,131 @@ pub enum CanvasAction { Custom(String), } -impl CanvasAction { - /// Convert string action name to CanvasAction enum (config-driven) - pub fn from_string(action: &str) -> Self { - match action { - "delete_char_backward" => Self::DeleteBackward, - "delete_char_forward" => Self::DeleteForward, - "move_left" => Self::MoveLeft, - "move_right" => Self::MoveRight, - "move_up" => Self::MoveUp, - "move_down" => Self::MoveDown, - "move_line_start" => Self::MoveLineStart, - "move_line_end" => Self::MoveLineEnd, - "move_first_line" => Self::MoveFirstLine, - "move_last_line" => Self::MoveLastLine, - "move_word_next" => Self::MoveWordNext, - "move_word_end" => Self::MoveWordEnd, - "move_word_prev" => Self::MoveWordPrev, - "move_word_end_prev" => Self::MoveWordEndPrev, - "next_field" => Self::NextField, - "prev_field" => Self::PrevField, - "trigger_autocomplete" => Self::TriggerAutocomplete, - "suggestion_up" => Self::SuggestionUp, - "suggestion_down" => Self::SuggestionDown, - "select_suggestion" => Self::SelectSuggestion, - "exit_suggestions" => Self::ExitSuggestions, - _ => Self::Custom(action.to_string()), - } - } -} - -#[derive(Debug, Clone, PartialEq)] +/// Result type for canvas actions +#[derive(Debug, Clone)] pub enum ActionResult { - Success(Option), - HandledByFeature(String), - RequiresContext(String), + Success, + Message(String), + HandledByApp(String), + HandledByFeature(String), // Keep for compatibility Error(String), } impl ActionResult { pub fn success() -> Self { - Self::Success(None) + Self::Success } pub fn success_with_message(msg: &str) -> Self { - Self::Success(Some(msg.to_string())) + 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.into()) + Self::Error(msg.to_string()) } pub fn is_success(&self) -> bool { - matches!(self, Self::Success(_) | Self::HandledByFeature(_)) + matches!(self, Self::Success | Self::Message(_) | Self::HandledByApp(_) | Self::HandledByFeature(_)) } pub fn message(&self) -> Option<&str> { match self { - Self::Success(msg) => msg.as_deref(), - Self::HandledByFeature(msg) => Some(msg), - Self::RequiresContext(msg) => Some(msg), - Self::Error(msg) => Some(msg), + Self::Message(msg) | Self::HandledByApp(msg) | Self::HandledByFeature(msg) | Self::Error(msg) => Some(msg), + Self::Success => None, } } } + +impl CanvasAction { + /// Get a human-readable description of this action + pub fn description(&self) -> &'static str { + match self { + Self::MoveLeft => "move left", + Self::MoveRight => "move right", + Self::MoveUp => "move up", + Self::MoveDown => "move down", + Self::MoveWordNext => "next word", + Self::MoveWordPrev => "previous word", + Self::MoveWordEnd => "word end", + Self::MoveWordEndPrev => "previous word end", + Self::MoveLineStart => "line start", + Self::MoveLineEnd => "line end", + Self::NextField => "next field", + Self::PrevField => "previous field", + Self::MoveFirstLine => "first field", + Self::MoveLastLine => "last field", + Self::InsertChar(_c) => "insert character", + Self::DeleteBackward => "delete backward", + Self::DeleteForward => "delete forward", + Self::TriggerAutocomplete => "trigger autocomplete", + Self::SuggestionUp => "suggestion up", + Self::SuggestionDown => "suggestion down", + Self::SelectSuggestion => "select suggestion", + Self::ExitSuggestions => "exit suggestions", + Self::Custom(_name) => "custom action", + } + } + + /// Get all movement-related actions + pub fn movement_actions() -> Vec { + vec![ + Self::MoveLeft, + Self::MoveRight, + Self::MoveUp, + Self::MoveDown, + Self::MoveWordNext, + Self::MoveWordPrev, + Self::MoveWordEnd, + Self::MoveWordEndPrev, + Self::MoveLineStart, + Self::MoveLineEnd, + Self::NextField, + Self::PrevField, + Self::MoveFirstLine, + Self::MoveLastLine, + ] + } + + /// Get all editing-related actions + pub fn editing_actions() -> Vec { + vec![ + Self::InsertChar(' '), // Example char + Self::DeleteBackward, + Self::DeleteForward, + ] + } + + /// Get all autocomplete-related actions + pub fn autocomplete_actions() -> Vec { + vec![ + Self::TriggerAutocomplete, + Self::SuggestionUp, + Self::SuggestionDown, + Self::SelectSuggestion, + Self::ExitSuggestions, + ] + } + + /// Check if this action modifies text content + pub fn is_editing_action(&self) -> bool { + matches!(self, + Self::InsertChar(_) | + Self::DeleteBackward | + Self::DeleteForward + ) + } + + /// Check if this action moves the cursor + pub fn is_movement_action(&self) -> bool { + matches!(self, + Self::MoveLeft | Self::MoveRight | Self::MoveUp | Self::MoveDown | + Self::MoveWordNext | Self::MoveWordPrev | Self::MoveWordEnd | Self::MoveWordEndPrev | + Self::MoveLineStart | Self::MoveLineEnd | Self::NextField | Self::PrevField | + Self::MoveFirstLine | Self::MoveLastLine + ) + } +} diff --git a/canvas/src/canvas/cursor.rs b/canvas/src/canvas/cursor.rs new file mode 100644 index 0000000..720ca6a --- /dev/null +++ b/canvas/src/canvas/cursor.rs @@ -0,0 +1,45 @@ +// src/canvas/cursor.rs +//! Cursor style management for different canvas modes + +#[cfg(feature = "cursor-style")] +use crossterm::{cursor::SetCursorStyle, execute}; +#[cfg(feature = "cursor-style")] +use std::io; + +use crate::canvas::modes::AppMode; + +/// Manages cursor styles based on canvas modes +pub struct CursorManager; + +impl CursorManager { + /// Update cursor style based on current mode + #[cfg(feature = "cursor-style")] + pub fn update_for_mode(mode: AppMode) -> io::Result<()> { + let style = match mode { + AppMode::Edit => SetCursorStyle::SteadyBar, // Thin line for insert + AppMode::ReadOnly => SetCursorStyle::SteadyBlock, // Block for normal + AppMode::Highlight => SetCursorStyle::BlinkingBlock, // Blinking for visual + AppMode::General => SetCursorStyle::SteadyBlock, // Block for general + AppMode::Command => SetCursorStyle::SteadyUnderScore, // Underscore for command + }; + + execute!(io::stdout(), style) + } + + /// No-op when cursor-style feature is disabled + #[cfg(not(feature = "cursor-style"))] + pub fn update_for_mode(_mode: AppMode) -> io::Result<()> { + Ok(()) + } + + /// Reset cursor to default on cleanup + #[cfg(feature = "cursor-style")] + pub fn reset() -> io::Result<()> { + execute!(io::stdout(), SetCursorStyle::DefaultUserShape) + } + + #[cfg(not(feature = "cursor-style"))] + pub fn reset() -> io::Result<()> { + Ok(()) + } +} diff --git a/canvas/src/canvas/gui.rs b/canvas/src/canvas/gui.rs index 4e06985..dcd106e 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::theme::{CanvasTheme, DefaultCanvasTheme}; +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()) @@ -336,3 +353,14 @@ fn set_cursor_position( let cursor_y = field_rect.y; f.set_cursor_position((cursor_x, cursor_y)); } + +/// Set default theme if custom not specified +#[cfg(feature = "gui")] +pub fn render_canvas_default( + f: &mut Frame, + area: Rect, + editor: &FormEditor, +) -> Option { + let theme = DefaultCanvasTheme::default(); + render_canvas(f, area, editor, &theme) +} diff --git a/canvas/src/canvas/mod.rs b/canvas/src/canvas/mod.rs index e68fac2..7485443 100644 --- a/canvas/src/canvas/mod.rs +++ b/canvas/src/canvas/mod.rs @@ -1,20 +1,19 @@ // 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 commonly used canvas types -pub use actions::{CanvasAction, ActionResult}; +#[cfg(feature = "cursor-style")] +pub mod cursor; + +// Keep these exports for current functionality pub use modes::{AppMode, ModeManager, HighlightState}; -pub use state::{CanvasState, ActionContext}; -// Re-export the main entry point -pub use crate::dispatcher::execute_canvas_action; - -#[cfg(feature = "gui")] -pub use theme::CanvasTheme; - -#[cfg(feature = "gui")] -pub use gui::render_canvas; +#[cfg(feature = "cursor-style")] +pub use cursor::CursorManager; diff --git a/canvas/src/canvas/modes/manager.rs b/canvas/src/canvas/modes/manager.rs index dcdf7b9..4b0556b 100644 --- a/canvas/src/canvas/modes/manager.rs +++ b/canvas/src/canvas/modes/manager.rs @@ -1,6 +1,8 @@ // src/modes/handlers/mode_manager.rs // canvas/src/modes/manager.rs +#[cfg(feature = "cursor-style")] +use crate::canvas::CursorManager; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AppMode { @@ -30,4 +32,39 @@ impl ModeManager { pub fn can_enter_highlight_mode(current_mode: AppMode) -> bool { matches!(current_mode, AppMode::ReadOnly) } + + + /// Transition to new mode with automatic cursor update (when cursor-style feature enabled) + pub fn transition_to_mode(current_mode: AppMode, new_mode: AppMode) -> std::io::Result { + if current_mode != new_mode { + #[cfg(feature = "cursor-style")] + { + let _ = CursorManager::update_for_mode(new_mode); + } + } + Ok(new_mode) + } + + /// Enter highlight mode with cursor styling + pub fn enter_highlight_mode_with_cursor(current_mode: AppMode) -> std::io::Result { + if Self::can_enter_highlight_mode(current_mode) { + #[cfg(feature = "cursor-style")] + { + let _ = CursorManager::update_for_mode(AppMode::Highlight); + } + Ok(true) + } else { + Ok(false) + } + } + + /// Exit highlight mode with cursor styling + pub fn exit_highlight_mode_with_cursor() -> std::io::Result { + let new_mode = AppMode::ReadOnly; + #[cfg(feature = "cursor-style")] + { + let _ = CursorManager::update_for_mode(new_mode); + } + Ok(new_mode) + } } diff --git a/canvas/src/canvas/state.rs b/canvas/src/canvas/state.rs index 29915ba..54fcad8 100644 --- a/canvas/src/canvas/state.rs +++ b/canvas/src/canvas/state.rs @@ -1,56 +1,137 @@ // src/canvas/state.rs +//! Library-owned UI state - user never directly modifies this -use crate::canvas::actions::CanvasAction; use crate::canvas::modes::AppMode; -/// Context passed to feature-specific action handlers -#[derive(Debug)] -pub struct ActionContext { - pub key_code: Option, // Kept for backwards compatibility - pub ideal_cursor_column: usize, - pub current_input: String, - 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 the canvas system. -/// This enables the same mode behaviors (edit, read-only, highlight) to work across -/// any implementation - login forms, data entry forms, configuration screens, etc. -pub trait CanvasState { - // --- Core Navigation --- - fn current_field(&self) -> usize; - fn current_cursor_pos(&self) -> usize; - fn set_current_field(&mut self, index: usize); - 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 --- - fn current_mode(&self) -> AppMode; +#[derive(Debug, Clone)] +pub enum SelectionState { + None, + Characterwise { anchor: (usize, usize) }, + Linewise { anchor_field: usize }, +} - // --- Data Access --- - fn get_current_input(&self) -> &str; - fn get_current_input_mut(&mut self) -> &mut String; - fn inputs(&self) -> Vec<&String>; - fn fields(&self) -> Vec<&str>; - - // --- State Management --- - fn has_unsaved_changes(&self) -> bool; - fn set_has_unsaved_changes(&mut self, changed: bool); - - // --- Feature-specific action handling --- - - /// Feature-specific action handling (Type-safe) - fn handle_feature_action(&mut self, _action: &CanvasAction, _context: &ActionContext) -> Option { - None // Default: no feature-specific 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, + } + } + + // =================================================================== + // 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 + } + + /// Get current cursor position (for user's business logic) + pub fn cursor_position(&self) -> usize { + self.cursor_pos } - // --- Display Overrides (for links, computed values, etc.) --- - fn get_display_value_for_field(&self, index: usize) -> &str { - self.inputs() - .get(index) - .map(|s| s.as_str()) - .unwrap_or("") + /// Get ideal cursor column (for vim-like behavior) + pub fn ideal_cursor_column(&self) -> usize { // ADD THIS + self.ideal_cursor_column } - - fn has_display_override(&self, _index: usize) -> bool { - false + + /// 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/canvas/theme.rs b/canvas/src/canvas/theme.rs index 6ea3932..d2f02d2 100644 --- a/canvas/src/canvas/theme.rs +++ b/canvas/src/canvas/theme.rs @@ -15,3 +15,36 @@ pub trait CanvasTheme { fn highlight_bg(&self) -> Color; fn warning(&self) -> Color; } + + +#[cfg(feature = "gui")] +#[derive(Debug, Clone, Default)] +pub struct DefaultCanvasTheme; + +#[cfg(feature = "gui")] +impl CanvasTheme for DefaultCanvasTheme { + fn bg(&self) -> Color { + Color::Black + } + fn fg(&self) -> Color { + Color::White + } + fn border(&self) -> Color { + Color::DarkGray + } + fn accent(&self) -> Color { + Color::Cyan + } + fn secondary(&self) -> Color { + Color::Gray + } + fn highlight(&self) -> Color { + Color::Yellow + } + fn highlight_bg(&self) -> Color { + Color::Blue + } + fn warning(&self) -> Color { + Color::Red + } +} diff --git a/canvas/src/config/config.rs b/canvas/src/config/config.rs deleted file mode 100644 index f020b6d..0000000 --- a/canvas/src/config/config.rs +++ /dev/null @@ -1,363 +0,0 @@ -// canvas/src/config.rs -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use crossterm::event::{KeyCode, KeyModifiers}; -use anyhow::{Context, Result}; - -use super::registry::{ActionRegistry, ActionSpec, ModeRegistry}; -use super::validation::{ConfigValidator, ValidationError, ValidationResult, ValidationWarning}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CanvasConfig { - #[serde(default)] - pub keybindings: CanvasKeybindings, - #[serde(default)] - pub behavior: CanvasBehavior, - #[serde(default)] - pub appearance: CanvasAppearance, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct CanvasKeybindings { - #[serde(default)] - pub read_only: HashMap>, - #[serde(default)] - pub edit: HashMap>, - #[serde(default)] - pub suggestions: HashMap>, - #[serde(default)] - pub global: HashMap>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CanvasBehavior { - #[serde(default = "default_wrap_around")] - pub wrap_around_fields: bool, - #[serde(default = "default_auto_save")] - pub auto_save_on_field_change: bool, - #[serde(default = "default_word_chars")] - pub word_chars: String, - #[serde(default = "default_suggestion_limit")] - pub max_suggestions: usize, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CanvasAppearance { - #[serde(default = "default_cursor_style")] - pub cursor_style: String, // "block", "bar", "underline" - #[serde(default = "default_show_field_numbers")] - pub show_field_numbers: bool, - #[serde(default = "default_highlight_current_field")] - pub highlight_current_field: bool, -} - -// Default values -fn default_wrap_around() -> bool { true } -fn default_auto_save() -> bool { false } -fn default_word_chars() -> String { "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_".to_string() } -fn default_suggestion_limit() -> usize { 10 } -fn default_cursor_style() -> String { "block".to_string() } -fn default_show_field_numbers() -> bool { false } -fn default_highlight_current_field() -> bool { true } - -impl Default for CanvasBehavior { - fn default() -> Self { - Self { - wrap_around_fields: default_wrap_around(), - auto_save_on_field_change: default_auto_save(), - word_chars: default_word_chars(), - max_suggestions: default_suggestion_limit(), - } - } -} - -impl Default for CanvasAppearance { - fn default() -> Self { - Self { - cursor_style: default_cursor_style(), - show_field_numbers: default_show_field_numbers(), - highlight_current_field: default_highlight_current_field(), - } - } -} - -impl Default for CanvasConfig { - fn default() -> Self { - Self { - keybindings: CanvasKeybindings::with_vim_defaults(), - behavior: CanvasBehavior::default(), - appearance: CanvasAppearance::default(), - } - } -} - -impl CanvasKeybindings { - pub fn with_vim_defaults() -> Self { - let mut keybindings = Self::default(); - - // Read-only mode (vim-style navigation) - keybindings.read_only.insert("move_left".to_string(), vec!["h".to_string()]); - keybindings.read_only.insert("move_right".to_string(), vec!["l".to_string()]); - keybindings.read_only.insert("move_up".to_string(), vec!["k".to_string()]); - keybindings.read_only.insert("move_down".to_string(), vec!["j".to_string()]); - keybindings.read_only.insert("move_word_next".to_string(), vec!["w".to_string()]); - keybindings.read_only.insert("move_word_end".to_string(), vec!["e".to_string()]); - keybindings.read_only.insert("move_word_prev".to_string(), vec!["b".to_string()]); - keybindings.read_only.insert("move_word_end_prev".to_string(), vec!["ge".to_string()]); - keybindings.read_only.insert("move_line_start".to_string(), vec!["0".to_string()]); - keybindings.read_only.insert("move_line_end".to_string(), vec!["$".to_string()]); - keybindings.read_only.insert("move_first_line".to_string(), vec!["gg".to_string()]); - keybindings.read_only.insert("move_last_line".to_string(), vec!["G".to_string()]); - keybindings.read_only.insert("next_field".to_string(), vec!["Tab".to_string()]); - keybindings.read_only.insert("prev_field".to_string(), vec!["Shift+Tab".to_string()]); - - // Edit mode - keybindings.edit.insert("delete_char_backward".to_string(), vec!["Backspace".to_string()]); - keybindings.edit.insert("delete_char_forward".to_string(), vec!["Delete".to_string()]); - keybindings.edit.insert("move_left".to_string(), vec!["Left".to_string()]); - keybindings.edit.insert("move_right".to_string(), vec!["Right".to_string()]); - keybindings.edit.insert("move_up".to_string(), vec!["Up".to_string()]); - keybindings.edit.insert("move_down".to_string(), vec!["Down".to_string()]); - keybindings.edit.insert("move_line_start".to_string(), vec!["Home".to_string()]); - keybindings.edit.insert("move_line_end".to_string(), vec!["End".to_string()]); - keybindings.edit.insert("move_word_next".to_string(), vec!["Ctrl+Right".to_string()]); - keybindings.edit.insert("move_word_prev".to_string(), vec!["Ctrl+Left".to_string()]); - keybindings.edit.insert("next_field".to_string(), vec!["Tab".to_string()]); - keybindings.edit.insert("prev_field".to_string(), vec!["Shift+Tab".to_string()]); - - // Suggestions - keybindings.suggestions.insert("suggestion_up".to_string(), vec!["Up".to_string(), "Ctrl+p".to_string()]); - keybindings.suggestions.insert("suggestion_down".to_string(), vec!["Down".to_string(), "Ctrl+n".to_string()]); - keybindings.suggestions.insert("select_suggestion".to_string(), vec!["Enter".to_string(), "Tab".to_string()]); - keybindings.suggestions.insert("exit_suggestions".to_string(), vec!["Esc".to_string()]); - - // Global (works in both modes) - keybindings.global.insert("move_up".to_string(), vec!["Up".to_string()]); - keybindings.global.insert("move_down".to_string(), vec!["Down".to_string()]); - - keybindings - } - - pub fn with_emacs_defaults() -> Self { - let mut keybindings = Self::default(); - - // Emacs-style bindings - keybindings.read_only.insert("move_left".to_string(), vec!["Ctrl+b".to_string()]); - keybindings.read_only.insert("move_right".to_string(), vec!["Ctrl+f".to_string()]); - keybindings.read_only.insert("move_up".to_string(), vec!["Ctrl+p".to_string()]); - keybindings.read_only.insert("move_down".to_string(), vec!["Ctrl+n".to_string()]); - keybindings.read_only.insert("move_word_next".to_string(), vec!["Alt+f".to_string()]); - keybindings.read_only.insert("move_word_prev".to_string(), vec!["Alt+b".to_string()]); - keybindings.read_only.insert("move_line_start".to_string(), vec!["Ctrl+a".to_string()]); - keybindings.read_only.insert("move_line_end".to_string(), vec!["Ctrl+e".to_string()]); - - keybindings.edit.insert("delete_char_backward".to_string(), vec!["Ctrl+h".to_string(), "Backspace".to_string()]); - keybindings.edit.insert("delete_char_forward".to_string(), vec!["Ctrl+d".to_string(), "Delete".to_string()]); - - keybindings - } -} - -impl CanvasConfig { - /// NEW: Load and validate configuration - pub fn load() -> Self { - match Self::load_and_validate() { - Ok(config) => config, - Err(e) => { - eprintln!("⚠️ Canvas config validation failed: {}", e); - eprintln!(" Using vim defaults. Run CanvasConfig::generate_template() for help."); - Self::default() - } - } - } - - /// NEW: Load configuration with validation - pub fn load_and_validate() -> Result { - // Try to load canvas_config.toml from current directory - let config = if let Ok(config) = Self::from_file(std::path::Path::new("canvas_config.toml")) { - config - } else { - // Fallback to vim defaults - Self::default() - }; - - // Validate the configuration - let validator = ConfigValidator::new(); - let validation_result = validator.validate_keybindings(&config.keybindings); - - if !validation_result.is_valid { - // Print validation errors - validator.print_validation_result(&validation_result); - - // Create error with suggestions - let error_msg = format!( - "Configuration validation failed with {} errors", - validation_result.errors.len() - ); - return Err(anyhow::anyhow!(error_msg)); - } - - // Print warnings if any - if !validation_result.warnings.is_empty() { - validator.print_validation_result(&validation_result); - } - - Ok(config) - } - - /// NEW: Generate a complete configuration template - pub fn generate_template() -> String { - let registry = ActionRegistry::new(); - registry.generate_config_template() - } - - /// NEW: Generate a clean, minimal configuration template - pub fn generate_clean_template() -> String { - let registry = ActionRegistry::new(); - registry.generate_clean_template() - } - - /// NEW: Validate current configuration - pub fn validate(&self) -> ValidationResult { - let validator = ConfigValidator::new(); - validator.validate_keybindings(&self.keybindings) - } - - /// NEW: Print validation results for current config - pub fn print_validation(&self) { - let validator = ConfigValidator::new(); - let result = validator.validate_keybindings(&self.keybindings); - validator.print_validation_result(&result); - } - - /// NEW: Generate config for missing required actions - pub fn generate_missing_config(&self) -> String { - let validator = ConfigValidator::new(); - validator.generate_missing_config(&self.keybindings) - } - - /// Load from TOML string - pub fn from_toml(toml_str: &str) -> Result { - toml::from_str(toml_str) - .with_context(|| "Failed to parse canvas config TOML") - } - - /// Load from file - pub fn from_file(path: &std::path::Path) -> Result { - let contents = std::fs::read_to_string(path) - .with_context(|| format!("Failed to read config file: {:?}", path))?; - Self::from_toml(&contents) - } - - /// NEW: Check if autocomplete should auto-trigger (simple logic) - pub fn should_auto_trigger_autocomplete(&self) -> bool { - // If trigger_autocomplete keybinding exists anywhere, use manual mode only - // If no trigger_autocomplete keybinding, use auto-trigger mode - !self.has_trigger_autocomplete_keybinding() - } - - /// NEW: Check if user has configured manual trigger keybinding - pub fn has_trigger_autocomplete_keybinding(&self) -> bool { - self.keybindings.edit.contains_key("trigger_autocomplete") || - self.keybindings.read_only.contains_key("trigger_autocomplete") || - self.keybindings.global.contains_key("trigger_autocomplete") - } - - // ... rest of your existing methods stay the same ... - - /// Get action for key in read-only mode - pub fn get_read_only_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> { - self.get_action_in_mode(&self.keybindings.read_only, key, modifiers) - .or_else(|| self.get_action_in_mode(&self.keybindings.global, key, modifiers)) - } - - /// Get action for key in edit mode - pub fn get_edit_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> { - self.get_action_in_mode(&self.keybindings.edit, key, modifiers) - .or_else(|| self.get_action_in_mode(&self.keybindings.global, key, modifiers)) - } - - /// Get action for key in suggestions mode - pub fn get_suggestion_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> { - self.get_action_in_mode(&self.keybindings.suggestions, key, modifiers) - } - - /// Get action for key (mode-aware) - pub fn get_action_for_key(&self, key: KeyCode, modifiers: KeyModifiers, is_edit_mode: bool, has_suggestions: bool) -> Option<&str> { - // Suggestions take priority when active - if has_suggestions { - if let Some(action) = self.get_suggestion_action(key, modifiers) { - return Some(action); - } - } - - // Then check mode-specific - if is_edit_mode { - self.get_edit_action(key, modifiers) - } else { - self.get_read_only_action(key, modifiers) - } - } - - // ... keep all your existing private methods ... - fn get_action_in_mode<'a>(&self, mode_bindings: &'a HashMap>, key: KeyCode, modifiers: KeyModifiers) -> Option<&'a str> { - for (action, bindings) in mode_bindings { - for binding in bindings { - if self.matches_keybinding(binding, key, modifiers) { - return Some(action); - } - } - } - None - } - - fn matches_keybinding(&self, binding: &str, key: KeyCode, modifiers: KeyModifiers) -> bool { - // ... keep all your existing key matching logic ... - // (This is a very long method, so I'm just indicating to keep it as-is) - - // Your existing implementation here... - true // placeholder - use your actual implementation - } - - /// Convenience method to create vim preset - pub fn vim_preset() -> Self { - Self { - keybindings: CanvasKeybindings::with_vim_defaults(), - behavior: CanvasBehavior::default(), - appearance: CanvasAppearance::default(), - } - } - - /// Convenience method to create emacs preset - pub fn emacs_preset() -> Self { - Self { - keybindings: CanvasKeybindings::with_emacs_defaults(), - behavior: CanvasBehavior::default(), - appearance: CanvasAppearance::default(), - } - } - - /// Debug method to print loaded keybindings - pub fn debug_keybindings(&self) { - println!("📋 Canvas keybindings loaded:"); - println!(" Read-only: {} actions", self.keybindings.read_only.len()); - println!(" Edit: {} actions", self.keybindings.edit.len()); - println!(" Suggestions: {} actions", self.keybindings.suggestions.len()); - println!(" Global: {} actions", self.keybindings.global.len()); - - // NEW: Show validation status - let validation = self.validate(); - if validation.is_valid { - println!(" ✅ Configuration is valid"); - } else { - println!(" ❌ Configuration has {} errors", validation.errors.len()); - } - if !validation.warnings.is_empty() { - println!(" ⚠️ Configuration has {} warnings", validation.warnings.len()); - } - } -} - -// Re-export for convenience -pub use crate::canvas::actions::CanvasAction; -pub use crate::dispatcher::ActionDispatcher; diff --git a/canvas/src/config/mod.rs b/canvas/src/config/mod.rs deleted file mode 100644 index 961bac2..0000000 --- a/canvas/src/config/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -// src/config/mod.rs - -mod registry; -mod config; -mod validation; - -// Re-export everything from the main config module -pub use registry::*; -pub use validation::*; -pub use config::*; diff --git a/canvas/src/config/registry.rs b/canvas/src/config/registry.rs deleted file mode 100644 index dcf442d..0000000 --- a/canvas/src/config/registry.rs +++ /dev/null @@ -1,451 +0,0 @@ -// src/config/registry.rs - -use std::collections::HashMap; -use crate::canvas::modes::AppMode; - -#[derive(Debug, Clone)] -pub struct ActionSpec { - pub name: String, - pub description: String, - pub examples: Vec, - pub mode_specific: bool, // true if different behavior per mode -} - -#[derive(Debug, Clone)] -pub struct ModeRegistry { - pub required: HashMap, - pub optional: HashMap, - pub auto_handled: Vec, // Never appear in config -} - -#[derive(Debug, Clone)] -pub struct ActionRegistry { - pub edit_mode: ModeRegistry, - pub readonly_mode: ModeRegistry, - pub suggestions: ModeRegistry, - pub global: ModeRegistry, -} - -impl ActionRegistry { - pub fn new() -> Self { - Self { - edit_mode: Self::edit_mode_registry(), - readonly_mode: Self::readonly_mode_registry(), - suggestions: Self::suggestions_registry(), - global: Self::global_registry(), - } - } - - fn edit_mode_registry() -> ModeRegistry { - let mut required = HashMap::new(); - let mut optional = HashMap::new(); - - // REQUIRED - These MUST be configured - required.insert("move_left".to_string(), ActionSpec { - name: "move_left".to_string(), - description: "Move cursor one position to the left".to_string(), - examples: vec!["Left".to_string(), "h".to_string()], - mode_specific: false, - }); - - required.insert("move_right".to_string(), ActionSpec { - name: "move_right".to_string(), - description: "Move cursor one position to the right".to_string(), - examples: vec!["Right".to_string(), "l".to_string()], - mode_specific: false, - }); - - required.insert("move_up".to_string(), ActionSpec { - name: "move_up".to_string(), - description: "Move to previous field or line".to_string(), - examples: vec!["Up".to_string(), "k".to_string()], - mode_specific: false, - }); - - required.insert("move_down".to_string(), ActionSpec { - name: "move_down".to_string(), - description: "Move to next field or line".to_string(), - examples: vec!["Down".to_string(), "j".to_string()], - mode_specific: false, - }); - - required.insert("delete_char_backward".to_string(), ActionSpec { - name: "delete_char_backward".to_string(), - description: "Delete character before cursor".to_string(), - examples: vec!["Backspace".to_string()], - mode_specific: false, - }); - - required.insert("next_field".to_string(), ActionSpec { - name: "next_field".to_string(), - description: "Move to next input field".to_string(), - examples: vec!["Tab".to_string(), "Enter".to_string()], - mode_specific: false, - }); - - required.insert("prev_field".to_string(), ActionSpec { - name: "prev_field".to_string(), - description: "Move to previous input field".to_string(), - examples: vec!["Shift+Tab".to_string()], - mode_specific: false, - }); - - // OPTIONAL - These can be configured or omitted - optional.insert("move_word_next".to_string(), ActionSpec { - name: "move_word_next".to_string(), - description: "Move cursor to start of next word".to_string(), - examples: vec!["Ctrl+Right".to_string(), "w".to_string()], - mode_specific: false, - }); - - optional.insert("move_word_prev".to_string(), ActionSpec { - name: "move_word_prev".to_string(), - description: "Move cursor to start of previous word".to_string(), - examples: vec!["Ctrl+Left".to_string(), "b".to_string()], - mode_specific: false, - }); - - optional.insert("move_word_end".to_string(), ActionSpec { - name: "move_word_end".to_string(), - description: "Move cursor to end of current/next word".to_string(), - examples: vec!["e".to_string()], - mode_specific: false, - }); - - optional.insert("move_word_end_prev".to_string(), ActionSpec { - name: "move_word_end_prev".to_string(), - description: "Move cursor to end of previous word".to_string(), - examples: vec!["ge".to_string()], - mode_specific: false, - }); - - optional.insert("move_line_start".to_string(), ActionSpec { - name: "move_line_start".to_string(), - description: "Move cursor to beginning of line".to_string(), - examples: vec!["Home".to_string(), "0".to_string()], - mode_specific: false, - }); - - optional.insert("move_line_end".to_string(), ActionSpec { - name: "move_line_end".to_string(), - description: "Move cursor to end of line".to_string(), - examples: vec!["End".to_string(), "$".to_string()], - mode_specific: false, - }); - - optional.insert("move_first_line".to_string(), ActionSpec { - name: "move_first_line".to_string(), - description: "Move to first field".to_string(), - examples: vec!["Ctrl+Home".to_string(), "gg".to_string()], - mode_specific: false, - }); - - optional.insert("move_last_line".to_string(), ActionSpec { - name: "move_last_line".to_string(), - description: "Move to last field".to_string(), - examples: vec!["Ctrl+End".to_string(), "G".to_string()], - mode_specific: false, - }); - - optional.insert("delete_char_forward".to_string(), ActionSpec { - name: "delete_char_forward".to_string(), - description: "Delete character after cursor".to_string(), - examples: vec!["Delete".to_string()], - mode_specific: false, - }); - - ModeRegistry { - required, - optional, - auto_handled: vec![ - "insert_char".to_string(), // Any printable character - ], - } - } - - fn readonly_mode_registry() -> ModeRegistry { - let mut required = HashMap::new(); - let mut optional = HashMap::new(); - - // REQUIRED - Navigation is essential in read-only mode - required.insert("move_left".to_string(), ActionSpec { - name: "move_left".to_string(), - description: "Move cursor one position to the left".to_string(), - examples: vec!["h".to_string(), "Left".to_string()], - mode_specific: true, - }); - - required.insert("move_right".to_string(), ActionSpec { - name: "move_right".to_string(), - description: "Move cursor one position to the right".to_string(), - examples: vec!["l".to_string(), "Right".to_string()], - mode_specific: true, - }); - - required.insert("move_up".to_string(), ActionSpec { - name: "move_up".to_string(), - description: "Move to previous field".to_string(), - examples: vec!["k".to_string(), "Up".to_string()], - mode_specific: true, - }); - - required.insert("move_down".to_string(), ActionSpec { - name: "move_down".to_string(), - description: "Move to next field".to_string(), - examples: vec!["j".to_string(), "Down".to_string()], - mode_specific: true, - }); - - // OPTIONAL - Advanced navigation - optional.insert("move_word_next".to_string(), ActionSpec { - name: "move_word_next".to_string(), - description: "Move cursor to start of next word".to_string(), - examples: vec!["w".to_string()], - mode_specific: true, - }); - - optional.insert("move_word_prev".to_string(), ActionSpec { - name: "move_word_prev".to_string(), - description: "Move cursor to start of previous word".to_string(), - examples: vec!["b".to_string()], - mode_specific: true, - }); - - optional.insert("move_word_end".to_string(), ActionSpec { - name: "move_word_end".to_string(), - description: "Move cursor to end of current/next word".to_string(), - examples: vec!["e".to_string()], - mode_specific: true, - }); - - optional.insert("move_word_end_prev".to_string(), ActionSpec { - name: "move_word_end_prev".to_string(), - description: "Move cursor to end of previous word".to_string(), - examples: vec!["ge".to_string()], - mode_specific: true, - }); - - optional.insert("move_line_start".to_string(), ActionSpec { - name: "move_line_start".to_string(), - description: "Move cursor to beginning of line".to_string(), - examples: vec!["0".to_string()], - mode_specific: true, - }); - - optional.insert("move_line_end".to_string(), ActionSpec { - name: "move_line_end".to_string(), - description: "Move cursor to end of line".to_string(), - examples: vec!["$".to_string()], - mode_specific: true, - }); - - optional.insert("move_first_line".to_string(), ActionSpec { - name: "move_first_line".to_string(), - description: "Move to first field".to_string(), - examples: vec!["gg".to_string()], - mode_specific: true, - }); - - optional.insert("move_last_line".to_string(), ActionSpec { - name: "move_last_line".to_string(), - description: "Move to last field".to_string(), - examples: vec!["G".to_string()], - mode_specific: true, - }); - - optional.insert("next_field".to_string(), ActionSpec { - name: "next_field".to_string(), - description: "Move to next input field".to_string(), - examples: vec!["Tab".to_string()], - mode_specific: true, - }); - - optional.insert("prev_field".to_string(), ActionSpec { - name: "prev_field".to_string(), - description: "Move to previous input field".to_string(), - examples: vec!["Shift+Tab".to_string()], - mode_specific: true, - }); - - ModeRegistry { - required, - optional, - auto_handled: vec![], // Read-only mode has no auto-handled actions - } - } - - fn suggestions_registry() -> ModeRegistry { - let mut required = HashMap::new(); - - // REQUIRED - Essential for suggestion navigation - required.insert("suggestion_up".to_string(), ActionSpec { - name: "suggestion_up".to_string(), - description: "Move selection to previous suggestion".to_string(), - examples: vec!["Up".to_string(), "Ctrl+p".to_string()], - mode_specific: false, - }); - - required.insert("suggestion_down".to_string(), ActionSpec { - name: "suggestion_down".to_string(), - description: "Move selection to next suggestion".to_string(), - examples: vec!["Down".to_string(), "Ctrl+n".to_string()], - mode_specific: false, - }); - - required.insert("select_suggestion".to_string(), ActionSpec { - name: "select_suggestion".to_string(), - description: "Select the currently highlighted suggestion".to_string(), - examples: vec!["Enter".to_string(), "Tab".to_string()], - mode_specific: false, - }); - - required.insert("exit_suggestions".to_string(), ActionSpec { - name: "exit_suggestions".to_string(), - description: "Close suggestions without selecting".to_string(), - examples: vec!["Esc".to_string()], - mode_specific: false, - }); - - ModeRegistry { - required, - optional: HashMap::new(), - auto_handled: vec![], - } - } - - fn global_registry() -> ModeRegistry { - let mut optional = HashMap::new(); - - // OPTIONAL - Global overrides - optional.insert("move_up".to_string(), ActionSpec { - name: "move_up".to_string(), - description: "Global override for up movement".to_string(), - examples: vec!["Up".to_string()], - mode_specific: false, - }); - - optional.insert("move_down".to_string(), ActionSpec { - name: "move_down".to_string(), - description: "Global override for down movement".to_string(), - examples: vec!["Down".to_string()], - mode_specific: false, - }); - - ModeRegistry { - required: HashMap::new(), - optional, - auto_handled: vec![], - } - } - - pub fn get_mode_registry(&self, mode: &str) -> &ModeRegistry { - match mode { - "edit" => &self.edit_mode, - "read_only" => &self.readonly_mode, - "suggestions" => &self.suggestions, - "global" => &self.global, - _ => &self.global, // fallback - } - } - - pub fn all_known_actions(&self) -> Vec { - let mut actions = Vec::new(); - - for registry in [&self.edit_mode, &self.readonly_mode, &self.suggestions, &self.global] { - actions.extend(registry.required.keys().cloned()); - actions.extend(registry.optional.keys().cloned()); - } - - actions.sort(); - actions.dedup(); - actions - } - - pub fn generate_config_template(&self) -> String { - let mut template = String::new(); - template.push_str("# Canvas Library Configuration Template\n"); - template.push_str("# Generated automatically - customize as needed\n\n"); - - template.push_str("[keybindings.edit]\n"); - template.push_str("# REQUIRED ACTIONS - These must be configured\n"); - for (name, spec) in &self.edit_mode.required { - template.push_str(&format!("# {}\n", spec.description)); - template.push_str(&format!("{} = {:?}\n\n", name, spec.examples)); - } - - template.push_str("# OPTIONAL ACTIONS - Configure these if you want them enabled\n"); - for (name, spec) in &self.edit_mode.optional { - template.push_str(&format!("# {}\n", spec.description)); - template.push_str(&format!("# {} = {:?}\n\n", name, spec.examples)); - } - - template.push_str("[keybindings.read_only]\n"); - template.push_str("# REQUIRED ACTIONS - These must be configured\n"); - for (name, spec) in &self.readonly_mode.required { - template.push_str(&format!("# {}\n", spec.description)); - template.push_str(&format!("{} = {:?}\n\n", name, spec.examples)); - } - - template.push_str("# OPTIONAL ACTIONS - Configure these if you want them enabled\n"); - for (name, spec) in &self.readonly_mode.optional { - template.push_str(&format!("# {}\n", spec.description)); - template.push_str(&format!("# {} = {:?}\n\n", name, spec.examples)); - } - - template.push_str("[keybindings.suggestions]\n"); - template.push_str("# REQUIRED ACTIONS - These must be configured\n"); - for (name, spec) in &self.suggestions.required { - template.push_str(&format!("# {}\n", spec.description)); - template.push_str(&format!("{} = {:?}\n\n", name, spec.examples)); - } - - template - } - - pub fn generate_clean_template(&self) -> String { - let mut template = String::new(); - - // Edit Mode - template.push_str("[keybindings.edit]\n"); - template.push_str("# Required\n"); - for (name, spec) in &self.edit_mode.required { - template.push_str(&format!("{} = {:?}\n", name, spec.examples)); - } - template.push_str("# Optional\n"); - for (name, spec) in &self.edit_mode.optional { - template.push_str(&format!("{} = {:?}\n", name, spec.examples)); - } - template.push('\n'); - - // Read-Only Mode - template.push_str("[keybindings.read_only]\n"); - template.push_str("# Required\n"); - for (name, spec) in &self.readonly_mode.required { - template.push_str(&format!("{} = {:?}\n", name, spec.examples)); - } - template.push_str("# Optional\n"); - for (name, spec) in &self.readonly_mode.optional { - template.push_str(&format!("{} = {:?}\n", name, spec.examples)); - } - template.push('\n'); - - // Suggestions Mode - template.push_str("[keybindings.suggestions]\n"); - template.push_str("# Required\n"); - for (name, spec) in &self.suggestions.required { - template.push_str(&format!("{} = {:?}\n", name, spec.examples)); - } - template.push('\n'); - - // Global (all optional) - if !self.global.optional.is_empty() { - template.push_str("[keybindings.global]\n"); - template.push_str("# Optional\n"); - for (name, spec) in &self.global.optional { - template.push_str(&format!("{} = {:?}\n", name, spec.examples)); - } - } - - template - } -} diff --git a/canvas/src/config/validation.rs b/canvas/src/config/validation.rs deleted file mode 100644 index e4be3c0..0000000 --- a/canvas/src/config/validation.rs +++ /dev/null @@ -1,279 +0,0 @@ -// src/config/validation.rs - -use std::collections::HashMap; -use thiserror::Error; -use crate::config::registry::{ActionRegistry, ModeRegistry}; -use crate::config::CanvasKeybindings; - -#[derive(Error, Debug)] -pub enum ValidationError { - #[error("Missing required action '{action}' in {mode} mode")] - MissingRequired { - action: String, - mode: String, - suggestion: String, - }, - - #[error("Unknown action '{action}' in {mode} mode")] - UnknownAction { - action: String, - mode: String, - similar: Vec, - }, - - #[error("Multiple validation errors")] - Multiple(Vec), -} - -#[derive(Debug)] -pub struct ValidationWarning { - pub message: String, - pub suggestion: Option, -} - -#[derive(Debug)] -pub struct ValidationResult { - pub errors: Vec, - pub warnings: Vec, - pub is_valid: bool, -} - -impl ValidationResult { - pub fn new() -> Self { - Self { - errors: Vec::new(), - warnings: Vec::new(), - is_valid: true, - } - } - - pub fn add_error(&mut self, error: ValidationError) { - self.errors.push(error); - self.is_valid = false; - } - - pub fn add_warning(&mut self, warning: ValidationWarning) { - self.warnings.push(warning); - } - - pub fn merge(&mut self, other: ValidationResult) { - self.errors.extend(other.errors); - self.warnings.extend(other.warnings); - if !other.is_valid { - self.is_valid = false; - } - } -} - -pub struct ConfigValidator { - registry: ActionRegistry, -} - -impl ConfigValidator { - pub fn new() -> Self { - Self { - registry: ActionRegistry::new(), - } - } - - pub fn validate_keybindings(&self, keybindings: &CanvasKeybindings) -> ValidationResult { - let mut result = ValidationResult::new(); - - // Validate each mode - result.merge(self.validate_mode_bindings( - "edit", - &keybindings.edit, - self.registry.get_mode_registry("edit") - )); - - result.merge(self.validate_mode_bindings( - "read_only", - &keybindings.read_only, - self.registry.get_mode_registry("read_only") - )); - - result.merge(self.validate_mode_bindings( - "suggestions", - &keybindings.suggestions, - self.registry.get_mode_registry("suggestions") - )); - - result.merge(self.validate_mode_bindings( - "global", - &keybindings.global, - self.registry.get_mode_registry("global") - )); - - result - } - - fn validate_mode_bindings( - &self, - mode_name: &str, - bindings: &HashMap>, - registry: &ModeRegistry - ) -> ValidationResult { - let mut result = ValidationResult::new(); - - // Check for missing required actions - for (action_name, spec) in ®istry.required { - if !bindings.contains_key(action_name) { - result.add_error(ValidationError::MissingRequired { - action: action_name.clone(), - mode: mode_name.to_string(), - suggestion: format!( - "Add to config: {} = {:?}", - action_name, - spec.examples - ), - }); - } - } - - // Check for unknown actions - let all_known: std::collections::HashSet<_> = registry.required.keys() - .chain(registry.optional.keys()) - .collect(); - - for action_name in bindings.keys() { - if !all_known.contains(action_name) { - let similar = self.find_similar_actions(action_name, &all_known); - result.add_error(ValidationError::UnknownAction { - action: action_name.clone(), - mode: mode_name.to_string(), - similar, - }); - } - } - - // Check for empty keybinding arrays - for (action_name, key_list) in bindings { - if key_list.is_empty() { - result.add_warning(ValidationWarning { - message: format!( - "Action '{}' in {} mode has empty keybinding list", - action_name, mode_name - ), - suggestion: Some(format!( - "Either add keybindings or remove the action from config" - )), - }); - } - } - - // Warn about auto-handled actions that shouldn't be in config - for auto_action in ®istry.auto_handled { - if bindings.contains_key(auto_action) { - result.add_warning(ValidationWarning { - message: format!( - "Action '{}' in {} mode is auto-handled and shouldn't be in config", - auto_action, mode_name - ), - suggestion: Some(format!( - "Remove '{}' from config - it's handled automatically", - auto_action - )), - }); - } - } - - result - } - - fn find_similar_actions(&self, action: &str, known_actions: &std::collections::HashSet<&String>) -> Vec { - let mut similar = Vec::new(); - - for known in known_actions { - if self.is_similar(action, known) { - similar.push(known.to_string()); - } - } - - similar.sort(); - similar.truncate(3); // Limit to 3 suggestions - similar - } - - fn is_similar(&self, a: &str, b: &str) -> bool { - // Simple similarity check - could be improved with proper edit distance - let a_lower = a.to_lowercase(); - let b_lower = b.to_lowercase(); - - // Check if one contains the other - if a_lower.contains(&b_lower) || b_lower.contains(&a_lower) { - return true; - } - - // Check for common prefixes - let common_prefixes = ["move_", "delete_", "suggestion_"]; - for prefix in &common_prefixes { - if a_lower.starts_with(prefix) && b_lower.starts_with(prefix) { - return true; - } - } - - false - } - - pub fn print_validation_result(&self, result: &ValidationResult) { - if result.is_valid && result.warnings.is_empty() { - println!("✅ Canvas configuration is valid!"); - return; - } - - if !result.errors.is_empty() { - println!("❌ Canvas configuration has errors:"); - for error in &result.errors { - match error { - ValidationError::MissingRequired { action, mode, suggestion } => { - println!(" • Missing required action '{}' in {} mode", action, mode); - println!(" 💡 {}", suggestion); - } - ValidationError::UnknownAction { action, mode, similar } => { - println!(" • Unknown action '{}' in {} mode", action, mode); - if !similar.is_empty() { - println!(" 💡 Did you mean: {}", similar.join(", ")); - } - } - ValidationError::Multiple(_) => { - println!(" • Multiple errors occurred"); - } - } - println!(); - } - } - - if !result.warnings.is_empty() { - println!("⚠️ Canvas configuration has warnings:"); - for warning in &result.warnings { - println!(" • {}", warning.message); - if let Some(suggestion) = &warning.suggestion { - println!(" 💡 {}", suggestion); - } - println!(); - } - } - - if !result.is_valid { - println!("🔧 To generate a config template, use:"); - println!(" CanvasConfig::generate_template()"); - } - } - - pub fn generate_missing_config(&self, keybindings: &CanvasKeybindings) -> String { - let mut config = String::new(); - let validation = self.validate_keybindings(keybindings); - - for error in &validation.errors { - if let ValidationError::MissingRequired { action, mode, suggestion } = error { - if config.is_empty() { - config.push_str(&format!("# Missing required actions for canvas\n\n")); - config.push_str(&format!("[keybindings.{}]\n", mode)); - } - config.push_str(&format!("{}\n", suggestion)); - } - } - - config - } -} diff --git a/canvas/src/data_provider.rs b/canvas/src/data_provider.rs new file mode 100644 index 0000000..aabce75 --- /dev/null +++ b/canvas/src/data_provider.rs @@ -0,0 +1,44 @@ +// 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 { + /// 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 display_text: String, + pub value_to_store: String, +} diff --git a/canvas/src/dispatcher.rs b/canvas/src/dispatcher.rs deleted file mode 100644 index 09a284d..0000000 --- a/canvas/src/dispatcher.rs +++ /dev/null @@ -1,110 +0,0 @@ -// src/dispatcher.rs - -use crate::canvas::state::{CanvasState, ActionContext}; -use crate::canvas::actions::{CanvasAction, ActionResult}; -use crate::canvas::actions::handlers::{handle_edit_action, handle_readonly_action, handle_highlight_action}; -use crate::canvas::modes::AppMode; -use crate::config::CanvasConfig; -use crossterm::event::{KeyCode, KeyModifiers}; - -/// Main entry point for executing canvas actions -pub async fn execute_canvas_action( - action: CanvasAction, - state: &mut S, - ideal_cursor_column: &mut usize, - config: Option<&CanvasConfig>, -) -> anyhow::Result { - ActionDispatcher::dispatch_with_config(action, state, ideal_cursor_column, config).await -} - -/// High-level action dispatcher that routes actions to mode-specific handlers -pub struct ActionDispatcher; - -impl ActionDispatcher { - /// Dispatch any action to the appropriate mode handler - pub async fn dispatch( - action: CanvasAction, - state: &mut S, - ideal_cursor_column: &mut usize, - ) -> anyhow::Result { - let config = CanvasConfig::load(); - Self::dispatch_with_config(action, state, ideal_cursor_column, Some(&config)).await - } - - /// Dispatch action with provided config - pub async fn dispatch_with_config( - action: CanvasAction, - state: &mut S, - ideal_cursor_column: &mut usize, - config: Option<&CanvasConfig>, - ) -> anyhow::Result { - // Check for feature-specific handling 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() { - AppMode::Edit => { - handle_edit_action(action, state, ideal_cursor_column, config).await - } - AppMode::ReadOnly => { - handle_readonly_action(action, state, ideal_cursor_column, config).await - } - AppMode::Highlight => { - handle_highlight_action(action, state, ideal_cursor_column, config).await - } - AppMode::General | AppMode::Command => { - // These modes might not handle canvas actions directly - Ok(ActionResult::success_with_message("Mode does not handle canvas actions")) - } - } - } - - /// Quick action dispatch from KeyCode using config - pub async fn dispatch_key( - key: KeyCode, - modifiers: KeyModifiers, - state: &mut S, - ideal_cursor_column: &mut usize, - is_edit_mode: bool, - has_suggestions: bool, - ) -> anyhow::Result> { - let config = CanvasConfig::load(); - - if let Some(action_name) = config.get_action_for_key(key, modifiers, is_edit_mode, has_suggestions) { - let action = CanvasAction::from_string(action_name); - let result = Self::dispatch_with_config(action, state, ideal_cursor_column, Some(&config)).await?; - Ok(Some(result)) - } else { - Ok(None) - } - } - - /// Batch dispatch multiple actions - pub async fn dispatch_batch( - actions: Vec, - state: &mut S, - ideal_cursor_column: &mut usize, - ) -> anyhow::Result> { - let mut results = Vec::new(); - for action in actions { - let result = Self::dispatch(action, state, ideal_cursor_column).await?; - let is_success = result.is_success(); - results.push(result); - - // Stop on first error - if !is_success { - break; - } - } - Ok(results) - } -} diff --git a/canvas/src/editor.rs b/canvas/src/editor.rs new file mode 100644 index 0000000..47165a1 --- /dev/null +++ b/canvas/src/editor.rs @@ -0,0 +1,543 @@ +// src/editor.rs +//! Main API for the canvas library - FormEditor with library-owned state + +#[cfg(feature = "cursor-style")] +use crate::canvas::CursorManager; + +use anyhow::Result; +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) { + #[cfg(feature = "cursor-style")] + let old_mode = self.ui_state.current_mode; + + self.ui_state.current_mode = mode; + + // Clear autocomplete when changing modes + if mode != AppMode::Edit { + self.ui_state.deactivate_autocomplete(); + } + + // Update cursor style if mode changed and cursor-style feature is enabled + #[cfg(feature = "cursor-style")] + if old_mode != mode { + let _ = crate::canvas::CursorManager::update_for_mode(mode); + } + } + + // =================================================================== + // 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, + { + 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 (no conversion needed!) + let query = self.current_text(); + self.suggestions = provider.fetch_suggestions(field_index, query).await?; + + // 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 + } + + // =================================================================== + // ADD THESE MISSING MOVEMENT METHODS + // =================================================================== + + /// Move to previous field (vim k / up arrow) + pub fn move_up(&mut self) { + let field_count = self.data_provider.field_count(); + if field_count == 0 { + return; + } + + let current_field = self.ui_state.current_field; + let new_field = current_field.saturating_sub(1); + + self.ui_state.move_to_field(new_field, field_count); + self.clamp_cursor_to_current_field(); + } + + /// Move to next field (vim j / down arrow) + pub fn move_down(&mut self) { + let field_count = self.data_provider.field_count(); + if field_count == 0 { + return; + } + + let current_field = self.ui_state.current_field; + let new_field = (current_field + 1).min(field_count - 1); + + self.ui_state.move_to_field(new_field, field_count); + self.clamp_cursor_to_current_field(); + } + + /// Move to first field (vim gg) + pub fn move_first_line(&mut self) { + let field_count = self.data_provider.field_count(); + if field_count == 0 { + return; + } + + self.ui_state.move_to_field(0, field_count); + self.clamp_cursor_to_current_field(); + } + + /// Move to last field (vim G) + pub fn move_last_line(&mut self) { + let field_count = self.data_provider.field_count(); + if field_count == 0 { + return; + } + + let last_field = field_count - 1; + self.ui_state.move_to_field(last_field, field_count); + self.clamp_cursor_to_current_field(); + } + + /// Move to previous field (alternative to move_up) + pub fn prev_field(&mut self) { + self.move_up(); + } + + /// Move to next field (alternative to move_down) + pub fn next_field(&mut self) { + self.move_down(); + } + + /// Move to start of current field (vim 0) + pub fn move_line_start(&mut self) { + use crate::canvas::actions::movement::line::line_start_position; + let new_pos = line_start_position(); + self.ui_state.cursor_pos = new_pos; + self.ui_state.ideal_cursor_column = new_pos; + } + + /// Move to end of current field (vim $) + pub fn move_line_end(&mut self) { + use crate::canvas::actions::movement::line::line_end_position; + let current_text = self.current_text(); + let is_edit_mode = self.ui_state.current_mode == AppMode::Edit; + + let new_pos = line_end_position(current_text, is_edit_mode); + self.ui_state.cursor_pos = new_pos; + self.ui_state.ideal_cursor_column = new_pos; + } + + /// Move to start of next word (vim w) + pub fn move_word_next(&mut self) { + use crate::canvas::actions::movement::word::find_next_word_start; + let current_text = self.current_text(); + + if current_text.is_empty() { + return; + } + + let new_pos = find_next_word_start(current_text, self.ui_state.cursor_pos); + let is_edit_mode = self.ui_state.current_mode == AppMode::Edit; + + // Clamp to valid bounds for current mode + let final_pos = if is_edit_mode { + new_pos.min(current_text.len()) + } else { + new_pos.min(current_text.len().saturating_sub(1)) + }; + + self.ui_state.cursor_pos = final_pos; + self.ui_state.ideal_cursor_column = final_pos; + } + + /// Move to start of previous word (vim b) + pub fn move_word_prev(&mut self) { + use crate::canvas::actions::movement::word::find_prev_word_start; + let current_text = self.current_text(); + + if current_text.is_empty() { + return; + } + + let new_pos = find_prev_word_start(current_text, self.ui_state.cursor_pos); + self.ui_state.cursor_pos = new_pos; + self.ui_state.ideal_cursor_column = new_pos; + } + + /// Move to end of current/next word (vim e) + pub fn move_word_end(&mut self) { + use crate::canvas::actions::movement::word::find_word_end; + let current_text = self.current_text(); + + if current_text.is_empty() { + return; + } + + let current_pos = self.ui_state.cursor_pos; + let new_pos = find_word_end(current_text, current_pos); + + // If we didn't move, try next word + let final_pos = if new_pos == current_pos && current_pos + 1 < current_text.len() { + find_word_end(current_text, current_pos + 1) + } else { + new_pos + }; + + // Clamp for read-only mode + let is_edit_mode = self.ui_state.current_mode == AppMode::Edit; + let clamped_pos = if is_edit_mode { + final_pos.min(current_text.len()) + } else { + final_pos.min(current_text.len().saturating_sub(1)) + }; + + self.ui_state.cursor_pos = clamped_pos; + self.ui_state.ideal_cursor_column = clamped_pos; + } + + /// Move to end of previous word (vim ge) + pub fn move_word_end_prev(&mut self) { + use crate::canvas::actions::movement::word::find_prev_word_end; + let current_text = self.current_text(); + + if current_text.is_empty() { + return; + } + + let new_pos = find_prev_word_end(current_text, self.ui_state.cursor_pos); + self.ui_state.cursor_pos = new_pos; + self.ui_state.ideal_cursor_column = new_pos; + } + + /// Delete character before cursor (vim x in insert mode / backspace) + pub fn delete_backward(&mut self) -> Result<()> { + if self.ui_state.current_mode != AppMode::Edit { + return Ok(()); // Silently ignore in non-edit modes + } + + if self.ui_state.cursor_pos == 0 { + return Ok(()); // Nothing to delete + } + + let field_index = self.ui_state.current_field; + let mut current_text = self.data_provider.field_value(field_index).to_string(); + + if self.ui_state.cursor_pos <= current_text.len() { + current_text.remove(self.ui_state.cursor_pos - 1); + self.data_provider.set_field_value(field_index, current_text); + self.ui_state.cursor_pos -= 1; + self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos; + } + + Ok(()) + } + + /// Delete character under cursor (vim x / delete key) + pub fn delete_forward(&mut self) -> Result<()> { + if self.ui_state.current_mode != AppMode::Edit { + return Ok(()); // Silently ignore in non-edit modes + } + + let field_index = self.ui_state.current_field; + let mut current_text = self.data_provider.field_value(field_index).to_string(); + + if self.ui_state.cursor_pos < current_text.len() { + current_text.remove(self.ui_state.cursor_pos); + self.data_provider.set_field_value(field_index, current_text); + } + + Ok(()) + } + + /// Exit edit mode to read-only mode (vim Escape) + pub fn exit_edit_mode(&mut self) { + self.set_mode(AppMode::ReadOnly); + // Deactivate autocomplete when exiting edit mode + self.ui_state.deactivate_autocomplete(); + } + + /// Enter edit mode from read-only mode (vim i/a/o) + pub fn enter_edit_mode(&mut self) { + self.set_mode(AppMode::Edit); + } + + // =================================================================== + // HELPER METHODS + // =================================================================== + + /// Clamp cursor position to valid bounds for current field and mode + fn clamp_cursor_to_current_field(&mut self) { + let current_text = self.current_text(); + let is_edit_mode = self.ui_state.current_mode == AppMode::Edit; + + use crate::canvas::actions::movement::line::safe_cursor_position; + let safe_pos = safe_cursor_position( + current_text, + self.ui_state.ideal_cursor_column, + is_edit_mode + ); + + self.ui_state.cursor_pos = safe_pos; + } + + + /// Set the value of the current field + pub fn set_current_field_value(&mut self, value: String) { + let field_index = self.ui_state.current_field; + self.data_provider.set_field_value(field_index, value); + // Reset cursor to start of field + self.ui_state.cursor_pos = 0; + self.ui_state.ideal_cursor_column = 0; + } + + /// Set the value of a specific field by index + pub fn set_field_value(&mut self, field_index: usize, value: String) { + if field_index < self.data_provider.field_count() { + self.data_provider.set_field_value(field_index, value); + // If we're modifying the current field, reset cursor + if field_index == self.ui_state.current_field { + self.ui_state.cursor_pos = 0; + self.ui_state.ideal_cursor_column = 0; + } + } + } + + /// Clear the current field (set to empty string) + pub fn clear_current_field(&mut self) { + self.set_current_field_value(String::new()); + } + + /// Get mutable access to data provider (for advanced operations) + pub fn data_provider_mut(&mut self) -> &mut D { + &mut self.data_provider + } + + /// Set cursor to exact position (for vim-style movements like f, F, t, T) + pub fn set_cursor_position(&mut self, position: usize) { + let current_text = self.current_text(); + let is_edit_mode = self.ui_state.current_mode == AppMode::Edit; + + // Clamp to valid bounds for current mode + let max_pos = if is_edit_mode { + current_text.len() // Edit mode: can go past end + } else { + current_text.len().saturating_sub(1).max(0) // Read-only: stay within text + }; + + let clamped_pos = position.min(max_pos); + + // Update cursor position directly + self.ui_state.cursor_pos = clamped_pos; + self.ui_state.ideal_cursor_column = clamped_pos; + } + + /// Cleanup cursor style (call this when shutting down) + pub fn cleanup_cursor(&self) -> std::io::Result<()> { + #[cfg(feature = "cursor-style")] + { + crate::canvas::CursorManager::reset() + } + #[cfg(not(feature = "cursor-style"))] + { + Ok(()) + } + } +} + +// Add Drop implementation for automatic cleanup +impl Drop for FormEditor { + fn drop(&mut self) { + // Reset cursor to default when FormEditor is dropped + let _ = self.cleanup_cursor(); + } +} diff --git a/canvas/src/lib.rs b/canvas/src/lib.rs index 26c5a40..badc944 100644 --- a/canvas/src/lib.rs +++ b/canvas/src/lib.rs @@ -1,11 +1,40 @@ // src/lib.rs -pub mod canvas; -pub mod autocomplete; -pub mod config; -pub mod dispatcher; -// Re-export the main API for easy access -pub use dispatcher::{execute_canvas_action, ActionDispatcher}; +pub mod canvas; +pub mod editor; +pub mod data_provider; + +// Only include autocomplete module if feature is enabled +#[cfg(feature = "autocomplete")] +pub mod autocomplete; + +#[cfg(feature = "cursor-style")] +pub use canvas::CursorManager; + +// =================================================================== +// 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}; -pub use canvas::state::{CanvasState, ActionContext}; -pub use canvas::modes::{AppMode, HighlightState, ModeManager}; + +// Theming and GUI +#[cfg(feature = "gui")] +pub use canvas::theme::{CanvasTheme, DefaultCanvasTheme}; + +#[cfg(feature = "gui")] +pub use canvas::gui::render_canvas; + +#[cfg(feature = "gui")] +pub use canvas::gui::render_canvas_default; + +#[cfg(all(feature = "gui", feature = "autocomplete"))] +pub use autocomplete::gui::render_autocomplete_dropdown; diff --git a/canvas/view_docs.sh b/canvas/view_docs.sh new file mode 100755 index 0000000..eece01d --- /dev/null +++ b/canvas/view_docs.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +# Enhanced documentation viewer for your canvas library +echo "==========================================" +echo "CANVAS LIBRARY DOCUMENTATION" +echo "==========================================" + +# Function to display module docs with colors +show_module() { + local module=$1 + local title=$2 + + echo -e "\n\033[1;34m=== $title ===\033[0m" + echo -e "\033[33mFiles in $module:\033[0m" + find src/$module -name "*.rs" 2>/dev/null | sort + echo + + # Show doc comments for this module + find src/$module -name "*.rs" 2>/dev/null | while read file; do + if grep -q "///" "$file"; then + echo -e "\033[32m--- $file ---\033[0m" + grep -n "^\s*///" "$file" | sed 's/^\([0-9]*:\)\s*\/\/\/ /\1 /' | head -10 + echo + fi + done +} + +# Main modules +show_module "canvas" "CANVAS SYSTEM" +show_module "autocomplete" "AUTOCOMPLETE SYSTEM" +show_module "config" "CONFIGURATION SYSTEM" + +# Show lib.rs and other root files +echo -e "\n\033[1;34m=== ROOT DOCUMENTATION ===\033[0m" +if [ -f "src/lib.rs" ]; then + echo -e "\033[32m--- src/lib.rs ---\033[0m" + grep -n "^\s*///" src/lib.rs | sed 's/^\([0-9]*:\)\s*\/\/\/ /\1 /' 2>/dev/null +fi + +if [ -f "src/dispatcher.rs" ]; then + echo -e "\033[32m--- src/dispatcher.rs ---\033[0m" + grep -n "^\s*///" src/dispatcher.rs | sed 's/^\([0-9]*:\)\s*\/\/\/ /\1 /' 2>/dev/null +fi + +echo -e "\n\033[1;36m==========================================" +echo "To view specific module documentation:" +echo " ./view_canvas_docs.sh canvas" +echo " ./view_canvas_docs.sh autocomplete" +echo " ./view_canvas_docs.sh config" +echo "==========================================\033[0m" + +# If specific module requested +if [ $# -eq 1 ]; then + show_module "$1" "$(echo $1 | tr '[:lower:]' '[:upper:]') MODULE DETAILS" +fi diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..24e2a7a --- /dev/null +++ b/client/.gitignore @@ -0,0 +1 @@ +canvas_config.toml.txt diff --git a/client/canvas_config.toml b/client/canvas_config.toml deleted file mode 100644 index 147ad93..0000000 --- a/client/canvas_config.toml +++ /dev/null @@ -1,58 +0,0 @@ -# canvas_config.toml - Complete Canvas Configuration - -[behavior] -wrap_around_fields = true -auto_save_on_field_change = false -word_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_" -max_suggestions = 6 - -[appearance] -cursor_style = "block" # "block", "bar", "underline" -show_field_numbers = false -highlight_current_field = true - -# Read-only mode keybindings (vim-style) -[keybindings.read_only] -move_left = ["h"] -move_right = ["l"] -move_up = ["k"] -move_down = ["j"] -move_word_next = ["w"] -move_word_end = ["e"] -move_word_prev = ["b"] -move_word_end_prev = ["ge"] -move_line_start = ["0"] -move_line_end = ["$"] -move_first_line = ["gg"] -move_last_line = ["shift+g"] -next_field = ["Tab"] -prev_field = ["Shift+Tab"] - -# Edit mode keybindings -[keybindings.edit] -delete_char_backward = ["Backspace"] -delete_char_forward = ["Delete"] -move_left = ["Left"] -move_right = ["Right"] -move_up = ["Up"] -move_down = ["Down"] -move_line_start = ["Home"] -move_line_end = ["End"] -move_word_next = ["Ctrl+Right"] -move_word_prev = ["Ctrl+Left"] -next_field = ["Tab"] -prev_field = ["Shift+Tab"] -trigger_autocomplete = ["Ctrl+p"] - -# Suggestion/autocomplete keybindings -[keybindings.suggestions] -suggestion_up = ["Up", "Ctrl+p"] -suggestion_down = ["Down", "Ctrl+n"] -select_suggestion = ["Enter", "Tab"] -exit_suggestions = ["Esc"] -trigger_autocomplete = ["Tab"] - -# Global keybindings (work in both modes) -[keybindings.global] -move_up = ["Up"] -move_down = ["Down"] diff --git a/client/config.toml b/client/config.toml index 1a43411..ab3ae25 100644 --- a/client/config.toml +++ b/client/config.toml @@ -42,10 +42,42 @@ next_entry = ["right","1"] enter_highlight_mode = ["v"] enter_highlight_mode_linewise = ["ctrl+v"] +### AUTOGENERATED CANVAS CONFIG +# Required +move_up = ["k", "Up"] +move_left = ["h", "Left"] +move_right = ["l", "Right"] +move_down = ["j", "Down"] +# Optional +move_line_end = ["$"] +# move_word_next = ["w"] +next_field = ["Tab"] +move_word_prev = ["b"] +move_word_end = ["e"] +move_last_line = ["shift+g"] +move_word_end_prev = ["ge"] +move_line_start = ["0"] +move_first_line = ["g+g"] +prev_field = ["Shift+Tab"] + [keybindings.highlight] exit_highlight_mode = ["esc"] enter_highlight_mode_linewise = ["ctrl+v"] +### AUTOGENERATED CANVAS CONFIG +# Required +move_left = ["h", "Left"] +move_right = ["l", "Right"] +move_up = ["k", "Up"] +move_down = ["j", "Down"] +# Optional +move_word_next = ["w"] +move_line_start = ["0"] +move_line_end = ["$"] +move_word_prev = ["b"] +move_word_end = ["e"] + + [keybindings.edit] # BIG CHANGES NOW EXIT HANDLES EITHER IF THOSE # exit_edit_mode = ["esc","ctrl+e"] @@ -53,13 +85,30 @@ enter_highlight_mode_linewise = ["ctrl+v"] # select_suggestion = ["enter"] # next_field = ["enter"] enter_decider = ["enter"] -prev_field = ["shift+enter"] exit = ["esc", "ctrl+e"] -delete_char_forward = ["delete"] -delete_char_backward = ["backspace"] suggestion_down = ["ctrl+n", "tab"] suggestion_up = ["ctrl+p", "shift+tab"] +### AUTOGENERATED CANVAS CONFIG +# Required +move_right = ["Right", "l"] +delete_char_backward = ["Backspace"] +next_field = ["Tab", "Enter"] +move_up = ["Up", "k"] +move_down = ["Down", "j"] +prev_field = ["Shift+Tab"] +move_left = ["Left", "h"] +# Optional +move_last_line = ["Ctrl+End", "G"] +delete_char_forward = ["Delete"] +move_word_prev = ["Ctrl+Left", "b"] +move_word_end = ["e"] +move_word_end_prev = ["ge"] +move_first_line = ["Ctrl+Home", "gg"] +move_word_next = ["Ctrl+Right", "w"] +move_line_start = ["Home", "0"] +move_line_end = ["End", "$"] + [keybindings.command] exit_command_mode = ["ctrl+g", "esc"] command_execute = ["enter"] @@ -77,3 +126,9 @@ keybinding_mode = "vim" # Options: "default", "vim", "emacs" [colors] theme = "dark" # Options: "light", "dark", "high_contrast" + + + + + + diff --git a/client/src/modes/canvas/edit.rs b/client/src/modes/canvas/edit.rs index 9cff458..dd814d3 100644 --- a/client/src/modes/canvas/edit.rs +++ b/client/src/modes/canvas/edit.rs @@ -12,7 +12,7 @@ use canvas::canvas::CanvasState; use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher, canvas::ActionResult}; use anyhow::Result; use common::proto::komp_ac::search::search_response::Hit; -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use tokio::sync::mpsc; use tracing::info; @@ -143,23 +143,46 @@ async fn execute_canvas_action( } } -/// NEW: Unified canvas action handler for any CanvasState (LoginState, RegisterState, etc.) -/// This replaces the old auth_e::execute_edit_action calls with the new canvas library -/// NEW: Unified canvas action handler for any CanvasState with character fallback -/// Complete canvas action handler with fallbacks for common keys -/// Debug version to see what's happening +/// FIXED: Unified canvas action handler with proper priority order for edit mode async fn handle_canvas_state_edit( key: KeyEvent, config: &Config, state: &mut S, ideal_cursor_column: &mut usize, ) -> Result { - println!("DEBUG: Key pressed: {:?}", key); // DEBUG - - // Try direct key mapping first (same pattern as FormState) + // println!("DEBUG: Key pressed: {:?}", key); // DEBUG + + // PRIORITY 1: Character insertion in edit mode comes FIRST + if let KeyCode::Char(c) = key.code { + // Only insert if no modifiers or just shift (for uppercase) + if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT { + // println!("DEBUG: Using character insertion priority for: {}", c); // DEBUG + let canvas_action = CanvasAction::InsertChar(c); + match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await { + Ok(ActionResult::Success(msg)) => { + return Ok(msg.unwrap_or_default()); + } + Ok(ActionResult::HandledByFeature(msg)) => { + return Ok(msg); + } + Ok(ActionResult::Error(msg)) => { + return Ok(format!("Error: {}", msg)); + } + Ok(ActionResult::RequiresContext(msg)) => { + return Ok(format!("Context needed: {}", msg)); + } + Err(e) => { + // println!("DEBUG: Character insertion failed: {:?}, trying config", e); + // Fall through to try config mappings + } + } + } + } + + // PRIORITY 2: Check canvas config for special keys/combinations let canvas_config = canvas::config::CanvasConfig::load(); if let Some(action_name) = canvas_config.get_edit_action(key.code, key.modifiers) { - println!("DEBUG: Canvas config mapped to: {}", action_name); // DEBUG + // println!("DEBUG: Canvas config mapped to: {}", action_name); // DEBUG let canvas_action = CanvasAction::from_string(action_name); match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await { @@ -176,62 +199,43 @@ async fn handle_canvas_state_edit( return Ok(format!("Context needed: {}", msg)); } Err(_) => { - println!("DEBUG: Canvas action failed, trying client config"); // DEBUG + // println!("DEBUG: Canvas action failed, trying client config"); // DEBUG } } } else { - println!("DEBUG: No canvas config mapping found"); // DEBUG + // println!("DEBUG: No canvas config mapping found"); // DEBUG } - // Try config-mapped action (same pattern as FormState) - if let Some(action_str) = config.get_edit_action_for_key(key.code, key.modifiers) { - println!("DEBUG: Client config mapped to: {}", action_str); // DEBUG - let canvas_action = CanvasAction::from_string(&action_str); - match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await { - Ok(ActionResult::Success(msg)) => { - return Ok(msg.unwrap_or_default()); - } - Ok(ActionResult::HandledByFeature(msg)) => { - return Ok(msg); - } - Ok(ActionResult::Error(msg)) => { - return Ok(format!("Error: {}", msg)); - } - Ok(ActionResult::RequiresContext(msg)) => { - return Ok(format!("Context needed: {}", msg)); - } - Err(e) => { - return Ok(format!("Action failed: {}", e)); + // PRIORITY 3: Check client config ONLY for non-character keys or modified keys + if !matches!(key.code, KeyCode::Char(_)) || !key.modifiers.is_empty() { + if let Some(action_str) = config.get_edit_action_for_key(key.code, key.modifiers) { + // println!("DEBUG: Client config mapped to: {} (for non-char key)", action_str); // DEBUG + let canvas_action = CanvasAction::from_string(&action_str); + match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await { + Ok(ActionResult::Success(msg)) => { + return Ok(msg.unwrap_or_default()); + } + Ok(ActionResult::HandledByFeature(msg)) => { + return Ok(msg); + } + Ok(ActionResult::Error(msg)) => { + return Ok(format!("Error: {}", msg)); + } + Ok(ActionResult::RequiresContext(msg)) => { + return Ok(format!("Context needed: {}", msg)); + } + Err(e) => { + return Ok(format!("Action failed: {}", e)); + } } + } else { + // println!("DEBUG: No client config mapping found for non-char key"); // DEBUG } } else { - println!("DEBUG: No client config mapping found"); // DEBUG + // println!("DEBUG: Skipping client config for character key in edit mode"); // DEBUG } - // Character insertion fallback - if let KeyCode::Char(c) = key.code { - println!("DEBUG: Using character fallback for: {}", c); // DEBUG - let canvas_action = CanvasAction::InsertChar(c); - match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await { - Ok(ActionResult::Success(msg)) => { - return Ok(msg.unwrap_or_default()); - } - Ok(ActionResult::HandledByFeature(msg)) => { - return Ok(msg); - } - Ok(ActionResult::Error(msg)) => { - return Ok(format!("Error: {}", msg)); - } - Ok(ActionResult::RequiresContext(msg)) => { - return Ok(format!("Context needed: {}", msg)); - } - Err(e) => { - return Ok(format!("Character insertion failed: {}", e)); - } - } - } - - println!("DEBUG: No action taken for key: {:?}", key); // DEBUG + // println!("DEBUG: No action taken for key: {:?}", key); // DEBUG Ok(String::new()) } diff --git a/client/src/modes/handlers/event.rs b/client/src/modes/handlers/event.rs index 7e560dc..fadcc6e 100644 --- a/client/src/modes/handlers/event.rs +++ b/client/src/modes/handlers/event.rs @@ -45,6 +45,7 @@ use crate::ui::handlers::rat_state::UiStateHandler; use anyhow::Result; use common::proto::komp_ac::search::search_response::Hit; use crossterm::cursor::SetCursorStyle; +use crossterm::event::KeyModifiers; use crossterm::event::{Event, KeyCode, KeyEvent}; use tokio::sync::mpsc; use tokio::sync::mpsc::unbounded_channel; @@ -776,7 +777,6 @@ impl EventHandler { if app_state.ui.show_form { if let Ok(Some(canvas_message)) = self.handle_form_canvas_action( key_event, - config, form_state, false, ).await { @@ -866,7 +866,6 @@ impl EventHandler { if app_state.ui.show_form { if let Ok(Some(canvas_message)) = self.handle_form_canvas_action( key_event, - config, form_state, true, ).await { @@ -1102,18 +1101,39 @@ impl EventHandler { async fn handle_form_canvas_action( &mut self, key_event: KeyEvent, - _config: &Config, form_state: &mut FormState, is_edit_mode: bool, ) -> Result> { let canvas_config = canvas::config::CanvasConfig::load(); - // Get action from config - handles all modes (edit/read-only/suggestions) + // PRIORITY 1: Handle character insertion in edit mode FIRST + if is_edit_mode { + if let KeyCode::Char(c) = key_event.code { + // Only insert if it's not a special modifier combination + if key_event.modifiers.is_empty() || key_event.modifiers == KeyModifiers::SHIFT { + let canvas_action = CanvasAction::InsertChar(c); + match ActionDispatcher::dispatch( + canvas_action, + form_state, + &mut self.ideal_cursor_column, + ).await { + Ok(result) => { + return Ok(Some(result.message().unwrap_or("").to_string())); + } + Err(_) => { + return Ok(Some("Character insertion failed".to_string())); + } + } + } + } + } + + // PRIORITY 2: Handle config-mapped actions for non-character keys let action_str = canvas_config.get_action_for_key( key_event.code, key_event.modifiers, is_edit_mode, - form_state.autocomplete_active + form_state.autocomplete_active, ); if let Some(action_str) = action_str { @@ -1138,25 +1158,6 @@ impl EventHandler { } } - // Handle character insertion for edit mode (not in config) - if is_edit_mode { - if let KeyCode::Char(c) = key_event.code { - let canvas_action = CanvasAction::InsertChar(c); - match ActionDispatcher::dispatch( - canvas_action, - form_state, - &mut self.ideal_cursor_column, - ).await { - Ok(result) => { - return Ok(Some(result.message().unwrap_or("").to_string())); - } - Err(_) => { - return Ok(Some("Character insertion failed".to_string())); - } - } - } - } - // No action found Ok(None) }