diff --git a/Cargo.lock b/Cargo.lock index acafab0..43699fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -478,6 +478,8 @@ dependencies = [ "common", "crossterm", "ratatui", + "tokio", + "tokio-test", ] [[package]] diff --git a/canvas/Cargo.toml b/canvas/Cargo.toml index 8657cd8..eda8ed0 100644 --- a/canvas/Cargo.toml +++ b/canvas/Cargo.toml @@ -14,3 +14,7 @@ common = { path = "../common" } ratatui = { workspace = true } crossterm = { workspace = true } anyhow = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +tokio-test = "0.4.4" diff --git a/canvas/src/actions/edit.rs b/canvas/src/actions/edit.rs index 1b5ea92..2a112d0 100644 --- a/canvas/src/actions/edit.rs +++ b/canvas/src/actions/edit.rs @@ -1,106 +1,135 @@ // canvas/src/actions/edit.rs -use crate::state::CanvasState; +use crate::state::{CanvasState, ActionContext}; +use crate::actions::types::{CanvasAction, ActionResult}; use crossterm::event::{KeyCode, KeyEvent}; use anyhow::Result; -/// Execute a generic edit action on any CanvasState implementation. -/// This is the core function that makes the mode system work across all features. +/// Execute a typed canvas action on any CanvasState implementation +pub async fn execute_canvas_action( + action: CanvasAction, + state: &mut S, + ideal_cursor_column: &mut usize, +) -> Result { + // 1. Try feature-specific handler first + let context = ActionContext { + key_code: None, // We don't need KeyCode anymore since action is typed + 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)); + } + + // 2. Handle suggestion actions + if let Some(result) = handle_suggestion_action(&action, state)? { + return Ok(result); + } + + // 3. Handle generic canvas actions + handle_generic_canvas_action(action, state, ideal_cursor_column).await +} + +/// Legacy function for string-based actions (backwards compatibility) pub async fn execute_edit_action( action: &str, key: KeyEvent, state: &mut S, ideal_cursor_column: &mut usize, ) -> Result { - // 1. Try feature-specific handler first (for autocomplete, field-specific logic, etc.) - let context = crate::state::ActionContext { - key_code: Some(key.code), - ideal_cursor_column: *ideal_cursor_column, - current_input: state.get_current_input().to_string(), - current_field: state.current_field(), + let typed_action = match action { + "insert_char" => { + if let KeyCode::Char(c) = key.code { + CanvasAction::InsertChar(c) + } else { + return Ok("Error: insert_char called without a char key.".to_string()); + } + } + _ => CanvasAction::from_string(action), }; + + let result = execute_canvas_action(typed_action, state, ideal_cursor_column).await?; - if let Some(result) = state.handle_feature_action(action, &context) { - return Ok(result); - } - - // 2. Handle suggestion-related actions generically - if handle_suggestion_actions(action, state)? { - return Ok("".to_string()); // Suggestion action handled - } - - // 3. Fall back to generic canvas actions (handles 95% of all actions) - handle_generic_action(action, key, state, ideal_cursor_column).await + // Convert ActionResult back to string for backwards compatibility + Ok(result.message().unwrap_or("").to_string()) } -/// Handle suggestion/autocomplete actions generically -fn handle_suggestion_actions(action: &str, state: &mut S) -> Result { +/// Handle suggestion-related actions +fn handle_suggestion_action( + action: &CanvasAction, + state: &mut S, +) -> Result> { match action { - "suggestion_down" => { + CanvasAction::SuggestionDown => { if let Some(suggestions) = state.get_suggestions() { if !suggestions.is_empty() { let current = state.get_selected_suggestion_index().unwrap_or(0); let next = (current + 1) % suggestions.len(); state.set_selected_suggestion_index(Some(next)); - return Ok(true); + return Ok(Some(ActionResult::success())); } } - Ok(false) + Ok(None) } - "suggestion_up" => { + + CanvasAction::SuggestionUp => { if let Some(suggestions) = state.get_suggestions() { if !suggestions.is_empty() { let current = state.get_selected_suggestion_index().unwrap_or(0); let prev = if current == 0 { suggestions.len() - 1 } else { current - 1 }; state.set_selected_suggestion_index(Some(prev)); - return Ok(true); + return Ok(Some(ActionResult::success())); } } - Ok(false) + Ok(None) } - "select_suggestion" => { + + CanvasAction::SelectSuggestion => { // Let feature handle this via handle_feature_action since it's feature-specific - Ok(false) + Ok(None) } - "exit_suggestions" => { + + CanvasAction::ExitSuggestions => { state.deactivate_suggestions(); - Ok(true) + Ok(Some(ActionResult::success())) } - _ => Ok(false) + + _ => Ok(None), } } -/// Handle generic canvas actions (movement, editing, etc.) -async fn handle_generic_action( - action: &str, - key: KeyEvent, +/// Handle core canvas actions with full type safety +async fn handle_generic_canvas_action( + action: CanvasAction, state: &mut S, ideal_cursor_column: &mut usize, -) -> Result { +) -> Result { match action { - "insert_char" => { - if let KeyCode::Char(c) = key.code { - let cursor_pos = state.current_cursor_pos(); - let field_value = state.get_current_input_mut(); - let mut chars: Vec = field_value.chars().collect(); - if cursor_pos <= chars.len() { - chars.insert(cursor_pos, c); - *field_value = chars.into_iter().collect(); - state.set_current_cursor_pos(cursor_pos + 1); - state.set_has_unsaved_changes(true); - *ideal_cursor_column = state.current_cursor_pos(); - } + CanvasAction::InsertChar(c) => { + let cursor_pos = state.current_cursor_pos(); + let field_value = state.get_current_input_mut(); + let mut chars: Vec = field_value.chars().collect(); + + if cursor_pos <= chars.len() { + chars.insert(cursor_pos, c); + *field_value = chars.into_iter().collect(); + state.set_current_cursor_pos(cursor_pos + 1); + state.set_has_unsaved_changes(true); + *ideal_cursor_column = state.current_cursor_pos(); + Ok(ActionResult::success()) } else { - return Ok("Error: insert_char called without a char key.".to_string()); + Ok(ActionResult::error("Invalid cursor position for character insertion")) } - Ok("".to_string()) } - "delete_char_backward" => { + CanvasAction::DeleteBackward => { if state.current_cursor_pos() > 0 { let cursor_pos = state.current_cursor_pos(); let field_value = state.get_current_input_mut(); let mut chars: Vec = field_value.chars().collect(); + if cursor_pos <= chars.len() { chars.remove(cursor_pos - 1); *field_value = chars.into_iter().collect(); @@ -110,23 +139,24 @@ async fn handle_generic_action( *ideal_cursor_column = new_pos; } } - Ok("".to_string()) + Ok(ActionResult::success()) } - "delete_char_forward" => { + CanvasAction::DeleteForward => { let cursor_pos = state.current_cursor_pos(); let field_value = state.get_current_input_mut(); let mut chars: Vec = field_value.chars().collect(); + if cursor_pos < chars.len() { chars.remove(cursor_pos); *field_value = chars.into_iter().collect(); state.set_has_unsaved_changes(true); *ideal_cursor_column = cursor_pos; } - Ok("".to_string()) + Ok(ActionResult::success()) } - "next_field" => { + CanvasAction::NextField => { let num_fields = state.fields().len(); if num_fields > 0 { let current_field = state.current_field(); @@ -136,10 +166,10 @@ async fn handle_generic_action( let max_pos = current_input.len(); state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos)); } - Ok("".to_string()) + Ok(ActionResult::success()) } - "prev_field" => { + CanvasAction::PrevField => { let num_fields = state.fields().len(); if num_fields > 0 { let current_field = state.current_field(); @@ -153,17 +183,17 @@ async fn handle_generic_action( let max_pos = current_input.len(); state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos)); } - Ok("".to_string()) + Ok(ActionResult::success()) } - "move_left" => { + CanvasAction::MoveLeft => { let new_pos = state.current_cursor_pos().saturating_sub(1); state.set_current_cursor_pos(new_pos); *ideal_cursor_column = new_pos; - Ok("".to_string()) + Ok(ActionResult::success()) } - "move_right" => { + CanvasAction::MoveRight => { let current_input = state.get_current_input(); let current_pos = state.current_cursor_pos(); if current_pos < current_input.len() { @@ -171,10 +201,10 @@ async fn handle_generic_action( state.set_current_cursor_pos(new_pos); *ideal_cursor_column = new_pos; } - Ok("".to_string()) + Ok(ActionResult::success()) } - "move_up" => { + CanvasAction::MoveUp => { let num_fields = state.fields().len(); if num_fields > 0 { let current_field = state.current_field(); @@ -184,10 +214,10 @@ async fn handle_generic_action( let max_pos = current_input.len(); state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos)); } - Ok("".to_string()) + Ok(ActionResult::success()) } - "move_down" => { + CanvasAction::MoveDown => { let num_fields = state.fields().len(); if num_fields > 0 { let new_field = (state.current_field() + 1).min(num_fields - 1); @@ -196,24 +226,24 @@ async fn handle_generic_action( let max_pos = current_input.len(); state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos)); } - Ok("".to_string()) + Ok(ActionResult::success()) } - "move_line_start" => { + CanvasAction::MoveLineStart => { state.set_current_cursor_pos(0); *ideal_cursor_column = 0; - Ok("".to_string()) + Ok(ActionResult::success()) } - "move_line_end" => { + CanvasAction::MoveLineEnd => { let current_input = state.get_current_input(); let new_pos = current_input.len(); state.set_current_cursor_pos(new_pos); *ideal_cursor_column = new_pos; - Ok("".to_string()) + Ok(ActionResult::success()) } - "move_first_line" => { + CanvasAction::MoveFirstLine => { let num_fields = state.fields().len(); if num_fields > 0 { state.set_current_field(0); @@ -221,10 +251,10 @@ async fn handle_generic_action( let max_pos = current_input.len(); state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos)); } - Ok("Moved to first field".to_string()) + Ok(ActionResult::success_with_message("Moved to first field")) } - "move_last_line" => { + CanvasAction::MoveLastLine => { let num_fields = state.fields().len(); if num_fields > 0 { let new_field = num_fields - 1; @@ -233,10 +263,10 @@ async fn handle_generic_action( let max_pos = current_input.len(); state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos)); } - Ok("Moved to last field".to_string()) + Ok(ActionResult::success_with_message("Moved to last field")) } - "move_word_next" => { + 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()); @@ -244,10 +274,10 @@ async fn handle_generic_action( state.set_current_cursor_pos(final_pos); *ideal_cursor_column = final_pos; } - Ok("".to_string()) + Ok(ActionResult::success()) } - "move_word_end" => { + CanvasAction::MoveWordEnd => { let current_input = state.get_current_input(); if !current_input.is_empty() { let current_pos = state.current_cursor_pos(); @@ -264,34 +294,43 @@ async fn handle_generic_action( state.set_current_cursor_pos(clamped_pos); *ideal_cursor_column = clamped_pos; } - Ok("".to_string()) + Ok(ActionResult::success()) } - "move_word_prev" => { + 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("".to_string()) + Ok(ActionResult::success()) } - "move_word_end_prev" => { + 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("Moved to previous word end".to_string()) + Ok(ActionResult::success_with_message("Moved to previous word end")) } - _ => Ok(format!("Unknown or unhandled edit action: {}", action)), + CanvasAction::Custom(action_str) => { + Ok(ActionResult::error(format!("Unknown or unhandled custom action: {}", action_str))) + } + + // Suggestion actions should have been handled above + CanvasAction::SuggestionUp | CanvasAction::SuggestionDown | + CanvasAction::SelectSuggestion | CanvasAction::ExitSuggestions => { + Ok(ActionResult::error("Suggestion action not handled properly")) + } } } // Word movement helper functions + #[derive(PartialEq)] enum CharType { Whitespace, diff --git a/canvas/src/actions/mod.rs b/canvas/src/actions/mod.rs index 079ea3c..2eda674 100644 --- a/canvas/src/actions/mod.rs +++ b/canvas/src/actions/mod.rs @@ -1,3 +1,8 @@ // canvas/src/actions/mod.rs +pub mod types; pub mod edit; + +// Re-export the main types for convenience +pub use types::{CanvasAction, ActionResult}; +pub use edit::{execute_canvas_action, execute_edit_action}; diff --git a/canvas/src/actions/types.rs b/canvas/src/actions/types.rs new file mode 100644 index 0000000..ef2b107 --- /dev/null +++ b/canvas/src/actions/types.rs @@ -0,0 +1,225 @@ +// canvas/src/actions/types.rs + +use crossterm::event::KeyCode; + +/// All possible canvas actions, type-safe and exhaustive +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CanvasAction { + // Character input + InsertChar(char), + + // Deletion + DeleteBackward, + DeleteForward, + + // Basic cursor movement + MoveLeft, + MoveRight, + MoveUp, + MoveDown, + + // Line movement + MoveLineStart, + MoveLineEnd, + MoveFirstLine, + MoveLastLine, + + // Word movement + MoveWordNext, + MoveWordEnd, + MoveWordPrev, + MoveWordEndPrev, + + // Field navigation + NextField, + PrevField, + + // Suggestions + SuggestionUp, + SuggestionDown, + SelectSuggestion, + ExitSuggestions, + + // Custom actions (escape hatch for feature-specific behavior) + Custom(String), +} + +impl CanvasAction { + /// Convert a string action to typed action (for backwards compatibility during migration) + pub fn from_string(action: &str) -> Self { + match action { + "insert_char" => { + // This is a bit tricky - we need the char from context + // For now, we'll use Custom until we refactor the call sites + Self::Custom(action.to_string()) + } + "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, + "suggestion_up" => Self::SuggestionUp, + "suggestion_down" => Self::SuggestionDown, + "select_suggestion" => Self::SelectSuggestion, + "exit_suggestions" => Self::ExitSuggestions, + _ => Self::Custom(action.to_string()), + } + } + + /// Get string representation (for logging, debugging) + pub fn as_str(&self) -> &str { + match self { + Self::InsertChar(_) => "insert_char", + Self::DeleteBackward => "delete_char_backward", + Self::DeleteForward => "delete_char_forward", + Self::MoveLeft => "move_left", + Self::MoveRight => "move_right", + Self::MoveUp => "move_up", + Self::MoveDown => "move_down", + Self::MoveLineStart => "move_line_start", + Self::MoveLineEnd => "move_line_end", + Self::MoveFirstLine => "move_first_line", + Self::MoveLastLine => "move_last_line", + Self::MoveWordNext => "move_word_next", + Self::MoveWordEnd => "move_word_end", + Self::MoveWordPrev => "move_word_prev", + Self::MoveWordEndPrev => "move_word_end_prev", + Self::NextField => "next_field", + Self::PrevField => "prev_field", + Self::SuggestionUp => "suggestion_up", + Self::SuggestionDown => "suggestion_down", + Self::SelectSuggestion => "select_suggestion", + Self::ExitSuggestions => "exit_suggestions", + Self::Custom(s) => s, + } + } + + /// Create action from KeyCode for common cases + pub fn from_key(key: KeyCode) -> Option { + match key { + KeyCode::Char(c) => Some(Self::InsertChar(c)), + KeyCode::Backspace => Some(Self::DeleteBackward), + KeyCode::Delete => Some(Self::DeleteForward), + KeyCode::Left => Some(Self::MoveLeft), + KeyCode::Right => Some(Self::MoveRight), + KeyCode::Up => Some(Self::MoveUp), + KeyCode::Down => Some(Self::MoveDown), + KeyCode::Home => Some(Self::MoveLineStart), + KeyCode::End => Some(Self::MoveLineEnd), + KeyCode::Tab => Some(Self::NextField), + KeyCode::BackTab => Some(Self::PrevField), + _ => None, + } + } + + /// Check if this action modifies content + pub fn is_modifying(&self) -> bool { + matches!(self, + Self::InsertChar(_) | + Self::DeleteBackward | + Self::DeleteForward | + Self::SelectSuggestion + ) + } + + /// Check if this action moves the cursor + pub fn is_movement(&self) -> bool { + matches!(self, + Self::MoveLeft | Self::MoveRight | Self::MoveUp | Self::MoveDown | + Self::MoveLineStart | Self::MoveLineEnd | Self::MoveFirstLine | Self::MoveLastLine | + Self::MoveWordNext | Self::MoveWordEnd | Self::MoveWordPrev | Self::MoveWordEndPrev | + Self::NextField | Self::PrevField + ) + } + + /// Check if this is a suggestion-related action + pub fn is_suggestion(&self) -> bool { + matches!(self, + Self::SuggestionUp | Self::SuggestionDown | + Self::SelectSuggestion | Self::ExitSuggestions + ) + } +} + +/// Result of executing a canvas action +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ActionResult { + /// Action completed successfully, optional message for user feedback + Success(Option), + /// Action was handled by custom feature logic + HandledByFeature(String), + /// Action requires additional context or cannot be performed + RequiresContext(String), + /// Action failed with error message + Error(String), +} + +impl ActionResult { + pub fn success() -> Self { + Self::Success(None) + } + + pub fn success_with_message(msg: impl Into) -> Self { + Self::Success(Some(msg.into())) + } + + pub fn error(msg: impl Into) -> Self { + Self::Error(msg.into()) + } + + pub fn is_success(&self) -> bool { + matches!(self, Self::Success(_) | 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), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_action_from_string() { + assert_eq!(CanvasAction::from_string("move_left"), CanvasAction::MoveLeft); + assert_eq!(CanvasAction::from_string("delete_char_backward"), CanvasAction::DeleteBackward); + assert_eq!(CanvasAction::from_string("unknown"), CanvasAction::Custom("unknown".to_string())); + } + + #[test] + fn test_action_from_key() { + assert_eq!(CanvasAction::from_key(KeyCode::Char('a')), Some(CanvasAction::InsertChar('a'))); + assert_eq!(CanvasAction::from_key(KeyCode::Left), Some(CanvasAction::MoveLeft)); + assert_eq!(CanvasAction::from_key(KeyCode::Backspace), Some(CanvasAction::DeleteBackward)); + assert_eq!(CanvasAction::from_key(KeyCode::F(1)), None); + } + + #[test] + fn test_action_properties() { + assert!(CanvasAction::InsertChar('a').is_modifying()); + assert!(!CanvasAction::MoveLeft.is_modifying()); + + assert!(CanvasAction::MoveLeft.is_movement()); + assert!(!CanvasAction::InsertChar('a').is_movement()); + + assert!(CanvasAction::SuggestionUp.is_suggestion()); + assert!(!CanvasAction::MoveLeft.is_suggestion()); + } +} diff --git a/canvas/src/dispatcher.rs b/canvas/src/dispatcher.rs new file mode 100644 index 0000000..1ac1d6e --- /dev/null +++ b/canvas/src/dispatcher.rs @@ -0,0 +1,180 @@ +// canvas/src/dispatcher.rs + +use crate::state::CanvasState; +use crate::actions::{CanvasAction, ActionResult, execute_canvas_action}; + +/// High-level action dispatcher that coordinates between different action types +pub struct ActionDispatcher; + +impl ActionDispatcher { + /// Dispatch any action to the appropriate handler + pub async fn dispatch( + action: CanvasAction, + state: &mut S, + ideal_cursor_column: &mut usize, + ) -> anyhow::Result { + execute_canvas_action(action, state, ideal_cursor_column).await + } + + /// Quick action dispatch from KeyCode + pub async fn dispatch_key( + key: crossterm::event::KeyCode, + state: &mut S, + ideal_cursor_column: &mut usize, + ) -> anyhow::Result> { + if let Some(action) = CanvasAction::from_key(key) { + let result = Self::dispatch(action, state, ideal_cursor_column).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(); // Check success before moving + results.push(result); + + // Stop on first error + if !is_success { + break; + } + } + Ok(results) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::actions::CanvasAction; + + // Simple test implementation + struct TestFormState { + current_field: usize, + cursor_pos: usize, + inputs: Vec, + field_names: Vec, + has_changes: bool, + } + + impl TestFormState { + fn new() -> Self { + Self { + current_field: 0, + cursor_pos: 0, + inputs: vec!["".to_string(), "".to_string()], + field_names: vec!["username".to_string(), "password".to_string()], + has_changes: false, + } + } + } + + impl CanvasState for TestFormState { + fn current_field(&self) -> usize { self.current_field } + fn current_cursor_pos(&self) -> usize { self.cursor_pos } + fn set_current_field(&mut self, index: usize) { self.current_field = index; } + fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; } + + fn get_current_input(&self) -> &str { &self.inputs[self.current_field] } + fn get_current_input_mut(&mut self) -> &mut String { &mut self.inputs[self.current_field] } + fn inputs(&self) -> Vec<&String> { self.inputs.iter().collect() } + fn fields(&self) -> Vec<&str> { self.field_names.iter().map(|s| s.as_str()).collect() } + + fn has_unsaved_changes(&self) -> bool { self.has_changes } + fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; } + + // Custom action handling for testing + fn handle_feature_action(&mut self, action: &CanvasAction, _context: &crate::state::ActionContext) -> Option { + match action { + CanvasAction::Custom(s) if s == "test_custom" => { + Some("Custom action handled".to_string()) + } + _ => None, + } + } + } + + #[tokio::test] + async fn test_typed_action_dispatch() { + let mut state = TestFormState::new(); + let mut ideal_cursor = 0; + + // Test character insertion + let result = ActionDispatcher::dispatch( + CanvasAction::InsertChar('a'), + &mut state, + &mut ideal_cursor, + ).await.unwrap(); + + assert!(result.is_success()); + assert_eq!(state.get_current_input(), "a"); + assert_eq!(state.cursor_pos, 1); + assert!(state.has_changes); + } + + #[tokio::test] + async fn test_key_dispatch() { + let mut state = TestFormState::new(); + let mut ideal_cursor = 0; + + let result = ActionDispatcher::dispatch_key( + crossterm::event::KeyCode::Char('b'), + &mut state, + &mut ideal_cursor, + ).await.unwrap(); + + assert!(result.is_some()); + assert!(result.unwrap().is_success()); + assert_eq!(state.get_current_input(), "b"); + } + + #[tokio::test] + async fn test_custom_action() { + let mut state = TestFormState::new(); + let mut ideal_cursor = 0; + + let result = ActionDispatcher::dispatch( + CanvasAction::Custom("test_custom".to_string()), + &mut state, + &mut ideal_cursor, + ).await.unwrap(); + + match result { + ActionResult::HandledByFeature(msg) => { + assert_eq!(msg, "Custom action handled"); + } + _ => panic!("Expected HandledByFeature result"), + } + } + + #[tokio::test] + async fn test_batch_dispatch() { + let mut state = TestFormState::new(); + let mut ideal_cursor = 0; + + let actions = vec![ + CanvasAction::InsertChar('h'), + CanvasAction::InsertChar('i'), + CanvasAction::MoveLeft, + CanvasAction::InsertChar('e'), + ]; + + let results = ActionDispatcher::dispatch_batch( + actions, + &mut state, + &mut ideal_cursor, + ).await.unwrap(); + + assert_eq!(results.len(), 4); + assert!(results.iter().all(|r| r.is_success())); + assert_eq!(state.get_current_input(), "hei"); + } +} diff --git a/canvas/src/lib.rs b/canvas/src/lib.rs index bfeeed2..e9a7544 100644 --- a/canvas/src/lib.rs +++ b/canvas/src/lib.rs @@ -1,7 +1,6 @@ // canvas/src/lib.rs - //! Canvas - A reusable text editing and form canvas system -//! +//! //! This crate provides a generic canvas abstraction for building text-based interfaces //! with multiple input fields, cursor management, and mode-based editing. @@ -9,19 +8,25 @@ pub mod state; pub mod actions; pub mod modes; pub mod suggestions; +pub mod dispatcher; // Re-export the main types for easy use pub use state::{CanvasState, ActionContext}; -pub use actions::edit::execute_edit_action; +pub use actions::{CanvasAction, ActionResult, execute_edit_action, execute_canvas_action}; pub use modes::{AppMode, ModeManager, HighlightState}; pub use suggestions::SuggestionState; +pub use dispatcher::ActionDispatcher; // High-level convenience API pub mod prelude { pub use crate::{ CanvasState, ActionContext, + CanvasAction, + ActionResult, execute_edit_action, + execute_canvas_action, + ActionDispatcher, AppMode, ModeManager, HighlightState, diff --git a/canvas/src/state.rs b/canvas/src/state.rs index f14cd53..05125d1 100644 --- a/canvas/src/state.rs +++ b/canvas/src/state.rs @@ -1,9 +1,11 @@ // canvas/src/state.rs +use crate::actions::CanvasAction; + /// Context passed to feature-specific action handlers #[derive(Debug)] pub struct ActionContext { - pub key_code: Option, + pub key_code: Option, // Kept for backwards compatibility pub ideal_cursor_column: usize, pub current_input: String, pub current_field: usize, @@ -43,14 +45,31 @@ pub trait CanvasState { // Default: no-op (override if you support suggestions) } fn deactivate_suggestions(&mut self) { - // Default: no-op (override if you support suggestions) + // Default: no-op (override if you support suggestions) } - - // --- Feature-specific action handling --- - fn handle_feature_action(&mut self, action: &str, context: &ActionContext) -> Option { + + // --- Feature-specific action handling (NEW: Type-safe) --- + fn handle_feature_action(&mut self, _action: &CanvasAction, _context: &ActionContext) -> Option { None // Default: no feature-specific handling } + // --- Legacy string-based action handling (for backwards compatibility) --- + fn handle_feature_action_legacy(&mut self, action: &str, context: &ActionContext) -> Option { + // Convert string to typed action and delegate + let typed_action = match action { + "insert_char" => { + // This is tricky - we need the char from the KeyCode in context + if let Some(crossterm::event::KeyCode::Char(c)) = context.key_code { + CanvasAction::InsertChar(c) + } else { + CanvasAction::Custom(action.to_string()) + } + } + _ => CanvasAction::from_string(action), + }; + self.handle_feature_action(&typed_action, context) + } + // --- Display Overrides (for links, computed values, etc.) --- fn get_display_value_for_field(&self, index: usize) -> &str { self.inputs()