strings to enum, eased state.rs

This commit is contained in:
Priec
2025-07-29 10:12:16 +02:00
parent 15922ed953
commit 2a7f94cf17
8 changed files with 572 additions and 93 deletions

View File

@@ -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"

View File

@@ -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<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<ActionResult> {
// 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<S: CanvasState>(
action: &str,
key: KeyEvent,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<String> {
// 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<S: CanvasState>(action: &str, state: &mut S) -> Result<bool> {
/// Handle suggestion-related actions
fn handle_suggestion_action<S: CanvasState>(
action: &CanvasAction,
state: &mut S,
) -> Result<Option<ActionResult>> {
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<S: CanvasState>(
action: &str,
key: KeyEvent,
/// Handle core canvas actions with full type safety
async fn handle_generic_canvas_action<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<String> {
) -> Result<ActionResult> {
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<char> = 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<char> = 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<char> = 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<S: CanvasState>(
*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<char> = 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<S: CanvasState>(
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<S: CanvasState>(
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<S: CanvasState>(
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<S: CanvasState>(
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<S: CanvasState>(
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<S: CanvasState>(
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<S: CanvasState>(
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<S: CanvasState>(
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<S: CanvasState>(
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,

View File

@@ -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};

225
canvas/src/actions/types.rs Normal file
View File

@@ -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<Self> {
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<String>),
/// 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<String>) -> Self {
Self::Success(Some(msg.into()))
}
pub fn error(msg: impl Into<String>) -> 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());
}
}

180
canvas/src/dispatcher.rs Normal file
View File

@@ -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<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> anyhow::Result<ActionResult> {
execute_canvas_action(action, state, ideal_cursor_column).await
}
/// Quick action dispatch from KeyCode
pub async fn dispatch_key<S: CanvasState>(
key: crossterm::event::KeyCode,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> anyhow::Result<Option<ActionResult>> {
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<S: CanvasState>(
actions: Vec<CanvasAction>,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> anyhow::Result<Vec<ActionResult>> {
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<String>,
field_names: Vec<String>,
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<String> {
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");
}
}

View File

@@ -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,

View File

@@ -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<crossterm::event::KeyCode>,
pub key_code: Option<crossterm::event::KeyCode>, // 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<String> {
// --- Feature-specific action handling (NEW: Type-safe) ---
fn handle_feature_action(&mut self, _action: &CanvasAction, _context: &ActionContext) -> Option<String> {
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<String> {
// 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()