completely redesign philosofy of this library

This commit is contained in:
Priec
2025-08-01 22:54:05 +02:00
parent 8f99aa79ec
commit 5c39386a3a
20 changed files with 961 additions and 1210 deletions

View File

@@ -13,7 +13,7 @@ categories.workspace = true
common = { path = "../common" } common = { path = "../common" }
ratatui = { workspace = true, optional = true } ratatui = { workspace = true, optional = true }
crossterm = { workspace = true } crossterm = { workspace = true }
anyhow = { workspace = true } anyhow.workspace = true
tokio = { workspace = true, optional = true } tokio = { workspace = true, optional = true }
toml = { workspace = true } toml = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
@@ -22,7 +22,7 @@ thiserror = { workspace = true }
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = "0.3.19" tracing-subscriber = "0.3.19"
async-trait = { workspace = true, optional = true } async-trait.workspace = true
[dev-dependencies] [dev-dependencies]
tokio-test = "0.4.4" tokio-test = "0.4.4"
@@ -30,7 +30,7 @@ tokio-test = "0.4.4"
[features] [features]
default = [] default = []
gui = ["ratatui"] gui = ["ratatui"]
autocomplete = ["tokio", "async-trait"] autocomplete = ["tokio"]
[[example]] [[example]]
name = "autocomplete" name = "autocomplete"

View File

@@ -20,21 +20,14 @@ use canvas::{
canvas::{ canvas::{
gui::render_canvas, gui::render_canvas,
modes::AppMode, modes::AppMode,
state::{ActionContext, CanvasState},
theme::CanvasTheme, theme::CanvasTheme,
}, },
autocomplete::{ autocomplete::gui::render_autocomplete_dropdown,
AutocompleteCanvasState, FormEditor, DataProvider, AutocompleteProvider, SuggestionItem,
AutocompleteState,
SuggestionItem,
execute_with_autocomplete,
handle_autocomplete_feature_action,
},
CanvasAction,
}; };
// Add the async_trait import
use async_trait::async_trait; use async_trait::async_trait;
use anyhow::Result;
// Simple theme implementation // Simple theme implementation
#[derive(Clone)] #[derive(Clone)]
@@ -58,150 +51,94 @@ struct EmailSuggestion {
provider: String, provider: String,
} }
// Demo form state with autocomplete // ===================================================================
struct AutocompleteFormState { // SIMPLE DATA PROVIDER - Only business data, no UI concerns!
fields: Vec<String>, // ===================================================================
field_names: Vec<String>,
current_field: usize,
cursor_pos: usize,
mode: AppMode,
has_changes: bool,
debug_message: String,
// Autocomplete state struct ContactForm {
autocomplete: AutocompleteState<EmailSuggestion>, // Only business data - no UI state!
name: String,
email: String,
phone: String,
city: String,
} }
impl AutocompleteFormState { impl ContactForm {
fn new() -> Self { fn new() -> Self {
Self { Self {
fields: vec![ name: "John Doe".to_string(),
"John Doe".to_string(), email: "john@".to_string(), // Partial email for demo
"john@".to_string(), // Partial email to demonstrate autocomplete phone: "+1 234 567 8900".to_string(),
"+1 234 567 8900".to_string(), city: "San Francisco".to_string(),
"San Francisco".to_string(),
],
field_names: vec![
"Name".to_string(),
"Email".to_string(),
"Phone".to_string(),
"City".to_string(),
],
current_field: 1, // Start on email field
cursor_pos: 5, // Position after "john@"
mode: AppMode::Edit,
has_changes: false,
debug_message: "Type in email field, Tab to trigger autocomplete, Enter to select, Esc to cancel".to_string(),
autocomplete: AutocompleteState::new(),
} }
} }
} }
impl CanvasState for AutocompleteFormState { // Simple trait implementation - only 4 methods!
fn current_field(&self) -> usize { self.current_field } impl DataProvider for ContactForm {
fn current_cursor_pos(&self) -> usize { self.cursor_pos } fn field_count(&self) -> usize { 4 }
fn set_current_field(&mut self, index: usize) {
self.current_field = index.min(self.fields.len().saturating_sub(1));
// Clear autocomplete when changing fields
if self.is_autocomplete_active() {
self.clear_autocomplete_suggestions();
}
}
fn set_current_cursor_pos(&mut self, pos: usize) {
let max_pos = if self.mode == AppMode::Edit {
self.fields[self.current_field].len()
} else {
self.fields[self.current_field].len().saturating_sub(1)
};
self.cursor_pos = pos.min(max_pos);
}
fn current_mode(&self) -> AppMode { self.mode }
fn get_current_input(&self) -> &str { &self.fields[self.current_field] }
fn get_current_input_mut(&mut self) -> &mut String { &mut self.fields[self.current_field] }
fn inputs(&self) -> Vec<&String> { self.fields.iter().collect() }
fn fields(&self) -> Vec<&str> { self.field_names.iter().map(|s| s.as_str()).collect() }
fn has_unsaved_changes(&self) -> bool { self.has_changes }
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> { fn field_name(&self, index: usize) -> &str {
// Handle autocomplete actions first match index {
if let Some(result) = handle_autocomplete_feature_action(action, self) { 0 => "Name",
return Some(result); 1 => "Email",
} 2 => "Phone",
3 => "City",
// Handle other custom actions _ => "",
match action {
CanvasAction::Custom(cmd) => {
match cmd.as_str() {
"toggle_mode" => {
self.mode = match self.mode {
AppMode::Edit => AppMode::ReadOnly,
AppMode::ReadOnly => AppMode::Edit,
_ => AppMode::Edit,
};
Some(format!("Switched to {:?} mode", self.mode))
}
_ => None,
}
}
_ => None,
}
} }
} }
// Add the #[async_trait] attribute to the implementation fn field_value(&self, index: usize) -> &str {
#[async_trait] match index {
impl AutocompleteCanvasState for AutocompleteFormState { 0 => &self.name,
type SuggestionData = EmailSuggestion; 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 { fn supports_autocomplete(&self, field_index: usize) -> bool {
// Only enable autocomplete for email field (index 1) field_index == 1 // Only email field
field_index == 1 }
} }
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> { // ===================================================================
Some(&self.autocomplete) // SIMPLE AUTOCOMPLETE PROVIDER - Only data fetching!
} // ===================================================================
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> { struct EmailAutocomplete;
Some(&mut self.autocomplete)
}
fn should_trigger_autocomplete(&self) -> bool { #[async_trait]
let current_input = self.get_current_input(); impl AutocompleteProvider for EmailAutocomplete {
let current_field = self.current_field(); type SuggestionData = EmailSuggestion;
// Trigger for email field when we have "@" and at least 1 more character async fn fetch_suggestions(&mut self, _field_index: usize, query: &str)
self.supports_autocomplete(current_field) && -> Result<Vec<SuggestionItem<Self::SuggestionData>>>
current_input.contains('@') && {
current_input.len() > current_input.find('@').unwrap_or(0) + 1 && // Extract domain part from email
!self.is_autocomplete_active() let (email_prefix, domain_part) = if let Some(at_pos) = query.find('@') {
} (query[..at_pos].to_string(), query[at_pos + 1..].to_string())
/// This is where the magic happens - user implements their own async fetching
async fn trigger_autocomplete_suggestions(&mut self) {
// 1. Activate UI (shows loading spinner)
self.activate_autocomplete();
self.set_autocomplete_loading(true);
// 2. Get current input for querying
let query = self.get_current_input().to_string();
// 3. Extract domain part from email
let domain_part = if let Some(at_pos) = query.find('@') {
query[at_pos + 1..].to_string()
} else { } else {
self.set_autocomplete_loading(false); return Ok(Vec::new()); // No @ symbol
return; // No @ symbol, can't suggest
}; };
// 4. SIMULATE ASYNC API CALL (in real code, this would be HTTP request) // Simulate async API call
let email_prefix = query[..query.find('@').unwrap()].to_string();
let suggestions = tokio::task::spawn_blocking(move || { let suggestions = tokio::task::spawn_blocking(move || {
// Simulate network delay // Simulate network delay
std::thread::sleep(std::time::Duration::from_millis(200)); std::thread::sleep(std::time::Duration::from_millis(200));
// Create mock suggestions based on domain input // Mock email suggestions
let popular_domains = vec![ let popular_domains = vec![
("gmail.com", "Gmail"), ("gmail.com", "Gmail"),
("yahoo.com", "Yahoo Mail"), ("yahoo.com", "Yahoo Mail"),
@@ -212,110 +149,148 @@ impl AutocompleteCanvasState for AutocompleteFormState {
]; ];
let mut results = Vec::new(); let mut results = Vec::new();
for (domain, provider) in popular_domains { for (domain, provider) in popular_domains {
if domain.starts_with(&domain_part) || domain_part.is_empty() { if domain.starts_with(&domain_part) || domain_part.is_empty() {
let full_email = format!("{}@{}", email_prefix, domain); let full_email = format!("{}@{}", email_prefix, domain);
results.push(SuggestionItem::new( results.push(SuggestionItem {
EmailSuggestion { data: EmailSuggestion {
email: full_email.clone(), email: full_email.clone(),
provider: provider.to_string(), provider: provider.to_string(),
}, },
format!("{} ({})", full_email, provider), // display text display_text: format!("{} ({})", full_email, provider),
full_email, // value to store value_to_store: full_email,
)); });
} }
} }
results results
}).await.unwrap_or_default(); }).await.unwrap_or_default();
// 5. Provide suggestions back to library Ok(suggestions)
self.set_autocomplete_suggestions(suggestions);
} }
} }
async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut AutocompleteFormState) -> bool { // ===================================================================
// APPLICATION STATE - Much simpler!
// ===================================================================
struct AppState {
editor: FormEditor<ContactForm>,
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)) { if key == KeyCode::F(10) || (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) {
return false; // Quit return false; // Quit
} }
let action = match key { // Handle input based on key
let result = match key {
// === AUTOCOMPLETE KEYS === // === AUTOCOMPLETE KEYS ===
KeyCode::Tab => { KeyCode::Tab => {
if state.is_autocomplete_active() { if state.editor.is_autocomplete_active() {
Some(CanvasAction::SuggestionDown) // Navigate suggestions state.editor.autocomplete_next();
} else if state.supports_autocomplete(state.current_field()) { Ok("Navigated to next suggestion".to_string())
Some(CanvasAction::TriggerAutocomplete) // Manual trigger } 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 { } else {
Some(CanvasAction::NextField) // Normal tab state.editor.move_to_next_field();
} Ok("Moved to next field".to_string())
}
KeyCode::BackTab => {
if state.is_autocomplete_active() {
Some(CanvasAction::SuggestionUp)
} else {
Some(CanvasAction::PrevField)
} }
} }
KeyCode::Enter => { KeyCode::Enter => {
if state.is_autocomplete_active() { if state.editor.is_autocomplete_active() {
Some(CanvasAction::SelectSuggestion) // Apply suggestion if let Some(applied) = state.editor.apply_autocomplete() {
Ok(format!("Applied: {}", applied))
} else { } else {
Some(CanvasAction::NextField) Ok("No suggestion to apply".to_string())
}
} else {
state.editor.move_to_next_field();
Ok("Moved to next field".to_string())
} }
} }
KeyCode::Esc => { KeyCode::Esc => {
if state.is_autocomplete_active() { if state.editor.is_autocomplete_active() {
Some(CanvasAction::ExitSuggestions) // Close autocomplete // Autocomplete will be cleared automatically by mode change
Ok("Cancelled autocomplete".to_string())
} else { } else {
Some(CanvasAction::Custom("toggle_mode".to_string())) // 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))
} }
} }
// === STANDARD CANVAS KEYS === // === MOVEMENT KEYS ===
KeyCode::Left => Some(CanvasAction::MoveLeft), KeyCode::Left => {
KeyCode::Right => Some(CanvasAction::MoveRight), state.editor.move_left();
KeyCode::Up => Some(CanvasAction::MoveUp), Ok("Moved left".to_string())
KeyCode::Down => Some(CanvasAction::MoveDown), }
KeyCode::Home => Some(CanvasAction::MoveLineStart), KeyCode::Right => {
KeyCode::End => Some(CanvasAction::MoveLineEnd), state.editor.move_right();
KeyCode::Backspace => Some(CanvasAction::DeleteBackward), Ok("Moved right".to_string())
KeyCode::Delete => Some(CanvasAction::DeleteForward), }
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())
}
// Character input // === TEXT INPUT ===
KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => { KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => {
Some(CanvasAction::InsertChar(c)) state.editor.insert_char(c)
.map(|_| format!("Inserted '{}'", c))
} }
_ => None, KeyCode::Backspace => {
// TODO: Add delete_backward method to FormEditor
Ok("Backspace (not implemented yet)".to_string())
}
_ => Ok(format!("Unhandled key: {:?}", key)),
}; };
if let Some(action) = action { // Update debug message
match execute_with_autocomplete(action.clone(), state).await { match result {
Ok(result) => { Ok(msg) => state.debug_message = msg,
if let Some(msg) = result.message() { Err(e) => state.debug_message = format!("Error: {}", e),
state.debug_message = msg.to_string();
} else {
state.debug_message = format!("Executed: {:?}", action);
}
true
}
Err(e) => {
state.debug_message = format!("Error: {}", e);
true
}
}
} else {
state.debug_message = format!("Unhandled key: {:?}", key);
true
}
} }
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut state: AutocompleteFormState) -> io::Result<()> { true
}
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut state: AppState) -> io::Result<()> {
let theme = DemoTheme; let theme = DemoTheme;
loop { loop {
@@ -332,7 +307,7 @@ async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut state: Autocomplete
Ok(()) Ok(())
} }
fn ui(f: &mut Frame, state: &AutocompleteFormState, theme: &DemoTheme) { fn ui(f: &mut Frame, state: &AppState, theme: &DemoTheme) {
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
@@ -341,33 +316,31 @@ fn ui(f: &mut Frame, state: &AutocompleteFormState, theme: &DemoTheme) {
]) ])
.split(f.area()); .split(f.area());
// Render the canvas form // Render the canvas form - much simpler!
let active_field_rect = render_canvas( let active_field_rect = render_canvas(
f, f,
chunks[0], chunks[0],
state, &state.editor,
theme, theme,
state.mode == AppMode::Edit,
&canvas::HighlightState::Off,
); );
// Render autocomplete dropdown on top if active // Render autocomplete dropdown if active
if let Some(input_rect) = active_field_rect { if let Some(input_rect) = active_field_rect {
canvas::render_autocomplete_dropdown( render_autocomplete_dropdown(
f, f,
chunks[0], chunks[0],
input_rect, input_rect,
theme, theme,
&state.autocomplete, &state.editor,
); );
} }
// Status info // Status info
let autocomplete_status = if state.is_autocomplete_active() { let autocomplete_status = if state.editor.is_autocomplete_active() {
if state.autocomplete.is_loading { if state.editor.ui_state().is_autocomplete_loading() {
"Loading suggestions..." "Loading suggestions..."
} else if state.has_autocomplete_suggestions() { } else if !state.editor.suggestions().is_empty() {
"Use Tab/Shift+Tab to navigate, Enter to select, Esc to cancel" "Use Tab to navigate, Enter to select, Esc to cancel"
} else { } else {
"No suggestions found" "No suggestions found"
} }
@@ -377,7 +350,10 @@ fn ui(f: &mut Frame, state: &AutocompleteFormState, theme: &DemoTheme) {
let status_lines = vec![ let status_lines = vec![
Line::from(Span::raw(format!("Mode: {:?} | Field: {}/{} | Cursor: {}", Line::from(Span::raw(format!("Mode: {:?} | Field: {}/{} | Cursor: {}",
state.mode, state.current_field + 1, state.fields.len(), state.cursor_pos))), state.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(format!("Autocomplete: {}", autocomplete_status))),
Line::from(Span::raw(state.debug_message.clone())), Line::from(Span::raw(state.debug_message.clone())),
Line::from(Span::raw("F10: Quit | Tab: Trigger/Navigate autocomplete | Enter: Select | Esc: Cancel/Toggle mode")), Line::from(Span::raw("F10: Quit | Tab: Trigger/Navigate autocomplete | Enter: Select | Esc: Cancel/Toggle mode")),
@@ -397,8 +373,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
let state = AutocompleteFormState::new(); let state = AppState::new();
let res = run_app(&mut terminal, state).await; let res = run_app(&mut terminal, state).await;
disable_raw_mode()?; disable_raw_mode()?;

View File

@@ -1,170 +1,47 @@
// src/autocomplete/actions.rs // src/autocomplete/actions.rs
//! Legacy autocomplete actions - deprecated in favor of FormEditor
use crate::canvas::state::CanvasState;
use crate::autocomplete::state::AutocompleteCanvasState;
use crate::canvas::actions::types::{CanvasAction, ActionResult}; use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::canvas::actions::execute;
use anyhow::Result; use anyhow::Result;
/// Enhanced execute function for states that support autocomplete /// Legacy function - use FormEditor.trigger_autocomplete() instead
/// This is the main entry point for autocomplete-aware canvas execution
/// ///
/// Use this instead of canvas::execute() if you want autocomplete behavior: /// # Migration Guide
/// ```rust ///
/// **Old way:**
/// ```rust,ignore
/// execute_with_autocomplete(action, &mut state).await?; /// execute_with_autocomplete(action, &mut state).await?;
/// ``` /// ```
pub async fn execute_with_autocomplete<S: CanvasState + AutocompleteCanvasState + Send>(
action: CanvasAction,
state: &mut S,
) -> Result<ActionResult> {
match &action {
// === AUTOCOMPLETE-SPECIFIC ACTIONS ===
CanvasAction::TriggerAutocomplete => {
if state.supports_autocomplete(state.current_field()) {
state.trigger_autocomplete_suggestions().await;
Ok(ActionResult::success_with_message("Triggered autocomplete"))
} else {
Ok(ActionResult::success_with_message("Autocomplete not supported for this field"))
}
}
CanvasAction::SuggestionUp => {
if state.has_autocomplete_suggestions() {
state.move_suggestion_selection(-1);
Ok(ActionResult::success())
} else {
Ok(ActionResult::success_with_message("No suggestions available"))
}
}
CanvasAction::SuggestionDown => {
if state.has_autocomplete_suggestions() {
state.move_suggestion_selection(1);
Ok(ActionResult::success())
} else {
Ok(ActionResult::success_with_message("No suggestions available"))
}
}
CanvasAction::SelectSuggestion => {
if let Some(message) = state.apply_selected_suggestion() {
Ok(ActionResult::success_with_message(&message))
} else {
Ok(ActionResult::success_with_message("No suggestion to select"))
}
}
CanvasAction::ExitSuggestions => {
state.clear_autocomplete_suggestions();
Ok(ActionResult::success_with_message("Closed autocomplete"))
}
// === TEXT INSERTION WITH AUTO-TRIGGER ===
CanvasAction::InsertChar(_) => {
// First, execute the character insertion normally
let result = execute(action, state).await?;
// After successful insertion, check if we should auto-trigger autocomplete
if result.is_success() && state.should_trigger_autocomplete() {
state.trigger_autocomplete_suggestions().await;
}
Ok(result)
}
// === NAVIGATION/EDITING ACTIONS (clear autocomplete first) ===
CanvasAction::MoveLeft | CanvasAction::MoveRight |
CanvasAction::MoveUp | CanvasAction::MoveDown |
CanvasAction::NextField | CanvasAction::PrevField |
CanvasAction::DeleteBackward | CanvasAction::DeleteForward => {
// Clear autocomplete when navigating/editing
if state.is_autocomplete_active() {
state.clear_autocomplete_suggestions();
}
// Execute the action normally
execute(action, state).await
}
// === ALL OTHER ACTIONS (normal execution) ===
_ => {
// For all other actions, just execute normally
execute(action, state).await
}
}
}
/// Helper function to integrate autocomplete actions with CanvasState.handle_feature_action()
/// ///
/// Use this in your CanvasState implementation like this: /// **New way:**
/// ```rust /// ```rust,ignore
/// fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> { /// let mut editor = FormEditor::new(your_data_provider);
/// // Try autocomplete first /// match action {
/// if let Some(result) = handle_autocomplete_feature_action(action, self) { /// CanvasAction::TriggerAutocomplete => {
/// return Some(result); /// editor.trigger_autocomplete(&mut autocomplete_provider).await?;
/// } /// }
/// /// CanvasAction::InsertChar(c) => {
/// // Handle your other custom actions... /// editor.insert_char(c)?;
/// None /// }
/// // ... etc
/// } /// }
/// ``` /// ```
pub fn handle_autocomplete_feature_action<S: CanvasState + AutocompleteCanvasState + Send>( #[deprecated(note = "Use FormEditor.trigger_autocomplete() and related methods instead")]
action: &CanvasAction, pub async fn execute_with_autocomplete<T>(
state: &S, _action: CanvasAction,
) -> Option<String> { _state: &mut T,
match action {
CanvasAction::TriggerAutocomplete => {
if state.supports_autocomplete(state.current_field()) {
if state.is_autocomplete_active() {
Some("Autocomplete already active".to_string())
} else {
None // Let execute_with_autocomplete handle it
}
} else {
Some("Autocomplete not available for this field".to_string())
}
}
CanvasAction::SuggestionUp | CanvasAction::SuggestionDown => {
if state.is_autocomplete_active() {
None // Let execute_with_autocomplete handle navigation
} else {
Some("No autocomplete suggestions to navigate".to_string())
}
}
CanvasAction::SelectSuggestion => {
if state.has_autocomplete_suggestions() {
None // Let execute_with_autocomplete handle selection
} else {
Some("No suggestion to select".to_string())
}
}
CanvasAction::ExitSuggestions => {
if state.is_autocomplete_active() {
None // Let execute_with_autocomplete handle exit
} else {
Some("No autocomplete to close".to_string())
}
}
_ => None // Not an autocomplete action
}
}
/// Legacy compatibility function - kept for backward compatibility
/// This is the old function signature, now it just wraps the new system
#[deprecated(note = "Use execute_with_autocomplete instead")]
pub async fn execute_canvas_action_with_autocomplete<S: CanvasState + AutocompleteCanvasState + Send>(
action: CanvasAction,
state: &mut S,
_ideal_cursor_column: &mut usize, // Ignored - new system manages this internally
_config: Option<&()>, // Ignored - no more config system
) -> Result<ActionResult> { ) -> Result<ActionResult> {
execute_with_autocomplete(action, state).await Err(anyhow::anyhow!(
"execute_with_autocomplete is deprecated. Use FormEditor API instead.\n\
Migration: Replace CanvasState trait with DataProvider trait and use FormEditor."
))
}
/// Legacy function - use FormEditor methods instead
#[deprecated(note = "Use FormEditor methods instead")]
pub fn handle_autocomplete_feature_action<T>(
_action: &CanvasAction,
_state: &T,
) -> Option<String> {
Some("handle_autocomplete_feature_action is deprecated. Use FormEditor API instead.".to_string())
} }

View File

@@ -1,4 +1,5 @@
// src/autocomplete/gui.rs // src/autocomplete/gui.rs
//! Autocomplete GUI updated to work with FormEditor
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
use ratatui::{ use ratatui::{
@@ -8,32 +9,33 @@ use ratatui::{
Frame, Frame,
}; };
// Use the correct import from our types module
use crate::autocomplete::types::AutocompleteState;
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
use crate::canvas::theme::CanvasTheme; use crate::canvas::theme::CanvasTheme;
use crate::data_provider::{DataProvider, SuggestionItem};
use crate::editor::FormEditor;
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
use unicode_width::UnicodeWidthStr; 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")] #[cfg(feature = "gui")]
pub fn render_autocomplete_dropdown<T: CanvasTheme, D: Clone + Send + 'static>( pub fn render_autocomplete_dropdown<T: CanvasTheme, D: DataProvider>(
f: &mut Frame, f: &mut Frame,
frame_area: Rect, frame_area: Rect,
input_rect: Rect, input_rect: Rect,
theme: &T, theme: &T,
autocomplete_state: &AutocompleteState<D>, editor: &FormEditor<D>,
) { ) {
if !autocomplete_state.is_active { let ui_state = editor.ui_state();
if !ui_state.is_autocomplete_active() {
return; return;
} }
if autocomplete_state.is_loading { if ui_state.autocomplete.is_loading {
render_loading_indicator(f, frame_area, input_rect, theme); render_loading_indicator(f, frame_area, input_rect, theme);
} else if !autocomplete_state.suggestions.is_empty() { } else if !editor.suggestions().is_empty() {
render_suggestions_dropdown(f, frame_area, input_rect, theme, autocomplete_state); render_suggestions_dropdown(f, frame_area, input_rect, theme, editor.suggestions(), ui_state.autocomplete.selected_index);
} }
} }
@@ -69,14 +71,15 @@ fn render_loading_indicator<T: CanvasTheme>(
/// Show actual suggestions list /// Show actual suggestions list
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
fn render_suggestions_dropdown<T: CanvasTheme, D: Clone + Send + 'static>( fn render_suggestions_dropdown<T: CanvasTheme>(
f: &mut Frame, f: &mut Frame,
frame_area: Rect, frame_area: Rect,
input_rect: Rect, input_rect: Rect,
theme: &T, theme: &T,
autocomplete_state: &AutocompleteState<D>, suggestions: &[SuggestionItem<String>],
selected_index: Option<usize>,
) { ) {
let display_texts: Vec<&str> = autocomplete_state.suggestions let display_texts: Vec<&str> = suggestions
.iter() .iter()
.map(|item| item.display_text.as_str()) .map(|item| item.display_text.as_str())
.collect(); .collect();
@@ -96,19 +99,19 @@ fn render_suggestions_dropdown<T: CanvasTheme, D: Clone + Send + 'static>(
// List items // List items
let items = create_suggestion_list_items( let items = create_suggestion_list_items(
&display_texts, &display_texts,
autocomplete_state.selected_index, selected_index,
dropdown_dimensions.width, dropdown_dimensions.width,
theme, theme,
); );
let list = List::new(items).block(dropdown_block); let list = List::new(items).block(dropdown_block);
let mut list_state = ListState::default(); 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); 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")] #[cfg(feature = "gui")]
fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions { fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions {
let max_width = display_texts let max_width = display_texts
@@ -117,9 +120,9 @@ fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions {
.max() .max()
.unwrap_or(0) as u16; .unwrap_or(0) as u16;
let horizontal_padding = 2; // Changed from 4 to 2 to match client let horizontal_padding = 2;
let width = (max_width + horizontal_padding).max(10); // Changed from 12 to 10 to match client let width = (max_width + horizontal_padding).max(10);
let height = (display_texts.len() as u16).min(5); // Removed +2 since no borders let height = (display_texts.len() as u16).min(5);
DropdownDimensions { width, height } DropdownDimensions { width, height }
} }
@@ -152,7 +155,7 @@ fn calculate_dropdown_position(
dropdown_area dropdown_area
} }
/// Create styled list items - updated to match client spacing /// Create styled list items
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
fn create_suggestion_list_items<'a, T: CanvasTheme>( fn create_suggestion_list_items<'a, T: CanvasTheme>(
display_texts: &'a [&'a str], display_texts: &'a [&'a str],
@@ -160,8 +163,7 @@ fn create_suggestion_list_items<'a, T: CanvasTheme>(
dropdown_width: u16, dropdown_width: u16,
theme: &T, theme: &T,
) -> Vec<ListItem<'a>> { ) -> Vec<ListItem<'a>> {
let horizontal_padding = 2; // Changed from 4 to 2 to match client let available_width = dropdown_width;
let available_width = dropdown_width; // No border padding needed
display_texts display_texts
.iter() .iter()

View File

@@ -9,7 +9,6 @@ pub mod gui;
// Re-export the main autocomplete API // Re-export the main autocomplete API
pub use types::{SuggestionItem, AutocompleteState}; pub use types::{SuggestionItem, AutocompleteState};
pub use state::AutocompleteCanvasState;
// Re-export the new action functions // Re-export the new action functions
pub use actions::{ pub use actions::{

View File

@@ -1,189 +1,9 @@
// src/autocomplete/state.rs // src/autocomplete/state.rs
//! Simple autocomplete provider pattern - replaces complex trait
use crate::canvas::state::CanvasState; // Re-export the main types from data_provider for backward compatibility
use async_trait::async_trait; pub use crate::data_provider::{AutocompleteProvider, SuggestionItem};
/// OPTIONAL extension trait for states that want rich autocomplete functionality. // Legacy compatibility - empty trait for migration
/// Only implement this if you need the new autocomplete features. #[deprecated(note = "Use AutocompleteProvider instead")]
/// pub trait AutocompleteCanvasState {}
/// # User Workflow:
/// 1. User presses trigger key (Tab, Ctrl+K, etc.)
/// 2. User's key mapping calls CanvasAction::TriggerAutocomplete
/// 3. Library calls your trigger_autocomplete_suggestions() method
/// 4. You implement async fetching logic in that method
/// 5. You call set_autocomplete_suggestions() with results
/// 6. Library manages UI state and navigation
#[async_trait]
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 (user decides which fields)
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<Self::SuggestionData>> {
None // Default: no autocomplete state
}
/// Get autocomplete state (mutable)
fn autocomplete_state_mut(&mut self) -> Option<&mut crate::autocomplete::AutocompleteState<Self::SuggestionData>> {
None // Default: no autocomplete state
}
// === PUBLIC API METHODS (called by library) ===
/// Activate autocomplete for current field (shows loading spinner)
fn activate_autocomplete(&mut self) {
let current_field = self.current_field();
if let Some(state) = self.autocomplete_state_mut() {
state.activate(current_field);
}
}
/// Deactivate autocomplete (hides dropdown)
fn deactivate_autocomplete(&mut self) {
if let Some(state) = self.autocomplete_state_mut() {
state.deactivate();
}
}
/// Set suggestions (called after your async fetch completes)
fn set_autocomplete_suggestions(&mut self, suggestions: Vec<crate::autocomplete::SuggestionItem<Self::SuggestionData>>) {
if let Some(state) = self.autocomplete_state_mut() {
state.set_suggestions(suggestions);
}
}
/// Set loading state (show/hide spinner)
fn set_autocomplete_loading(&mut self, loading: bool) {
if let Some(state) = self.autocomplete_state_mut() {
state.is_loading = loading;
}
}
// === QUERY METHODS ===
/// Check if autocomplete is currently active/visible
fn is_autocomplete_active(&self) -> bool {
self.autocomplete_state()
.map(|state| state.is_active)
.unwrap_or(false)
}
/// Check if autocomplete has suggestions ready for navigation
fn is_autocomplete_ready(&self) -> bool {
self.autocomplete_state()
.map(|state| state.is_ready())
.unwrap_or(false)
}
/// Check if there are available suggestions
fn has_autocomplete_suggestions(&self) -> bool {
self.autocomplete_state()
.map(|state| !state.suggestions.is_empty())
.unwrap_or(false)
}
// === USER-IMPLEMENTABLE METHODS ===
/// Check if autocomplete should be triggered automatically (e.g., after typing 2+ chars)
/// Override this to implement your own trigger logic
fn should_trigger_autocomplete(&self) -> bool {
let current_input = self.get_current_input();
let current_field = self.current_field();
self.supports_autocomplete(current_field) &&
current_input.len() >= 2 && // Default: trigger after 2 chars
!self.is_autocomplete_active()
}
/// **USER MUST IMPLEMENT**: Trigger autocomplete suggestions (async)
/// This is where you implement your API calls, caching, etc.
///
/// # Example Implementation:
/// ```rust
/// #[async_trait]
/// impl AutocompleteCanvasState for MyState {
/// type SuggestionData = MyData;
///
/// async fn trigger_autocomplete_suggestions(&mut self) {
/// self.activate_autocomplete(); // Show loading state
///
/// let query = self.get_current_input().to_string();
/// let suggestions = my_api.search(&query).await.unwrap_or_default();
///
/// self.set_autocomplete_suggestions(suggestions);
/// }
/// }
/// ```
async fn trigger_autocomplete_suggestions(&mut self) {
// Activate autocomplete UI
self.activate_autocomplete();
// Default: just show loading state
// User should override this to do actual async fetching
self.set_autocomplete_loading(true);
// In a real implementation, you'd:
// 1. Get current input: let query = self.get_current_input();
// 2. Make API call: let results = api.search(query).await;
// 3. Convert to suggestions: let suggestions = results.into_suggestions();
// 4. Set suggestions: self.set_autocomplete_suggestions(suggestions);
}
// === INTERNAL NAVIGATION METHODS (called by library actions) ===
/// Clear autocomplete suggestions and hide dropdown
fn clear_autocomplete_suggestions(&mut self) {
self.deactivate_autocomplete();
}
/// Move selection up/down in suggestions list
fn move_suggestion_selection(&mut self, direction: i32) {
if let Some(state) = self.autocomplete_state_mut() {
if direction > 0 {
state.select_next();
} else {
state.select_previous();
}
}
}
/// Get currently selected suggestion for display/application
fn get_selected_suggestion(&self) -> Option<crate::autocomplete::SuggestionItem<Self::SuggestionData>> {
self.autocomplete_state()?
.get_selected()
.cloned()
}
/// Apply the selected suggestion to the current field
fn apply_suggestion(&mut self, suggestion: &crate::autocomplete::SuggestionItem<Self::SuggestionData>) {
// Apply the value to current field
*self.get_current_input_mut() = suggestion.value_to_store.clone();
self.set_has_unsaved_changes(true);
// Clear autocomplete
self.clear_autocomplete_suggestions();
}
/// Apply the currently selected suggestion (convenience method)
fn apply_selected_suggestion(&mut self) -> Option<String> {
if let Some(suggestion) = self.get_selected_suggestion() {
let display_text = suggestion.display_text.clone();
self.apply_suggestion(&suggestion);
Some(format!("Applied: {}", display_text))
} else {
None
}
}
// === LEGACY COMPATIBILITY ===
/// INTERNAL: Apply selected autocomplete value to current field (legacy method)
fn apply_autocomplete_selection(&mut self) -> Option<String> {
self.apply_selected_suggestion()
}
}

View File

@@ -1,126 +1,21 @@
// canvas/src/autocomplete.rs // src/autocomplete/types.rs
//! Legacy autocomplete types - deprecated
/// Generic suggestion item that clients push to canvas // Re-export the new simplified types
#[derive(Debug, Clone)] pub use crate::data_provider::SuggestionItem;
pub struct SuggestionItem<T> {
/// 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<T> SuggestionItem<T> { /// Legacy type - use FormEditor instead
pub fn new(data: T, display_text: String, value_to_store: String) -> Self { #[deprecated(note = "Use FormEditor instead")]
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)] #[derive(Debug, Clone)]
pub struct AutocompleteState<T> { pub struct AutocompleteState<T> {
/// Whether autocomplete is currently active/visible _phantom: std::marker::PhantomData<T>,
pub is_active: bool,
/// Whether suggestions are being loaded (for spinner/loading indicator)
pub is_loading: bool,
/// Current suggestions to display
pub suggestions: Vec<SuggestionItem<T>>,
/// Currently selected suggestion index
pub selected_index: Option<usize>,
/// Field index that triggered autocomplete (for context)
pub active_field: Option<usize>,
}
impl<T> Default for AutocompleteState<T> {
fn default() -> Self {
Self {
is_active: false,
is_loading: false,
suggestions: Vec::new(),
selected_index: None,
active_field: None,
}
}
} }
#[allow(dead_code)]
impl<T> AutocompleteState<T> { impl<T> AutocompleteState<T> {
pub fn new() -> Self { /// Legacy method - use FormEditor.is_autocomplete_active() instead
Self::default() #[deprecated(note = "Use FormEditor.is_autocomplete_active() instead")]
} pub fn is_active(&self) -> bool {
false
/// 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<SuggestionItem<T>>) {
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<T>> {
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
} }
} }

View File

@@ -1,43 +1,30 @@
// src/canvas/actions/handlers/dispatcher.rs // src/canvas/actions/handlers/dispatcher.rs
use crate::canvas::state::{CanvasState, ActionContext}; use crate::canvas::state::EditorState;
use crate::canvas::actions::{CanvasAction, ActionResult}; use crate::canvas::actions::{CanvasAction, ActionResult};
use crate::canvas::modes::AppMode; use crate::canvas::modes::AppMode;
use anyhow::Result;
use super::{handle_edit_action, handle_readonly_action, handle_highlight_action}; use super::{handle_edit_action, handle_readonly_action, handle_highlight_action};
/// Main action dispatcher - routes actions to mode-specific handlers /// Internal action dispatcher - routes actions to mode-specific handlers
pub async fn dispatch_action<S: CanvasState>( pub(crate) fn dispatch_action_internal(
action: CanvasAction, action: CanvasAction,
state: &mut S, editor_state: &mut EditorState,
ideal_cursor_column: &mut usize, current_text: &str,
) -> Result<ActionResult> { ) -> ActionResult {
// Check if the application wants to handle this action first // Route to mode-specific handler based on current mode
let context = ActionContext { match editor_state.current_mode {
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 => { AppMode::Edit => {
handle_edit_action(action, state, ideal_cursor_column).await handle_edit_action(action, editor_state, current_text)
} }
AppMode::ReadOnly => { AppMode::ReadOnly => {
handle_readonly_action(action, state, ideal_cursor_column).await handle_readonly_action(action, editor_state, current_text)
} }
AppMode::Highlight => { AppMode::Highlight => {
handle_highlight_action(action, state, ideal_cursor_column).await handle_highlight_action(action, editor_state, current_text)
} }
AppMode::General | AppMode::Command => { AppMode::General | AppMode::Command => {
Ok(ActionResult::success_with_message("Mode does not handle canvas actions directly")) ActionResult::success_with_message("Mode does not handle canvas actions directly")
} }
} }
} }

View File

@@ -1,213 +1,143 @@
// src/canvas/actions/handlers/edit.rs // src/canvas/actions/handlers/edit.rs
//! Edit mode action handler //! Edit mode action handler with EditorState
//!
//! Handles user input when in edit mode, supporting text entry, deletion,
//! and cursor movement with edit-specific behavior (cursor can go past end of text).
use crate::canvas::actions::types::{CanvasAction, ActionResult}; use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::canvas::actions::movement::*; use crate::canvas::actions::movement::*;
use crate::canvas::state::CanvasState; use crate::canvas::state::EditorState;
use anyhow::Result;
/// Edit mode uses cursor-past-end behavior for text insertion /// Edit mode uses cursor-past-end behavior for text insertion
const FOR_EDIT_MODE: bool = true; const FOR_EDIT_MODE: bool = true;
/// Handle actions in edit mode with edit-specific cursor behavior /// Handle actions in edit mode with edit-specific cursor behavior
/// pub(crate) fn handle_edit_action(
/// Edit mode allows text modification and uses cursor positioning that can
/// go past the end of existing text to facilitate insertion.
///
/// # Arguments
/// * `action` - The action to perform
/// * `state` - Mutable canvas state
/// * `ideal_cursor_column` - Desired column for vertical movement (maintained across line changes)
pub async fn handle_edit_action<S: CanvasState>(
action: CanvasAction, action: CanvasAction,
state: &mut S, editor_state: &mut EditorState,
ideal_cursor_column: &mut usize, current_text: &str,
) -> Result<ActionResult> { ) -> ActionResult {
match action { match action {
CanvasAction::InsertChar(c) => { // Note: Text insertion is handled at the FormEditor level
// Insert character at cursor position and advance cursor // These handlers only deal with cursor movement and navigation
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 => {
// Delete character before cursor (Backspace behavior)
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 => {
// Delete character at cursor position (Delete key behavior)
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())
}
// Cursor movement actions // Cursor movement actions
CanvasAction::MoveLeft => { CanvasAction::MoveLeft => {
let new_pos = move_left(state.current_cursor_pos()); let new_pos = move_left(editor_state.cursor_pos);
state.set_current_cursor_pos(new_pos); editor_state.cursor_pos = new_pos;
*ideal_cursor_column = new_pos; editor_state.ideal_cursor_column = new_pos;
Ok(ActionResult::success()) ActionResult::success()
} }
CanvasAction::MoveRight => { CanvasAction::MoveRight => {
let current_input = state.get_current_input(); let new_pos = move_right(editor_state.cursor_pos, current_text, FOR_EDIT_MODE);
let current_pos = state.current_cursor_pos(); editor_state.cursor_pos = new_pos;
let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE); editor_state.ideal_cursor_column = new_pos;
state.set_current_cursor_pos(new_pos); ActionResult::success()
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
} }
// Field navigation (treating single-line fields as "lines") // Field navigation (treating single-line fields as "lines")
CanvasAction::MoveUp => { CanvasAction::MoveUp => {
let current_field = state.current_field(); if editor_state.current_field > 0 {
if current_field > 0 { editor_state.current_field -= 1;
state.set_current_field(current_field - 1); let new_pos = safe_cursor_position(current_text, editor_state.ideal_cursor_column, FOR_EDIT_MODE);
let current_input = state.get_current_input(); editor_state.cursor_pos = new_pos;
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
} }
Ok(ActionResult::success()) ActionResult::success()
} }
CanvasAction::MoveDown => { CanvasAction::MoveDown => {
let current_field = state.current_field(); // Note: field count validation happens at FormEditor level
let total_fields = state.fields().len(); editor_state.current_field += 1;
if current_field < total_fields - 1 { let new_pos = safe_cursor_position(current_text, editor_state.ideal_cursor_column, FOR_EDIT_MODE);
state.set_current_field(current_field + 1); editor_state.cursor_pos = new_pos;
let current_input = state.get_current_input(); ActionResult::success()
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
}
Ok(ActionResult::success())
} }
// Line-based movement // Line-based movement
CanvasAction::MoveLineStart => { CanvasAction::MoveLineStart => {
let new_pos = line_start_position(); let new_pos = line_start_position();
state.set_current_cursor_pos(new_pos); editor_state.cursor_pos = new_pos;
*ideal_cursor_column = new_pos; editor_state.ideal_cursor_column = new_pos;
Ok(ActionResult::success()) ActionResult::success()
} }
CanvasAction::MoveLineEnd => { CanvasAction::MoveLineEnd => {
let current_input = state.get_current_input(); let new_pos = line_end_position(current_text, FOR_EDIT_MODE);
let new_pos = line_end_position(current_input, FOR_EDIT_MODE); editor_state.cursor_pos = new_pos;
state.set_current_cursor_pos(new_pos); editor_state.ideal_cursor_column = new_pos;
*ideal_cursor_column = new_pos; ActionResult::success()
Ok(ActionResult::success())
} }
// Document-level movement (first/last field) // Document-level movement (first/last field)
CanvasAction::MoveFirstLine => { CanvasAction::MoveFirstLine => {
state.set_current_field(0); editor_state.current_field = 0;
let current_input = state.get_current_input(); let new_pos = safe_cursor_position(current_text, 0, FOR_EDIT_MODE);
let new_pos = safe_cursor_position(current_input, 0, FOR_EDIT_MODE); editor_state.cursor_pos = new_pos;
state.set_current_cursor_pos(new_pos); editor_state.ideal_cursor_column = new_pos;
*ideal_cursor_column = new_pos; ActionResult::success()
Ok(ActionResult::success())
} }
CanvasAction::MoveLastLine => { CanvasAction::MoveLastLine => {
let last_field = state.fields().len() - 1; // Note: field count validation happens at FormEditor level
state.set_current_field(last_field); let new_pos = line_end_position(current_text, FOR_EDIT_MODE);
let current_input = state.get_current_input(); editor_state.cursor_pos = new_pos;
let new_pos = line_end_position(current_input, FOR_EDIT_MODE); editor_state.ideal_cursor_column = new_pos;
state.set_current_cursor_pos(new_pos); ActionResult::success()
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
} }
// Word-based movement // Word-based movement
CanvasAction::MoveWordNext => { CanvasAction::MoveWordNext => {
let current_input = state.get_current_input(); if !current_text.is_empty() {
if !current_input.is_empty() { let new_pos = find_next_word_start(current_text, editor_state.cursor_pos);
let new_pos = find_next_word_start(current_input, state.current_cursor_pos()); editor_state.cursor_pos = new_pos;
state.set_current_cursor_pos(new_pos); editor_state.ideal_cursor_column = new_pos;
*ideal_cursor_column = new_pos;
} }
Ok(ActionResult::success()) ActionResult::success()
} }
CanvasAction::MoveWordEnd => { CanvasAction::MoveWordEnd => {
let current_input = state.get_current_input(); if !current_text.is_empty() {
if !current_input.is_empty() { let new_pos = find_word_end(current_text, editor_state.cursor_pos);
let new_pos = find_word_end(current_input, state.current_cursor_pos()); editor_state.cursor_pos = new_pos;
state.set_current_cursor_pos(new_pos); editor_state.ideal_cursor_column = new_pos;
*ideal_cursor_column = new_pos;
} }
Ok(ActionResult::success()) ActionResult::success()
} }
CanvasAction::MoveWordPrev => { CanvasAction::MoveWordPrev => {
let current_input = state.get_current_input(); if !current_text.is_empty() {
if !current_input.is_empty() { let new_pos = find_prev_word_start(current_text, editor_state.cursor_pos);
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos()); editor_state.cursor_pos = new_pos;
state.set_current_cursor_pos(new_pos); editor_state.ideal_cursor_column = new_pos;
*ideal_cursor_column = new_pos;
} }
Ok(ActionResult::success()) ActionResult::success()
} }
CanvasAction::MoveWordEndPrev => { CanvasAction::MoveWordEndPrev => {
let current_input = state.get_current_input(); if !current_text.is_empty() {
if !current_input.is_empty() { let new_pos = find_prev_word_end(current_text, editor_state.cursor_pos);
let new_pos = find_prev_word_end(current_input, state.current_cursor_pos()); editor_state.cursor_pos = new_pos;
state.set_current_cursor_pos(new_pos); editor_state.ideal_cursor_column = new_pos;
*ideal_cursor_column = new_pos;
} }
Ok(ActionResult::success()) ActionResult::success()
} }
// Field navigation with simple wrapping behavior // Field navigation - handled at FormEditor level for bounds checking
CanvasAction::NextField | CanvasAction::PrevField => { CanvasAction::NextField | CanvasAction::PrevField => {
let current_field = state.current_field(); ActionResult::success_with_message("Field navigation handled by FormEditor")
let total_fields = state.fields().len();
let new_field = match action {
CanvasAction::NextField => {
(current_field + 1) % total_fields // Simple wrap
} }
CanvasAction::PrevField => {
if current_field == 0 { total_fields - 1 } else { current_field - 1 } // Simple wrap
}
_ => unreachable!(),
};
state.set_current_field(new_field); // Text editing actions - handled at FormEditor level
let current_input = state.get_current_input(); CanvasAction::InsertChar(_) |
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE); CanvasAction::DeleteBackward |
state.set_current_cursor_pos(new_pos); CanvasAction::DeleteForward => {
Ok(ActionResult::success()) ActionResult::success_with_message("Text editing handled by FormEditor")
} }
CanvasAction::Custom(action_str) => { CanvasAction::Custom(action_str) => {
Ok(ActionResult::success_with_message(&format!("Custom edit action: {}", action_str))) ActionResult::success_with_message(&format!("Custom edit action: {}", action_str))
} }
_ => { _ => {
Ok(ActionResult::success_with_message("Action not implemented for edit mode")) ActionResult::success_with_message("Action not implemented for edit mode")
} }
} }
} }

View File

@@ -1,104 +1,97 @@
// src/canvas/actions/handlers/highlight.rs // src/canvas/actions/handlers/highlight.rs
//! Highlight mode action handler with EditorState
use crate::canvas::actions::types::{CanvasAction, ActionResult}; use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::canvas::actions::movement::*; use crate::canvas::actions::movement::*;
use crate::canvas::state::CanvasState; use crate::canvas::state::EditorState;
use anyhow::Result;
const FOR_EDIT_MODE: bool = false; // Highlight mode uses read-only cursor behavior const FOR_EDIT_MODE: bool = false; // Highlight mode uses read-only cursor behavior
/// Handle actions in highlight/visual mode /// Handle actions in highlight/visual mode
/// TODO: Implement selection logic and highlight-specific behaviors pub(crate) fn handle_highlight_action(
pub async fn handle_highlight_action<S: CanvasState>(
action: CanvasAction, action: CanvasAction,
state: &mut S, editor_state: &mut EditorState,
ideal_cursor_column: &mut usize, current_text: &str,
) -> Result<ActionResult> { ) -> ActionResult {
match action { match action {
// Movement actions work similar to read-only mode but with selection // Movement actions work similar to read-only mode but with selection
CanvasAction::MoveLeft => { CanvasAction::MoveLeft => {
let new_pos = move_left(state.current_cursor_pos()); let new_pos = move_left(editor_state.cursor_pos);
state.set_current_cursor_pos(new_pos); editor_state.cursor_pos = new_pos;
*ideal_cursor_column = new_pos; editor_state.ideal_cursor_column = new_pos;
// TODO: Update selection range // TODO: Update selection range
Ok(ActionResult::success()) ActionResult::success()
} }
CanvasAction::MoveRight => { CanvasAction::MoveRight => {
let current_input = state.get_current_input(); let new_pos = move_right(editor_state.cursor_pos, current_text, FOR_EDIT_MODE);
let current_pos = state.current_cursor_pos(); editor_state.cursor_pos = new_pos;
let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE); editor_state.ideal_cursor_column = new_pos;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
// TODO: Update selection range // TODO: Update selection range
Ok(ActionResult::success()) ActionResult::success()
} }
CanvasAction::MoveWordNext => { CanvasAction::MoveWordNext => {
let current_input = state.get_current_input(); if !current_text.is_empty() {
if !current_input.is_empty() { let new_pos = find_next_word_start(current_text, editor_state.cursor_pos);
let new_pos = find_next_word_start(current_input, state.current_cursor_pos()); let final_pos = clamp_cursor_position(new_pos, current_text, FOR_EDIT_MODE);
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE); editor_state.cursor_pos = final_pos;
state.set_current_cursor_pos(final_pos); editor_state.ideal_cursor_column = final_pos;
*ideal_cursor_column = final_pos;
// TODO: Update selection range // TODO: Update selection range
} }
Ok(ActionResult::success()) ActionResult::success()
} }
CanvasAction::MoveWordEnd => { CanvasAction::MoveWordEnd => {
let current_input = state.get_current_input(); if !current_text.is_empty() {
if !current_input.is_empty() { let new_pos = find_word_end(current_text, editor_state.cursor_pos);
let new_pos = find_word_end(current_input, state.current_cursor_pos()); let final_pos = clamp_cursor_position(new_pos, current_text, FOR_EDIT_MODE);
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE); editor_state.cursor_pos = final_pos;
state.set_current_cursor_pos(final_pos); editor_state.ideal_cursor_column = final_pos;
*ideal_cursor_column = final_pos;
// TODO: Update selection range // TODO: Update selection range
} }
Ok(ActionResult::success()) ActionResult::success()
} }
CanvasAction::MoveWordPrev => { CanvasAction::MoveWordPrev => {
let current_input = state.get_current_input(); if !current_text.is_empty() {
if !current_input.is_empty() { let new_pos = find_prev_word_start(current_text, editor_state.cursor_pos);
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos()); editor_state.cursor_pos = new_pos;
state.set_current_cursor_pos(new_pos); editor_state.ideal_cursor_column = new_pos;
*ideal_cursor_column = new_pos;
// TODO: Update selection range // TODO: Update selection range
} }
Ok(ActionResult::success()) ActionResult::success()
} }
CanvasAction::MoveLineStart => { CanvasAction::MoveLineStart => {
let new_pos = line_start_position(); let new_pos = line_start_position();
state.set_current_cursor_pos(new_pos); editor_state.cursor_pos = new_pos;
*ideal_cursor_column = new_pos; editor_state.ideal_cursor_column = new_pos;
// TODO: Update selection range // TODO: Update selection range
Ok(ActionResult::success()) ActionResult::success()
} }
CanvasAction::MoveLineEnd => { CanvasAction::MoveLineEnd => {
let current_input = state.get_current_input(); let new_pos = line_end_position(current_text, FOR_EDIT_MODE);
let new_pos = line_end_position(current_input, FOR_EDIT_MODE); editor_state.cursor_pos = new_pos;
state.set_current_cursor_pos(new_pos); editor_state.ideal_cursor_column = new_pos;
*ideal_cursor_column = new_pos;
// TODO: Update selection range // TODO: Update selection range
Ok(ActionResult::success()) ActionResult::success()
} }
// Highlight mode doesn't handle editing actions // Highlight mode doesn't handle editing actions
CanvasAction::InsertChar(_) | CanvasAction::InsertChar(_) |
CanvasAction::DeleteBackward | CanvasAction::DeleteBackward |
CanvasAction::DeleteForward => { CanvasAction::DeleteForward => {
Ok(ActionResult::success_with_message("Action not available in highlight mode")) ActionResult::success_with_message("Action not available in highlight mode")
} }
CanvasAction::Custom(action_str) => { CanvasAction::Custom(action_str) => {
Ok(ActionResult::success_with_message(&format!("Custom highlight action: {}", action_str))) ActionResult::success_with_message(&format!("Custom highlight action: {}", action_str))
} }
_ => { _ => {
Ok(ActionResult::success_with_message("Action not implemented for highlight mode")) ActionResult::success_with_message("Action not implemented for highlight mode")
} }
} }
} }

View File

@@ -5,7 +5,7 @@ pub mod readonly;
pub mod highlight; pub mod highlight;
pub mod dispatcher; pub mod dispatcher;
pub use edit::handle_edit_action; pub use edit::*;
pub use readonly::handle_readonly_action; pub use readonly::*;
pub use highlight::handle_highlight_action; pub use highlight::*;
pub use dispatcher::dispatch_action; pub use dispatcher::*;

View File

@@ -1,183 +1,136 @@
// src/canvas/actions/handlers/readonly.rs // src/canvas/actions/handlers/readonly.rs
//! ReadOnly mode action handler with EditorState
use crate::canvas::actions::types::{CanvasAction, ActionResult}; use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::canvas::actions::movement::*; use crate::canvas::actions::movement::*;
use crate::canvas::state::CanvasState; use crate::canvas::state::EditorState;
use anyhow::Result;
const FOR_EDIT_MODE: bool = false; // Read-only mode flag const FOR_EDIT_MODE: bool = false; // Read-only mode flag
/// Handle actions in read-only mode with read-only specific cursor behavior /// Handle actions in read-only mode with read-only specific cursor behavior
pub async fn handle_readonly_action<S: CanvasState>( pub(crate) fn handle_readonly_action(
action: CanvasAction, action: CanvasAction,
state: &mut S, editor_state: &mut EditorState,
ideal_cursor_column: &mut usize, current_text: &str,
) -> Result<ActionResult> { ) -> ActionResult {
match action { match action {
CanvasAction::MoveLeft => { CanvasAction::MoveLeft => {
let new_pos = move_left(state.current_cursor_pos()); let new_pos = move_left(editor_state.cursor_pos);
state.set_current_cursor_pos(new_pos); editor_state.cursor_pos = new_pos;
*ideal_cursor_column = new_pos; editor_state.ideal_cursor_column = new_pos;
Ok(ActionResult::success()) ActionResult::success()
} }
CanvasAction::MoveRight => { CanvasAction::MoveRight => {
let current_input = state.get_current_input(); let new_pos = move_right(editor_state.cursor_pos, current_text, FOR_EDIT_MODE);
let current_pos = state.current_cursor_pos(); editor_state.cursor_pos = new_pos;
let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE); editor_state.ideal_cursor_column = new_pos;
state.set_current_cursor_pos(new_pos); ActionResult::success()
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
} }
CanvasAction::MoveUp => { CanvasAction::MoveUp => {
let current_field = state.current_field(); if editor_state.current_field > 0 {
let new_field = current_field.saturating_sub(1); editor_state.current_field -= 1;
state.set_current_field(new_field); let new_pos = safe_cursor_position(current_text, editor_state.ideal_cursor_column, FOR_EDIT_MODE);
editor_state.cursor_pos = new_pos;
// Apply ideal cursor column with read-only bounds }
let current_input = state.get_current_input(); ActionResult::success()
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 => { CanvasAction::MoveDown => {
let current_field = state.current_field(); // Note: bounds checking happens at FormEditor level
let total_fields = state.fields().len(); editor_state.current_field += 1;
if total_fields == 0 { let new_pos = safe_cursor_position(current_text, editor_state.ideal_cursor_column, FOR_EDIT_MODE);
return Ok(ActionResult::success_with_message("No fields to navigate")); editor_state.cursor_pos = new_pos;
} ActionResult::success()
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 => { CanvasAction::MoveFirstLine => {
let total_fields = state.fields().len(); editor_state.current_field = 0;
if total_fields == 0 { let new_pos = safe_cursor_position(current_text, editor_state.ideal_cursor_column, FOR_EDIT_MODE);
return Ok(ActionResult::success_with_message("No fields to navigate")); editor_state.cursor_pos = new_pos;
} editor_state.ideal_cursor_column = new_pos;
ActionResult::success()
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 => { CanvasAction::MoveLastLine => {
let total_fields = state.fields().len(); // Note: field count validation happens at FormEditor level
if total_fields == 0 { let new_pos = safe_cursor_position(current_text, editor_state.ideal_cursor_column, FOR_EDIT_MODE);
return Ok(ActionResult::success_with_message("No fields to navigate")); editor_state.cursor_pos = new_pos;
} editor_state.ideal_cursor_column = new_pos;
ActionResult::success()
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 => { CanvasAction::MoveLineStart => {
let new_pos = line_start_position(); let new_pos = line_start_position();
state.set_current_cursor_pos(new_pos); editor_state.cursor_pos = new_pos;
*ideal_cursor_column = new_pos; editor_state.ideal_cursor_column = new_pos;
Ok(ActionResult::success()) ActionResult::success()
} }
CanvasAction::MoveLineEnd => { CanvasAction::MoveLineEnd => {
let current_input = state.get_current_input(); let new_pos = line_end_position(current_text, FOR_EDIT_MODE);
let new_pos = line_end_position(current_input, FOR_EDIT_MODE); editor_state.cursor_pos = new_pos;
state.set_current_cursor_pos(new_pos); editor_state.ideal_cursor_column = new_pos;
*ideal_cursor_column = new_pos; ActionResult::success()
Ok(ActionResult::success())
} }
CanvasAction::MoveWordNext => { CanvasAction::MoveWordNext => {
let current_input = state.get_current_input(); if !current_text.is_empty() {
if !current_input.is_empty() { let new_pos = find_next_word_start(current_text, editor_state.cursor_pos);
let new_pos = find_next_word_start(current_input, state.current_cursor_pos()); let final_pos = clamp_cursor_position(new_pos, current_text, FOR_EDIT_MODE);
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE); editor_state.cursor_pos = final_pos;
state.set_current_cursor_pos(final_pos); editor_state.ideal_cursor_column = final_pos;
*ideal_cursor_column = final_pos;
} }
Ok(ActionResult::success()) ActionResult::success()
} }
CanvasAction::MoveWordEnd => { CanvasAction::MoveWordEnd => {
let current_input = state.get_current_input(); if !current_text.is_empty() {
if !current_input.is_empty() { let new_pos = find_word_end(current_text, editor_state.cursor_pos);
let current_pos = state.current_cursor_pos(); let final_pos = clamp_cursor_position(new_pos, current_text, FOR_EDIT_MODE);
let new_pos = find_word_end(current_input, current_pos); editor_state.cursor_pos = final_pos;
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE); editor_state.ideal_cursor_column = final_pos;
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
} }
Ok(ActionResult::success()) ActionResult::success()
} }
CanvasAction::MoveWordPrev => { CanvasAction::MoveWordPrev => {
let current_input = state.get_current_input(); if !current_text.is_empty() {
if !current_input.is_empty() { let new_pos = find_prev_word_start(current_text, editor_state.cursor_pos);
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos()); editor_state.cursor_pos = new_pos;
state.set_current_cursor_pos(new_pos); editor_state.ideal_cursor_column = new_pos;
*ideal_cursor_column = new_pos;
} }
Ok(ActionResult::success()) ActionResult::success()
} }
CanvasAction::MoveWordEndPrev => { CanvasAction::MoveWordEndPrev => {
let current_input = state.get_current_input(); if !current_text.is_empty() {
if !current_input.is_empty() { let new_pos = find_prev_word_end(current_text, editor_state.cursor_pos);
let new_pos = find_prev_word_end(current_input, state.current_cursor_pos()); editor_state.cursor_pos = new_pos;
state.set_current_cursor_pos(new_pos); editor_state.ideal_cursor_column = new_pos;
*ideal_cursor_column = new_pos;
} }
Ok(ActionResult::success()) ActionResult::success()
} }
// Field navigation - handled at FormEditor level
CanvasAction::NextField | CanvasAction::PrevField => { CanvasAction::NextField | CanvasAction::PrevField => {
let current_field = state.current_field(); ActionResult::success_with_message("Field navigation handled by FormEditor")
let total_fields = state.fields().len();
let new_field = match action {
CanvasAction::NextField => {
(current_field + 1) % total_fields // Simple wrap
}
CanvasAction::PrevField => {
if current_field == 0 { total_fields - 1 } else { current_field - 1 } // Simple wrap
}
_ => 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 // Read-only mode doesn't handle editing actions
CanvasAction::InsertChar(_) | CanvasAction::InsertChar(_) |
CanvasAction::DeleteBackward | CanvasAction::DeleteBackward |
CanvasAction::DeleteForward => { CanvasAction::DeleteForward => {
Ok(ActionResult::success_with_message("Action not available in read-only mode")) ActionResult::success_with_message("Action not available in read-only mode")
} }
CanvasAction::Custom(action_str) => { CanvasAction::Custom(action_str) => {
Ok(ActionResult::success_with_message(&format!("Custom readonly action: {}", action_str))) ActionResult::success_with_message(&format!("Custom readonly action: {}", action_str))
} }
_ => { _ => {
Ok(ActionResult::success_with_message("Action not implemented for read-only mode")) ActionResult::success_with_message("Action not implemented for read-only mode")
} }
} }
} }

View File

@@ -5,4 +5,4 @@ pub mod handlers;
pub mod movement; pub mod movement;
// Re-export the main API // Re-export the main API
pub use types::{CanvasAction, ActionResult, execute}; pub use types::{CanvasAction, ActionResult};

View File

@@ -1,7 +1,6 @@
// src/canvas/actions/types.rs // src/canvas/actions/types.rs
use crate::canvas::state::CanvasState; use crate::canvas::state::EditorState;
use anyhow::Result;
/// All available canvas actions /// All available canvas actions
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@@ -83,17 +82,13 @@ impl ActionResult {
} }
} }
/// Execute a canvas action on the given state impl CanvasAction {
pub async fn execute<S: CanvasState>( /// Internal method used by FormEditor
action: CanvasAction, pub(crate) fn apply_to_editor_state(self, editor_state: &mut EditorState, current_text: &str) -> ActionResult {
state: &mut S, // Internal method used by FormEditor
) -> Result<ActionResult> { crate::canvas::actions::handlers::dispatch_action_internal(self, editor_state, current_text)
let mut ideal_cursor_column = 0;
super::handlers::dispatch_action(action, state, &mut ideal_cursor_column).await
} }
impl CanvasAction {
/// Get a human-readable description of this action /// Get a human-readable description of this action
pub fn description(&self) -> &'static str { pub fn description(&self) -> &'static str {
match self { match self {
@@ -111,7 +106,7 @@ impl CanvasAction {
Self::PrevField => "previous field", Self::PrevField => "previous field",
Self::MoveFirstLine => "first field", Self::MoveFirstLine => "first field",
Self::MoveLastLine => "last field", Self::MoveLastLine => "last field",
Self::InsertChar(c) => "insert character", Self::InsertChar(_c) => "insert character",
Self::DeleteBackward => "delete backward", Self::DeleteBackward => "delete backward",
Self::DeleteForward => "delete forward", Self::DeleteForward => "delete forward",
Self::TriggerAutocomplete => "trigger autocomplete", Self::TriggerAutocomplete => "trigger autocomplete",
@@ -119,7 +114,7 @@ impl CanvasAction {
Self::SuggestionDown => "suggestion down", Self::SuggestionDown => "suggestion down",
Self::SelectSuggestion => "select suggestion", Self::SelectSuggestion => "select suggestion",
Self::ExitSuggestions => "exit suggestions", Self::ExitSuggestions => "exit suggestions",
Self::Custom(name) => "custom action", Self::Custom(_name) => "custom action",
} }
} }

View File

@@ -1,4 +1,5 @@
// canvas/src/canvas/gui.rs // src/canvas/gui.rs
//! Canvas GUI updated to work with FormEditor
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
use ratatui::{ use ratatui::{
@@ -9,28 +10,42 @@ use ratatui::{
Frame, Frame,
}; };
use crate::canvas::state::CanvasState;
use crate::canvas::modes::HighlightState;
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
use crate::canvas::theme::CanvasTheme; use crate::canvas::theme::CanvasTheme;
use crate::canvas::modes::HighlightState;
use crate::data_provider::DataProvider;
use crate::editor::FormEditor;
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
use std::cmp::{max, min}; use std::cmp::{max, min};
/// Render ONLY the canvas form fields - no autocomplete /// Render ONLY the canvas form fields - no autocomplete
/// Updated to work with FormEditor instead of CanvasState trait
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
pub fn render_canvas<T: CanvasTheme>( pub fn render_canvas<T: CanvasTheme, D: DataProvider>(
f: &mut Frame, f: &mut Frame,
area: Rect, area: Rect,
form_state: &impl CanvasState, editor: &FormEditor<D>,
theme: &T, theme: &T,
is_edit_mode: bool,
highlight_state: &HighlightState,
) -> Option<Rect> { ) -> Option<Rect> {
let fields: Vec<&str> = form_state.fields(); let ui_state = editor.ui_state();
let current_field_idx = form_state.current_field(); let data_provider = editor.data_provider();
let inputs: Vec<&String> = form_state.inputs();
// Build field information
let field_count = data_provider.field_count();
let mut fields: Vec<&str> = Vec::with_capacity(field_count);
let mut inputs: Vec<String> = 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( render_canvas_fields(
f, f,
@@ -40,11 +55,13 @@ pub fn render_canvas<T: CanvasTheme>(
&inputs, &inputs,
theme, theme,
is_edit_mode, is_edit_mode,
highlight_state, &highlight_state,
form_state.current_cursor_pos(), ui_state.cursor_position(),
form_state.has_unsaved_changes(), false, // TODO: track unsaved changes in editor
|i| form_state.get_display_value_for_field(i).to_string(), |i| {
|i| form_state.has_display_override(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<T: CanvasTheme, F1, F2>(
area: Rect, area: Rect,
fields: &[&str], fields: &[&str],
current_field_idx: &usize, current_field_idx: &usize,
inputs: &[&String], inputs: &[String],
theme: &T, theme: &T,
is_edit_mode: bool, is_edit_mode: bool,
highlight_state: &HighlightState, highlight_state: &HighlightState,
@@ -112,7 +129,7 @@ where
// Render field values and return active field rect // Render field values and return active field rect
render_field_values( render_field_values(
f, f,
input_rows.to_vec(), // Fix: Convert Rc<[Rect]> to Vec<Rect> input_rows.to_vec(),
inputs, inputs,
current_field_idx, current_field_idx,
theme, theme,
@@ -154,7 +171,7 @@ fn render_field_labels<T: CanvasTheme>(
fn render_field_values<T: CanvasTheme, F1, F2>( fn render_field_values<T: CanvasTheme, F1, F2>(
f: &mut Frame, f: &mut Frame,
input_rows: Vec<Rect>, input_rows: Vec<Rect>,
inputs: &[&String], inputs: &[String],
current_field_idx: &usize, current_field_idx: &usize,
theme: &T, theme: &T,
highlight_state: &HighlightState, highlight_state: &HighlightState,

View File

@@ -1,18 +1,14 @@
// src/canvas/mod.rs // src/canvas/mod.rs
pub mod actions; pub mod actions;
pub mod gui;
pub mod modes;
pub mod state; pub mod state;
pub mod modes;
#[cfg(feature = "gui")]
pub mod gui;
#[cfg(feature = "gui")]
pub mod theme; pub mod theme;
// Re-export main types for convenience // Keep these exports for current functionality
pub use actions::{CanvasAction, ActionResult};
pub use modes::{AppMode, ModeManager, HighlightState}; pub use modes::{AppMode, ModeManager, HighlightState};
pub use state::{CanvasState, ActionContext};
#[cfg(feature = "gui")]
pub use theme::CanvasTheme;
#[cfg(feature = "gui")]
pub use gui::render_canvas;

View File

@@ -1,117 +1,132 @@
// src/canvas/state.rs // src/canvas/state.rs
//! Canvas state trait and related types //! Library-owned UI state - user never directly modifies this
//!
//! This module defines the core trait that any form or input system must implement
//! to work with the canvas library.
use crate::canvas::actions::CanvasAction;
use crate::canvas::modes::AppMode; use crate::canvas::modes::AppMode;
/// Context information passed to feature-specific action handlers /// Library-owned UI state - user never directly modifies this
#[derive(Debug)] #[derive(Debug, Clone)]
pub struct ActionContext { pub struct EditorState {
/// Original key code that triggered this action (for backwards compatibility) // Navigation state
pub key_code: Option<crossterm::event::KeyCode>, pub(crate) current_field: usize,
/// Current ideal cursor column for vertical movement pub(crate) cursor_pos: usize,
pub ideal_cursor_column: usize, pub(crate) ideal_cursor_column: usize,
/// Current input text
pub current_input: String, // Mode state
/// Current field index pub(crate) current_mode: AppMode,
pub current_field: usize,
// 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 canvas #[derive(Debug, Clone)]
/// pub struct AutocompleteUIState {
/// This trait enables the same mode behaviors (edit, read-only, highlight) to work pub(crate) is_active: bool,
/// across any implementation - login forms, data entry forms, configuration screens, etc. pub(crate) is_loading: bool,
/// pub(crate) selected_index: Option<usize>,
/// # Required Implementation pub(crate) active_field: Option<usize>,
///
/// Your struct needs to track:
/// - Current field index and cursor position
/// - All input field values
/// - Current interaction mode
/// - Whether there are unsaved changes
///
/// # Example Implementation
///
/// ```rust
/// struct MyForm {
/// fields: Vec<String>,
/// current_field: usize,
/// cursor_pos: usize,
/// mode: AppMode,
/// dirty: bool,
/// }
///
/// impl CanvasState for MyForm {
/// fn current_field(&self) -> usize { self.current_field }
/// fn current_cursor_pos(&self) -> usize { self.cursor_pos }
/// // ... implement other required methods
/// }
/// ```
pub trait CanvasState {
// --- Core Navigation ---
/// Get current field index (0-based)
fn current_field(&self) -> usize;
/// Get current cursor position within the current field
fn current_cursor_pos(&self) -> usize;
/// Set current field index (should clamp to valid range)
fn set_current_field(&mut self, index: usize);
/// Set cursor position within current field (should clamp to valid range)
fn set_current_cursor_pos(&mut self, pos: usize);
// --- Mode Information ---
/// Get current interaction mode (edit, read-only, highlight, etc.)
fn current_mode(&self) -> AppMode;
// --- Data Access ---
/// Get immutable reference to current field's text
fn get_current_input(&self) -> &str;
/// Get mutable reference to current field's text
fn get_current_input_mut(&mut self) -> &mut String;
/// Get all input values as immutable references
fn inputs(&self) -> Vec<&String>;
/// Get all field names/labels
fn fields(&self) -> Vec<&str>;
// --- State Management ---
/// Check if there are unsaved changes
fn has_unsaved_changes(&self) -> bool;
/// Mark whether there are unsaved changes
fn set_has_unsaved_changes(&mut self, changed: bool);
// --- Optional Overrides ---
/// Handle application-specific actions not covered by standard handlers
/// Return Some(message) if the action was handled, None to use standard handling
fn handle_feature_action(&mut self, _action: &CanvasAction, _context: &ActionContext) -> Option<String> {
None // Default: no custom handling
} }
/// Get display value for a field (may differ from actual value) #[derive(Debug, Clone)]
/// Used for things like password masking or computed display values pub enum SelectionState {
fn get_display_value_for_field(&self, index: usize) -> &str { None,
self.inputs() Characterwise { anchor: (usize, usize) },
.get(index) Linewise { anchor_field: usize },
.map(|s| s.as_str())
.unwrap_or("")
} }
/// Check if a field has a custom display value impl EditorState {
/// Return true if get_display_value_for_field returns something different than the actual value pub fn new() -> Self {
fn has_display_override(&self, _index: usize) -> bool { Self {
false 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
}
/// 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()
} }
} }

View File

@@ -0,0 +1,47 @@
// 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 {
type SuggestionData: Clone + Send + 'static;
/// Fetch autocomplete suggestions (user's business logic)
async fn fetch_suggestions(&mut self, field_index: usize, query: &str)
-> Result<Vec<SuggestionItem<Self::SuggestionData>>>;
}
#[derive(Debug, Clone)]
pub struct SuggestionItem<T> {
pub data: T,
pub display_text: String,
pub value_to_store: String,
}

234
canvas/src/editor.rs Normal file
View File

@@ -0,0 +1,234 @@
// src/editor.rs
//! Main API for the canvas library - FormEditor with library-owned state
use anyhow::Result;
use async_trait::async_trait;
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<D: DataProvider> {
// 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<SuggestionItem<String>>,
}
impl<D: DataProvider> FormEditor<D> {
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<String>] {
&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) {
self.ui_state.current_mode = mode;
// Clear autocomplete when changing modes
if mode != AppMode::Edit {
self.ui_state.deactivate_autocomplete();
}
}
// ===================================================================
// ASYNC OPERATIONS: Only autocomplete needs async
// ===================================================================
/// Trigger autocomplete (async because it fetches data)
pub async fn trigger_autocomplete<A>(&mut self, provider: &mut A) -> Result<()>
where
A: AutocompleteProvider,
A::SuggestionData: std::fmt::Debug, // Change from Display to Debug
{
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
let query = self.current_text();
let suggestions = provider.fetch_suggestions(field_index, query).await?;
// Convert to library's format (could be avoided with better generics)
self.suggestions = suggestions.into_iter()
.map(|item| SuggestionItem {
data: format!("{:?}", item.data), // Use Debug formatting instead
display_text: item.display_text,
value_to_store: item.value_to_store,
})
.collect();
// 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<String> {
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
}
}

View File

@@ -1,31 +1,47 @@
// src/lib.rs // src/lib.rs
pub mod canvas; pub mod canvas;
pub mod editor;
pub mod data_provider;
// Only include autocomplete module if feature is enabled // Only include autocomplete module if feature is enabled
#[cfg(feature = "autocomplete")] #[cfg(feature = "autocomplete")]
pub mod autocomplete; pub mod autocomplete;
// Re-export the main API for easy access // ===================================================================
pub use canvas::actions::{CanvasAction, ActionResult, execute}; // NEW API: Library-owned state pattern
pub use canvas::state::{CanvasState, ActionContext}; // ===================================================================
pub use canvas::modes::{AppMode, ModeManager, HighlightState};
// 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};
// Theming and GUI
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
pub use canvas::theme::CanvasTheme; pub use canvas::theme::CanvasTheme;
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
pub use canvas::gui::render_canvas; pub use canvas::gui::render_canvas;
// Re-export autocomplete API if feature is enabled
#[cfg(feature = "autocomplete")]
pub use autocomplete::{
AutocompleteCanvasState,
AutocompleteState,
SuggestionItem,
execute_with_autocomplete,
handle_autocomplete_feature_action,
};
#[cfg(all(feature = "gui", feature = "autocomplete"))] #[cfg(all(feature = "gui", feature = "autocomplete"))]
pub use autocomplete::render_autocomplete_dropdown; pub use autocomplete::gui::render_autocomplete_dropdown;
// ===================================================================
// LEGACY COMPATIBILITY: Old trait-based API (deprecated)
// ===================================================================
// Legacy exports for backward compatibility - mark as deprecated
#[deprecated(note = "Use FormEditor and AutocompleteProvider instead")]
#[cfg(feature = "autocomplete")]
pub use crate::autocomplete::state::AutocompleteCanvasState;
// Mode management (still used)
pub use canvas::modes::{ModeManager, HighlightState};