Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f99aa79ec | ||
|
|
c594c35b37 | ||
|
|
828a63c30c | ||
|
|
36690e674a |
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -475,6 +475,7 @@ name = "canvas"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"common",
|
||||
"crossterm",
|
||||
"ratatui",
|
||||
|
||||
@@ -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"
|
||||
|
||||
417
canvas/examples/autocomplete.rs
Normal file
417
canvas/examples/autocomplete.rs
Normal 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(())
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
43
canvas/src/canvas/actions/handlers/dispatcher.rs
Normal file
43
canvas/src/canvas/actions/handlers/dispatcher.rs
Normal 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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(®istry)
|
||||
}
|
||||
|
||||
/// 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 ®istry.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(®istry);
|
||||
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(®istry);
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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 ®istry.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 ®istry.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 ®istry.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 ®istry.required {
|
||||
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
|
||||
}
|
||||
}
|
||||
|
||||
if !registry.optional.is_empty() {
|
||||
template.push_str("# Optional\n");
|
||||
for (name, spec) in ®istry.optional {
|
||||
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
|
||||
}
|
||||
}
|
||||
|
||||
template.push('\n');
|
||||
}
|
||||
|
||||
template
|
||||
}
|
||||
}
|
||||
@@ -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 ®istry.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 ®istry.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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user