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" }
ratatui = { workspace = true, optional = true }
crossterm = { workspace = true }
anyhow = { workspace = true }
anyhow.workspace = true
tokio = { workspace = true, optional = true }
toml = { workspace = true }
serde = { workspace = true }
@@ -22,7 +22,7 @@ thiserror = { workspace = true }
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
async-trait = { workspace = true, optional = true }
async-trait.workspace = true
[dev-dependencies]
tokio-test = "0.4.4"
@@ -30,7 +30,7 @@ tokio-test = "0.4.4"
[features]
default = []
gui = ["ratatui"]
autocomplete = ["tokio", "async-trait"]
autocomplete = ["tokio"]
[[example]]
name = "autocomplete"

View File

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

View File

@@ -1,170 +1,47 @@
// 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::execute;
use anyhow::Result;
/// Enhanced execute function for states that support autocomplete
/// This is the main entry point for autocomplete-aware canvas execution
///
/// Use this instead of canvas::execute() if you want autocomplete behavior:
/// ```rust
/// Legacy function - use FormEditor.trigger_autocomplete() instead
///
/// # Migration Guide
///
/// **Old way:**
/// ```rust,ignore
/// 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:
/// ```rust
/// fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
/// // Try autocomplete first
/// if let Some(result) = handle_autocomplete_feature_action(action, self) {
/// return Some(result);
///
/// **New way:**
/// ```rust,ignore
/// let mut editor = FormEditor::new(your_data_provider);
/// match action {
/// CanvasAction::TriggerAutocomplete => {
/// editor.trigger_autocomplete(&mut autocomplete_provider).await?;
/// }
///
/// // Handle your other custom actions...
/// None
/// CanvasAction::InsertChar(c) => {
/// editor.insert_char(c)?;
/// }
/// // ... etc
/// }
/// ```
pub fn handle_autocomplete_feature_action<S: CanvasState + AutocompleteCanvasState + Send>(
action: &CanvasAction,
state: &S,
) -> Option<String> {
match action {
CanvasAction::TriggerAutocomplete => {
if state.supports_autocomplete(state.current_field()) {
if state.is_autocomplete_active() {
Some("Autocomplete already active".to_string())
} else {
None // Let execute_with_autocomplete handle it
}
} else {
Some("Autocomplete not available for this field".to_string())
}
}
CanvasAction::SuggestionUp | CanvasAction::SuggestionDown => {
if state.is_autocomplete_active() {
None // Let execute_with_autocomplete handle navigation
} else {
Some("No autocomplete suggestions to navigate".to_string())
}
}
CanvasAction::SelectSuggestion => {
if state.has_autocomplete_suggestions() {
None // Let execute_with_autocomplete handle selection
} else {
Some("No suggestion to select".to_string())
}
}
CanvasAction::ExitSuggestions => {
if state.is_autocomplete_active() {
None // Let execute_with_autocomplete handle exit
} else {
Some("No autocomplete to close".to_string())
}
}
_ => None // Not an autocomplete action
}
}
/// Legacy compatibility function - kept for backward compatibility
/// This is the old function signature, now it just wraps the new system
#[deprecated(note = "Use execute_with_autocomplete instead")]
pub async fn execute_canvas_action_with_autocomplete<S: CanvasState + AutocompleteCanvasState + Send>(
action: CanvasAction,
state: &mut S,
_ideal_cursor_column: &mut usize, // Ignored - new system manages this internally
_config: Option<&()>, // Ignored - no more config system
#[deprecated(note = "Use FormEditor.trigger_autocomplete() and related methods instead")]
pub async fn execute_with_autocomplete<T>(
_action: CanvasAction,
_state: &mut T,
) -> 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
//! Autocomplete GUI updated to work with FormEditor
#[cfg(feature = "gui")]
use ratatui::{
@@ -8,32 +9,33 @@ use ratatui::{
Frame,
};
// Use the correct import from our types module
use crate::autocomplete::types::AutocompleteState;
#[cfg(feature = "gui")]
use crate::canvas::theme::CanvasTheme;
use crate::data_provider::{DataProvider, SuggestionItem};
use crate::editor::FormEditor;
#[cfg(feature = "gui")]
use unicode_width::UnicodeWidthStr;
/// Render autocomplete dropdown - call this AFTER rendering canvas
/// Render autocomplete dropdown for FormEditor - call this AFTER rendering canvas
#[cfg(feature = "gui")]
pub fn render_autocomplete_dropdown<T: CanvasTheme, D: Clone + Send + 'static>(
pub fn render_autocomplete_dropdown<T: CanvasTheme, D: DataProvider>(
f: &mut Frame,
frame_area: Rect,
input_rect: Rect,
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;
}
if autocomplete_state.is_loading {
if ui_state.autocomplete.is_loading {
render_loading_indicator(f, frame_area, input_rect, theme);
} else if !autocomplete_state.suggestions.is_empty() {
render_suggestions_dropdown(f, frame_area, input_rect, theme, autocomplete_state);
} else if !editor.suggestions().is_empty() {
render_suggestions_dropdown(f, frame_area, input_rect, theme, editor.suggestions(), ui_state.autocomplete.selected_index);
}
}
@@ -69,14 +71,15 @@ fn render_loading_indicator<T: CanvasTheme>(
/// Show actual suggestions list
#[cfg(feature = "gui")]
fn render_suggestions_dropdown<T: CanvasTheme, D: Clone + Send + 'static>(
fn render_suggestions_dropdown<T: CanvasTheme>(
f: &mut Frame,
frame_area: Rect,
input_rect: Rect,
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()
.map(|item| item.display_text.as_str())
.collect();
@@ -96,19 +99,19 @@ fn render_suggestions_dropdown<T: CanvasTheme, D: Clone + Send + 'static>(
// List items
let items = create_suggestion_list_items(
&display_texts,
autocomplete_state.selected_index,
selected_index,
dropdown_dimensions.width,
theme,
);
let list = List::new(items).block(dropdown_block);
let mut list_state = ListState::default();
list_state.select(autocomplete_state.selected_index);
list_state.select(selected_index);
f.render_stateful_widget(list, dropdown_area, &mut list_state);
}
/// Calculate dropdown size based on suggestions - updated to match client dimensions
/// Calculate dropdown size based on suggestions
#[cfg(feature = "gui")]
fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions {
let max_width = display_texts
@@ -117,9 +120,9 @@ fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions {
.max()
.unwrap_or(0) as u16;
let horizontal_padding = 2; // Changed from 4 to 2 to match client
let width = (max_width + horizontal_padding).max(10); // Changed from 12 to 10 to match client
let height = (display_texts.len() as u16).min(5); // Removed +2 since no borders
let horizontal_padding = 2;
let width = (max_width + horizontal_padding).max(10);
let height = (display_texts.len() as u16).min(5);
DropdownDimensions { width, height }
}
@@ -152,7 +155,7 @@ fn calculate_dropdown_position(
dropdown_area
}
/// Create styled list items - updated to match client spacing
/// Create styled list items
#[cfg(feature = "gui")]
fn create_suggestion_list_items<'a, T: CanvasTheme>(
display_texts: &'a [&'a str],
@@ -160,8 +163,7 @@ fn create_suggestion_list_items<'a, T: CanvasTheme>(
dropdown_width: u16,
theme: &T,
) -> Vec<ListItem<'a>> {
let horizontal_padding = 2; // Changed from 4 to 2 to match client
let available_width = dropdown_width; // No border padding needed
let available_width = dropdown_width;
display_texts
.iter()

View File

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

View File

@@ -1,189 +1,9 @@
// src/autocomplete/state.rs
//! Simple autocomplete provider pattern - replaces complex trait
use crate::canvas::state::CanvasState;
use async_trait::async_trait;
// Re-export the main types from data_provider for backward compatibility
pub use crate::data_provider::{AutocompleteProvider, SuggestionItem};
/// OPTIONAL extension trait for states that want rich autocomplete functionality.
/// Only implement this if you need the new autocomplete features.
///
/// # User Workflow:
/// 1. User presses trigger key (Tab, Ctrl+K, etc.)
/// 2. User's key mapping calls CanvasAction::TriggerAutocomplete
/// 3. Library calls your trigger_autocomplete_suggestions() method
/// 4. You implement async fetching logic in that method
/// 5. You call set_autocomplete_suggestions() with results
/// 6. Library manages UI state and navigation
#[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()
}
}
// Legacy compatibility - empty trait for migration
#[deprecated(note = "Use AutocompleteProvider instead")]
pub trait AutocompleteCanvasState {}

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
#[derive(Debug, Clone)]
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,
}
// Re-export the new simplified types
pub use crate::data_provider::SuggestionItem;
impl<T> SuggestionItem<T> {
pub fn new(data: T, display_text: String, value_to_store: String) -> Self {
Self {
data,
display_text,
value_to_store,
}
}
/// Convenience constructor for simple string suggestions
pub fn simple(data: T, text: String) -> Self {
Self {
data,
display_text: text.clone(),
value_to_store: text,
}
}
}
/// Autocomplete state managed by canvas
/// Legacy type - use FormEditor instead
#[deprecated(note = "Use FormEditor instead")]
#[derive(Debug, Clone)]
pub struct AutocompleteState<T> {
/// Whether autocomplete is currently active/visible
pub is_active: bool,
/// Whether suggestions are being loaded (for spinner/loading indicator)
pub is_loading: bool,
/// Current suggestions to display
pub suggestions: Vec<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,
}
}
_phantom: std::marker::PhantomData<T>,
}
#[allow(dead_code)]
impl<T> AutocompleteState<T> {
pub fn new() -> Self {
Self::default()
}
/// Activate autocomplete for a specific field
pub fn activate(&mut self, field_index: usize) {
self.is_active = true;
self.active_field = Some(field_index);
self.selected_index = None;
self.suggestions.clear();
self.is_loading = true;
}
/// Deactivate autocomplete and clear state
pub fn deactivate(&mut self) {
self.is_active = false;
self.is_loading = false;
self.suggestions.clear();
self.selected_index = None;
self.active_field = None;
}
/// Set suggestions and stop loading
pub fn set_suggestions(&mut self, suggestions: Vec<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
/// Legacy method - use FormEditor.is_autocomplete_active() instead
#[deprecated(note = "Use FormEditor.is_autocomplete_active() instead")]
pub fn is_active(&self) -> bool {
false
}
}

View File

@@ -1,43 +1,30 @@
// 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::modes::AppMode;
use anyhow::Result;
use super::{handle_edit_action, handle_readonly_action, handle_highlight_action};
/// Main action dispatcher - routes actions to mode-specific handlers
pub async fn dispatch_action<S: CanvasState>(
/// Internal action dispatcher - routes actions to mode-specific handlers
pub(crate) fn dispatch_action_internal(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<ActionResult> {
// Check if the application wants to handle this action first
let context = ActionContext {
key_code: None,
ideal_cursor_column: *ideal_cursor_column,
current_input: state.get_current_input().to_string(),
current_field: state.current_field(),
};
if let Some(result) = state.handle_feature_action(&action, &context) {
return Ok(ActionResult::HandledByFeature(result));
}
// Route to mode-specific handler
match state.current_mode() {
editor_state: &mut EditorState,
current_text: &str,
) -> ActionResult {
// Route to mode-specific handler based on current mode
match editor_state.current_mode {
AppMode::Edit => {
handle_edit_action(action, state, ideal_cursor_column).await
handle_edit_action(action, editor_state, current_text)
}
AppMode::ReadOnly => {
handle_readonly_action(action, state, ideal_cursor_column).await
handle_readonly_action(action, editor_state, current_text)
}
AppMode::Highlight => {
handle_highlight_action(action, state, ideal_cursor_column).await
handle_highlight_action(action, editor_state, current_text)
}
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
//! Edit mode action handler
//!
//! 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).
//! Edit mode action handler with EditorState
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::canvas::actions::movement::*;
use crate::canvas::state::CanvasState;
use anyhow::Result;
use crate::canvas::state::EditorState;
/// Edit mode uses cursor-past-end behavior for text insertion
const FOR_EDIT_MODE: bool = true;
/// Handle actions in edit mode with edit-specific cursor behavior
///
/// 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>(
pub(crate) fn handle_edit_action(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<ActionResult> {
editor_state: &mut EditorState,
current_text: &str,
) -> ActionResult {
match action {
CanvasAction::InsertChar(c) => {
// Insert character at cursor position and advance cursor
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())
}
// Note: Text insertion is handled at the FormEditor level
// These handlers only deal with cursor movement and navigation
// Cursor movement actions
CanvasAction::MoveLeft => {
let new_pos = move_left(state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
let new_pos = move_left(editor_state.cursor_pos);
editor_state.cursor_pos = new_pos;
editor_state.ideal_cursor_column = new_pos;
ActionResult::success()
}
CanvasAction::MoveRight => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
let new_pos = move_right(editor_state.cursor_pos, current_text, FOR_EDIT_MODE);
editor_state.cursor_pos = new_pos;
editor_state.ideal_cursor_column = new_pos;
ActionResult::success()
}
// Field navigation (treating single-line fields as "lines")
CanvasAction::MoveUp => {
let current_field = state.current_field();
if current_field > 0 {
state.set_current_field(current_field - 1);
let current_input = state.get_current_input();
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
if editor_state.current_field > 0 {
editor_state.current_field -= 1;
let new_pos = safe_cursor_position(current_text, editor_state.ideal_cursor_column, FOR_EDIT_MODE);
editor_state.cursor_pos = new_pos;
}
Ok(ActionResult::success())
ActionResult::success()
}
CanvasAction::MoveDown => {
let current_field = state.current_field();
let total_fields = state.fields().len();
if current_field < total_fields - 1 {
state.set_current_field(current_field + 1);
let current_input = state.get_current_input();
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
}
Ok(ActionResult::success())
// Note: field count validation happens at FormEditor level
editor_state.current_field += 1;
let new_pos = safe_cursor_position(current_text, editor_state.ideal_cursor_column, FOR_EDIT_MODE);
editor_state.cursor_pos = new_pos;
ActionResult::success()
}
// Line-based movement
CanvasAction::MoveLineStart => {
let new_pos = line_start_position();
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
editor_state.cursor_pos = new_pos;
editor_state.ideal_cursor_column = new_pos;
ActionResult::success()
}
CanvasAction::MoveLineEnd => {
let current_input = state.get_current_input();
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
let new_pos = line_end_position(current_text, FOR_EDIT_MODE);
editor_state.cursor_pos = new_pos;
editor_state.ideal_cursor_column = new_pos;
ActionResult::success()
}
// Document-level movement (first/last field)
CanvasAction::MoveFirstLine => {
state.set_current_field(0);
let current_input = state.get_current_input();
let new_pos = safe_cursor_position(current_input, 0, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
editor_state.current_field = 0;
let new_pos = safe_cursor_position(current_text, 0, FOR_EDIT_MODE);
editor_state.cursor_pos = new_pos;
editor_state.ideal_cursor_column = new_pos;
ActionResult::success()
}
CanvasAction::MoveLastLine => {
let last_field = state.fields().len() - 1;
state.set_current_field(last_field);
let current_input = state.get_current_input();
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
// Note: field count validation happens at FormEditor level
let new_pos = line_end_position(current_text, FOR_EDIT_MODE);
editor_state.cursor_pos = new_pos;
editor_state.ideal_cursor_column = new_pos;
ActionResult::success()
}
// Word-based movement
CanvasAction::MoveWordNext => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
if !current_text.is_empty() {
let new_pos = find_next_word_start(current_text, editor_state.cursor_pos);
editor_state.cursor_pos = new_pos;
editor_state.ideal_cursor_column = new_pos;
}
Ok(ActionResult::success())
ActionResult::success()
}
CanvasAction::MoveWordEnd => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_word_end(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
if !current_text.is_empty() {
let new_pos = find_word_end(current_text, editor_state.cursor_pos);
editor_state.cursor_pos = new_pos;
editor_state.ideal_cursor_column = new_pos;
}
Ok(ActionResult::success())
ActionResult::success()
}
CanvasAction::MoveWordPrev => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
if !current_text.is_empty() {
let new_pos = find_prev_word_start(current_text, editor_state.cursor_pos);
editor_state.cursor_pos = new_pos;
editor_state.ideal_cursor_column = new_pos;
}
Ok(ActionResult::success())
ActionResult::success()
}
CanvasAction::MoveWordEndPrev => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_end(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
if !current_text.is_empty() {
let new_pos = find_prev_word_end(current_text, editor_state.cursor_pos);
editor_state.cursor_pos = new_pos;
editor_state.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 => {
let current_field = state.current_field();
let total_fields = state.fields().len();
ActionResult::success_with_message("Field navigation handled by FormEditor")
}
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);
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())
// Text editing actions - handled at FormEditor level
CanvasAction::InsertChar(_) |
CanvasAction::DeleteBackward |
CanvasAction::DeleteForward => {
ActionResult::success_with_message("Text editing handled by FormEditor")
}
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
//! Highlight mode action handler with EditorState
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::canvas::actions::movement::*;
use crate::canvas::state::CanvasState;
use anyhow::Result;
use crate::canvas::state::EditorState;
const FOR_EDIT_MODE: bool = false; // Highlight mode uses read-only cursor behavior
/// Handle actions in highlight/visual mode
/// TODO: Implement selection logic and highlight-specific behaviors
pub async fn handle_highlight_action<S: CanvasState>(
pub(crate) fn handle_highlight_action(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<ActionResult> {
editor_state: &mut EditorState,
current_text: &str,
) -> ActionResult {
match action {
// Movement actions work similar to read-only mode but with selection
CanvasAction::MoveLeft => {
let new_pos = move_left(state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
let new_pos = move_left(editor_state.cursor_pos);
editor_state.cursor_pos = new_pos;
editor_state.ideal_cursor_column = new_pos;
// TODO: Update selection range
Ok(ActionResult::success())
ActionResult::success()
}
CanvasAction::MoveRight => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
let new_pos = move_right(editor_state.cursor_pos, current_text, FOR_EDIT_MODE);
editor_state.cursor_pos = new_pos;
editor_state.ideal_cursor_column = new_pos;
// TODO: Update selection range
Ok(ActionResult::success())
ActionResult::success()
}
CanvasAction::MoveWordNext => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
if !current_text.is_empty() {
let new_pos = find_next_word_start(current_text, editor_state.cursor_pos);
let final_pos = clamp_cursor_position(new_pos, current_text, FOR_EDIT_MODE);
editor_state.cursor_pos = final_pos;
editor_state.ideal_cursor_column = final_pos;
// TODO: Update selection range
}
Ok(ActionResult::success())
ActionResult::success()
}
CanvasAction::MoveWordEnd => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_word_end(current_input, state.current_cursor_pos());
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
if !current_text.is_empty() {
let new_pos = find_word_end(current_text, editor_state.cursor_pos);
let final_pos = clamp_cursor_position(new_pos, current_text, FOR_EDIT_MODE);
editor_state.cursor_pos = final_pos;
editor_state.ideal_cursor_column = final_pos;
// TODO: Update selection range
}
Ok(ActionResult::success())
ActionResult::success()
}
CanvasAction::MoveWordPrev => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
if !current_text.is_empty() {
let new_pos = find_prev_word_start(current_text, editor_state.cursor_pos);
editor_state.cursor_pos = new_pos;
editor_state.ideal_cursor_column = new_pos;
// TODO: Update selection range
}
Ok(ActionResult::success())
ActionResult::success()
}
CanvasAction::MoveLineStart => {
let new_pos = line_start_position();
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
editor_state.cursor_pos = new_pos;
editor_state.ideal_cursor_column = new_pos;
// TODO: Update selection range
Ok(ActionResult::success())
ActionResult::success()
}
CanvasAction::MoveLineEnd => {
let current_input = state.get_current_input();
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
let new_pos = line_end_position(current_text, FOR_EDIT_MODE);
editor_state.cursor_pos = new_pos;
editor_state.ideal_cursor_column = new_pos;
// TODO: Update selection range
Ok(ActionResult::success())
ActionResult::success()
}
// Highlight mode doesn't handle editing actions
CanvasAction::InsertChar(_) |
CanvasAction::DeleteBackward |
CanvasAction::DeleteForward => {
Ok(ActionResult::success_with_message("Action not available in highlight mode"))
ActionResult::success_with_message("Action not available in highlight mode")
}
CanvasAction::Custom(action_str) => {
Ok(ActionResult::success_with_message(&format!("Custom highlight action: {}", action_str)))
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 dispatcher;
pub use edit::handle_edit_action;
pub use readonly::handle_readonly_action;
pub use highlight::handle_highlight_action;
pub use dispatcher::dispatch_action;
pub use edit::*;
pub use readonly::*;
pub use highlight::*;
pub use dispatcher::*;

View File

@@ -1,183 +1,136 @@
// src/canvas/actions/handlers/readonly.rs
//! ReadOnly mode action handler with EditorState
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::canvas::actions::movement::*;
use crate::canvas::state::CanvasState;
use anyhow::Result;
use crate::canvas::state::EditorState;
const FOR_EDIT_MODE: bool = false; // Read-only mode flag
/// Handle actions in read-only mode with read-only specific cursor behavior
pub async fn handle_readonly_action<S: CanvasState>(
pub(crate) fn handle_readonly_action(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<ActionResult> {
editor_state: &mut EditorState,
current_text: &str,
) -> ActionResult {
match action {
CanvasAction::MoveLeft => {
let new_pos = move_left(state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
let new_pos = move_left(editor_state.cursor_pos);
editor_state.cursor_pos = new_pos;
editor_state.ideal_cursor_column = new_pos;
ActionResult::success()
}
CanvasAction::MoveRight => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
let new_pos = move_right(editor_state.cursor_pos, current_text, FOR_EDIT_MODE);
editor_state.cursor_pos = new_pos;
editor_state.ideal_cursor_column = new_pos;
ActionResult::success()
}
CanvasAction::MoveUp => {
let current_field = state.current_field();
let new_field = current_field.saturating_sub(1);
state.set_current_field(new_field);
// Apply ideal cursor column with read-only bounds
let current_input = state.get_current_input();
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
Ok(ActionResult::success())
if editor_state.current_field > 0 {
editor_state.current_field -= 1;
let new_pos = safe_cursor_position(current_text, editor_state.ideal_cursor_column, FOR_EDIT_MODE);
editor_state.cursor_pos = new_pos;
}
ActionResult::success()
}
CanvasAction::MoveDown => {
let current_field = state.current_field();
let total_fields = state.fields().len();
if total_fields == 0 {
return Ok(ActionResult::success_with_message("No fields to navigate"));
}
let new_field = (current_field + 1).min(total_fields - 1);
state.set_current_field(new_field);
// Apply ideal cursor column with read-only bounds
let current_input = state.get_current_input();
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
Ok(ActionResult::success())
// Note: bounds checking happens at FormEditor level
editor_state.current_field += 1;
let new_pos = safe_cursor_position(current_text, editor_state.ideal_cursor_column, FOR_EDIT_MODE);
editor_state.cursor_pos = new_pos;
ActionResult::success()
}
CanvasAction::MoveFirstLine => {
let total_fields = state.fields().len();
if total_fields == 0 {
return Ok(ActionResult::success_with_message("No fields to navigate"));
}
state.set_current_field(0);
let current_input = state.get_current_input();
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
editor_state.current_field = 0;
let new_pos = safe_cursor_position(current_text, editor_state.ideal_cursor_column, FOR_EDIT_MODE);
editor_state.cursor_pos = new_pos;
editor_state.ideal_cursor_column = new_pos;
ActionResult::success()
}
CanvasAction::MoveLastLine => {
let total_fields = state.fields().len();
if total_fields == 0 {
return Ok(ActionResult::success_with_message("No fields to navigate"));
}
let last_field = total_fields - 1;
state.set_current_field(last_field);
let current_input = state.get_current_input();
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
// Note: field count validation happens at FormEditor level
let new_pos = safe_cursor_position(current_text, editor_state.ideal_cursor_column, FOR_EDIT_MODE);
editor_state.cursor_pos = new_pos;
editor_state.ideal_cursor_column = new_pos;
ActionResult::success()
}
CanvasAction::MoveLineStart => {
let new_pos = line_start_position();
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
editor_state.cursor_pos = new_pos;
editor_state.ideal_cursor_column = new_pos;
ActionResult::success()
}
CanvasAction::MoveLineEnd => {
let current_input = state.get_current_input();
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
let new_pos = line_end_position(current_text, FOR_EDIT_MODE);
editor_state.cursor_pos = new_pos;
editor_state.ideal_cursor_column = new_pos;
ActionResult::success()
}
CanvasAction::MoveWordNext => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
if !current_text.is_empty() {
let new_pos = find_next_word_start(current_text, editor_state.cursor_pos);
let final_pos = clamp_cursor_position(new_pos, current_text, FOR_EDIT_MODE);
editor_state.cursor_pos = final_pos;
editor_state.ideal_cursor_column = final_pos;
}
Ok(ActionResult::success())
ActionResult::success()
}
CanvasAction::MoveWordEnd => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let current_pos = state.current_cursor_pos();
let new_pos = find_word_end(current_input, current_pos);
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
if !current_text.is_empty() {
let new_pos = find_word_end(current_text, editor_state.cursor_pos);
let final_pos = clamp_cursor_position(new_pos, current_text, FOR_EDIT_MODE);
editor_state.cursor_pos = final_pos;
editor_state.ideal_cursor_column = final_pos;
}
Ok(ActionResult::success())
ActionResult::success()
}
CanvasAction::MoveWordPrev => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
if !current_text.is_empty() {
let new_pos = find_prev_word_start(current_text, editor_state.cursor_pos);
editor_state.cursor_pos = new_pos;
editor_state.ideal_cursor_column = new_pos;
}
Ok(ActionResult::success())
ActionResult::success()
}
CanvasAction::MoveWordEndPrev => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_end(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
if !current_text.is_empty() {
let new_pos = find_prev_word_end(current_text, editor_state.cursor_pos);
editor_state.cursor_pos = new_pos;
editor_state.ideal_cursor_column = new_pos;
}
Ok(ActionResult::success())
ActionResult::success()
}
// Field navigation - handled at FormEditor level
CanvasAction::NextField | CanvasAction::PrevField => {
let current_field = state.current_field();
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())
ActionResult::success_with_message("Field navigation handled by FormEditor")
}
// Read-only mode doesn't handle editing actions
CanvasAction::InsertChar(_) |
CanvasAction::DeleteBackward |
CanvasAction::DeleteForward => {
Ok(ActionResult::success_with_message("Action not available in read-only mode"))
ActionResult::success_with_message("Action not available in read-only mode")
}
CanvasAction::Custom(action_str) => {
Ok(ActionResult::success_with_message(&format!("Custom readonly action: {}", action_str)))
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;
// 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
use crate::canvas::state::CanvasState;
use anyhow::Result;
use crate::canvas::state::EditorState;
/// All available canvas actions
#[derive(Debug, Clone, PartialEq)]
@@ -11,35 +10,35 @@ pub enum CanvasAction {
MoveRight,
MoveUp,
MoveDown,
// Word movement
MoveWordNext,
MoveWordPrev,
MoveWordEnd,
MoveWordEndPrev,
// Line movement
MoveLineStart,
MoveLineEnd,
// Field movement
NextField,
PrevField,
MoveFirstLine,
MoveLastLine,
// Editing actions
InsertChar(char),
DeleteBackward,
DeleteForward,
// Autocomplete actions
TriggerAutocomplete,
SuggestionUp,
SuggestionDown,
SelectSuggestion,
ExitSuggestions,
// Custom actions
Custom(String),
}
@@ -58,23 +57,23 @@ impl ActionResult {
pub fn success() -> Self {
Self::Success
}
pub fn success_with_message(msg: &str) -> Self {
Self::Message(msg.to_string())
}
pub fn handled_by_app(msg: &str) -> Self {
Self::HandledByApp(msg.to_string())
}
pub fn error(msg: &str) -> Self {
Self::Error(msg.to_string())
}
pub fn is_success(&self) -> bool {
matches!(self, Self::Success | Self::Message(_) | Self::HandledByApp(_) | Self::HandledByFeature(_))
}
pub fn message(&self) -> Option<&str> {
match self {
Self::Message(msg) | Self::HandledByApp(msg) | Self::HandledByFeature(msg) | Self::Error(msg) => Some(msg),
@@ -83,17 +82,13 @@ impl ActionResult {
}
}
/// Execute a canvas action on the given state
pub async fn execute<S: CanvasState>(
action: CanvasAction,
state: &mut S,
) -> Result<ActionResult> {
let mut ideal_cursor_column = 0;
super::handlers::dispatch_action(action, state, &mut ideal_cursor_column).await
}
impl CanvasAction {
/// Internal method used by FormEditor
pub(crate) fn apply_to_editor_state(self, editor_state: &mut EditorState, current_text: &str) -> ActionResult {
// Internal method used by FormEditor
crate::canvas::actions::handlers::dispatch_action_internal(self, editor_state, current_text)
}
/// Get a human-readable description of this action
pub fn description(&self) -> &'static str {
match self {
@@ -111,7 +106,7 @@ impl CanvasAction {
Self::PrevField => "previous field",
Self::MoveFirstLine => "first field",
Self::MoveLastLine => "last field",
Self::InsertChar(c) => "insert character",
Self::InsertChar(_c) => "insert character",
Self::DeleteBackward => "delete backward",
Self::DeleteForward => "delete forward",
Self::TriggerAutocomplete => "trigger autocomplete",
@@ -119,7 +114,7 @@ impl CanvasAction {
Self::SuggestionDown => "suggestion down",
Self::SelectSuggestion => "select suggestion",
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")]
use ratatui::{
@@ -9,29 +10,43 @@ use ratatui::{
Frame,
};
use crate::canvas::state::CanvasState;
use crate::canvas::modes::HighlightState;
#[cfg(feature = "gui")]
use crate::canvas::theme::CanvasTheme;
use crate::canvas::modes::HighlightState;
use crate::data_provider::DataProvider;
use crate::editor::FormEditor;
#[cfg(feature = "gui")]
use std::cmp::{max, min};
/// Render ONLY the canvas form fields - no autocomplete
/// Updated to work with FormEditor instead of CanvasState trait
#[cfg(feature = "gui")]
pub fn render_canvas<T: CanvasTheme>(
pub fn render_canvas<T: CanvasTheme, D: DataProvider>(
f: &mut Frame,
area: Rect,
form_state: &impl CanvasState,
editor: &FormEditor<D>,
theme: &T,
is_edit_mode: bool,
highlight_state: &HighlightState,
) -> Option<Rect> {
let fields: Vec<&str> = form_state.fields();
let current_field_idx = form_state.current_field();
let inputs: Vec<&String> = form_state.inputs();
let ui_state = editor.ui_state();
let data_provider = editor.data_provider();
// Build field information
let field_count = data_provider.field_count();
let mut fields: Vec<&str> = Vec::with_capacity(field_count);
let mut inputs: Vec<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(
f,
area,
@@ -40,11 +55,13 @@ pub fn render_canvas<T: CanvasTheme>(
&inputs,
theme,
is_edit_mode,
highlight_state,
form_state.current_cursor_pos(),
form_state.has_unsaved_changes(),
|i| form_state.get_display_value_for_field(i).to_string(),
|i| form_state.has_display_override(i),
&highlight_state,
ui_state.cursor_position(),
false, // TODO: track unsaved changes in editor
|i| {
data_provider.display_value(i).unwrap_or(data_provider.field_value(i)).to_string()
},
|i| data_provider.display_value(i).is_some(),
)
}
@@ -55,7 +72,7 @@ fn render_canvas_fields<T: CanvasTheme, F1, F2>(
area: Rect,
fields: &[&str],
current_field_idx: &usize,
inputs: &[&String],
inputs: &[String],
theme: &T,
is_edit_mode: bool,
highlight_state: &HighlightState,
@@ -112,7 +129,7 @@ where
// Render field values and return active field rect
render_field_values(
f,
input_rows.to_vec(), // Fix: Convert Rc<[Rect]> to Vec<Rect>
input_rows.to_vec(),
inputs,
current_field_idx,
theme,
@@ -154,7 +171,7 @@ fn render_field_labels<T: CanvasTheme>(
fn render_field_values<T: CanvasTheme, F1, F2>(
f: &mut Frame,
input_rows: Vec<Rect>,
inputs: &[&String],
inputs: &[String],
current_field_idx: &usize,
theme: &T,
highlight_state: &HighlightState,
@@ -171,7 +188,7 @@ where
for (i, _input) in inputs.iter().enumerate() {
let is_active = i == *current_field_idx;
let text = get_display_value(i);
// Apply highlighting
let line = apply_highlighting(
&text,
@@ -301,7 +318,7 @@ fn apply_linewise_highlighting<'a, T: CanvasTheme>(
) -> Line<'a> {
let start_field = min(*anchor_line, *current_field_idx);
let end_field = max(*anchor_line, *current_field_idx);
let highlight_style = Style::default()
.fg(theme.highlight())
.bg(theme.highlight_bg())

View File

@@ -1,18 +1,14 @@
// src/canvas/mod.rs
pub mod actions;
pub mod gui;
pub mod modes;
pub mod state;
pub mod modes;
#[cfg(feature = "gui")]
pub mod gui;
#[cfg(feature = "gui")]
pub mod theme;
// Re-export main types for convenience
pub use actions::{CanvasAction, ActionResult};
// Keep these exports for current functionality
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
//! Canvas state trait and related types
//!
//! This module defines the core trait that any form or input system must implement
//! to work with the canvas library.
//! Library-owned UI state - user never directly modifies this
use crate::canvas::actions::CanvasAction;
use crate::canvas::modes::AppMode;
/// Context information passed to feature-specific action handlers
#[derive(Debug)]
pub struct ActionContext {
/// Original key code that triggered this action (for backwards compatibility)
pub key_code: Option<crossterm::event::KeyCode>,
/// Current ideal cursor column for vertical movement
pub ideal_cursor_column: usize,
/// Current input text
pub current_input: String,
/// Current field index
pub current_field: usize,
/// Library-owned UI state - user never directly modifies this
#[derive(Debug, Clone)]
pub struct EditorState {
// Navigation state
pub(crate) current_field: usize,
pub(crate) cursor_pos: usize,
pub(crate) ideal_cursor_column: usize,
// Mode state
pub(crate) current_mode: AppMode,
// Autocomplete state
pub(crate) autocomplete: AutocompleteUIState,
// Selection state (for vim visual mode)
pub(crate) selection: SelectionState,
}
/// Core trait that any form-like state must implement to work with canvas
///
/// This trait enables the same mode behaviors (edit, read-only, highlight) to work
/// across any implementation - login forms, data entry forms, configuration screens, etc.
///
/// # Required Implementation
///
/// 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);
#[derive(Debug, Clone)]
pub struct AutocompleteUIState {
pub(crate) is_active: bool,
pub(crate) is_loading: bool,
pub(crate) selected_index: Option<usize>,
pub(crate) active_field: Option<usize>,
}
// --- Mode Information ---
/// Get current interaction mode (edit, read-only, highlight, etc.)
fn current_mode(&self) -> AppMode;
#[derive(Debug, Clone)]
pub enum SelectionState {
None,
Characterwise { anchor: (usize, usize) },
Linewise { anchor_field: usize },
}
// --- 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
impl EditorState {
pub fn new() -> Self {
Self {
current_field: 0,
cursor_pos: 0,
ideal_cursor_column: 0,
current_mode: AppMode::Edit,
autocomplete: AutocompleteUIState {
is_active: false,
is_loading: false,
selected_index: None,
active_field: None,
},
selection: SelectionState::None,
}
}
/// Get display value for a field (may differ from actual value)
/// Used for things like password masking or computed display values
fn get_display_value_for_field(&self, index: usize) -> &str {
self.inputs()
.get(index)
.map(|s| s.as_str())
.unwrap_or("")
// ===================================================================
// 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
}
/// Check if a field has a custom display value
/// Return true if get_display_value_for_field returns something different than the actual value
fn has_display_override(&self, _index: usize) -> bool {
false
/// 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
pub mod canvas;
pub mod editor;
pub mod data_provider;
// Only include autocomplete module if feature is enabled
#[cfg(feature = "autocomplete")]
pub mod autocomplete;
// Re-export the main API for easy access
pub use canvas::actions::{CanvasAction, ActionResult, execute};
pub use canvas::state::{CanvasState, ActionContext};
pub use canvas::modes::{AppMode, ModeManager, HighlightState};
// ===================================================================
// NEW API: Library-owned state pattern
// ===================================================================
// Main API exports
pub use editor::FormEditor;
pub use data_provider::{DataProvider, AutocompleteProvider, SuggestionItem};
// UI state (read-only access for users)
pub use canvas::state::EditorState;
pub use canvas::modes::AppMode;
// Actions and results (for users who want to handle actions manually)
pub use canvas::actions::{CanvasAction, ActionResult};
// Theming and GUI
#[cfg(feature = "gui")]
pub use canvas::theme::CanvasTheme;
#[cfg(feature = "gui")]
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"))]
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};