Compare commits

..

4 Commits

Author SHA1 Message Date
Priec
8f99aa79ec working autocomplete now, with backwards deprecation 2025-07-31 22:44:21 +02:00
Priec
c594c35b37 autocomplete now working 2025-07-31 22:25:43 +02:00
Priec
828a63c30c canvas is fixed, lets fix autocomplete also 2025-07-31 22:04:15 +02:00
Priec
36690e674a canvas library config removed compeltely 2025-07-31 21:41:54 +02:00
25 changed files with 1059 additions and 2043 deletions

1
Cargo.lock generated
View File

@@ -475,6 +475,7 @@ name = "canvas"
version = "0.4.2"
dependencies = [
"anyhow",
"async-trait",
"common",
"crossterm",
"ratatui",

View File

@@ -14,7 +14,7 @@ common = { path = "../common" }
ratatui = { workspace = true, optional = true }
crossterm = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true }
tokio = { workspace = true, optional = true }
toml = { workspace = true }
serde = { workspace = true }
unicode-width.workspace = true
@@ -22,6 +22,7 @@ thiserror = { workspace = true }
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
async-trait = { workspace = true, optional = true }
[dev-dependencies]
tokio-test = "0.4.4"
@@ -29,7 +30,14 @@ tokio-test = "0.4.4"
[features]
default = []
gui = ["ratatui"]
autocomplete = ["tokio", "async-trait"]
[[example]]
name = "ratatui_demo"
path = "examples/ratatui_demo.rs"
name = "autocomplete"
required-features = ["autocomplete", "gui"]
path = "examples/autocomplete.rs"
[[example]]
name = "canvas_gui_demo"
required-features = ["gui"]
path = "examples/canvas_gui_demo.rs"

View File

@@ -0,0 +1,417 @@
// examples/autocomplete.rs
// Run with: cargo run --example autocomplete --features "autocomplete,gui"
use std::io;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout},
style::Color,
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame, Terminal,
};
use canvas::{
canvas::{
gui::render_canvas,
modes::AppMode,
state::{ActionContext, CanvasState},
theme::CanvasTheme,
},
autocomplete::{
AutocompleteCanvasState,
AutocompleteState,
SuggestionItem,
execute_with_autocomplete,
handle_autocomplete_feature_action,
},
CanvasAction,
};
// Add the async_trait import
use async_trait::async_trait;
// Simple theme implementation
#[derive(Clone)]
struct DemoTheme;
impl CanvasTheme for DemoTheme {
fn bg(&self) -> Color { Color::Reset }
fn fg(&self) -> Color { Color::White }
fn accent(&self) -> Color { Color::Cyan }
fn secondary(&self) -> Color { Color::Gray }
fn highlight(&self) -> Color { Color::Yellow }
fn highlight_bg(&self) -> Color { Color::DarkGray }
fn warning(&self) -> Color { Color::Red }
fn border(&self) -> Color { Color::Gray }
}
// Custom suggestion data type
#[derive(Clone, Debug)]
struct EmailSuggestion {
email: String,
provider: String,
}
// 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,
// Autocomplete state
autocomplete: AutocompleteState<EmailSuggestion>,
}
impl AutocompleteFormState {
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(),
}
}
}
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();
}
}
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,
}
}
}
// Add the #[async_trait] attribute to the implementation
#[async_trait]
impl AutocompleteCanvasState for AutocompleteFormState {
type SuggestionData = EmailSuggestion;
fn supports_autocomplete(&self, field_index: usize) -> bool {
// Only enable autocomplete for email field (index 1)
field_index == 1
}
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> {
Some(&self.autocomplete)
}
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> {
Some(&mut self.autocomplete)
}
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()
} else {
self.set_autocomplete_loading(false);
return; // No @ symbol, can't suggest
};
// 4. SIMULATE ASYNC API CALL (in real code, this would be HTTP request)
let email_prefix = query[..query.find('@').unwrap()].to_string();
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
let popular_domains = vec![
("gmail.com", "Gmail"),
("yahoo.com", "Yahoo Mail"),
("outlook.com", "Outlook"),
("hotmail.com", "Hotmail"),
("company.com", "Company Email"),
("university.edu", "University"),
];
let mut results = Vec::new();
for (domain, provider) in popular_domains {
if domain.starts_with(&domain_part) || domain_part.is_empty() {
let full_email = format!("{}@{}", email_prefix, domain);
results.push(SuggestionItem::new(
EmailSuggestion {
email: full_email.clone(),
provider: provider.to_string(),
},
format!("{} ({})", full_email, provider), // display text
full_email, // value to store
));
}
}
results
}).await.unwrap_or_default();
// 5. Provide suggestions back to library
self.set_autocomplete_suggestions(suggestions);
}
}
async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut AutocompleteFormState) -> bool {
if key == KeyCode::F(10) || (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) {
return false; // Quit
}
let action = 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
} else {
Some(CanvasAction::NextField) // Normal tab
}
}
KeyCode::BackTab => {
if state.is_autocomplete_active() {
Some(CanvasAction::SuggestionUp)
} else {
Some(CanvasAction::PrevField)
}
}
KeyCode::Enter => {
if state.is_autocomplete_active() {
Some(CanvasAction::SelectSuggestion) // Apply suggestion
} else {
Some(CanvasAction::NextField)
}
}
KeyCode::Esc => {
if state.is_autocomplete_active() {
Some(CanvasAction::ExitSuggestions) // Close autocomplete
} else {
Some(CanvasAction::Custom("toggle_mode".to_string()))
}
}
// === 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))
}
_ => None,
};
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
}
}
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut state: AutocompleteFormState) -> io::Result<()> {
let theme = DemoTheme;
loop {
terminal.draw(|f| ui(f, &state, &theme))?;
if let Event::Key(key) = event::read()? {
let should_continue = handle_key_press(key.code, key.modifiers, &mut state).await;
if !should_continue {
break;
}
}
}
Ok(())
}
fn ui(f: &mut Frame, state: &AutocompleteFormState, theme: &DemoTheme) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(8),
Constraint::Length(5),
])
.split(f.area());
// Render the canvas form
let active_field_rect = render_canvas(
f,
chunks[0],
state,
theme,
state.mode == AppMode::Edit,
&canvas::HighlightState::Off,
);
// Render autocomplete dropdown on top if active
if let Some(input_rect) = active_field_rect {
canvas::render_autocomplete_dropdown(
f,
chunks[0],
input_rect,
theme,
&state.autocomplete,
);
}
// Status info
let autocomplete_status = if state.is_autocomplete_active() {
if state.autocomplete.is_loading {
"Loading suggestions..."
} else if state.has_autocomplete_suggestions() {
"Use Tab/Shift+Tab to navigate, Enter to select, Esc to cancel"
} else {
"No suggestions found"
}
} else {
"Tab to trigger autocomplete"
};
let status_lines = vec![
Line::from(Span::raw(format!("Mode: {:?} | Field: {}/{} | Cursor: {}",
state.mode, state.current_field + 1, state.fields.len(), state.cursor_pos))),
Line::from(Span::raw(format!("Autocomplete: {}", autocomplete_status))),
Line::from(Span::raw(state.debug_message.clone())),
Line::from(Span::raw("F10: Quit | Tab: Trigger/Navigate autocomplete | Enter: Select | Esc: Cancel/Toggle mode")),
];
let status = Paragraph::new(status_lines)
.block(Block::default().borders(Borders::ALL).title("Status & Help"));
f.render_widget(status, chunks[1]);
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let state = AutocompleteFormState::new();
let res = run_app(&mut terminal, state).await;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err);
}
Ok(())
}

View File

@@ -1,3 +1,5 @@
// examples/canvas_gui_demo.rs
use std::io;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
@@ -20,9 +22,7 @@ use canvas::{
state::{ActionContext, CanvasState},
theme::CanvasTheme,
},
config::CanvasConfig,
dispatcher::ActionDispatcher,
CanvasAction,
CanvasAction, execute,
};
// Simple theme implementation
@@ -49,8 +49,6 @@ struct DemoFormState {
mode: AppMode,
highlight_state: HighlightState,
has_changes: bool,
ideal_cursor_column: usize,
last_action: Option<String>,
debug_message: String,
}
@@ -78,9 +76,7 @@ impl DemoFormState {
mode: AppMode::ReadOnly,
highlight_state: HighlightState::Off,
has_changes: false,
ideal_cursor_column: 0,
last_action: None,
debug_message: "Ready".to_string(),
debug_message: "Ready - Use hjkl to move, w for next word, i to edit".to_string(),
}
}
@@ -181,98 +177,125 @@ impl CanvasState for DemoFormState {
}
}
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut state: DemoFormState, config: CanvasConfig) -> io::Result<()> {
/// Simple key mapping - users have full control!
async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut DemoFormState) -> bool {
let is_edit_mode = state.mode == AppMode::Edit;
// Handle quit first
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL)) ||
(key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) ||
key == KeyCode::F(10) {
return false; // Signal to quit
}
// Users directly map keys to actions - no configuration needed!
let action = match (state.mode, key, modifiers) {
// === READ-ONLY MODE KEYS ===
(AppMode::ReadOnly, KeyCode::Char('h'), _) => Some(CanvasAction::MoveLeft),
(AppMode::ReadOnly, KeyCode::Char('j'), _) => Some(CanvasAction::MoveDown),
(AppMode::ReadOnly, KeyCode::Char('k'), _) => Some(CanvasAction::MoveUp),
(AppMode::ReadOnly, KeyCode::Char('l'), _) => Some(CanvasAction::MoveRight),
(AppMode::ReadOnly, KeyCode::Char('w'), _) => Some(CanvasAction::MoveWordNext),
(AppMode::ReadOnly, KeyCode::Char('b'), _) => Some(CanvasAction::MoveWordPrev),
(AppMode::ReadOnly, KeyCode::Char('e'), _) => Some(CanvasAction::MoveWordEnd),
(AppMode::ReadOnly, KeyCode::Char('0'), _) => Some(CanvasAction::MoveLineStart),
(AppMode::ReadOnly, KeyCode::Char('$'), _) => Some(CanvasAction::MoveLineEnd),
(AppMode::ReadOnly, KeyCode::Tab, _) => Some(CanvasAction::NextField),
(AppMode::ReadOnly, KeyCode::BackTab, _) => Some(CanvasAction::PrevField),
// === EDIT MODE KEYS ===
(AppMode::Edit, KeyCode::Left, _) => Some(CanvasAction::MoveLeft),
(AppMode::Edit, KeyCode::Right, _) => Some(CanvasAction::MoveRight),
(AppMode::Edit, KeyCode::Up, _) => Some(CanvasAction::MoveUp),
(AppMode::Edit, KeyCode::Down, _) => Some(CanvasAction::MoveDown),
(AppMode::Edit, KeyCode::Home, _) => Some(CanvasAction::MoveLineStart),
(AppMode::Edit, KeyCode::End, _) => Some(CanvasAction::MoveLineEnd),
(AppMode::Edit, KeyCode::Backspace, _) => Some(CanvasAction::DeleteBackward),
(AppMode::Edit, KeyCode::Delete, _) => Some(CanvasAction::DeleteForward),
(AppMode::Edit, KeyCode::Tab, _) => Some(CanvasAction::NextField),
(AppMode::Edit, KeyCode::BackTab, _) => Some(CanvasAction::PrevField),
// Vim-style movement in edit mode (optional)
(AppMode::Edit, KeyCode::Char('h'), m) if m.contains(KeyModifiers::CONTROL) => Some(CanvasAction::MoveLeft),
(AppMode::Edit, KeyCode::Char('l'), m) if m.contains(KeyModifiers::CONTROL) => Some(CanvasAction::MoveRight),
// Word movement with Ctrl in edit mode
(AppMode::Edit, KeyCode::Left, m) if m.contains(KeyModifiers::CONTROL) => Some(CanvasAction::MoveWordPrev),
(AppMode::Edit, KeyCode::Right, m) if m.contains(KeyModifiers::CONTROL) => Some(CanvasAction::MoveWordNext),
// === MODE TRANSITIONS ===
(AppMode::ReadOnly, KeyCode::Char('i'), _) => Some(CanvasAction::Custom("enter_edit_mode".to_string())),
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
// 'a' moves to end of line then enters edit mode
if let Ok(_) = execute(CanvasAction::MoveLineEnd, state).await {
Some(CanvasAction::Custom("enter_edit_mode".to_string()))
} else {
None
}
},
(AppMode::ReadOnly, KeyCode::Char('v'), _) => Some(CanvasAction::Custom("enter_highlight_mode".to_string())),
(_, KeyCode::Esc, _) => Some(CanvasAction::Custom("enter_readonly_mode".to_string())),
// === CHARACTER INPUT IN EDIT MODE ===
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) => {
Some(CanvasAction::InsertChar(c))
},
// === ARROW KEYS IN READ-ONLY MODE ===
(AppMode::ReadOnly, KeyCode::Left, _) => Some(CanvasAction::MoveLeft),
(AppMode::ReadOnly, KeyCode::Right, _) => Some(CanvasAction::MoveRight),
(AppMode::ReadOnly, KeyCode::Up, _) => Some(CanvasAction::MoveUp),
(AppMode::ReadOnly, KeyCode::Down, _) => Some(CanvasAction::MoveDown),
_ => None,
};
// Execute the action if we found one
if let Some(action) = action {
match execute(action.clone(), state).await {
Ok(result) => {
if result.is_success() {
// Mark as changed for editing actions
if is_edit_mode {
match action {
CanvasAction::InsertChar(_) | CanvasAction::DeleteBackward | CanvasAction::DeleteForward => {
state.set_has_unsaved_changes(true);
}
_ => {}
}
}
if let Some(msg) = result.message() {
state.debug_message = msg.to_string();
} else {
state.debug_message = format!("Executed: {}", action.description());
}
} else if let Some(msg) = result.message() {
state.debug_message = format!("Error: {}", msg);
}
}
Err(e) => {
state.debug_message = format!("Error executing action: {}", e);
}
}
} else {
state.debug_message = format!("Unhandled key: {:?} (mode: {:?})", key, state.mode);
}
true // Continue running
}
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut state: DemoFormState) -> io::Result<()> {
let theme = DemoTheme;
loop {
terminal.draw(|f| ui(f, &state, &theme))?;
if let Event::Key(key) = event::read()? {
// Handle quit
if (key.code == KeyCode::Char('q') && key.modifiers.contains(KeyModifiers::CONTROL)) ||
(key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)) ||
key.code == KeyCode::F(10) {
let should_continue = handle_key_press(key.code, key.modifiers, &mut state).await;
if !should_continue {
break;
}
let is_edit_mode = state.mode == AppMode::Edit;
let mut handled = false;
// First priority: Try to dispatch through config system
let mut ideal_cursor = state.ideal_cursor_column;
if let Ok(Some(result)) = ActionDispatcher::dispatch_key(
key.code,
key.modifiers,
&mut state,
&mut ideal_cursor,
is_edit_mode,
false,
).await {
state.ideal_cursor_column = ideal_cursor;
state.debug_message = format!("Config handled: {:?}", key.code);
// Mark as changed for text modification keys in edit mode
if is_edit_mode {
match key.code {
KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Delete => {
state.set_has_unsaved_changes(true);
}
_ => {}
}
}
handled = true;
}
// Second priority: Handle character input in edit mode
if !handled && is_edit_mode {
if let KeyCode::Char(c) = key.code {
if !key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT) {
let action = CanvasAction::InsertChar(c);
let mut ideal_cursor = state.ideal_cursor_column;
if let Ok(_) = ActionDispatcher::dispatch_with_config(
action,
&mut state,
&mut ideal_cursor,
Some(&config),
).await {
state.ideal_cursor_column = ideal_cursor;
state.set_has_unsaved_changes(true);
state.debug_message = format!("Inserted char: '{}'", c);
handled = true;
}
}
}
}
// Third priority: Fallback mode transitions
if !handled {
match (state.mode, key.code) {
(AppMode::ReadOnly, KeyCode::Char('i') | KeyCode::Char('a') | KeyCode::Insert) => {
state.enter_edit_mode();
if key.code == KeyCode::Char('a') {
state.cursor_pos = state.fields[state.current_field].len();
}
state.debug_message = format!("Entered edit mode via {:?}", key.code);
handled = true;
}
(AppMode::ReadOnly, KeyCode::Char('v')) => {
state.enter_highlight_mode();
state.debug_message = "Entered visual mode".to_string();
handled = true;
}
(_, KeyCode::Esc) => {
state.enter_readonly_mode();
state.debug_message = "Entered read-only mode".to_string();
handled = true;
}
_ => {}
}
}
if !handled {
state.debug_message = format!("Unhandled key: {:?}", key.code);
}
}
}
@@ -313,15 +336,15 @@ fn ui(f: &mut Frame, state: &DemoFormState, theme: &DemoTheme) {
format!("-- {} --", mode_text)
};
let position_text = format!("Field: {}/{} | Cursor: {} | Column: {}",
let position_text = format!("Field: {}/{} | Cursor: {} | Actions: {}",
state.current_field + 1,
state.fields.len(),
state.cursor_pos,
state.ideal_cursor_column);
CanvasAction::movement_actions().len() + CanvasAction::editing_actions().len());
let help_text = match state.mode {
AppMode::ReadOnly => "hjkl/arrows: Move | Tab/Shift+Tab: Fields | w/b/e: Words | 0/$: Line | gg/G: File | i/a: Edit | v: Visual | F10: Quit",
AppMode::Edit => "Type to edit | hjkl/arrows: Move | Tab/Enter: Next field | Backspace/Delete: Delete | Home/End: Line | Esc: Normal | F10: Quit",
AppMode::ReadOnly => "hjkl/arrows: Move | Tab/Shift+Tab: Fields | w/b/e: Words | 0/$: Line | i/a: Edit | v: Visual | F10: Quit",
AppMode::Edit => "Type to edit | Arrows/Ctrl+arrows: Move | Tab: Next field | Backspace/Delete: Delete | Home/End: Line | Esc: Normal | F10: Quit",
AppMode::Highlight => "hjkl/arrows: Select | w/b/e: Words | 0/$: Line | Esc: Normal | F10: Quit",
_ => "Esc: Normal | F10: Quit",
};
@@ -339,8 +362,6 @@ fn ui(f: &mut Frame, state: &DemoFormState, theme: &DemoTheme) {
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = CanvasConfig::load();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
@@ -349,7 +370,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let state = DemoFormState::new();
let res = run_app(&mut terminal, state, config).await;
let res = run_app(&mut terminal, state).await;
disable_raw_mode()?;
execute!(

View File

@@ -1,21 +0,0 @@
// examples/generate_template.rs
use canvas::config::CanvasConfig;
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() > 1 && args[1] == "clean" {
// Generate clean template with 80% active code
let template = CanvasConfig::generate_clean_template();
println!("{}", template);
} else {
// Generate verbose template with descriptions (default)
let template = CanvasConfig::generate_template();
println!("{}", template);
}
}
// Usage:
// cargo run --example generate_template > canvas_config.toml
// cargo run --example generate_template clean > canvas_config_clean.toml

View File

@@ -1,128 +1,170 @@
// src/autocomplete/actions.rs
use crate::canvas::state::{CanvasState, ActionContext};
use crate::canvas::state::CanvasState;
use crate::autocomplete::state::AutocompleteCanvasState;
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::dispatcher::ActionDispatcher; // NEW: Use dispatcher directly
use crate::config::CanvasConfig;
use crate::canvas::actions::execute;
use anyhow::Result;
/// Version for states that implement rich autocomplete
pub async fn execute_canvas_action_with_autocomplete<S: CanvasState + AutocompleteCanvasState>(
/// 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
/// execute_with_autocomplete(action, &mut state).await?;
/// ```
pub async fn execute_with_autocomplete<S: CanvasState + AutocompleteCanvasState + Send>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
config: Option<&CanvasConfig>,
) -> Result<ActionResult> {
// 1. Try feature-specific handler first
let context = ActionContext {
key_code: None,
ideal_cursor_column: *ideal_cursor_column,
current_input: state.get_current_input().to_string(),
current_field: state.current_field(),
};
match &action {
// === AUTOCOMPLETE-SPECIFIC ACTIONS ===
if let Some(result) = handle_rich_autocomplete_action(action.clone(), state, &context) {
return Ok(result);
}
// 2. Handle generic actions using the new dispatcher directly
let result = ActionDispatcher::dispatch_with_config(action.clone(), state, ideal_cursor_column, config).await?;
// 3. AUTO-TRIGGER LOGIC: Check if we should activate/deactivate autocomplete
if let Some(cfg) = config {
if cfg.should_auto_trigger_autocomplete() {
match action {
CanvasAction::InsertChar(_) => {
let current_field = state.current_field();
let current_input = state.get_current_input();
if state.supports_autocomplete(current_field)
&& !state.is_autocomplete_active()
&& current_input.len() >= 1
{
state.activate_autocomplete();
}
}
CanvasAction::NextField | CanvasAction::PrevField => {
let current_field = state.current_field();
if state.supports_autocomplete(current_field) && !state.is_autocomplete_active() {
state.activate_autocomplete();
} else if !state.supports_autocomplete(current_field) && state.is_autocomplete_active() {
state.deactivate_autocomplete();
}
}
_ => {} // No auto-trigger for other actions
}
}
}
Ok(result)
}
/// Handle rich autocomplete actions for AutocompleteCanvasState
fn handle_rich_autocomplete_action<S: CanvasState + AutocompleteCanvasState>(
action: CanvasAction,
state: &mut S,
_context: &ActionContext,
) -> Option<ActionResult> {
match action {
CanvasAction::TriggerAutocomplete => {
let current_field = state.current_field();
if state.supports_autocomplete(current_field) {
state.activate_autocomplete();
Some(ActionResult::success_with_message("Autocomplete activated"))
if state.supports_autocomplete(state.current_field()) {
state.trigger_autocomplete_suggestions().await;
Ok(ActionResult::success_with_message("Triggered autocomplete"))
} else {
Some(ActionResult::success_with_message("Autocomplete not supported for this field"))
Ok(ActionResult::success_with_message("Autocomplete not supported for this field"))
}
}
CanvasAction::SuggestionUp => {
if state.is_autocomplete_ready() {
if let Some(autocomplete_state) = state.autocomplete_state_mut() {
autocomplete_state.select_previous();
}
Some(ActionResult::success())
if state.has_autocomplete_suggestions() {
state.move_suggestion_selection(-1);
Ok(ActionResult::success())
} else {
Some(ActionResult::success_with_message("No suggestions available"))
Ok(ActionResult::success_with_message("No suggestions available"))
}
}
CanvasAction::SuggestionDown => {
if state.is_autocomplete_ready() {
if let Some(autocomplete_state) = state.autocomplete_state_mut() {
autocomplete_state.select_next();
}
Some(ActionResult::success())
if state.has_autocomplete_suggestions() {
state.move_suggestion_selection(1);
Ok(ActionResult::success())
} else {
Some(ActionResult::success_with_message("No suggestions available"))
Ok(ActionResult::success_with_message("No suggestions available"))
}
}
CanvasAction::SelectSuggestion => {
if state.is_autocomplete_ready() {
if let Some(msg) = state.apply_autocomplete_selection() {
Some(ActionResult::success_with_message(&msg))
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);
/// }
///
/// // Handle your other custom actions...
/// None
/// }
/// ```
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 {
Some(ActionResult::success_with_message("No suggestion selected"))
None // Let execute_with_autocomplete handle it
}
} else {
Some(ActionResult::success_with_message("No suggestions available"))
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() {
state.deactivate_autocomplete();
Some(ActionResult::success_with_message("Exited autocomplete"))
None // Let execute_with_autocomplete handle exit
} else {
Some(ActionResult::success())
Some("No autocomplete to close".to_string())
}
}
_ => None, // Not a rich autocomplete action
_ => 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> {
execute_with_autocomplete(action, state).await
}

View File

@@ -1,4 +1,4 @@
// canvas/src/autocomplete/gui.rs
// src/autocomplete/gui.rs
#[cfg(feature = "gui")]
use ratatui::{
@@ -8,6 +8,7 @@ use ratatui::{
Frame,
};
// Use the correct import from our types module
use crate::autocomplete::types::AutocompleteState;
#[cfg(feature = "gui")]
@@ -18,12 +19,12 @@ use unicode_width::UnicodeWidthStr;
/// Render autocomplete dropdown - call this AFTER rendering canvas
#[cfg(feature = "gui")]
pub fn render_autocomplete_dropdown<T: CanvasTheme>(
pub fn render_autocomplete_dropdown<T: CanvasTheme, D: Clone + Send + 'static>(
f: &mut Frame,
frame_area: Rect,
input_rect: Rect,
theme: &T,
autocomplete_state: &AutocompleteState<impl Clone + Send + 'static>,
autocomplete_state: &AutocompleteState<D>,
) {
if !autocomplete_state.is_active {
return;
@@ -68,12 +69,12 @@ fn render_loading_indicator<T: CanvasTheme>(
/// Show actual suggestions list
#[cfg(feature = "gui")]
fn render_suggestions_dropdown<T: CanvasTheme>(
fn render_suggestions_dropdown<T: CanvasTheme, D: Clone + Send + 'static>(
f: &mut Frame,
frame_area: Rect,
input_rect: Rect,
theme: &T,
autocomplete_state: &AutocompleteState<impl Clone + Send + 'static>,
autocomplete_state: &AutocompleteState<D>,
) {
let display_texts: Vec<&str> = autocomplete_state.suggestions
.iter()

View File

@@ -1,10 +1,22 @@
// src/autocomplete/mod.rs
pub mod types;
pub mod gui;
pub mod state;
pub mod actions;
// Re-export autocomplete types
#[cfg(feature = "gui")]
pub mod gui;
// Re-export the main autocomplete API
pub use types::{SuggestionItem, AutocompleteState};
pub use state::AutocompleteCanvasState;
pub use actions::execute_canvas_action_with_autocomplete;
// Re-export the new action functions
pub use actions::{
execute_with_autocomplete,
handle_autocomplete_feature_action,
};
// Re-export GUI functions if available
#[cfg(feature = "gui")]
pub use gui::render_autocomplete_dropdown;

View File

@@ -1,14 +1,24 @@
// canvas/src/state.rs
// src/autocomplete/state.rs
use crate::canvas::state::CanvasState;
use async_trait::async_trait;
/// 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
/// Check if a field supports autocomplete (user decides which fields)
fn supports_autocomplete(&self, _field_index: usize) -> bool {
false // Default: no autocomplete support
}
@@ -23,74 +33,157 @@ pub trait AutocompleteCanvasState: CanvasState {
None // Default: no autocomplete state
}
/// CLIENT API: Activate autocomplete for current field
// === 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(); // Get field first
let current_field = self.current_field();
if let Some(state) = self.autocomplete_state_mut() {
state.activate(current_field); // Then use it
state.activate(current_field);
}
}
/// CLIENT API: Deactivate autocomplete
/// Deactivate autocomplete (hides dropdown)
fn deactivate_autocomplete(&mut self) {
if let Some(state) = self.autocomplete_state_mut() {
state.deactivate();
}
}
/// CLIENT API: Set suggestions (called after async fetch completes)
/// 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);
}
}
/// CLIENT API: Set loading state
/// 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;
}
}
/// Check if autocomplete is currently active
// === 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 is ready for interaction
/// 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)
}
/// INTERNAL: Apply selected autocomplete value to current field
fn apply_autocomplete_selection(&mut self) -> Option<String> {
// First, get the selected value and display text (if any)
let selection_info = if let Some(state) = self.autocomplete_state() {
state.get_selected().map(|selected| {
(selected.value_to_store.clone(), selected.display_text.clone())
})
} else {
None
};
/// Check if there are available suggestions
fn has_autocomplete_suggestions(&self) -> bool {
self.autocomplete_state()
.map(|state| !state.suggestions.is_empty())
.unwrap_or(false)
}
// Apply the selection if we have one
if let Some((value, display)) = selection_info {
// Apply the value to current field
*self.get_current_input_mut() = value;
self.set_has_unsaved_changes(true);
// === USER-IMPLEMENTABLE METHODS ===
// Deactivate autocomplete
if let Some(state_mut) = self.autocomplete_state_mut() {
state_mut.deactivate();
/// 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();
}
}
}
Some(format!("Selected: {}", display))
/// 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

@@ -0,0 +1,43 @@
// src/canvas/actions/handlers/dispatcher.rs
use crate::canvas::state::{CanvasState, ActionContext};
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>(
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() {
AppMode::Edit => {
handle_edit_action(action, state, ideal_cursor_column).await
}
AppMode::ReadOnly => {
handle_readonly_action(action, state, ideal_cursor_column).await
}
AppMode::Highlight => {
handle_highlight_action(action, state, ideal_cursor_column).await
}
AppMode::General | AppMode::Command => {
Ok(ActionResult::success_with_message("Mode does not handle canvas actions directly"))
}
}
}

View File

@@ -5,18 +5,13 @@
//! and cursor movement with edit-specific behavior (cursor can go past end of text).
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::config::introspection::{ActionHandlerIntrospection, HandlerCapabilities, ActionSpec};
use crate::canvas::actions::movement::*;
use crate::canvas::state::CanvasState;
use crate::config::CanvasConfig;
use anyhow::Result;
/// Edit mode uses cursor-past-end behavior for text insertion
const FOR_EDIT_MODE: bool = true;
/// Empty struct that implements edit mode capabilities
pub struct EditHandler;
/// Handle actions in edit mode with edit-specific cursor behavior
///
/// Edit mode allows text modification and uses cursor positioning that can
@@ -26,12 +21,10 @@ pub struct EditHandler;
/// * `action` - The action to perform
/// * `state` - Mutable canvas state
/// * `ideal_cursor_column` - Desired column for vertical movement (maintained across line changes)
/// * `config` - Optional configuration for behavior customization
pub async fn handle_edit_action<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
config: Option<&CanvasConfig>,
) -> Result<ActionResult> {
match action {
CanvasAction::InsertChar(c) => {
@@ -187,25 +180,17 @@ pub async fn handle_edit_action<S: CanvasState>(
Ok(ActionResult::success())
}
// Field navigation with wrapping behavior
// Field navigation with simple wrapping behavior
CanvasAction::NextField | CanvasAction::PrevField => {
let current_field = state.current_field();
let total_fields = state.fields().len();
let new_field = match action {
CanvasAction::NextField => {
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
(current_field + 1) % total_fields // Wrap to first field
} else {
(current_field + 1).min(total_fields - 1) // Stop at last field
}
(current_field + 1) % total_fields // Simple wrap
}
CanvasAction::PrevField => {
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
if current_field == 0 { total_fields - 1 } else { current_field - 1 } // Wrap to last field
} else {
current_field.saturating_sub(1) // Stop at first field
}
if current_field == 0 { total_fields - 1 } else { current_field - 1 } // Simple wrap
}
_ => unreachable!(),
};
@@ -226,151 +211,3 @@ pub async fn handle_edit_action<S: CanvasState>(
}
}
}
impl ActionHandlerIntrospection for EditHandler {
/// Report all actions this handler supports with examples and requirements
/// Used for automatic config generation and validation
fn introspect() -> HandlerCapabilities {
let mut actions = Vec::new();
// REQUIRED ACTIONS - These must be configured for edit mode to work properly
actions.push(ActionSpec {
name: "move_left".to_string(),
description: "Move cursor one position to the left".to_string(),
examples: vec!["Left".to_string(), "h".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "move_right".to_string(),
description: "Move cursor one position to the right".to_string(),
examples: vec!["Right".to_string(), "l".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "move_up".to_string(),
description: "Move to previous field or line".to_string(),
examples: vec!["Up".to_string(), "k".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "move_down".to_string(),
description: "Move to next field or line".to_string(),
examples: vec!["Down".to_string(), "j".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "delete_char_backward".to_string(),
description: "Delete character before cursor (Backspace)".to_string(),
examples: vec!["Backspace".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "next_field".to_string(),
description: "Move to next input field".to_string(),
examples: vec!["Tab".to_string(), "Enter".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "prev_field".to_string(),
description: "Move to previous input field".to_string(),
examples: vec!["Shift+Tab".to_string()],
is_required: true,
});
// OPTIONAL ACTIONS - These enhance functionality but aren't required
actions.push(ActionSpec {
name: "move_word_next".to_string(),
description: "Move cursor to start of next word".to_string(),
examples: vec!["Ctrl+Right".to_string(), "w".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_word_prev".to_string(),
description: "Move cursor to start of previous word".to_string(),
examples: vec!["Ctrl+Left".to_string(), "b".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_word_end".to_string(),
description: "Move cursor to end of current/next word".to_string(),
examples: vec!["e".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_word_end_prev".to_string(),
description: "Move cursor to end of previous word".to_string(),
examples: vec!["ge".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_line_start".to_string(),
description: "Move cursor to beginning of line".to_string(),
examples: vec!["Home".to_string(), "0".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_line_end".to_string(),
description: "Move cursor to end of line".to_string(),
examples: vec!["End".to_string(), "$".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_first_line".to_string(),
description: "Move to first field".to_string(),
examples: vec!["Ctrl+Home".to_string(), "gg".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_last_line".to_string(),
description: "Move to last field".to_string(),
examples: vec!["Ctrl+End".to_string(), "G".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "delete_char_forward".to_string(),
description: "Delete character after cursor (Delete key)".to_string(),
examples: vec!["Delete".to_string()],
is_required: false,
});
HandlerCapabilities {
mode_name: "edit".to_string(),
actions,
auto_handled: vec![
"insert_char".to_string(), // Any printable character is inserted automatically
],
}
}
fn validate_capabilities() -> Result<(), String> {
// TODO: Could add runtime validation that the handler actually
// implements all the actions it claims to support
// For now, just validate that we have the essential actions
let caps = Self::introspect();
let required_count = caps.actions.iter().filter(|a| a.is_required).count();
if required_count < 7 { // We expect at least 7 required actions
return Err(format!(
"Edit handler claims only {} required actions, expected at least 7",
required_count
));
}
Ok(())
}
}

View File

@@ -1,24 +1,18 @@
// src/canvas/actions/handlers/highlight.rs
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::config::introspection::{ActionHandlerIntrospection, HandlerCapabilities, ActionSpec};
use crate::canvas::actions::movement::*;
use crate::canvas::state::CanvasState;
use crate::config::CanvasConfig;
use anyhow::Result;
const FOR_EDIT_MODE: bool = false; // Highlight mode uses read-only cursor behavior
pub struct HighlightHandler;
/// Handle actions in highlight/visual mode
/// TODO: Implement selection logic and highlight-specific behaviors
pub async fn handle_highlight_action<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
config: Option<&CanvasConfig>,
) -> Result<ActionResult> {
match action {
// Movement actions work similar to read-only mode but with selection
@@ -108,98 +102,3 @@ pub async fn handle_highlight_action<S: CanvasState>(
}
}
}
impl ActionHandlerIntrospection for HighlightHandler {
fn introspect() -> HandlerCapabilities {
let mut actions = Vec::new();
// For now, highlight mode uses similar movement to readonly
// but this will be discovered from actual implementation
// REQUIRED ACTIONS - Basic movement in highlight mode
actions.push(ActionSpec {
name: "move_left".to_string(),
description: "Move cursor left and extend selection".to_string(),
examples: vec!["h".to_string(), "Left".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "move_right".to_string(),
description: "Move cursor right and extend selection".to_string(),
examples: vec!["l".to_string(), "Right".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "move_up".to_string(),
description: "Move up and extend selection".to_string(),
examples: vec!["k".to_string(), "Up".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "move_down".to_string(),
description: "Move down and extend selection".to_string(),
examples: vec!["j".to_string(), "Down".to_string()],
is_required: true,
});
// OPTIONAL ACTIONS - Advanced highlight movement
actions.push(ActionSpec {
name: "move_word_next".to_string(),
description: "Move to next word and extend selection".to_string(),
examples: vec!["w".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_word_end".to_string(),
description: "Move to word end and extend selection".to_string(),
examples: vec!["e".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_word_prev".to_string(),
description: "Move to previous word and extend selection".to_string(),
examples: vec!["b".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_line_start".to_string(),
description: "Move to line start and extend selection".to_string(),
examples: vec!["0".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_line_end".to_string(),
description: "Move to line end and extend selection".to_string(),
examples: vec!["$".to_string()],
is_required: false,
});
HandlerCapabilities {
mode_name: "highlight".to_string(),
actions,
auto_handled: vec![], // Highlight mode has no auto-handled actions
}
}
fn validate_capabilities() -> Result<(), String> {
let caps = Self::introspect();
let required_count = caps.actions.iter().filter(|a| a.is_required).count();
if required_count < 4 { // We expect at least 4 required actions (basic movement)
return Err(format!(
"Highlight handler claims only {} required actions, expected at least 4",
required_count
));
}
Ok(())
}
}

View File

@@ -3,8 +3,9 @@
pub mod edit;
pub mod readonly;
pub mod highlight;
pub mod dispatcher;
// Re-export handler functions
pub use edit::handle_edit_action;
pub use readonly::handle_readonly_action;
pub use highlight::handle_highlight_action;
pub use dispatcher::dispatch_action;

View File

@@ -1,10 +1,8 @@
// src/canvas/actions/handlers/readonly.rs
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::config::introspection::{ActionHandlerIntrospection, HandlerCapabilities, ActionSpec};
use crate::canvas::actions::movement::*;
use crate::canvas::state::CanvasState;
use crate::config::CanvasConfig;
use anyhow::Result;
const FOR_EDIT_MODE: bool = false; // Read-only mode flag
@@ -14,7 +12,6 @@ pub async fn handle_readonly_action<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
config: Option<&CanvasConfig>,
) -> Result<ActionResult> {
match action {
CanvasAction::MoveLeft => {
@@ -155,18 +152,10 @@ pub async fn handle_readonly_action<S: CanvasState>(
let new_field = match action {
CanvasAction::NextField => {
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
(current_field + 1) % total_fields
} else {
(current_field + 1).min(total_fields - 1)
}
(current_field + 1) % total_fields // Simple wrap
}
CanvasAction::PrevField => {
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
if current_field == 0 { total_fields - 1 } else { current_field - 1 }
} else {
current_field.saturating_sub(1)
}
if current_field == 0 { total_fields - 1 } else { current_field - 1 } // Simple wrap
}
_ => unreachable!(),
};
@@ -192,131 +181,3 @@ pub async fn handle_readonly_action<S: CanvasState>(
}
}
}
pub struct ReadOnlyHandler;
impl ActionHandlerIntrospection for ReadOnlyHandler {
fn introspect() -> HandlerCapabilities {
let mut actions = Vec::new();
// REQUIRED ACTIONS - Navigation is essential in read-only mode
actions.push(ActionSpec {
name: "move_left".to_string(),
description: "Move cursor one position to the left".to_string(),
examples: vec!["h".to_string(), "Left".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "move_right".to_string(),
description: "Move cursor one position to the right".to_string(),
examples: vec!["l".to_string(), "Right".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "move_up".to_string(),
description: "Move to previous field".to_string(),
examples: vec!["k".to_string(), "Up".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "move_down".to_string(),
description: "Move to next field".to_string(),
examples: vec!["j".to_string(), "Down".to_string()],
is_required: true,
});
// OPTIONAL ACTIONS - Advanced navigation features
actions.push(ActionSpec {
name: "move_word_next".to_string(),
description: "Move cursor to start of next word".to_string(),
examples: vec!["w".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_word_prev".to_string(),
description: "Move cursor to start of previous word".to_string(),
examples: vec!["b".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_word_end".to_string(),
description: "Move cursor to end of current/next word".to_string(),
examples: vec!["e".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_word_end_prev".to_string(),
description: "Move cursor to end of previous word".to_string(),
examples: vec!["ge".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_line_start".to_string(),
description: "Move cursor to beginning of line".to_string(),
examples: vec!["0".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_line_end".to_string(),
description: "Move cursor to end of line".to_string(),
examples: vec!["$".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_first_line".to_string(),
description: "Move to first field".to_string(),
examples: vec!["gg".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_last_line".to_string(),
description: "Move to last field".to_string(),
examples: vec!["G".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "next_field".to_string(),
description: "Move to next input field".to_string(),
examples: vec!["Tab".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "prev_field".to_string(),
description: "Move to previous input field".to_string(),
examples: vec!["Shift+Tab".to_string()],
is_required: false,
});
HandlerCapabilities {
mode_name: "read_only".to_string(),
actions,
auto_handled: vec![], // Read-only mode has no auto-handled actions
}
}
fn validate_capabilities() -> Result<(), String> {
let caps = Self::introspect();
let required_count = caps.actions.iter().filter(|a| a.is_required).count();
if required_count < 4 { // We expect at least 4 required actions (basic movement)
return Err(format!(
"ReadOnly handler claims only {} required actions, expected at least 4",
required_count
));
}
Ok(())
}
}

View File

@@ -1,8 +1,8 @@
// src/canvas/actions/mod.rs
pub mod types;
pub mod movement;
pub mod handlers;
pub mod movement;
// Re-export the main types
pub use types::{CanvasAction, ActionResult};
// Re-export the main API
pub use types::{CanvasAction, ActionResult, execute};

View File

@@ -1,35 +1,37 @@
// src/canvas/actions/types.rs
use crate::canvas::state::CanvasState;
use anyhow::Result;
/// All available canvas actions
#[derive(Debug, Clone, PartialEq)]
pub enum CanvasAction {
// Character input
InsertChar(char),
// Deletion
DeleteBackward,
DeleteForward,
// Basic cursor movement
// Movement actions
MoveLeft,
MoveRight,
MoveUp,
MoveDown,
// Word movement
MoveWordNext,
MoveWordPrev,
MoveWordEnd,
MoveWordEndPrev,
// Line movement
MoveLineStart,
MoveLineEnd,
// Field movement
NextField,
PrevField,
MoveFirstLine,
MoveLastLine,
// Word movement
MoveWordNext,
MoveWordEnd,
MoveWordPrev,
MoveWordEndPrev,
// Field navigation
NextField,
PrevField,
// Editing actions
InsertChar(char),
DeleteBackward,
DeleteForward,
// Autocomplete actions
TriggerAutocomplete,
@@ -42,67 +44,141 @@ pub enum CanvasAction {
Custom(String),
}
impl CanvasAction {
/// Convert string action name to CanvasAction enum (config-driven)
pub fn from_string(action: &str) -> Self {
match action {
"delete_char_backward" => Self::DeleteBackward,
"delete_char_forward" => Self::DeleteForward,
"move_left" => Self::MoveLeft,
"move_right" => Self::MoveRight,
"move_up" => Self::MoveUp,
"move_down" => Self::MoveDown,
"move_line_start" => Self::MoveLineStart,
"move_line_end" => Self::MoveLineEnd,
"move_first_line" => Self::MoveFirstLine,
"move_last_line" => Self::MoveLastLine,
"move_word_next" => Self::MoveWordNext,
"move_word_end" => Self::MoveWordEnd,
"move_word_prev" => Self::MoveWordPrev,
"move_word_end_prev" => Self::MoveWordEndPrev,
"next_field" => Self::NextField,
"prev_field" => Self::PrevField,
"trigger_autocomplete" => Self::TriggerAutocomplete,
"suggestion_up" => Self::SuggestionUp,
"suggestion_down" => Self::SuggestionDown,
"select_suggestion" => Self::SelectSuggestion,
"exit_suggestions" => Self::ExitSuggestions,
_ => Self::Custom(action.to_string()),
}
}
}
#[derive(Debug, Clone, PartialEq)]
/// Result type for canvas actions
#[derive(Debug, Clone)]
pub enum ActionResult {
Success(Option<String>),
HandledByFeature(String),
RequiresContext(String),
Success,
Message(String),
HandledByApp(String),
HandledByFeature(String), // Keep for compatibility
Error(String),
}
impl ActionResult {
pub fn success() -> Self {
Self::Success(None)
Self::Success
}
pub fn success_with_message(msg: &str) -> Self {
Self::Success(Some(msg.to_string()))
Self::Message(msg.to_string())
}
pub fn handled_by_app(msg: &str) -> Self {
Self::HandledByApp(msg.to_string())
}
pub fn error(msg: &str) -> Self {
Self::Error(msg.into())
Self::Error(msg.to_string())
}
pub fn is_success(&self) -> bool {
matches!(self, Self::Success(_) | Self::HandledByFeature(_))
matches!(self, Self::Success | Self::Message(_) | Self::HandledByApp(_) | Self::HandledByFeature(_))
}
pub fn message(&self) -> Option<&str> {
match self {
Self::Success(msg) => msg.as_deref(),
Self::HandledByFeature(msg) => Some(msg),
Self::RequiresContext(msg) => Some(msg),
Self::Error(msg) => Some(msg),
Self::Message(msg) | Self::HandledByApp(msg) | Self::HandledByFeature(msg) | Self::Error(msg) => Some(msg),
Self::Success => None,
}
}
}
/// 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 {
/// Get a human-readable description of this action
pub fn description(&self) -> &'static str {
match self {
Self::MoveLeft => "move left",
Self::MoveRight => "move right",
Self::MoveUp => "move up",
Self::MoveDown => "move down",
Self::MoveWordNext => "next word",
Self::MoveWordPrev => "previous word",
Self::MoveWordEnd => "word end",
Self::MoveWordEndPrev => "previous word end",
Self::MoveLineStart => "line start",
Self::MoveLineEnd => "line end",
Self::NextField => "next field",
Self::PrevField => "previous field",
Self::MoveFirstLine => "first field",
Self::MoveLastLine => "last field",
Self::InsertChar(c) => "insert character",
Self::DeleteBackward => "delete backward",
Self::DeleteForward => "delete forward",
Self::TriggerAutocomplete => "trigger autocomplete",
Self::SuggestionUp => "suggestion up",
Self::SuggestionDown => "suggestion down",
Self::SelectSuggestion => "select suggestion",
Self::ExitSuggestions => "exit suggestions",
Self::Custom(name) => "custom action",
}
}
/// Get all movement-related actions
pub fn movement_actions() -> Vec<CanvasAction> {
vec![
Self::MoveLeft,
Self::MoveRight,
Self::MoveUp,
Self::MoveDown,
Self::MoveWordNext,
Self::MoveWordPrev,
Self::MoveWordEnd,
Self::MoveWordEndPrev,
Self::MoveLineStart,
Self::MoveLineEnd,
Self::NextField,
Self::PrevField,
Self::MoveFirstLine,
Self::MoveLastLine,
]
}
/// Get all editing-related actions
pub fn editing_actions() -> Vec<CanvasAction> {
vec![
Self::InsertChar(' '), // Example char
Self::DeleteBackward,
Self::DeleteForward,
]
}
/// Get all autocomplete-related actions
pub fn autocomplete_actions() -> Vec<CanvasAction> {
vec![
Self::TriggerAutocomplete,
Self::SuggestionUp,
Self::SuggestionDown,
Self::SelectSuggestion,
Self::ExitSuggestions,
]
}
/// Check if this action modifies text content
pub fn is_editing_action(&self) -> bool {
matches!(self,
Self::InsertChar(_) |
Self::DeleteBackward |
Self::DeleteForward
)
}
/// Check if this action moves the cursor
pub fn is_movement_action(&self) -> bool {
matches!(self,
Self::MoveLeft | Self::MoveRight | Self::MoveUp | Self::MoveDown |
Self::MoveWordNext | Self::MoveWordPrev | Self::MoveWordEnd | Self::MoveWordEndPrev |
Self::MoveLineStart | Self::MoveLineEnd | Self::NextField | Self::PrevField |
Self::MoveFirstLine | Self::MoveLastLine
)
}
}

View File

@@ -1,18 +1,16 @@
// src/canvas/mod.rs
pub mod actions;
pub mod gui;
pub mod modes;
pub mod state;
pub mod theme;
// Re-export commonly used canvas types
// Re-export main types for convenience
pub use actions::{CanvasAction, ActionResult};
pub use modes::{AppMode, ModeManager, HighlightState};
pub use state::{CanvasState, ActionContext};
// Re-export the main entry point
pub use crate::dispatcher::execute_canvas_action;
#[cfg(feature = "gui")]
pub use theme::CanvasTheme;

View File

@@ -1,665 +0,0 @@
// src/config/config.rs
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crossterm::event::{KeyCode, KeyModifiers};
use anyhow::{Context, Result};
// Import from sibling modules
use super::registry::ActionRegistry;
use super::validation::{ConfigValidator, ValidationError, ValidationResult, ValidationWarning};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanvasKeybindings {
pub edit: HashMap<String, Vec<String>>,
pub read_only: HashMap<String, Vec<String>>,
pub global: HashMap<String, Vec<String>>,
}
impl Default for CanvasKeybindings {
fn default() -> Self {
Self {
edit: HashMap::new(),
read_only: HashMap::new(),
global: HashMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanvasBehavior {
pub confirm_on_save: bool,
pub auto_indent: bool,
pub wrap_search: bool,
pub wrap_around_fields: bool,
}
impl Default for CanvasBehavior {
fn default() -> Self {
Self {
confirm_on_save: true,
auto_indent: true,
wrap_search: true,
wrap_around_fields: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanvasAppearance {
pub line_numbers: bool,
pub syntax_highlighting: bool,
pub current_line_highlight: bool,
}
impl Default for CanvasAppearance {
fn default() -> Self {
Self {
line_numbers: true,
syntax_highlighting: true,
current_line_highlight: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanvasConfig {
pub keybindings: CanvasKeybindings,
pub behavior: CanvasBehavior,
pub appearance: CanvasAppearance,
}
impl Default for CanvasConfig {
fn default() -> Self {
Self {
keybindings: CanvasKeybindings::with_vim_defaults(),
behavior: CanvasBehavior::default(),
appearance: CanvasAppearance::default(),
}
}
}
impl CanvasKeybindings {
/// Generate complete vim defaults from introspection system
/// This ensures defaults are always in sync with actual handler capabilities
pub fn with_vim_defaults() -> Self {
let registry = ActionRegistry::from_handlers();
Self::generate_from_registry(&registry)
}
/// Generate keybindings from action registry (used by both defaults and config generation)
/// This is the single source of truth for what keybindings should exist
fn generate_from_registry(registry: &ActionRegistry) -> Self {
let mut keybindings = Self::default();
// Generate keybindings for each mode discovered by introspection
for (mode_name, mode_registry) in &registry.modes {
let mode_bindings = match mode_name.as_str() {
"edit" => &mut keybindings.edit,
"read_only" => &mut keybindings.read_only,
"highlight" => &mut keybindings.global, // Highlight actions go in global
_ => {
// Handle any future modes discovered by introspection
eprintln!("Warning: Unknown mode '{}' discovered by introspection", mode_name);
continue;
}
};
// Add ALL required actions
for (action_name, action_spec) in &mode_registry.required {
if !action_spec.examples.is_empty() {
mode_bindings.insert(
action_name.clone(),
action_spec.examples.clone()
);
}
}
// Add ALL optional actions
for (action_name, action_spec) in &mode_registry.optional {
if !action_spec.examples.is_empty() {
mode_bindings.insert(
action_name.clone(),
action_spec.examples.clone()
);
}
}
}
keybindings
}
/// Generate a minimal fallback configuration if introspection fails
/// This should rarely be used, but provides safety net
fn minimal_fallback() -> Self {
let mut keybindings = Self::default();
// Absolute minimum required for basic functionality
keybindings.read_only.insert("move_left".to_string(), vec!["h".to_string()]);
keybindings.read_only.insert("move_right".to_string(), vec!["l".to_string()]);
keybindings.read_only.insert("move_up".to_string(), vec!["k".to_string()]);
keybindings.read_only.insert("move_down".to_string(), vec!["j".to_string()]);
keybindings.edit.insert("delete_char_backward".to_string(), vec!["Backspace".to_string()]);
keybindings.edit.insert("move_left".to_string(), vec!["Left".to_string()]);
keybindings.edit.insert("move_right".to_string(), vec!["Right".to_string()]);
keybindings.edit.insert("move_up".to_string(), vec!["Up".to_string()]);
keybindings.edit.insert("move_down".to_string(), vec!["Down".to_string()]);
keybindings
}
/// Validate that generated keybindings match the current introspection state
/// This helps catch when handlers change but defaults become stale
pub fn validate_against_introspection(&self) -> Result<(), Vec<String>> {
let registry = ActionRegistry::from_handlers();
let expected = Self::generate_from_registry(&registry);
let mut errors = Vec::new();
// Check each mode
for (mode_name, expected_bindings) in [
("edit", &expected.edit),
("read_only", &expected.read_only),
("global", &expected.global),
] {
let actual_bindings = match mode_name {
"edit" => &self.edit,
"read_only" => &self.read_only,
"global" => &self.global,
_ => continue,
};
// Check for missing actions
for action_name in expected_bindings.keys() {
if !actual_bindings.contains_key(action_name) {
errors.push(format!(
"Missing action '{}' in {} mode (expected by introspection)",
action_name, mode_name
));
}
}
// Check for unexpected actions
for action_name in actual_bindings.keys() {
if !expected_bindings.contains_key(action_name) {
errors.push(format!(
"Unexpected action '{}' in {} mode (not found in introspection)",
action_name, mode_name
));
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
impl CanvasConfig {
/// Enhanced load method with introspection validation
pub fn load() -> Self {
match Self::load_and_validate() {
Ok(config) => config,
Err(e) => {
eprintln!("Failed to load config file: {}", e);
eprintln!("Using auto-generated defaults from introspection");
Self::default()
}
}
}
/// Load and validate configuration with enhanced introspection checks
pub fn load_and_validate() -> Result<Self> {
// Try to load canvas_config.toml from current directory
let config = if let Ok(config) = Self::from_file(std::path::Path::new("canvas_config.toml")) {
config
} else {
// Use auto-generated defaults if file doesn't exist
eprintln!("Config file not found, using auto-generated defaults");
Self::default()
};
// Validate the configuration against current introspection state
let registry = ActionRegistry::from_handlers();
// Validate handlers are working correctly
if let Err(handler_errors) = registry.validate_against_implementation() {
eprintln!("Handler validation warnings:");
for error in handler_errors {
eprintln!(" - {}", error);
}
}
// Validate the configuration against the dynamic registry
let validator = ConfigValidator::new(registry);
let validation_result = validator.validate_keybindings(&config.keybindings);
if !validation_result.is_valid {
eprintln!("Configuration validation failed:");
validator.print_validation_result(&validation_result);
} else if !validation_result.warnings.is_empty() {
eprintln!("Configuration validation warnings:");
validator.print_validation_result(&validation_result);
}
// Optional: Validate that our defaults match introspection
if let Err(sync_errors) = config.keybindings.validate_against_introspection() {
eprintln!("Default keybindings out of sync with introspection:");
for error in sync_errors {
eprintln!(" - {}", error);
}
}
Ok(config)
}
/// Generate a complete configuration template that matches current defaults
/// This ensures the generated config file has the same content as defaults
pub fn generate_complete_template() -> String {
let registry = ActionRegistry::from_handlers();
let defaults = CanvasKeybindings::generate_from_registry(&registry);
let mut template = String::new();
template.push_str("# Canvas Library Configuration\n");
template.push_str("# Auto-generated from handler introspection\n");
template.push_str("# This config contains ALL available actions\n\n");
// Generate sections for each mode
for (mode_name, bindings) in [
("read_only", &defaults.read_only),
("edit", &defaults.edit),
("global", &defaults.global),
] {
if bindings.is_empty() {
continue;
}
template.push_str(&format!("[keybindings.{}]\n", mode_name));
// Get mode registry for categorization
if let Some(mode_registry) = registry.get_mode_registry(mode_name) {
// Required actions first
let mut found_required = false;
for (action_name, keybindings) in bindings {
if mode_registry.required.contains_key(action_name) {
if !found_required {
template.push_str("# Required\n");
found_required = true;
}
template.push_str(&format!("{} = {:?}\n", action_name, keybindings));
}
}
// Optional actions second
let mut found_optional = false;
for (action_name, keybindings) in bindings {
if mode_registry.optional.contains_key(action_name) {
if !found_optional {
template.push_str("# Optional\n");
found_optional = true;
}
template.push_str(&format!("{} = {:?}\n", action_name, keybindings));
}
}
} else {
// Fallback: just list all actions
for (action_name, keybindings) in bindings {
template.push_str(&format!("{} = {:?}\n", action_name, keybindings));
}
}
template.push('\n');
}
template
}
/// Generate config that only contains actions different from defaults
/// Useful for minimal user configs
pub fn generate_minimal_template() -> String {
let defaults = CanvasKeybindings::with_vim_defaults();
let mut template = String::new();
template.push_str("# Minimal Canvas Configuration\n");
template.push_str("# Only uncomment and modify the keybindings you want to change\n");
template.push_str("# All other actions will use their default vim-style keybindings\n\n");
for (mode_name, bindings) in [
("read_only", &defaults.read_only),
("edit", &defaults.edit),
("global", &defaults.global),
] {
if bindings.is_empty() {
continue;
}
template.push_str(&format!("# [keybindings.{}]\n", mode_name));
for (action_name, keybindings) in bindings {
template.push_str(&format!("# {} = {:?}\n", action_name, keybindings));
}
template.push('\n');
}
template
}
/// Generate template from actual handler capabilities (legacy method for compatibility)
pub fn generate_template() -> String {
Self::generate_complete_template()
}
/// Generate clean template from actual handler capabilities (legacy method for compatibility)
pub fn generate_clean_template() -> String {
let registry = ActionRegistry::from_handlers();
// Validate handlers first
if let Err(errors) = registry.validate_against_implementation() {
for error in errors {
eprintln!(" - {}", error);
}
}
registry.generate_clean_template()
}
/// Validate current configuration against actual implementation
pub fn validate(&self) -> ValidationResult {
let registry = ActionRegistry::from_handlers();
let validator = ConfigValidator::new(registry);
validator.validate_keybindings(&self.keybindings)
}
/// Print validation results for current config
pub fn print_validation(&self) {
let registry = ActionRegistry::from_handlers();
let validator = ConfigValidator::new(registry);
let result = validator.validate_keybindings(&self.keybindings);
validator.print_validation_result(&result);
}
/// Load from TOML string
pub fn from_toml(toml_str: &str) -> Result<Self> {
toml::from_str(toml_str)
.context("Failed to parse TOML configuration")
}
/// Load from file
pub fn from_file(path: &std::path::Path) -> Result<Self> {
let contents = std::fs::read_to_string(path)
.context("Failed to read config file")?;
Self::from_toml(&contents)
}
/// Check if autocomplete should auto-trigger (simple logic)
pub fn should_auto_trigger_autocomplete(&self) -> bool {
// If trigger_autocomplete keybinding exists anywhere, use manual mode only
// If no trigger_autocomplete keybinding, use auto-trigger mode
!self.has_trigger_autocomplete_keybinding()
}
/// Check if user has configured manual trigger keybinding
pub fn has_trigger_autocomplete_keybinding(&self) -> bool {
self.keybindings.edit.contains_key("trigger_autocomplete") ||
self.keybindings.read_only.contains_key("trigger_autocomplete") ||
self.keybindings.global.contains_key("trigger_autocomplete")
}
/// Get action for key in read-only mode
pub fn get_read_only_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
self.get_action_in_mode(&self.keybindings.read_only, key, modifiers)
.or_else(|| self.get_action_in_mode(&self.keybindings.global, key, modifiers))
}
/// Get action for key in edit mode
pub fn get_edit_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
self.get_action_in_mode(&self.keybindings.edit, key, modifiers)
.or_else(|| self.get_action_in_mode(&self.keybindings.global, key, modifiers))
}
/// Get action for key (mode-aware)
pub fn get_action_for_key(&self, key: KeyCode, modifiers: KeyModifiers, is_edit_mode: bool, _has_suggestions: bool) -> Option<&str> {
// Check mode-specific
if is_edit_mode {
self.get_edit_action(key, modifiers)
} else {
self.get_read_only_action(key, modifiers)
}
}
fn get_action_in_mode<'a>(&self, mode_bindings: &'a HashMap<String, Vec<String>>, key: KeyCode, modifiers: KeyModifiers) -> Option<&'a str> {
for (action, bindings) in mode_bindings {
for binding in bindings {
if self.matches_keybinding(binding, key, modifiers) {
return Some(action);
}
}
}
None
}
fn matches_keybinding(&self, binding: &str, key: KeyCode, modifiers: KeyModifiers) -> bool {
// Special handling for shift+character combinations
if binding.to_lowercase().starts_with("shift+") {
let parts: Vec<&str> = binding.split('+').collect();
if parts.len() == 2 && parts[1].len() == 1 {
let expected_lowercase = parts[1].chars().next().unwrap().to_lowercase().next().unwrap();
let expected_uppercase = expected_lowercase.to_uppercase().next().unwrap();
if let KeyCode::Char(actual_char) = key {
if actual_char == expected_uppercase && modifiers.contains(KeyModifiers::SHIFT) {
return true;
}
}
}
}
// Handle Shift+Tab -> BackTab
if binding.to_lowercase() == "shift+tab" && key == KeyCode::BackTab && modifiers.is_empty() {
return true;
}
// Handle multi-character bindings (all standard keys without modifiers)
if binding.len() > 1 && !binding.contains('+') {
return match binding.to_lowercase().as_str() {
// Navigation keys
"left" => key == KeyCode::Left,
"right" => key == KeyCode::Right,
"up" => key == KeyCode::Up,
"down" => key == KeyCode::Down,
"home" => key == KeyCode::Home,
"end" => key == KeyCode::End,
"pageup" | "pgup" => key == KeyCode::PageUp,
"pagedown" | "pgdn" => key == KeyCode::PageDown,
// Editing keys
"insert" | "ins" => key == KeyCode::Insert,
"delete" | "del" => key == KeyCode::Delete,
"backspace" => key == KeyCode::Backspace,
// Tab keys
"tab" => key == KeyCode::Tab,
"backtab" => key == KeyCode::BackTab,
// Special keys
"enter" | "return" => key == KeyCode::Enter,
"escape" | "esc" => key == KeyCode::Esc,
"space" => key == KeyCode::Char(' '),
// Function keys F1-F24
"f1" => key == KeyCode::F(1),
"f2" => key == KeyCode::F(2),
"f3" => key == KeyCode::F(3),
"f4" => key == KeyCode::F(4),
"f5" => key == KeyCode::F(5),
"f6" => key == KeyCode::F(6),
"f7" => key == KeyCode::F(7),
"f8" => key == KeyCode::F(8),
"f9" => key == KeyCode::F(9),
"f10" => key == KeyCode::F(10),
"f11" => key == KeyCode::F(11),
"f12" => key == KeyCode::F(12),
"f13" => key == KeyCode::F(13),
"f14" => key == KeyCode::F(14),
"f15" => key == KeyCode::F(15),
"f16" => key == KeyCode::F(16),
"f17" => key == KeyCode::F(17),
"f18" => key == KeyCode::F(18),
"f19" => key == KeyCode::F(19),
"f20" => key == KeyCode::F(20),
"f21" => key == KeyCode::F(21),
"f22" => key == KeyCode::F(22),
"f23" => key == KeyCode::F(23),
"f24" => key == KeyCode::F(24),
// Lock keys (may not work reliably in all terminals)
"capslock" => key == KeyCode::CapsLock,
"scrolllock" => key == KeyCode::ScrollLock,
"numlock" => key == KeyCode::NumLock,
// System keys
"printscreen" => key == KeyCode::PrintScreen,
"pause" => key == KeyCode::Pause,
"menu" => key == KeyCode::Menu,
"keypadbegin" => key == KeyCode::KeypadBegin,
// Media keys (rarely supported but included for completeness)
"mediaplay" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Play),
"mediapause" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Pause),
"mediaplaypause" => key == KeyCode::Media(crossterm::event::MediaKeyCode::PlayPause),
"mediareverse" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Reverse),
"mediastop" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Stop),
"mediafastforward" => key == KeyCode::Media(crossterm::event::MediaKeyCode::FastForward),
"mediarewind" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Rewind),
"mediatracknext" => key == KeyCode::Media(crossterm::event::MediaKeyCode::TrackNext),
"mediatrackprevious" => key == KeyCode::Media(crossterm::event::MediaKeyCode::TrackPrevious),
"mediarecord" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Record),
"medialowervolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::LowerVolume),
"mediaraisevolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::RaiseVolume),
"mediamutevolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::MuteVolume),
// Modifier keys (these work better as part of combinations)
"leftshift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftShift),
"leftcontrol" | "leftctrl" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftControl),
"leftalt" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftAlt),
"leftsuper" | "leftwindows" | "leftcmd" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftSuper),
"lefthyper" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftHyper),
"leftmeta" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftMeta),
"rightshift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightShift),
"rightcontrol" | "rightctrl" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightControl),
"rightalt" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightAlt),
"rightsuper" | "rightwindows" | "rightcmd" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightSuper),
"righthyper" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightHyper),
"rightmeta" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightMeta),
"isolevel3shift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::IsoLevel3Shift),
"isolevel5shift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::IsoLevel5Shift),
// Multi-key sequences need special handling
"gg" => false, // This needs sequence handling
_ => {
// Handle single characters and punctuation
if binding.len() == 1 {
if let Some(c) = binding.chars().next() {
key == KeyCode::Char(c)
} else {
false
}
} else {
false
}
}
};
}
// Handle modifier combinations (like "Ctrl+F5", "Alt+Shift+A")
let parts: Vec<&str> = binding.split('+').collect();
let mut expected_modifiers = KeyModifiers::empty();
let mut expected_key = None;
for part in parts {
match part.to_lowercase().as_str() {
// Modifiers
"ctrl" | "control" => expected_modifiers |= KeyModifiers::CONTROL,
"shift" => expected_modifiers |= KeyModifiers::SHIFT,
"alt" => expected_modifiers |= KeyModifiers::ALT,
"super" | "windows" | "cmd" => expected_modifiers |= KeyModifiers::SUPER,
"hyper" => expected_modifiers |= KeyModifiers::HYPER,
"meta" => expected_modifiers |= KeyModifiers::META,
// Navigation keys
"left" => expected_key = Some(KeyCode::Left),
"right" => expected_key = Some(KeyCode::Right),
"up" => expected_key = Some(KeyCode::Up),
"down" => expected_key = Some(KeyCode::Down),
"home" => expected_key = Some(KeyCode::Home),
"end" => expected_key = Some(KeyCode::End),
"pageup" | "pgup" => expected_key = Some(KeyCode::PageUp),
"pagedown" | "pgdn" => expected_key = Some(KeyCode::PageDown),
// Editing keys
"insert" | "ins" => expected_key = Some(KeyCode::Insert),
"delete" | "del" => expected_key = Some(KeyCode::Delete),
"backspace" => expected_key = Some(KeyCode::Backspace),
// Tab keys
"tab" => expected_key = Some(KeyCode::Tab),
"backtab" => expected_key = Some(KeyCode::BackTab),
// Special keys
"enter" | "return" => expected_key = Some(KeyCode::Enter),
"escape" | "esc" => expected_key = Some(KeyCode::Esc),
"space" => expected_key = Some(KeyCode::Char(' ')),
// Function keys
"f1" => expected_key = Some(KeyCode::F(1)),
"f2" => expected_key = Some(KeyCode::F(2)),
"f3" => expected_key = Some(KeyCode::F(3)),
"f4" => expected_key = Some(KeyCode::F(4)),
"f5" => expected_key = Some(KeyCode::F(5)),
"f6" => expected_key = Some(KeyCode::F(6)),
"f7" => expected_key = Some(KeyCode::F(7)),
"f8" => expected_key = Some(KeyCode::F(8)),
"f9" => expected_key = Some(KeyCode::F(9)),
"f10" => expected_key = Some(KeyCode::F(10)),
"f11" => expected_key = Some(KeyCode::F(11)),
"f12" => expected_key = Some(KeyCode::F(12)),
"f13" => expected_key = Some(KeyCode::F(13)),
"f14" => expected_key = Some(KeyCode::F(14)),
"f15" => expected_key = Some(KeyCode::F(15)),
"f16" => expected_key = Some(KeyCode::F(16)),
"f17" => expected_key = Some(KeyCode::F(17)),
"f18" => expected_key = Some(KeyCode::F(18)),
"f19" => expected_key = Some(KeyCode::F(19)),
"f20" => expected_key = Some(KeyCode::F(20)),
"f21" => expected_key = Some(KeyCode::F(21)),
"f22" => expected_key = Some(KeyCode::F(22)),
"f23" => expected_key = Some(KeyCode::F(23)),
"f24" => expected_key = Some(KeyCode::F(24)),
// Lock keys
"capslock" => expected_key = Some(KeyCode::CapsLock),
"scrolllock" => expected_key = Some(KeyCode::ScrollLock),
"numlock" => expected_key = Some(KeyCode::NumLock),
// System keys
"printscreen" => expected_key = Some(KeyCode::PrintScreen),
"pause" => expected_key = Some(KeyCode::Pause),
"menu" => expected_key = Some(KeyCode::Menu),
"keypadbegin" => expected_key = Some(KeyCode::KeypadBegin),
// Single character (letters, numbers, punctuation)
part => {
if part.len() == 1 {
if let Some(c) = part.chars().next() {
expected_key = Some(KeyCode::Char(c));
}
}
}
}
}
modifiers == expected_modifiers && Some(key) == expected_key
}
}

View File

@@ -1,93 +0,0 @@
// src/config/introspection.rs
//! Handler capability introspection system
//!
//! This module provides traits and utilities for handlers to report their capabilities,
//! enabling automatic configuration generation and validation.
use std::collections::HashMap;
/// Specification for a single action that a handler can perform
#[derive(Debug, Clone)]
pub struct ActionSpec {
/// Action name (e.g., "move_left", "delete_char_backward")
pub name: String,
/// Human-readable description of what this action does
pub description: String,
/// Example keybindings for this action (e.g., ["Left", "h"])
pub examples: Vec<String>,
/// Whether this action is required for the handler to function properly
pub is_required: bool,
}
/// Complete capability description for a single handler
#[derive(Debug, Clone)]
pub struct HandlerCapabilities {
/// Mode name this handler operates in (e.g., "edit", "read_only")
pub mode_name: String,
/// All actions this handler can perform
pub actions: Vec<ActionSpec>,
/// Actions handled automatically without configuration (e.g., "insert_char")
pub auto_handled: Vec<String>,
}
/// Trait that handlers implement to report their capabilities
///
/// This enables the configuration system to automatically discover what actions
/// are available and validate user configurations against actual implementations.
pub trait ActionHandlerIntrospection {
/// Return complete capability information for this handler
fn introspect() -> HandlerCapabilities;
/// Validate that this handler actually supports its claimed actions
/// Override this to add custom validation logic
fn validate_capabilities() -> Result<(), String> {
Ok(()) // Default: assume handler is valid
}
}
/// Discovers capabilities from all registered handlers
pub struct HandlerDiscovery;
impl HandlerDiscovery {
/// Discover capabilities from all known handlers
/// Add new handlers to this function as they are created
pub fn discover_all() -> HashMap<String, HandlerCapabilities> {
let mut capabilities = HashMap::new();
// Register all known handlers here
let edit_caps = crate::canvas::actions::handlers::edit::EditHandler::introspect();
capabilities.insert("edit".to_string(), edit_caps);
let readonly_caps = crate::canvas::actions::handlers::readonly::ReadOnlyHandler::introspect();
capabilities.insert("read_only".to_string(), readonly_caps);
let highlight_caps = crate::canvas::actions::handlers::highlight::HighlightHandler::introspect();
capabilities.insert("highlight".to_string(), highlight_caps);
capabilities
}
/// Validate all handlers support their claimed capabilities
pub fn validate_all_handlers() -> Result<(), Vec<String>> {
let mut errors = Vec::new();
// Validate each handler
if let Err(e) = crate::canvas::actions::handlers::edit::EditHandler::validate_capabilities() {
errors.push(format!("Edit handler: {}", e));
}
if let Err(e) = crate::canvas::actions::handlers::readonly::ReadOnlyHandler::validate_capabilities() {
errors.push(format!("ReadOnly handler: {}", e));
}
if let Err(e) = crate::canvas::actions::handlers::highlight::HighlightHandler::validate_capabilities() {
errors.push(format!("Highlight handler: {}", e));
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}

View File

@@ -1,12 +0,0 @@
// src/config/mod.rs
mod registry;
mod config;
mod validation;
pub mod introspection;
// Re-export everything from the main config module
pub use registry::*;
pub use validation::*;
pub use config::*;
pub use introspection::*;

View File

@@ -1,135 +0,0 @@
// src/config/registry.rs
use std::collections::HashMap;
use crate::config::introspection::{HandlerDiscovery, ActionSpec, HandlerCapabilities};
#[derive(Debug, Clone)]
pub struct ModeRegistry {
pub required: HashMap<String, ActionSpec>,
pub optional: HashMap<String, ActionSpec>,
pub auto_handled: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct ActionRegistry {
pub modes: HashMap<String, ModeRegistry>,
}
impl ActionRegistry {
/// NEW: Create registry by discovering actual handler capabilities
pub fn from_handlers() -> Self {
let handler_capabilities = HandlerDiscovery::discover_all();
let mut modes = HashMap::new();
for (mode_name, capabilities) in handler_capabilities {
let mode_registry = Self::build_mode_registry(capabilities);
modes.insert(mode_name, mode_registry);
}
Self { modes }
}
/// Build a mode registry from handler capabilities
fn build_mode_registry(capabilities: HandlerCapabilities) -> ModeRegistry {
let mut required = HashMap::new();
let mut optional = HashMap::new();
for action_spec in capabilities.actions {
if action_spec.is_required {
required.insert(action_spec.name.clone(), action_spec);
} else {
optional.insert(action_spec.name.clone(), action_spec);
}
}
ModeRegistry {
required,
optional,
auto_handled: capabilities.auto_handled,
}
}
/// Validate that the registry matches the actual implementation
pub fn validate_against_implementation(&self) -> Result<(), Vec<String>> {
HandlerDiscovery::validate_all_handlers()
}
pub fn get_mode_registry(&self, mode: &str) -> Option<&ModeRegistry> {
self.modes.get(mode)
}
pub fn all_known_actions(&self) -> Vec<String> {
let mut actions = Vec::new();
for registry in self.modes.values() {
actions.extend(registry.required.keys().cloned());
actions.extend(registry.optional.keys().cloned());
}
actions.sort();
actions.dedup();
actions
}
pub fn generate_config_template(&self) -> String {
let mut template = String::new();
template.push_str("# Canvas Library Configuration Template\n");
template.push_str("# Generated automatically from actual handler capabilities\n\n");
for (mode_name, registry) in &self.modes {
template.push_str(&format!("[keybindings.{}]\n", mode_name));
if !registry.required.is_empty() {
template.push_str("# REQUIRED ACTIONS - These must be configured\n");
for (name, spec) in &registry.required {
template.push_str(&format!("# {}\n", spec.description));
template.push_str(&format!("{} = {:?}\n\n", name, spec.examples));
}
}
if !registry.optional.is_empty() {
template.push_str("# OPTIONAL ACTIONS - Configure these if you want them enabled\n");
for (name, spec) in &registry.optional {
template.push_str(&format!("# {}\n", spec.description));
template.push_str(&format!("# {} = {:?}\n\n", name, spec.examples));
}
}
if !registry.auto_handled.is_empty() {
template.push_str("# AUTO-HANDLED - These are handled automatically, don't configure:\n");
for auto_action in &registry.auto_handled {
template.push_str(&format!("# {} (automatic)\n", auto_action));
}
template.push('\n');
}
}
template
}
pub fn generate_clean_template(&self) -> String {
let mut template = String::new();
for (mode_name, registry) in &self.modes {
template.push_str(&format!("[keybindings.{}]\n", mode_name));
if !registry.required.is_empty() {
template.push_str("# Required\n");
for (name, spec) in &registry.required {
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
}
}
if !registry.optional.is_empty() {
template.push_str("# Optional\n");
for (name, spec) in &registry.optional {
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
}
}
template.push('\n');
}
template
}
}

View File

@@ -1,278 +0,0 @@
// src/config/validation.rs
use std::collections::HashMap;
use thiserror::Error;
use crate::config::registry::{ActionRegistry, ModeRegistry};
use crate::config::CanvasKeybindings;
#[derive(Error, Debug)]
pub enum ValidationError {
#[error("Missing required action '{action}' in {mode} mode")]
MissingRequired {
action: String,
mode: String,
suggestion: String,
},
#[error("Unknown action '{action}' in {mode} mode")]
UnknownAction {
action: String,
mode: String,
similar: Vec<String>,
},
#[error("Multiple validation errors")]
Multiple(Vec<ValidationError>),
}
#[derive(Debug)]
pub struct ValidationWarning {
pub message: String,
pub suggestion: Option<String>,
}
#[derive(Debug)]
pub struct ValidationResult {
pub errors: Vec<ValidationError>,
pub warnings: Vec<ValidationWarning>,
pub is_valid: bool,
}
impl ValidationResult {
pub fn new() -> Self {
Self {
errors: Vec::new(),
warnings: Vec::new(),
is_valid: true,
}
}
pub fn add_error(&mut self, error: ValidationError) {
self.errors.push(error);
self.is_valid = false;
}
pub fn add_warning(&mut self, warning: ValidationWarning) {
self.warnings.push(warning);
}
pub fn merge(&mut self, other: ValidationResult) {
self.errors.extend(other.errors);
self.warnings.extend(other.warnings);
if !other.is_valid {
self.is_valid = false;
}
}
}
pub struct ConfigValidator {
registry: ActionRegistry,
}
impl ConfigValidator {
// FIXED: Accept registry parameter to match config.rs calls
pub fn new(registry: ActionRegistry) -> Self {
Self {
registry,
}
}
pub fn validate_keybindings(&self, keybindings: &CanvasKeybindings) -> ValidationResult {
let mut result = ValidationResult::new();
// Validate each mode that exists in the registry
if let Some(edit_registry) = self.registry.get_mode_registry("edit") {
result.merge(self.validate_mode_bindings(
"edit",
&keybindings.edit,
edit_registry
));
}
if let Some(readonly_registry) = self.registry.get_mode_registry("read_only") {
result.merge(self.validate_mode_bindings(
"read_only",
&keybindings.read_only,
readonly_registry
));
}
// Skip suggestions mode if not discovered by introspection
// (autocomplete is separate concern as requested)
// Skip global mode if not discovered by introspection
// (can be added later if needed)
result
}
fn validate_mode_bindings(
&self,
mode_name: &str,
bindings: &HashMap<String, Vec<String>>,
registry: &ModeRegistry
) -> ValidationResult {
let mut result = ValidationResult::new();
// Check for missing required actions
for (action_name, spec) in &registry.required {
if !bindings.contains_key(action_name) {
result.add_error(ValidationError::MissingRequired {
action: action_name.clone(),
mode: mode_name.to_string(),
suggestion: format!(
"Add to config: {} = {:?}",
action_name,
spec.examples
),
});
}
}
// Check for unknown actions
let all_known: std::collections::HashSet<_> = registry.required.keys()
.chain(registry.optional.keys())
.collect();
for action_name in bindings.keys() {
if !all_known.contains(action_name) {
let similar = self.find_similar_actions(action_name, &all_known);
result.add_error(ValidationError::UnknownAction {
action: action_name.clone(),
mode: mode_name.to_string(),
similar,
});
}
}
// Check for empty keybinding arrays
for (action_name, key_list) in bindings {
if key_list.is_empty() {
result.add_warning(ValidationWarning {
message: format!(
"Action '{}' in {} mode has empty keybinding list",
action_name, mode_name
),
suggestion: Some(format!(
"Either add keybindings or remove the action from config"
)),
});
}
}
// Warn about auto-handled actions that shouldn't be in config
for auto_action in &registry.auto_handled {
if bindings.contains_key(auto_action) {
result.add_warning(ValidationWarning {
message: format!(
"Action '{}' in {} mode is auto-handled and shouldn't be in config",
auto_action, mode_name
),
suggestion: Some(format!(
"Remove '{}' from config - it's handled automatically",
auto_action
)),
});
}
}
result
}
fn find_similar_actions(&self, action: &str, known_actions: &std::collections::HashSet<&String>) -> Vec<String> {
let mut similar = Vec::new();
for known in known_actions {
if self.is_similar(action, known) {
similar.push(known.to_string());
}
}
similar.sort();
similar.truncate(3); // Limit to 3 suggestions
similar
}
fn is_similar(&self, a: &str, b: &str) -> bool {
// Simple similarity check - could be improved with proper edit distance
let a_lower = a.to_lowercase();
let b_lower = b.to_lowercase();
// Check if one contains the other
if a_lower.contains(&b_lower) || b_lower.contains(&a_lower) {
return true;
}
// Check for common prefixes
let common_prefixes = ["move_", "delete_", "suggestion_"];
for prefix in &common_prefixes {
if a_lower.starts_with(prefix) && b_lower.starts_with(prefix) {
return true;
}
}
false
}
pub fn print_validation_result(&self, result: &ValidationResult) {
if result.is_valid && result.warnings.is_empty() {
println!("✅ Canvas configuration is valid!");
return;
}
if !result.errors.is_empty() {
println!("❌ Canvas configuration has errors:");
for error in &result.errors {
match error {
ValidationError::MissingRequired { action, mode, suggestion } => {
println!(" • Missing required action '{}' in {} mode", action, mode);
println!(" 💡 {}", suggestion);
}
ValidationError::UnknownAction { action, mode, similar } => {
println!(" • Unknown action '{}' in {} mode", action, mode);
if !similar.is_empty() {
println!(" 💡 Did you mean: {}", similar.join(", "));
}
}
ValidationError::Multiple(_) => {
println!(" • Multiple errors occurred");
}
}
println!();
}
}
if !result.warnings.is_empty() {
println!("⚠️ Canvas configuration has warnings:");
for warning in &result.warnings {
println!("{}", warning.message);
if let Some(suggestion) = &warning.suggestion {
println!(" 💡 {}", suggestion);
}
println!();
}
}
if !result.is_valid {
println!("🔧 To generate a config template, use:");
println!(" CanvasConfig::generate_template()");
}
}
pub fn generate_missing_config(&self, keybindings: &CanvasKeybindings) -> String {
let mut config = String::new();
let validation = self.validate_keybindings(keybindings);
for error in &validation.errors {
if let ValidationError::MissingRequired { action, mode, suggestion } = error {
if config.is_empty() {
config.push_str(&format!("# Missing required actions for canvas\n\n"));
config.push_str(&format!("[keybindings.{}]\n", mode));
}
config.push_str(&format!("{}\n", suggestion));
}
}
config
}
}

View File

@@ -1,110 +0,0 @@
// src/dispatcher.rs
use crate::canvas::state::{CanvasState, ActionContext};
use crate::canvas::actions::{CanvasAction, ActionResult};
use crate::canvas::actions::handlers::{handle_edit_action, handle_readonly_action, handle_highlight_action};
use crate::canvas::modes::AppMode;
use crate::config::CanvasConfig;
use crossterm::event::{KeyCode, KeyModifiers};
/// Main entry point for executing canvas actions
pub async fn execute_canvas_action<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
config: Option<&CanvasConfig>,
) -> anyhow::Result<ActionResult> {
ActionDispatcher::dispatch_with_config(action, state, ideal_cursor_column, config).await
}
/// High-level action dispatcher that routes actions to mode-specific handlers
pub struct ActionDispatcher;
impl ActionDispatcher {
/// Dispatch any action to the appropriate mode handler
pub async fn dispatch<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> anyhow::Result<ActionResult> {
let config = CanvasConfig::load();
Self::dispatch_with_config(action, state, ideal_cursor_column, Some(&config)).await
}
/// Dispatch action with provided config
pub async fn dispatch_with_config<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
config: Option<&CanvasConfig>,
) -> anyhow::Result<ActionResult> {
// Check for feature-specific handling first
let context = ActionContext {
key_code: None,
ideal_cursor_column: *ideal_cursor_column,
current_input: state.get_current_input().to_string(),
current_field: state.current_field(),
};
if let Some(result) = state.handle_feature_action(&action, &context) {
return Ok(ActionResult::HandledByFeature(result));
}
// Route to mode-specific handler
match state.current_mode() {
AppMode::Edit => {
handle_edit_action(action, state, ideal_cursor_column, config).await
}
AppMode::ReadOnly => {
handle_readonly_action(action, state, ideal_cursor_column, config).await
}
AppMode::Highlight => {
handle_highlight_action(action, state, ideal_cursor_column, config).await
}
AppMode::General | AppMode::Command => {
// These modes might not handle canvas actions directly
Ok(ActionResult::success_with_message("Mode does not handle canvas actions"))
}
}
}
/// Quick action dispatch from KeyCode using config
pub async fn dispatch_key<S: CanvasState>(
key: KeyCode,
modifiers: KeyModifiers,
state: &mut S,
ideal_cursor_column: &mut usize,
is_edit_mode: bool,
has_suggestions: bool,
) -> anyhow::Result<Option<ActionResult>> {
let config = CanvasConfig::load();
if let Some(action_name) = config.get_action_for_key(key, modifiers, is_edit_mode, has_suggestions) {
let action = CanvasAction::from_string(action_name);
let result = Self::dispatch_with_config(action, state, ideal_cursor_column, Some(&config)).await?;
Ok(Some(result))
} else {
Ok(None)
}
}
/// Batch dispatch multiple actions
pub async fn dispatch_batch<S: CanvasState>(
actions: Vec<CanvasAction>,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> anyhow::Result<Vec<ActionResult>> {
let mut results = Vec::new();
for action in actions {
let result = Self::dispatch(action, state, ideal_cursor_column).await?;
let is_success = result.is_success();
results.push(result);
// Stop on first error
if !is_success {
break;
}
}
Ok(results)
}
}

View File

@@ -1,11 +1,31 @@
// src/lib.rs
pub mod canvas;
// Only include autocomplete module if feature is enabled
#[cfg(feature = "autocomplete")]
pub mod autocomplete;
pub mod config;
pub mod dispatcher;
// Re-export the main API for easy access
pub use dispatcher::{execute_canvas_action, ActionDispatcher};
pub use canvas::actions::{CanvasAction, ActionResult};
pub use canvas::actions::{CanvasAction, ActionResult, execute};
pub use canvas::state::{CanvasState, ActionContext};
pub use canvas::modes::{AppMode, HighlightState, ModeManager};
pub use canvas::modes::{AppMode, ModeManager, HighlightState};
#[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;

View File

@@ -50,7 +50,7 @@ move_right = ["l", "Right"]
move_down = ["j", "Down"]
# Optional
move_line_end = ["$"]
move_word_next = ["w"]
# move_word_next = ["w"]
next_field = ["Tab"]
move_word_prev = ["b"]
move_word_end = ["e"]