Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8788323c62 | ||
|
|
5b64996462 | ||
|
|
3f4380ff48 | ||
|
|
59a29aa54b | ||
|
|
5d084bf822 | ||
|
|
ebe4adaa5d | ||
|
|
c3441647e0 | ||
|
|
574803988d | ||
|
|
9ff3c59961 | ||
|
|
c5f22d7da1 | ||
|
|
3c62877757 | ||
|
|
cc19c61f37 | ||
|
|
ad82bd4302 | ||
|
|
d584a25fdb |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@
|
||||
server/tantivy_indexes
|
||||
steel_decimal/tests/property_tests.proptest-regressions
|
||||
.direnv/
|
||||
canvas/*.toml
|
||||
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -479,6 +479,7 @@ dependencies = [
|
||||
"crossterm",
|
||||
"ratatui",
|
||||
"serde",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
"toml",
|
||||
|
||||
@@ -18,6 +18,7 @@ tokio = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
unicode-width.workspace = true
|
||||
thiserror = { workspace = true }
|
||||
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.19"
|
||||
@@ -28,3 +29,7 @@ tokio-test = "0.4.4"
|
||||
[features]
|
||||
default = []
|
||||
gui = ["ratatui"]
|
||||
|
||||
[[example]]
|
||||
name = "ratatui_demo"
|
||||
path = "examples/ratatui_demo.rs"
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
# canvas_config.toml - Complete Canvas Configuration
|
||||
|
||||
[behavior]
|
||||
wrap_around_fields = true
|
||||
auto_save_on_field_change = false
|
||||
word_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"
|
||||
max_suggestions = 6
|
||||
|
||||
[appearance]
|
||||
cursor_style = "block" # "block", "bar", "underline"
|
||||
show_field_numbers = false
|
||||
highlight_current_field = true
|
||||
|
||||
# Read-only mode keybindings (vim-style)
|
||||
[keybindings.read_only]
|
||||
move_left = ["h"]
|
||||
move_right = ["l"]
|
||||
move_up = ["k"]
|
||||
move_down = ["j"]
|
||||
move_word_next = ["w"]
|
||||
move_word_end = ["e"]
|
||||
move_word_prev = ["b"]
|
||||
move_word_end_prev = ["ge"]
|
||||
move_line_start = ["0"]
|
||||
move_line_end = ["$"]
|
||||
move_first_line = ["gg"]
|
||||
move_last_line = ["shift+g"]
|
||||
next_field = ["Tab"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
|
||||
# Edit mode keybindings
|
||||
[keybindings.edit]
|
||||
delete_char_backward = ["Backspace"]
|
||||
delete_char_forward = ["Delete"]
|
||||
move_left = ["Left"]
|
||||
move_right = ["Right"]
|
||||
move_up = ["Up"]
|
||||
move_down = ["Down"]
|
||||
move_line_start = ["Home"]
|
||||
move_line_end = ["End"]
|
||||
move_word_next = ["Ctrl+Right"]
|
||||
move_word_prev = ["Ctrl+Left"]
|
||||
next_field = ["Tab"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
trigger_autocomplete = ["Ctrl+p"]
|
||||
|
||||
|
||||
# Suggestion/autocomplete keybindings
|
||||
[keybindings.suggestions]
|
||||
suggestion_up = ["Up", "Ctrl+p"]
|
||||
suggestion_down = ["Down", "Ctrl+n"]
|
||||
select_suggestion = ["Enter", "Tab"]
|
||||
exit_suggestions = ["Esc"]
|
||||
|
||||
# Global keybindings (work in both modes)
|
||||
[keybindings.global]
|
||||
move_up = ["Up"]
|
||||
move_down = ["Down"]
|
||||
77
canvas/docs/new_function_to_config.txt
Normal file
77
canvas/docs/new_function_to_config.txt
Normal file
@@ -0,0 +1,77 @@
|
||||
❯ git status
|
||||
On branch main
|
||||
Your branch is ahead of 'origin/main' by 1 commit.
|
||||
(use "git push" to publish your local commits)
|
||||
|
||||
Changes not staged for commit:
|
||||
(use "git add <file>..." to update what will be committed)
|
||||
(use "git restore <file>..." to discard changes in working directory)
|
||||
modified: src/canvas/actions/handlers/edit.rs
|
||||
modified: src/canvas/actions/types.rs
|
||||
|
||||
no changes added to commit (use "git add" and/or "git commit -a")
|
||||
❯ git --no-pager diff
|
||||
diff --git a/canvas/src/canvas/actions/handlers/edit.rs b/canvas/src/canvas/actions/handlers/edit.rs
|
||||
index a26fe6f..fa1becb 100644
|
||||
--- a/canvas/src/canvas/actions/handlers/edit.rs
|
||||
+++ b/canvas/src/canvas/actions/handlers/edit.rs
|
||||
@@ -29,6 +29,21 @@ pub async fn handle_edit_action<S: CanvasState>(
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
+ CanvasAction::SelectAll => {
|
||||
+ // Select all text in current field
|
||||
+ let current_input = state.get_current_input();
|
||||
+ let text_length = current_input.len();
|
||||
+
|
||||
+ // Set cursor to start and select all
|
||||
+ state.set_current_cursor_pos(0);
|
||||
+ // TODO: You'd need to add selection state to CanvasState trait
|
||||
+ // For now, just move cursor to end to "select" all
|
||||
+ state.set_current_cursor_pos(text_length);
|
||||
+ *ideal_cursor_column = text_length;
|
||||
+
|
||||
+ Ok(ActionResult::success_with_message(&format!("Selected all {} characters", text_length)))
|
||||
+ }
|
||||
+
|
||||
CanvasAction::DeleteBackward => {
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
if cursor_pos > 0 {
|
||||
@@ -323,6 +338,13 @@ impl ActionHandlerIntrospection for EditHandler {
|
||||
is_required: false,
|
||||
});
|
||||
|
||||
+ actions.push(ActionSpec {
|
||||
+ name: "select_all".to_string(),
|
||||
+ description: "Select all text in current field".to_string(),
|
||||
+ examples: vec!["Ctrl+a".to_string()],
|
||||
+ is_required: false, // Optional action
|
||||
+ });
|
||||
+
|
||||
HandlerCapabilities {
|
||||
mode_name: "edit".to_string(),
|
||||
actions,
|
||||
diff --git a/canvas/src/canvas/actions/types.rs b/canvas/src/canvas/actions/types.rs
|
||||
index 433a4d5..3794596 100644
|
||||
--- a/canvas/src/canvas/actions/types.rs
|
||||
+++ b/canvas/src/canvas/actions/types.rs
|
||||
@@ -31,6 +31,8 @@ pub enum CanvasAction {
|
||||
NextField,
|
||||
PrevField,
|
||||
|
||||
+ SelectAll,
|
||||
+
|
||||
// Autocomplete actions
|
||||
TriggerAutocomplete,
|
||||
SuggestionUp,
|
||||
@@ -62,6 +64,7 @@ impl CanvasAction {
|
||||
"move_word_end_prev" => Self::MoveWordEndPrev,
|
||||
"next_field" => Self::NextField,
|
||||
"prev_field" => Self::PrevField,
|
||||
+ "select_all" => Self::SelectAll,
|
||||
"trigger_autocomplete" => Self::TriggerAutocomplete,
|
||||
"suggestion_up" => Self::SuggestionUp,
|
||||
"suggestion_down" => Self::SuggestionDown,
|
||||
╭─ ~/Doc/p/komp_ac/canvas on main ⇡1 !2
|
||||
╰─
|
||||
|
||||
367
canvas/examples/canvas_gui_demo.rs
Normal file
367
canvas/examples/canvas_gui_demo.rs
Normal file
@@ -0,0 +1,367 @@
|
||||
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, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use canvas::{
|
||||
canvas::{
|
||||
gui::render_canvas,
|
||||
modes::{AppMode, HighlightState, ModeManager},
|
||||
state::{ActionContext, CanvasState},
|
||||
theme::CanvasTheme,
|
||||
},
|
||||
config::CanvasConfig,
|
||||
dispatcher::ActionDispatcher,
|
||||
CanvasAction,
|
||||
};
|
||||
|
||||
// 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 }
|
||||
}
|
||||
|
||||
// Demo form state
|
||||
struct DemoFormState {
|
||||
fields: Vec<String>,
|
||||
field_names: Vec<String>,
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
mode: AppMode,
|
||||
highlight_state: HighlightState,
|
||||
has_changes: bool,
|
||||
ideal_cursor_column: usize,
|
||||
last_action: Option<String>,
|
||||
debug_message: String,
|
||||
}
|
||||
|
||||
impl DemoFormState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
fields: vec![
|
||||
"John Doe".to_string(),
|
||||
"john.doe@example.com".to_string(),
|
||||
"+1 234 567 8900".to_string(),
|
||||
"123 Main Street Apt 4B".to_string(),
|
||||
"San Francisco".to_string(),
|
||||
"This is a test comment with multiple words".to_string(),
|
||||
],
|
||||
field_names: vec![
|
||||
"Name".to_string(),
|
||||
"Email".to_string(),
|
||||
"Phone".to_string(),
|
||||
"Address".to_string(),
|
||||
"City".to_string(),
|
||||
"Comments".to_string(),
|
||||
],
|
||||
current_field: 0,
|
||||
cursor_pos: 0,
|
||||
mode: AppMode::ReadOnly,
|
||||
highlight_state: HighlightState::Off,
|
||||
has_changes: false,
|
||||
ideal_cursor_column: 0,
|
||||
last_action: None,
|
||||
debug_message: "Ready".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn enter_edit_mode(&mut self) {
|
||||
if ModeManager::can_enter_edit_mode(self.mode) {
|
||||
self.mode = AppMode::Edit;
|
||||
self.debug_message = "Entered EDIT mode".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn enter_readonly_mode(&mut self) {
|
||||
if ModeManager::can_enter_read_only_mode(self.mode) {
|
||||
self.mode = AppMode::ReadOnly;
|
||||
self.highlight_state = HighlightState::Off;
|
||||
self.debug_message = "Entered READ-ONLY mode".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn enter_highlight_mode(&mut self) {
|
||||
if ModeManager::can_enter_highlight_mode(self.mode) {
|
||||
self.mode = AppMode::Highlight;
|
||||
self.highlight_state = HighlightState::Characterwise {
|
||||
anchor: (self.current_field, self.cursor_pos),
|
||||
};
|
||||
self.debug_message = "Entered VISUAL mode".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasState for DemoFormState {
|
||||
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));
|
||||
self.cursor_pos = self.fields[self.current_field].len();
|
||||
}
|
||||
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||
let max_pos = self.fields[self.current_field].len();
|
||||
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> {
|
||||
match action {
|
||||
CanvasAction::Custom(cmd) => {
|
||||
match cmd.as_str() {
|
||||
"enter_edit_mode" => {
|
||||
self.enter_edit_mode();
|
||||
Some("Entered edit mode".to_string())
|
||||
}
|
||||
"enter_readonly_mode" => {
|
||||
self.enter_readonly_mode();
|
||||
Some("Entered read-only mode".to_string())
|
||||
}
|
||||
"enter_highlight_mode" => {
|
||||
self.enter_highlight_mode();
|
||||
Some("Entered highlight mode".to_string())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut state: DemoFormState, config: CanvasConfig) -> 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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ui(f: &mut Frame, state: &DemoFormState, theme: &DemoTheme) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(8),
|
||||
Constraint::Length(4),
|
||||
])
|
||||
.split(f.area());
|
||||
|
||||
// Render the canvas form
|
||||
render_canvas(
|
||||
f,
|
||||
chunks[0],
|
||||
state,
|
||||
theme,
|
||||
state.mode == AppMode::Edit,
|
||||
&state.highlight_state,
|
||||
);
|
||||
|
||||
// Render status bar
|
||||
let mode_text = match state.mode {
|
||||
AppMode::Edit => "EDIT",
|
||||
AppMode::ReadOnly => "NORMAL",
|
||||
AppMode::Highlight => "VISUAL",
|
||||
AppMode::General => "GENERAL",
|
||||
AppMode::Command => "COMMAND",
|
||||
};
|
||||
|
||||
let status_text = if state.has_changes {
|
||||
format!("-- {} -- [Modified]", mode_text)
|
||||
} else {
|
||||
format!("-- {} --", mode_text)
|
||||
};
|
||||
|
||||
let position_text = format!("Field: {}/{} | Cursor: {} | Column: {}",
|
||||
state.current_field + 1,
|
||||
state.fields.len(),
|
||||
state.cursor_pos,
|
||||
state.ideal_cursor_column);
|
||||
|
||||
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::Highlight => "hjkl/arrows: Select | w/b/e: Words | 0/$: Line | Esc: Normal | F10: Quit",
|
||||
_ => "Esc: Normal | F10: Quit",
|
||||
};
|
||||
|
||||
let status = Paragraph::new(vec![
|
||||
Line::from(Span::styled(status_text, Style::default().fg(theme.accent()))),
|
||||
Line::from(Span::styled(position_text, Style::default().fg(theme.fg()))),
|
||||
Line::from(Span::styled(state.debug_message.clone(), Style::default().fg(theme.warning()))),
|
||||
Line::from(Span::styled(help_text, Style::default().fg(theme.secondary()))),
|
||||
])
|
||||
.block(Block::default().borders(Borders::ALL).title("Status"));
|
||||
|
||||
f.render_widget(status, chunks[1]);
|
||||
}
|
||||
|
||||
#[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)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let state = DemoFormState::new();
|
||||
|
||||
let res = run_app(&mut terminal, state, config).await;
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(err) = res {
|
||||
println!("{:?}", err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
21
canvas/examples/generate_template.rs
Normal file
21
canvas/examples/generate_template.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
// 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,9 +1,9 @@
|
||||
// canvas/src/autocomplete/actions.rs
|
||||
// src/autocomplete/actions.rs
|
||||
|
||||
use crate::canvas::state::{CanvasState, ActionContext};
|
||||
use crate::autocomplete::state::AutocompleteCanvasState;
|
||||
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
||||
use crate::canvas::actions::edit::handle_generic_canvas_action;
|
||||
use crate::dispatcher::ActionDispatcher; // NEW: Use dispatcher directly
|
||||
use crate::config::CanvasConfig;
|
||||
use anyhow::Result;
|
||||
|
||||
@@ -26,40 +26,35 @@ pub async fn execute_canvas_action_with_autocomplete<S: CanvasState + Autocomple
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// 2. Handle generic actions and add auto-trigger logic
|
||||
let result = handle_generic_canvas_action(action.clone(), state, ideal_cursor_column, config).await?;
|
||||
|
||||
// 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 {
|
||||
println!("{:?}, {}", action, cfg.should_auto_trigger_autocomplete());
|
||||
if cfg.should_auto_trigger_autocomplete() {
|
||||
println!("AUTO-TRIGGER");
|
||||
match action {
|
||||
CanvasAction::InsertChar(_) => {
|
||||
println!("AUTO-T on Ins");
|
||||
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
|
||||
&& current_input.len() >= 1
|
||||
{
|
||||
println!("ACT AUTOC");
|
||||
state.activate_autocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
CanvasAction::NextField | CanvasAction::PrevField => {
|
||||
println!("AUTO-T on nav");
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
// canvas/src/canvas/actions/edit.rs
|
||||
|
||||
use crate::canvas::state::{CanvasState, ActionContext};
|
||||
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
||||
use crate::config::CanvasConfig;
|
||||
use anyhow::Result;
|
||||
|
||||
/// Execute a typed canvas action on any CanvasState implementation
|
||||
pub async fn execute_canvas_action<S: CanvasState>(
|
||||
action: CanvasAction,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
config: Option<&CanvasConfig>,
|
||||
) -> Result<ActionResult> {
|
||||
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));
|
||||
}
|
||||
|
||||
handle_generic_canvas_action(action, state, ideal_cursor_column, config).await
|
||||
}
|
||||
|
||||
/// Handle core canvas actions with full type safety
|
||||
pub async fn handle_generic_canvas_action<S: CanvasState>(
|
||||
action: CanvasAction,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
config: Option<&CanvasConfig>,
|
||||
) -> Result<ActionResult> {
|
||||
match action {
|
||||
CanvasAction::InsertChar(c) => {
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
let input = state.get_current_input_mut();
|
||||
input.insert(cursor_pos, c);
|
||||
state.set_current_cursor_pos(cursor_pos + 1);
|
||||
state.set_has_unsaved_changes(true);
|
||||
*ideal_cursor_column = cursor_pos + 1;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::NextField | CanvasAction::PrevField => {
|
||||
let old_field = state.current_field();
|
||||
let total_fields = state.fields().len();
|
||||
|
||||
// Perform field navigation
|
||||
let new_field = match action {
|
||||
CanvasAction::NextField => {
|
||||
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
|
||||
(old_field + 1) % total_fields
|
||||
} else {
|
||||
(old_field + 1).min(total_fields - 1)
|
||||
}
|
||||
}
|
||||
CanvasAction::PrevField => {
|
||||
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
|
||||
if old_field == 0 { total_fields - 1 } else { old_field - 1 }
|
||||
} else {
|
||||
old_field.saturating_sub(1)
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
state.set_current_field(new_field);
|
||||
*ideal_cursor_column = state.current_cursor_pos();
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::DeleteBackward => {
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
if cursor_pos > 0 {
|
||||
let input = state.get_current_input_mut();
|
||||
input.remove(cursor_pos - 1);
|
||||
state.set_current_cursor_pos(cursor_pos - 1);
|
||||
state.set_has_unsaved_changes(true);
|
||||
*ideal_cursor_column = cursor_pos - 1;
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::DeleteForward => {
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
let input = state.get_current_input_mut();
|
||||
if cursor_pos < input.len() {
|
||||
input.remove(cursor_pos);
|
||||
state.set_has_unsaved_changes(true);
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveLeft => {
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
if cursor_pos > 0 {
|
||||
state.set_current_cursor_pos(cursor_pos - 1);
|
||||
*ideal_cursor_column = cursor_pos - 1;
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveRight => {
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
let current_input = state.get_current_input();
|
||||
if cursor_pos < current_input.len() {
|
||||
state.set_current_cursor_pos(cursor_pos + 1);
|
||||
*ideal_cursor_column = cursor_pos + 1;
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveLineStart => {
|
||||
state.set_current_cursor_pos(0);
|
||||
*ideal_cursor_column = 0;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveLineEnd => {
|
||||
let end_pos = state.get_current_input().len();
|
||||
state.set_current_cursor_pos(end_pos);
|
||||
*ideal_cursor_column = end_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveUp => {
|
||||
// For single-line fields, move to previous field
|
||||
let current_field = state.current_field();
|
||||
if current_field > 0 {
|
||||
state.set_current_field(current_field - 1);
|
||||
*ideal_cursor_column = state.current_cursor_pos();
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveDown => {
|
||||
// For single-line fields, move to next field
|
||||
let current_field = state.current_field();
|
||||
let total_fields = state.fields().len();
|
||||
if current_field < total_fields - 1 {
|
||||
state.set_current_field(current_field + 1);
|
||||
*ideal_cursor_column = state.current_cursor_pos();
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveFirstLine => {
|
||||
state.set_current_field(0);
|
||||
state.set_current_cursor_pos(0);
|
||||
*ideal_cursor_column = 0;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveLastLine => {
|
||||
let last_field = state.fields().len() - 1;
|
||||
state.set_current_field(last_field);
|
||||
let end_pos = state.get_current_input().len();
|
||||
state.set_current_cursor_pos(end_pos);
|
||||
*ideal_cursor_column = end_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordNext => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordEnd => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_word_end(current_input, state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordPrev => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::Custom(action_str) => {
|
||||
Ok(ActionResult::success_with_message(&format!("Custom action: {}", action_str)))
|
||||
}
|
||||
|
||||
_ => Ok(ActionResult::success_with_message("Action not implemented")),
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for word navigation
|
||||
fn find_next_word_start(text: &str, cursor_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut pos = cursor_pos;
|
||||
|
||||
// Skip current word
|
||||
while pos < chars.len() && chars[pos].is_alphanumeric() {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
// Skip whitespace
|
||||
while pos < chars.len() && chars[pos].is_whitespace() {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
fn find_word_end(text: &str, cursor_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut pos = cursor_pos;
|
||||
|
||||
// Move to end of current word
|
||||
while pos < chars.len() && chars[pos].is_alphanumeric() {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
fn find_prev_word_start(text: &str, cursor_pos: usize) -> usize {
|
||||
if cursor_pos == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut pos = cursor_pos.saturating_sub(1);
|
||||
|
||||
// Skip whitespace
|
||||
while pos > 0 && chars[pos].is_whitespace() {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
// Skip to start of word
|
||||
while pos > 0 && chars[pos - 1].is_alphanumeric() {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
376
canvas/src/canvas/actions/handlers/edit.rs
Normal file
376
canvas/src/canvas/actions/handlers/edit.rs
Normal file
@@ -0,0 +1,376 @@
|
||||
// src/canvas/actions/handlers/edit.rs
|
||||
//! Edit mode action handler
|
||||
//!
|
||||
//! Handles user input when in edit mode, supporting text entry, deletion,
|
||||
//! and cursor movement with edit-specific behavior (cursor can go past end of text).
|
||||
|
||||
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
|
||||
/// go past the end of existing text to facilitate insertion.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `action` - The action to perform
|
||||
/// * `state` - Mutable canvas state
|
||||
/// * `ideal_cursor_column` - Desired column for vertical movement (maintained across line changes)
|
||||
/// * `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) => {
|
||||
// Insert character at cursor position and advance cursor
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
let input = state.get_current_input_mut();
|
||||
input.insert(cursor_pos, c);
|
||||
state.set_current_cursor_pos(cursor_pos + 1);
|
||||
state.set_has_unsaved_changes(true);
|
||||
*ideal_cursor_column = cursor_pos + 1;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::DeleteBackward => {
|
||||
// Delete character before cursor (Backspace behavior)
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
if cursor_pos > 0 {
|
||||
let input = state.get_current_input_mut();
|
||||
input.remove(cursor_pos - 1);
|
||||
state.set_current_cursor_pos(cursor_pos - 1);
|
||||
state.set_has_unsaved_changes(true);
|
||||
*ideal_cursor_column = cursor_pos - 1;
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::DeleteForward => {
|
||||
// Delete character at cursor position (Delete key behavior)
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
let input = state.get_current_input_mut();
|
||||
if cursor_pos < input.len() {
|
||||
input.remove(cursor_pos);
|
||||
state.set_has_unsaved_changes(true);
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
// Cursor movement actions
|
||||
CanvasAction::MoveLeft => {
|
||||
let new_pos = move_left(state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveRight => {
|
||||
let current_input = state.get_current_input();
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
// Field navigation (treating single-line fields as "lines")
|
||||
CanvasAction::MoveUp => {
|
||||
let current_field = state.current_field();
|
||||
if current_field > 0 {
|
||||
state.set_current_field(current_field - 1);
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveDown => {
|
||||
let current_field = state.current_field();
|
||||
let total_fields = state.fields().len();
|
||||
if current_field < total_fields - 1 {
|
||||
state.set_current_field(current_field + 1);
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
// Line-based movement
|
||||
CanvasAction::MoveLineStart => {
|
||||
let new_pos = line_start_position();
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveLineEnd => {
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
// Document-level movement (first/last field)
|
||||
CanvasAction::MoveFirstLine => {
|
||||
state.set_current_field(0);
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = safe_cursor_position(current_input, 0, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveLastLine => {
|
||||
let last_field = state.fields().len() - 1;
|
||||
state.set_current_field(last_field);
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
// Word-based movement
|
||||
CanvasAction::MoveWordNext => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordEnd => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_word_end(current_input, state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordPrev => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordEndPrev => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_end(current_input, state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
// Field navigation with 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
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::Custom(action_str) => {
|
||||
Ok(ActionResult::success_with_message(&format!("Custom edit action: {}", action_str)))
|
||||
}
|
||||
|
||||
_ => {
|
||||
Ok(ActionResult::success_with_message("Action not implemented for edit mode"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
205
canvas/src/canvas/actions/handlers/highlight.rs
Normal file
205
canvas/src/canvas/actions/handlers/highlight.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
// 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
|
||||
CanvasAction::MoveLeft => {
|
||||
let new_pos = move_left(state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
// TODO: Update selection range
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveRight => {
|
||||
let current_input = state.get_current_input();
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
// TODO: Update selection range
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordNext => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
|
||||
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(final_pos);
|
||||
*ideal_cursor_column = final_pos;
|
||||
// TODO: Update selection range
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordEnd => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_word_end(current_input, state.current_cursor_pos());
|
||||
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(final_pos);
|
||||
*ideal_cursor_column = final_pos;
|
||||
// TODO: Update selection range
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordPrev => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
// TODO: Update selection range
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveLineStart => {
|
||||
let new_pos = line_start_position();
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
// TODO: Update selection range
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveLineEnd => {
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
// TODO: Update selection range
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
// Highlight mode doesn't handle editing actions
|
||||
CanvasAction::InsertChar(_) |
|
||||
CanvasAction::DeleteBackward |
|
||||
CanvasAction::DeleteForward => {
|
||||
Ok(ActionResult::success_with_message("Action not available in highlight mode"))
|
||||
}
|
||||
|
||||
CanvasAction::Custom(action_str) => {
|
||||
Ok(ActionResult::success_with_message(&format!("Custom highlight action: {}", action_str)))
|
||||
}
|
||||
|
||||
_ => {
|
||||
Ok(ActionResult::success_with_message("Action not implemented for highlight mode"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
10
canvas/src/canvas/actions/handlers/mod.rs
Normal file
10
canvas/src/canvas/actions/handlers/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
// src/canvas/actions/handlers/mod.rs
|
||||
|
||||
pub mod edit;
|
||||
pub mod readonly;
|
||||
pub mod highlight;
|
||||
|
||||
// Re-export handler functions
|
||||
pub use edit::handle_edit_action;
|
||||
pub use readonly::handle_readonly_action;
|
||||
pub use highlight::handle_highlight_action;
|
||||
322
canvas/src/canvas/actions/handlers/readonly.rs
Normal file
322
canvas/src/canvas/actions/handlers/readonly.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
// 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
|
||||
|
||||
/// Handle actions in read-only mode with read-only specific cursor behavior
|
||||
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 => {
|
||||
let new_pos = move_left(state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveRight => {
|
||||
let current_input = state.get_current_input();
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveUp => {
|
||||
let current_field = state.current_field();
|
||||
let new_field = current_field.saturating_sub(1);
|
||||
state.set_current_field(new_field);
|
||||
|
||||
// Apply ideal cursor column with read-only bounds
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveDown => {
|
||||
let current_field = state.current_field();
|
||||
let total_fields = state.fields().len();
|
||||
if total_fields == 0 {
|
||||
return Ok(ActionResult::success_with_message("No fields to navigate"));
|
||||
}
|
||||
|
||||
let new_field = (current_field + 1).min(total_fields - 1);
|
||||
state.set_current_field(new_field);
|
||||
|
||||
// Apply ideal cursor column with read-only bounds
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveFirstLine => {
|
||||
let total_fields = state.fields().len();
|
||||
if total_fields == 0 {
|
||||
return Ok(ActionResult::success_with_message("No fields to navigate"));
|
||||
}
|
||||
|
||||
state.set_current_field(0);
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveLastLine => {
|
||||
let total_fields = state.fields().len();
|
||||
if total_fields == 0 {
|
||||
return Ok(ActionResult::success_with_message("No fields to navigate"));
|
||||
}
|
||||
|
||||
let last_field = total_fields - 1;
|
||||
state.set_current_field(last_field);
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveLineStart => {
|
||||
let new_pos = line_start_position();
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveLineEnd => {
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordNext => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
|
||||
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(final_pos);
|
||||
*ideal_cursor_column = final_pos;
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordEnd => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let new_pos = find_word_end(current_input, current_pos);
|
||||
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(final_pos);
|
||||
*ideal_cursor_column = final_pos;
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordPrev => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordEndPrev => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_end(current_input, state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
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
|
||||
} else {
|
||||
(current_field + 1).min(total_fields - 1)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
state.set_current_field(new_field);
|
||||
*ideal_cursor_column = state.current_cursor_pos();
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
// Read-only mode doesn't handle editing actions
|
||||
CanvasAction::InsertChar(_) |
|
||||
CanvasAction::DeleteBackward |
|
||||
CanvasAction::DeleteForward => {
|
||||
Ok(ActionResult::success_with_message("Action not available in read-only mode"))
|
||||
}
|
||||
|
||||
CanvasAction::Custom(action_str) => {
|
||||
Ok(ActionResult::success_with_message(&format!("Custom readonly action: {}", action_str)))
|
||||
}
|
||||
|
||||
_ => {
|
||||
Ok(ActionResult::success_with_message("Action not implemented for read-only mode"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,7 +1,8 @@
|
||||
// canvas/src/canvas/actions/mod.rs
|
||||
pub mod types;
|
||||
pub mod edit;
|
||||
// src/canvas/actions/mod.rs
|
||||
|
||||
// Re-export the main types for convenience
|
||||
pub mod types;
|
||||
pub mod movement;
|
||||
pub mod handlers;
|
||||
|
||||
// Re-export the main types
|
||||
pub use types::{CanvasAction, ActionResult};
|
||||
pub use edit::execute_canvas_action;
|
||||
|
||||
49
canvas/src/canvas/actions/movement/char.rs
Normal file
49
canvas/src/canvas/actions/movement/char.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
// src/canvas/actions/movement/char.rs
|
||||
|
||||
/// Calculate new position when moving left
|
||||
pub fn move_left(current_pos: usize) -> usize {
|
||||
current_pos.saturating_sub(1)
|
||||
}
|
||||
|
||||
/// Calculate new position when moving right
|
||||
pub fn move_right(current_pos: usize, text: &str, for_edit_mode: bool) -> usize {
|
||||
if text.is_empty() {
|
||||
return current_pos;
|
||||
}
|
||||
|
||||
if for_edit_mode {
|
||||
// Edit mode: can move past end of text
|
||||
(current_pos + 1).min(text.len())
|
||||
} else {
|
||||
// Read-only/highlight mode: stays within text bounds
|
||||
if current_pos < text.len().saturating_sub(1) {
|
||||
current_pos + 1
|
||||
} else {
|
||||
current_pos
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if cursor position is valid for the given mode
|
||||
pub fn is_valid_cursor_position(pos: usize, text: &str, for_edit_mode: bool) -> bool {
|
||||
if text.is_empty() {
|
||||
return pos == 0;
|
||||
}
|
||||
|
||||
if for_edit_mode {
|
||||
pos <= text.len()
|
||||
} else {
|
||||
pos < text.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp cursor position to valid bounds for the given mode
|
||||
pub fn clamp_cursor_position(pos: usize, text: &str, for_edit_mode: bool) -> usize {
|
||||
if text.is_empty() {
|
||||
0
|
||||
} else if for_edit_mode {
|
||||
pos.min(text.len())
|
||||
} else {
|
||||
pos.min(text.len().saturating_sub(1))
|
||||
}
|
||||
}
|
||||
32
canvas/src/canvas/actions/movement/line.rs
Normal file
32
canvas/src/canvas/actions/movement/line.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
// src/canvas/actions/movement/line.rs
|
||||
|
||||
/// Calculate cursor position for line start
|
||||
pub fn line_start_position() -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
/// Calculate cursor position for line end
|
||||
pub fn line_end_position(text: &str, for_edit_mode: bool) -> usize {
|
||||
if text.is_empty() {
|
||||
0
|
||||
} else if for_edit_mode {
|
||||
// Edit mode: cursor can go past end of text
|
||||
text.len()
|
||||
} else {
|
||||
// Read-only/highlight mode: cursor stays on last character
|
||||
text.len().saturating_sub(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate safe cursor position when switching fields
|
||||
pub fn safe_cursor_position(text: &str, ideal_column: usize, for_edit_mode: bool) -> usize {
|
||||
if text.is_empty() {
|
||||
0
|
||||
} else if for_edit_mode {
|
||||
// Edit mode: cursor can go past end
|
||||
ideal_column.min(text.len())
|
||||
} else {
|
||||
// Read-only/highlight mode: cursor stays within text
|
||||
ideal_column.min(text.len().saturating_sub(1))
|
||||
}
|
||||
}
|
||||
10
canvas/src/canvas/actions/movement/mod.rs
Normal file
10
canvas/src/canvas/actions/movement/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
// src/canvas/actions/movement/mod.rs
|
||||
|
||||
pub mod word;
|
||||
pub mod line;
|
||||
pub mod char;
|
||||
|
||||
// Re-export commonly used functions
|
||||
pub use word::{find_next_word_start, find_word_end, find_prev_word_start, find_prev_word_end};
|
||||
pub use line::{line_start_position, line_end_position, safe_cursor_position};
|
||||
pub use char::{move_left, move_right, is_valid_cursor_position, clamp_cursor_position};
|
||||
146
canvas/src/canvas/actions/movement/word.rs
Normal file
146
canvas/src/canvas/actions/movement/word.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
// src/canvas/actions/movement/word.rs
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum CharType {
|
||||
Whitespace,
|
||||
Alphanumeric,
|
||||
Punctuation,
|
||||
}
|
||||
|
||||
fn get_char_type(c: char) -> CharType {
|
||||
if c.is_whitespace() {
|
||||
CharType::Whitespace
|
||||
} else if c.is_alphanumeric() {
|
||||
CharType::Alphanumeric
|
||||
} else {
|
||||
CharType::Punctuation
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the start of the next word from the current position
|
||||
pub fn find_next_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let current_pos = current_pos.min(chars.len());
|
||||
|
||||
if current_pos == chars.len() {
|
||||
return current_pos;
|
||||
}
|
||||
|
||||
let mut pos = current_pos;
|
||||
let initial_type = get_char_type(chars[pos]);
|
||||
|
||||
// Skip current word/token
|
||||
while pos < chars.len() && get_char_type(chars[pos]) == initial_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
// Skip whitespace
|
||||
while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
/// Find the end of the current or next word
|
||||
pub fn find_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
if len == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.min(len - 1);
|
||||
let current_type = get_char_type(chars[pos]);
|
||||
|
||||
// If we're not on whitespace, move to end of current word
|
||||
if current_type != CharType::Whitespace {
|
||||
while pos < len && get_char_type(chars[pos]) == current_type {
|
||||
pos += 1;
|
||||
}
|
||||
return pos.saturating_sub(1);
|
||||
}
|
||||
|
||||
// If we're on whitespace, find next word and go to its end
|
||||
pos = find_next_word_start(text, pos);
|
||||
if pos >= len {
|
||||
return len.saturating_sub(1);
|
||||
}
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos < len && get_char_type(chars[pos]) == word_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos.saturating_sub(1).min(len.saturating_sub(1))
|
||||
}
|
||||
|
||||
/// Find the start of the previous word
|
||||
pub fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
|
||||
// Skip whitespace backwards
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
// Move to start of word
|
||||
if get_char_type(chars[pos]) != CharType::Whitespace {
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
|
||||
0
|
||||
} else {
|
||||
pos
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the end of the previous word
|
||||
pub fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
|
||||
// Skip whitespace backwards
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
|
||||
return 0;
|
||||
}
|
||||
if pos == 0 && get_char_type(chars[0]) != CharType::Whitespace {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
// Skip whitespace before this word
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos > 0 {
|
||||
pos - 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ pub enum CanvasAction {
|
||||
MoveWordNext,
|
||||
MoveWordEnd,
|
||||
MoveWordPrev,
|
||||
MoveWordEndPrev,
|
||||
|
||||
// Field navigation
|
||||
NextField,
|
||||
@@ -42,24 +43,7 @@ pub enum CanvasAction {
|
||||
}
|
||||
|
||||
impl CanvasAction {
|
||||
pub fn from_key(key: crossterm::event::KeyCode) -> Option<Self> {
|
||||
match key {
|
||||
crossterm::event::KeyCode::Char(c) => Some(Self::InsertChar(c)),
|
||||
crossterm::event::KeyCode::Backspace => Some(Self::DeleteBackward),
|
||||
crossterm::event::KeyCode::Delete => Some(Self::DeleteForward),
|
||||
crossterm::event::KeyCode::Left => Some(Self::MoveLeft),
|
||||
crossterm::event::KeyCode::Right => Some(Self::MoveRight),
|
||||
crossterm::event::KeyCode::Up => Some(Self::MoveUp),
|
||||
crossterm::event::KeyCode::Down => Some(Self::MoveDown),
|
||||
crossterm::event::KeyCode::Home => Some(Self::MoveLineStart),
|
||||
crossterm::event::KeyCode::End => Some(Self::MoveLineEnd),
|
||||
crossterm::event::KeyCode::Tab => Some(Self::NextField),
|
||||
crossterm::event::KeyCode::BackTab => Some(Self::PrevField),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compatibility method
|
||||
/// Convert string action name to CanvasAction enum (config-driven)
|
||||
pub fn from_string(action: &str) -> Self {
|
||||
match action {
|
||||
"delete_char_backward" => Self::DeleteBackward,
|
||||
@@ -75,6 +59,7 @@ impl CanvasAction {
|
||||
"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,
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
// src/canvas/mod.rs
|
||||
pub mod actions;
|
||||
pub mod modes;
|
||||
pub mod gui;
|
||||
pub mod theme;
|
||||
pub mod modes;
|
||||
pub mod state;
|
||||
pub mod theme;
|
||||
|
||||
// Re-export commonly used canvas types
|
||||
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,44 +1,107 @@
|
||||
// canvas/src/state.rs
|
||||
// src/canvas/state.rs
|
||||
//! Canvas state trait and related types
|
||||
//!
|
||||
//! This module defines the core trait that any form or input system must implement
|
||||
//! to work with the canvas library.
|
||||
|
||||
use crate::canvas::actions::CanvasAction;
|
||||
use crate::canvas::modes::AppMode;
|
||||
|
||||
/// Context passed to feature-specific action handlers
|
||||
/// Context information passed to feature-specific action handlers
|
||||
#[derive(Debug)]
|
||||
pub struct ActionContext {
|
||||
pub key_code: Option<crossterm::event::KeyCode>, // Kept for backwards compatibility
|
||||
/// Original key code that triggered this action (for backwards compatibility)
|
||||
pub key_code: Option<crossterm::event::KeyCode>,
|
||||
/// Current ideal cursor column for vertical movement
|
||||
pub ideal_cursor_column: usize,
|
||||
/// Current input text
|
||||
pub current_input: String,
|
||||
/// Current field index
|
||||
pub current_field: usize,
|
||||
}
|
||||
|
||||
/// Core trait that any form-like state must implement to work with the canvas system.
|
||||
/// This enables the same mode behaviors (edit, read-only, highlight) to work across
|
||||
/// any implementation - login forms, data entry forms, configuration screens, etc.
|
||||
/// Core trait that any form-like state must implement to work with canvas
|
||||
///
|
||||
/// This trait enables the same mode behaviors (edit, read-only, highlight) to work
|
||||
/// across any implementation - login forms, data entry forms, configuration screens, etc.
|
||||
///
|
||||
/// # Required Implementation
|
||||
///
|
||||
/// Your struct needs to track:
|
||||
/// - Current field index and cursor position
|
||||
/// - All input field values
|
||||
/// - Current interaction mode
|
||||
/// - Whether there are unsaved changes
|
||||
///
|
||||
/// # Example Implementation
|
||||
///
|
||||
/// ```rust
|
||||
/// struct MyForm {
|
||||
/// fields: Vec<String>,
|
||||
/// current_field: usize,
|
||||
/// cursor_pos: usize,
|
||||
/// mode: AppMode,
|
||||
/// dirty: bool,
|
||||
/// }
|
||||
///
|
||||
/// impl CanvasState for MyForm {
|
||||
/// fn current_field(&self) -> usize { self.current_field }
|
||||
/// fn current_cursor_pos(&self) -> usize { self.cursor_pos }
|
||||
/// // ... implement other required methods
|
||||
/// }
|
||||
/// ```
|
||||
pub trait CanvasState {
|
||||
// --- Core Navigation ---
|
||||
|
||||
/// Get current field index (0-based)
|
||||
fn current_field(&self) -> usize;
|
||||
|
||||
/// Get current cursor position within the current field
|
||||
fn current_cursor_pos(&self) -> usize;
|
||||
|
||||
/// Set current field index (should clamp to valid range)
|
||||
fn set_current_field(&mut self, index: usize);
|
||||
|
||||
/// Set cursor position within current field (should clamp to valid range)
|
||||
fn set_current_cursor_pos(&mut self, pos: usize);
|
||||
|
||||
// --- Mode Information ---
|
||||
|
||||
/// Get current interaction mode (edit, read-only, highlight, etc.)
|
||||
fn current_mode(&self) -> AppMode;
|
||||
|
||||
// --- Data Access ---
|
||||
|
||||
/// Get immutable reference to current field's text
|
||||
fn get_current_input(&self) -> &str;
|
||||
|
||||
/// Get mutable reference to current field's text
|
||||
fn get_current_input_mut(&mut self) -> &mut String;
|
||||
|
||||
/// Get all input values as immutable references
|
||||
fn inputs(&self) -> Vec<&String>;
|
||||
|
||||
/// Get all field names/labels
|
||||
fn fields(&self) -> Vec<&str>;
|
||||
|
||||
// --- State Management ---
|
||||
|
||||
/// Check if there are unsaved changes
|
||||
fn has_unsaved_changes(&self) -> bool;
|
||||
|
||||
/// Mark whether there are unsaved changes
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool);
|
||||
|
||||
// --- Feature-specific action handling ---
|
||||
// --- Optional Overrides ---
|
||||
|
||||
/// Feature-specific action handling (NEW: Type-safe)
|
||||
/// Handle application-specific actions not covered by standard handlers
|
||||
/// Return Some(message) if the action was handled, None to use standard handling
|
||||
fn handle_feature_action(&mut self, _action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
None // Default: no feature-specific handling
|
||||
None // Default: no custom handling
|
||||
}
|
||||
|
||||
// --- Display Overrides (for links, computed values, etc.) ---
|
||||
/// Get display value for a field (may differ from actual value)
|
||||
/// Used for things like password masking or computed display values
|
||||
fn get_display_value_for_field(&self, index: usize) -> &str {
|
||||
self.inputs()
|
||||
.get(index)
|
||||
@@ -46,6 +109,8 @@ pub trait CanvasState {
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
/// Check if a field has a custom display value
|
||||
/// Return true if get_display_value_for_field returns something different than the actual value
|
||||
fn has_display_override(&self, _index: usize) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -1,83 +1,73 @@
|
||||
// canvas/src/config.rs
|
||||
// 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 CanvasConfig {
|
||||
#[serde(default)]
|
||||
pub keybindings: CanvasKeybindings,
|
||||
#[serde(default)]
|
||||
pub behavior: CanvasBehavior,
|
||||
#[serde(default)]
|
||||
pub appearance: CanvasAppearance,
|
||||
pub struct CanvasKeybindings {
|
||||
pub edit: HashMap<String, Vec<String>>,
|
||||
pub read_only: HashMap<String, Vec<String>>,
|
||||
pub global: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CanvasKeybindings {
|
||||
#[serde(default)]
|
||||
pub read_only: HashMap<String, Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub edit: HashMap<String, Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub suggestions: HashMap<String, Vec<String>>,
|
||||
#[serde(default)]
|
||||
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 {
|
||||
#[serde(default = "default_wrap_around")]
|
||||
pub confirm_on_save: bool,
|
||||
pub auto_indent: bool,
|
||||
pub wrap_search: bool,
|
||||
pub wrap_around_fields: bool,
|
||||
#[serde(default = "default_auto_save")]
|
||||
pub auto_save_on_field_change: bool,
|
||||
#[serde(default = "default_word_chars")]
|
||||
pub word_chars: String,
|
||||
#[serde(default = "default_suggestion_limit")]
|
||||
pub max_suggestions: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CanvasAppearance {
|
||||
#[serde(default = "default_cursor_style")]
|
||||
pub cursor_style: String, // "block", "bar", "underline"
|
||||
#[serde(default = "default_show_field_numbers")]
|
||||
pub show_field_numbers: bool,
|
||||
#[serde(default = "default_highlight_current_field")]
|
||||
pub highlight_current_field: bool,
|
||||
}
|
||||
|
||||
// Default values
|
||||
fn default_wrap_around() -> bool { true }
|
||||
fn default_auto_save() -> bool { false }
|
||||
fn default_word_chars() -> String { "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_".to_string() }
|
||||
fn default_suggestion_limit() -> usize { 10 }
|
||||
fn default_cursor_style() -> String { "block".to_string() }
|
||||
fn default_show_field_numbers() -> bool { false }
|
||||
fn default_highlight_current_field() -> bool { true }
|
||||
|
||||
impl Default for CanvasBehavior {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
wrap_around_fields: default_wrap_around(),
|
||||
auto_save_on_field_change: default_auto_save(),
|
||||
word_chars: default_word_chars(),
|
||||
max_suggestions: default_suggestion_limit(),
|
||||
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 {
|
||||
cursor_style: default_cursor_style(),
|
||||
show_field_numbers: default_show_field_numbers(),
|
||||
highlight_current_field: default_highlight_current_field(),
|
||||
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 {
|
||||
@@ -89,105 +79,329 @@ impl Default for CanvasConfig {
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// Read-only mode (vim-style navigation)
|
||||
// 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.read_only.insert("move_word_next".to_string(), vec!["w".to_string()]);
|
||||
keybindings.read_only.insert("move_word_end".to_string(), vec!["e".to_string()]);
|
||||
keybindings.read_only.insert("move_word_prev".to_string(), vec!["b".to_string()]);
|
||||
keybindings.read_only.insert("move_word_end_prev".to_string(), vec!["ge".to_string()]);
|
||||
keybindings.read_only.insert("move_line_start".to_string(), vec!["0".to_string()]);
|
||||
keybindings.read_only.insert("move_line_end".to_string(), vec!["$".to_string()]);
|
||||
keybindings.read_only.insert("move_first_line".to_string(), vec!["gg".to_string()]);
|
||||
keybindings.read_only.insert("move_last_line".to_string(), vec!["G".to_string()]);
|
||||
keybindings.read_only.insert("next_field".to_string(), vec!["Tab".to_string()]);
|
||||
keybindings.read_only.insert("prev_field".to_string(), vec!["Shift+Tab".to_string()]);
|
||||
|
||||
// Edit mode
|
||||
keybindings.edit.insert("delete_char_backward".to_string(), vec!["Backspace".to_string()]);
|
||||
keybindings.edit.insert("delete_char_forward".to_string(), vec!["Delete".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.edit.insert("move_line_start".to_string(), vec!["Home".to_string()]);
|
||||
keybindings.edit.insert("move_line_end".to_string(), vec!["End".to_string()]);
|
||||
keybindings.edit.insert("move_word_next".to_string(), vec!["Ctrl+Right".to_string()]);
|
||||
keybindings.edit.insert("move_word_prev".to_string(), vec!["Ctrl+Left".to_string()]);
|
||||
keybindings.edit.insert("next_field".to_string(), vec!["Tab".to_string()]);
|
||||
keybindings.edit.insert("prev_field".to_string(), vec!["Shift+Tab".to_string()]);
|
||||
|
||||
// Suggestions
|
||||
keybindings.suggestions.insert("suggestion_up".to_string(), vec!["Up".to_string(), "Ctrl+p".to_string()]);
|
||||
keybindings.suggestions.insert("suggestion_down".to_string(), vec!["Down".to_string(), "Ctrl+n".to_string()]);
|
||||
keybindings.suggestions.insert("select_suggestion".to_string(), vec!["Enter".to_string(), "Tab".to_string()]);
|
||||
keybindings.suggestions.insert("exit_suggestions".to_string(), vec!["Esc".to_string()]);
|
||||
|
||||
// Global (works in both modes)
|
||||
keybindings.global.insert("move_up".to_string(), vec!["Up".to_string()]);
|
||||
keybindings.global.insert("move_down".to_string(), vec!["Down".to_string()]);
|
||||
|
||||
keybindings
|
||||
}
|
||||
|
||||
pub fn with_emacs_defaults() -> Self {
|
||||
let mut keybindings = Self::default();
|
||||
/// 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();
|
||||
|
||||
// Emacs-style bindings
|
||||
keybindings.read_only.insert("move_left".to_string(), vec!["Ctrl+b".to_string()]);
|
||||
keybindings.read_only.insert("move_right".to_string(), vec!["Ctrl+f".to_string()]);
|
||||
keybindings.read_only.insert("move_up".to_string(), vec!["Ctrl+p".to_string()]);
|
||||
keybindings.read_only.insert("move_down".to_string(), vec!["Ctrl+n".to_string()]);
|
||||
keybindings.read_only.insert("move_word_next".to_string(), vec!["Alt+f".to_string()]);
|
||||
keybindings.read_only.insert("move_word_prev".to_string(), vec!["Alt+b".to_string()]);
|
||||
keybindings.read_only.insert("move_line_start".to_string(), vec!["Ctrl+a".to_string()]);
|
||||
keybindings.read_only.insert("move_line_end".to_string(), vec!["Ctrl+e".to_string()]);
|
||||
// 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,
|
||||
};
|
||||
|
||||
keybindings.edit.insert("delete_char_backward".to_string(), vec!["Ctrl+h".to_string(), "Backspace".to_string()]);
|
||||
keybindings.edit.insert("delete_char_forward".to_string(), vec!["Ctrl+d".to_string(), "Delete".to_string()]);
|
||||
// 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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
keybindings
|
||||
// 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 {
|
||||
/// Load from canvas_config.toml or fallback to vim defaults
|
||||
/// Enhanced load method with introspection validation
|
||||
pub fn load() -> Self {
|
||||
// Try to load canvas_config.toml from current directory
|
||||
if let Ok(config) = Self::from_file(std::path::Path::new("canvas_config.toml")) {
|
||||
return config;
|
||||
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();
|
||||
|
||||
// Fallback to vim defaults
|
||||
Self::default()
|
||||
// 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)
|
||||
.with_context(|| "Failed to parse canvas config TOML")
|
||||
.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)
|
||||
.with_context(|| format!("Failed to read config file: {:?}", path))?;
|
||||
.context("Failed to read config file")?;
|
||||
Self::from_toml(&contents)
|
||||
}
|
||||
|
||||
/// NEW: Check if autocomplete should auto-trigger (simple logic)
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// NEW: Check if user has configured manual trigger 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") ||
|
||||
@@ -206,21 +420,9 @@ impl CanvasConfig {
|
||||
.or_else(|| self.get_action_in_mode(&self.keybindings.global, key, modifiers))
|
||||
}
|
||||
|
||||
/// Get action for key in suggestions mode
|
||||
pub fn get_suggestion_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
||||
self.get_action_in_mode(&self.keybindings.suggestions, 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> {
|
||||
// Suggestions take priority when active
|
||||
if has_suggestions {
|
||||
if let Some(action) = self.get_suggestion_action(key, modifiers) {
|
||||
return Some(action);
|
||||
}
|
||||
}
|
||||
|
||||
// Then check mode-specific
|
||||
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 {
|
||||
@@ -271,21 +473,21 @@ impl CanvasConfig {
|
||||
"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),
|
||||
@@ -311,18 +513,18 @@ impl CanvasConfig {
|
||||
"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),
|
||||
@@ -337,7 +539,7 @@ impl CanvasConfig {
|
||||
"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),
|
||||
@@ -353,7 +555,7 @@ impl CanvasConfig {
|
||||
"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
|
||||
_ => {
|
||||
@@ -385,7 +587,7 @@ impl CanvasConfig {
|
||||
"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),
|
||||
@@ -395,21 +597,21 @@ impl CanvasConfig {
|
||||
"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)),
|
||||
@@ -435,18 +637,18 @@ impl CanvasConfig {
|
||||
"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 {
|
||||
@@ -460,35 +662,4 @@ impl CanvasConfig {
|
||||
|
||||
modifiers == expected_modifiers && Some(key) == expected_key
|
||||
}
|
||||
|
||||
/// Convenience method to create vim preset
|
||||
pub fn vim_preset() -> Self {
|
||||
Self {
|
||||
keybindings: CanvasKeybindings::with_vim_defaults(),
|
||||
behavior: CanvasBehavior::default(),
|
||||
appearance: CanvasAppearance::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience method to create emacs preset
|
||||
pub fn emacs_preset() -> Self {
|
||||
Self {
|
||||
keybindings: CanvasKeybindings::with_emacs_defaults(),
|
||||
behavior: CanvasBehavior::default(),
|
||||
appearance: CanvasAppearance::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Debug method to print loaded keybindings
|
||||
pub fn debug_keybindings(&self) {
|
||||
println!("📋 Canvas keybindings loaded:");
|
||||
println!(" Read-only: {} actions", self.keybindings.read_only.len());
|
||||
println!(" Edit: {} actions", self.keybindings.edit.len());
|
||||
println!(" Suggestions: {} actions", self.keybindings.suggestions.len());
|
||||
println!(" Global: {} actions", self.keybindings.global.len());
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
pub use crate::canvas::actions::CanvasAction;
|
||||
pub use crate::dispatcher::ActionDispatcher;
|
||||
93
canvas/src/config/introspection.rs
Normal file
93
canvas/src/config/introspection.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
12
canvas/src/config/mod.rs
Normal file
12
canvas/src/config/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
// 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::*;
|
||||
135
canvas/src/config/registry.rs
Normal file
135
canvas/src/config/registry.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
278
canvas/src/config/validation.rs
Normal file
278
canvas/src/config/validation.rs
Normal file
@@ -0,0 +1,278 @@
|
||||
// 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,32 +1,87 @@
|
||||
// canvas/src/dispatcher.rs
|
||||
// src/dispatcher.rs
|
||||
|
||||
use crate::canvas::state::CanvasState;
|
||||
use crate::canvas::actions::{CanvasAction, ActionResult, execute_canvas_action};
|
||||
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};
|
||||
|
||||
/// High-level action dispatcher that coordinates between different action types
|
||||
/// 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 handler
|
||||
/// 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> {
|
||||
|
||||
// Load config once here instead of threading it everywhere
|
||||
execute_canvas_action(action, state, ideal_cursor_column, Some(&CanvasConfig::load())).await
|
||||
let config = CanvasConfig::load();
|
||||
Self::dispatch_with_config(action, state, ideal_cursor_column, Some(&config)).await
|
||||
}
|
||||
|
||||
/// Quick action dispatch from KeyCode
|
||||
pub async fn dispatch_key<S: CanvasState>(
|
||||
key: crossterm::event::KeyCode,
|
||||
/// 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>> {
|
||||
if let Some(action) = CanvasAction::from_key(key) {
|
||||
let result = Self::dispatch(action, state, ideal_cursor_column).await?;
|
||||
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)
|
||||
@@ -42,7 +97,7 @@ impl ActionDispatcher {
|
||||
let mut results = Vec::new();
|
||||
for action in actions {
|
||||
let result = Self::dispatch(action, state, ideal_cursor_column).await?;
|
||||
let is_success = result.is_success(); // Check success before moving
|
||||
let is_success = result.is_success();
|
||||
results.push(result);
|
||||
|
||||
// Stop on first error
|
||||
@@ -53,131 +108,3 @@ impl ActionDispatcher {
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::actions::CanvasAction;
|
||||
|
||||
// Simple test implementation
|
||||
struct TestFormState {
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
inputs: Vec<String>,
|
||||
field_names: Vec<String>,
|
||||
has_changes: bool,
|
||||
}
|
||||
|
||||
impl TestFormState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
current_field: 0,
|
||||
cursor_pos: 0,
|
||||
inputs: vec!["".to_string(), "".to_string()],
|
||||
field_names: vec!["username".to_string(), "password".to_string()],
|
||||
has_changes: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasState for TestFormState {
|
||||
fn current_field(&self) -> usize { self.current_field }
|
||||
fn current_cursor_pos(&self) -> usize { self.cursor_pos }
|
||||
fn set_current_field(&mut self, index: usize) { self.current_field = index; }
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
|
||||
|
||||
fn get_current_input(&self) -> &str { &self.inputs[self.current_field] }
|
||||
fn get_current_input_mut(&mut self) -> &mut String { &mut self.inputs[self.current_field] }
|
||||
fn inputs(&self) -> Vec<&String> { self.inputs.iter().collect() }
|
||||
fn fields(&self) -> Vec<&str> { self.field_names.iter().map(|s| s.as_str()).collect() }
|
||||
|
||||
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
|
||||
|
||||
// Custom action handling for testing
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &crate::state::ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::Custom(s) if s == "test_custom" => {
|
||||
Some("Custom action handled".to_string())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_typed_action_dispatch() {
|
||||
let mut state = TestFormState::new();
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
// Test character insertion
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::InsertChar('a'),
|
||||
&mut state,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
|
||||
assert!(result.is_success());
|
||||
assert_eq!(state.get_current_input(), "a");
|
||||
assert_eq!(state.cursor_pos, 1);
|
||||
assert!(state.has_changes);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_key_dispatch() {
|
||||
let mut state = TestFormState::new();
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
let result = ActionDispatcher::dispatch_key(
|
||||
crossterm::event::KeyCode::Char('b'),
|
||||
&mut state,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
|
||||
assert!(result.is_some());
|
||||
assert!(result.unwrap().is_success());
|
||||
assert_eq!(state.get_current_input(), "b");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_custom_action() {
|
||||
let mut state = TestFormState::new();
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::Custom("test_custom".to_string()),
|
||||
&mut state,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
|
||||
match result {
|
||||
ActionResult::HandledByFeature(msg) => {
|
||||
assert_eq!(msg, "Custom action handled");
|
||||
}
|
||||
_ => panic!("Expected HandledByFeature result"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_batch_dispatch() {
|
||||
let mut state = TestFormState::new();
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
let actions = vec![
|
||||
CanvasAction::InsertChar('h'),
|
||||
CanvasAction::InsertChar('i'),
|
||||
CanvasAction::MoveLeft,
|
||||
CanvasAction::InsertChar('e'),
|
||||
];
|
||||
|
||||
let results = ActionDispatcher::dispatch_batch(
|
||||
actions,
|
||||
&mut state,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
|
||||
assert_eq!(results.len(), 4);
|
||||
assert!(results.iter().all(|r| r.is_success()));
|
||||
assert_eq!(state.get_current_input(), "hei");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,3 +3,9 @@ pub mod canvas;
|
||||
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::state::{CanvasState, ActionContext};
|
||||
pub use canvas::modes::{AppMode, HighlightState, ModeManager};
|
||||
|
||||
55
canvas/view_docs.sh
Executable file
55
canvas/view_docs.sh
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Enhanced documentation viewer for your canvas library
|
||||
echo "=========================================="
|
||||
echo "CANVAS LIBRARY DOCUMENTATION"
|
||||
echo "=========================================="
|
||||
|
||||
# Function to display module docs with colors
|
||||
show_module() {
|
||||
local module=$1
|
||||
local title=$2
|
||||
|
||||
echo -e "\n\033[1;34m=== $title ===\033[0m"
|
||||
echo -e "\033[33mFiles in $module:\033[0m"
|
||||
find src/$module -name "*.rs" 2>/dev/null | sort
|
||||
echo
|
||||
|
||||
# Show doc comments for this module
|
||||
find src/$module -name "*.rs" 2>/dev/null | while read file; do
|
||||
if grep -q "///" "$file"; then
|
||||
echo -e "\033[32m--- $file ---\033[0m"
|
||||
grep -n "^\s*///" "$file" | sed 's/^\([0-9]*:\)\s*\/\/\/ /\1 /' | head -10
|
||||
echo
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Main modules
|
||||
show_module "canvas" "CANVAS SYSTEM"
|
||||
show_module "autocomplete" "AUTOCOMPLETE SYSTEM"
|
||||
show_module "config" "CONFIGURATION SYSTEM"
|
||||
|
||||
# Show lib.rs and other root files
|
||||
echo -e "\n\033[1;34m=== ROOT DOCUMENTATION ===\033[0m"
|
||||
if [ -f "src/lib.rs" ]; then
|
||||
echo -e "\033[32m--- src/lib.rs ---\033[0m"
|
||||
grep -n "^\s*///" src/lib.rs | sed 's/^\([0-9]*:\)\s*\/\/\/ /\1 /' 2>/dev/null
|
||||
fi
|
||||
|
||||
if [ -f "src/dispatcher.rs" ]; then
|
||||
echo -e "\033[32m--- src/dispatcher.rs ---\033[0m"
|
||||
grep -n "^\s*///" src/dispatcher.rs | sed 's/^\([0-9]*:\)\s*\/\/\/ /\1 /' 2>/dev/null
|
||||
fi
|
||||
|
||||
echo -e "\n\033[1;36m=========================================="
|
||||
echo "To view specific module documentation:"
|
||||
echo " ./view_canvas_docs.sh canvas"
|
||||
echo " ./view_canvas_docs.sh autocomplete"
|
||||
echo " ./view_canvas_docs.sh config"
|
||||
echo "==========================================\033[0m"
|
||||
|
||||
# If specific module requested
|
||||
if [ $# -eq 1 ]; then
|
||||
show_module "$1" "$(echo $1 | tr '[:lower:]' '[:upper:]') MODULE DETAILS"
|
||||
fi
|
||||
1
client/.gitignore
vendored
Normal file
1
client/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
canvas_config.toml.txt
|
||||
@@ -1,58 +0,0 @@
|
||||
# canvas_config.toml - Complete Canvas Configuration
|
||||
|
||||
[behavior]
|
||||
wrap_around_fields = true
|
||||
auto_save_on_field_change = false
|
||||
word_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"
|
||||
max_suggestions = 6
|
||||
|
||||
[appearance]
|
||||
cursor_style = "block" # "block", "bar", "underline"
|
||||
show_field_numbers = false
|
||||
highlight_current_field = true
|
||||
|
||||
# Read-only mode keybindings (vim-style)
|
||||
[keybindings.read_only]
|
||||
move_left = ["h"]
|
||||
move_right = ["l"]
|
||||
move_up = ["k"]
|
||||
move_down = ["j"]
|
||||
move_word_next = ["w"]
|
||||
move_word_end = ["e"]
|
||||
move_word_prev = ["b"]
|
||||
move_word_end_prev = ["ge"]
|
||||
move_line_start = ["0"]
|
||||
move_line_end = ["$"]
|
||||
move_first_line = ["gg"]
|
||||
move_last_line = ["shift+g"]
|
||||
next_field = ["Tab"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
|
||||
# Edit mode keybindings
|
||||
[keybindings.edit]
|
||||
delete_char_backward = ["Backspace"]
|
||||
delete_char_forward = ["Delete"]
|
||||
move_left = ["Left"]
|
||||
move_right = ["Right"]
|
||||
move_up = ["Up"]
|
||||
move_down = ["Down"]
|
||||
move_line_start = ["Home"]
|
||||
move_line_end = ["End"]
|
||||
move_word_next = ["Ctrl+Right"]
|
||||
move_word_prev = ["Ctrl+Left"]
|
||||
next_field = ["Tab"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
trigger_autocomplete = ["Ctrl+p"]
|
||||
|
||||
# Suggestion/autocomplete keybindings
|
||||
[keybindings.suggestions]
|
||||
suggestion_up = ["Up", "Ctrl+p"]
|
||||
suggestion_down = ["Down", "Ctrl+n"]
|
||||
select_suggestion = ["Enter", "Tab"]
|
||||
exit_suggestions = ["Esc"]
|
||||
trigger_autocomplete = ["Tab"]
|
||||
|
||||
# Global keybindings (work in both modes)
|
||||
[keybindings.global]
|
||||
move_up = ["Up"]
|
||||
move_down = ["Down"]
|
||||
@@ -39,25 +39,45 @@ enter_edit_mode_after = ["a"]
|
||||
previous_entry = ["left","q"]
|
||||
next_entry = ["right","1"]
|
||||
|
||||
move_left = ["h"]
|
||||
move_right = ["l"]
|
||||
move_up = ["k"]
|
||||
move_down = ["j"]
|
||||
move_word_next = ["w"]
|
||||
move_word_end = ["e"]
|
||||
move_word_prev = ["b"]
|
||||
move_word_end_prev = ["ge"]
|
||||
move_line_start = ["0"]
|
||||
move_line_end = ["$"]
|
||||
move_first_line = ["gg"]
|
||||
move_last_line = ["x"]
|
||||
enter_highlight_mode = ["v"]
|
||||
enter_highlight_mode_linewise = ["ctrl+v"]
|
||||
|
||||
### AUTOGENERATED CANVAS CONFIG
|
||||
# Required
|
||||
move_up = ["k", "Up"]
|
||||
move_left = ["h", "Left"]
|
||||
move_right = ["l", "Right"]
|
||||
move_down = ["j", "Down"]
|
||||
# Optional
|
||||
move_line_end = ["$"]
|
||||
move_word_next = ["w"]
|
||||
next_field = ["Tab"]
|
||||
move_word_prev = ["b"]
|
||||
move_word_end = ["e"]
|
||||
move_last_line = ["shift+g"]
|
||||
move_word_end_prev = ["ge"]
|
||||
move_line_start = ["0"]
|
||||
move_first_line = ["g+g"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
|
||||
[keybindings.highlight]
|
||||
exit_highlight_mode = ["esc"]
|
||||
enter_highlight_mode_linewise = ["ctrl+v"]
|
||||
|
||||
### AUTOGENERATED CANVAS CONFIG
|
||||
# Required
|
||||
move_left = ["h", "Left"]
|
||||
move_right = ["l", "Right"]
|
||||
move_up = ["k", "Up"]
|
||||
move_down = ["j", "Down"]
|
||||
# Optional
|
||||
move_word_next = ["w"]
|
||||
move_line_start = ["0"]
|
||||
move_line_end = ["$"]
|
||||
move_word_prev = ["b"]
|
||||
move_word_end = ["e"]
|
||||
|
||||
|
||||
[keybindings.edit]
|
||||
# BIG CHANGES NOW EXIT HANDLES EITHER IF THOSE
|
||||
# exit_edit_mode = ["esc","ctrl+e"]
|
||||
@@ -65,15 +85,30 @@ enter_highlight_mode_linewise = ["ctrl+v"]
|
||||
# select_suggestion = ["enter"]
|
||||
# next_field = ["enter"]
|
||||
enter_decider = ["enter"]
|
||||
prev_field = ["shift+enter"]
|
||||
exit = ["esc", "ctrl+e"]
|
||||
delete_char_forward = ["delete"]
|
||||
delete_char_backward = ["backspace"]
|
||||
move_left = ["left"]
|
||||
move_right = ["right"]
|
||||
suggestion_down = ["ctrl+n", "tab"]
|
||||
suggestion_up = ["ctrl+p", "shift+tab"]
|
||||
|
||||
### AUTOGENERATED CANVAS CONFIG
|
||||
# Required
|
||||
move_right = ["Right", "l"]
|
||||
delete_char_backward = ["Backspace"]
|
||||
next_field = ["Tab", "Enter"]
|
||||
move_up = ["Up", "k"]
|
||||
move_down = ["Down", "j"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
move_left = ["Left", "h"]
|
||||
# Optional
|
||||
move_last_line = ["Ctrl+End", "G"]
|
||||
delete_char_forward = ["Delete"]
|
||||
move_word_prev = ["Ctrl+Left", "b"]
|
||||
move_word_end = ["e"]
|
||||
move_word_end_prev = ["ge"]
|
||||
move_first_line = ["Ctrl+Home", "gg"]
|
||||
move_word_next = ["Ctrl+Right", "w"]
|
||||
move_line_start = ["Home", "0"]
|
||||
move_line_end = ["End", "$"]
|
||||
|
||||
[keybindings.command]
|
||||
exit_command_mode = ["ctrl+g", "esc"]
|
||||
command_execute = ["enter"]
|
||||
@@ -91,3 +126,9 @@ keybinding_mode = "vim" # Options: "default", "vim", "emacs"
|
||||
[colors]
|
||||
theme = "dark"
|
||||
# Options: "light", "dark", "high_contrast"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use canvas::canvas::CanvasState;
|
||||
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher, canvas::ActionResult};
|
||||
use anyhow::Result;
|
||||
use common::proto::komp_ac::search::search_response::Hit;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::info;
|
||||
|
||||
@@ -79,7 +79,9 @@ pub async fn handle_form_edit_with_canvas(
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<String> {
|
||||
// Try canvas action from key first
|
||||
if let Some(canvas_action) = CanvasAction::from_key(key_event.code) {
|
||||
let canvas_config = canvas::config::CanvasConfig::load();
|
||||
if let Some(action_name) = canvas_config.get_edit_action(key_event.code, key_event.modifiers) {
|
||||
let canvas_action = CanvasAction::from_string(action_name);
|
||||
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
@@ -141,16 +143,48 @@ async fn execute_canvas_action(
|
||||
}
|
||||
}
|
||||
|
||||
/// NEW: Unified canvas action handler for any CanvasState (LoginState, RegisterState, etc.)
|
||||
/// This replaces the old auth_e::execute_edit_action calls with the new canvas library
|
||||
/// FIXED: Unified canvas action handler with proper priority order for edit mode
|
||||
async fn handle_canvas_state_edit<S: CanvasState>(
|
||||
key: KeyEvent,
|
||||
config: &Config,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<String> {
|
||||
// Try direct key mapping first (same pattern as FormState)
|
||||
if let Some(canvas_action) = CanvasAction::from_key(key.code) {
|
||||
// println!("DEBUG: Key pressed: {:?}", key); // DEBUG
|
||||
|
||||
// PRIORITY 1: Character insertion in edit mode comes FIRST
|
||||
if let KeyCode::Char(c) = key.code {
|
||||
// Only insert if no modifiers or just shift (for uppercase)
|
||||
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT {
|
||||
// println!("DEBUG: Using character insertion priority for: {}", c); // DEBUG
|
||||
let canvas_action = CanvasAction::InsertChar(c);
|
||||
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
}
|
||||
Ok(ActionResult::HandledByFeature(msg)) => {
|
||||
return Ok(msg);
|
||||
}
|
||||
Ok(ActionResult::Error(msg)) => {
|
||||
return Ok(format!("Error: {}", msg));
|
||||
}
|
||||
Ok(ActionResult::RequiresContext(msg)) => {
|
||||
return Ok(format!("Context needed: {}", msg));
|
||||
}
|
||||
Err(e) => {
|
||||
// println!("DEBUG: Character insertion failed: {:?}, trying config", e);
|
||||
// Fall through to try config mappings
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PRIORITY 2: Check canvas config for special keys/combinations
|
||||
let canvas_config = canvas::config::CanvasConfig::load();
|
||||
if let Some(action_name) = canvas_config.get_edit_action(key.code, key.modifiers) {
|
||||
// println!("DEBUG: Canvas config mapped to: {}", action_name); // DEBUG
|
||||
let canvas_action = CanvasAction::from_string(action_name);
|
||||
|
||||
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
@@ -165,33 +199,43 @@ async fn handle_canvas_state_edit<S: CanvasState>(
|
||||
return Ok(format!("Context needed: {}", msg));
|
||||
}
|
||||
Err(_) => {
|
||||
// Fall through to try config mapping
|
||||
// println!("DEBUG: Canvas action failed, trying client config"); // DEBUG
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// println!("DEBUG: No canvas config mapping found"); // DEBUG
|
||||
}
|
||||
|
||||
// Try config-mapped action (same pattern as FormState)
|
||||
if let Some(action_str) = config.get_edit_action_for_key(key.code, key.modifiers) {
|
||||
let canvas_action = CanvasAction::from_string(&action_str);
|
||||
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
}
|
||||
Ok(ActionResult::HandledByFeature(msg)) => {
|
||||
return Ok(msg);
|
||||
}
|
||||
Ok(ActionResult::Error(msg)) => {
|
||||
return Ok(format!("Error: {}", msg));
|
||||
}
|
||||
Ok(ActionResult::RequiresContext(msg)) => {
|
||||
return Ok(format!("Context needed: {}", msg));
|
||||
}
|
||||
Err(e) => {
|
||||
return Ok(format!("Action failed: {}", e));
|
||||
// PRIORITY 3: Check client config ONLY for non-character keys or modified keys
|
||||
if !matches!(key.code, KeyCode::Char(_)) || !key.modifiers.is_empty() {
|
||||
if let Some(action_str) = config.get_edit_action_for_key(key.code, key.modifiers) {
|
||||
// println!("DEBUG: Client config mapped to: {} (for non-char key)", action_str); // DEBUG
|
||||
let canvas_action = CanvasAction::from_string(&action_str);
|
||||
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
}
|
||||
Ok(ActionResult::HandledByFeature(msg)) => {
|
||||
return Ok(msg);
|
||||
}
|
||||
Ok(ActionResult::Error(msg)) => {
|
||||
return Ok(format!("Error: {}", msg));
|
||||
}
|
||||
Ok(ActionResult::RequiresContext(msg)) => {
|
||||
return Ok(format!("Context needed: {}", msg));
|
||||
}
|
||||
Err(e) => {
|
||||
return Ok(format!("Action failed: {}", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// println!("DEBUG: No client config mapping found for non-char key"); // DEBUG
|
||||
}
|
||||
} else {
|
||||
// println!("DEBUG: Skipping client config for character key in edit mode"); // DEBUG
|
||||
}
|
||||
|
||||
// println!("DEBUG: No action taken for key: {:?}", key); // DEBUG
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,9 @@ pub async fn handle_form_readonly_with_canvas(
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<String> {
|
||||
// Try canvas action from key first
|
||||
if let Some(canvas_action) = CanvasAction::from_key(key_event.code) {
|
||||
let canvas_config = canvas::config::CanvasConfig::load();
|
||||
if let Some(action_name) = canvas_config.get_read_only_action(key_event.code, key_event.modifiers) {
|
||||
let canvas_action = CanvasAction::from_string(action_name);
|
||||
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
|
||||
@@ -45,6 +45,7 @@ use crate::ui::handlers::rat_state::UiStateHandler;
|
||||
use anyhow::Result;
|
||||
use common::proto::komp_ac::search::search_response::Hit;
|
||||
use crossterm::cursor::SetCursorStyle;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
@@ -776,7 +777,6 @@ impl EventHandler {
|
||||
if app_state.ui.show_form {
|
||||
if let Ok(Some(canvas_message)) = self.handle_form_canvas_action(
|
||||
key_event,
|
||||
config,
|
||||
form_state,
|
||||
false,
|
||||
).await {
|
||||
@@ -866,7 +866,6 @@ impl EventHandler {
|
||||
if app_state.ui.show_form {
|
||||
if let Ok(Some(canvas_message)) = self.handle_form_canvas_action(
|
||||
key_event,
|
||||
config,
|
||||
form_state,
|
||||
true,
|
||||
).await {
|
||||
@@ -1102,77 +1101,49 @@ impl EventHandler {
|
||||
async fn handle_form_canvas_action(
|
||||
&mut self,
|
||||
key_event: KeyEvent,
|
||||
_config: &Config,
|
||||
form_state: &mut FormState,
|
||||
is_edit_mode: bool,
|
||||
) -> Result<Option<String>> {
|
||||
let canvas_config = canvas::config::CanvasConfig::load();
|
||||
|
||||
// Handle suggestion actions first if suggestions are active
|
||||
if form_state.autocomplete_active {
|
||||
if let Some(action_str) = canvas_config.get_suggestion_action(key_event.code, key_event.modifiers) {
|
||||
let canvas_action = CanvasAction::from_string(&action_str);
|
||||
match ActionDispatcher::dispatch(canvas_action, form_state, &mut self.ideal_cursor_column).await {
|
||||
Ok(result) => return Ok(Some(result.message().unwrap_or("").to_string())),
|
||||
Err(_) => return Ok(Some("Suggestion action failed".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback hardcoded suggestion handling
|
||||
match key_event.code {
|
||||
KeyCode::Up => {
|
||||
if let Ok(result) = ActionDispatcher::dispatch(
|
||||
CanvasAction::SuggestionUp,
|
||||
// PRIORITY 1: Handle character insertion in edit mode FIRST
|
||||
if is_edit_mode {
|
||||
if let KeyCode::Char(c) = key_event.code {
|
||||
// Only insert if it's not a special modifier combination
|
||||
if key_event.modifiers.is_empty() || key_event.modifiers == KeyModifiers::SHIFT {
|
||||
let canvas_action = CanvasAction::InsertChar(c);
|
||||
match ActionDispatcher::dispatch(
|
||||
canvas_action,
|
||||
form_state,
|
||||
&mut self.ideal_cursor_column,
|
||||
).await {
|
||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
||||
Ok(result) => {
|
||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
||||
}
|
||||
Err(_) => {
|
||||
return Ok(Some("Character insertion failed".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if let Ok(result) = ActionDispatcher::dispatch(
|
||||
CanvasAction::SuggestionDown,
|
||||
form_state,
|
||||
&mut self.ideal_cursor_column,
|
||||
).await {
|
||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Ok(result) = ActionDispatcher::dispatch(
|
||||
CanvasAction::SelectSuggestion,
|
||||
form_state,
|
||||
&mut self.ideal_cursor_column,
|
||||
).await {
|
||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
||||
}
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
if let Ok(result) = ActionDispatcher::dispatch(
|
||||
CanvasAction::ExitSuggestions,
|
||||
form_state,
|
||||
&mut self.ideal_cursor_column,
|
||||
).await {
|
||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// PRIORITY 2: Handle config-mapped actions for non-character keys
|
||||
let action_str = canvas_config.get_action_for_key(
|
||||
key_event.code,
|
||||
key_event.modifiers,
|
||||
is_edit_mode,
|
||||
form_state.autocomplete_active
|
||||
form_state.autocomplete_active,
|
||||
);
|
||||
|
||||
if let Some(action_str) = action_str {
|
||||
// Skip mode transition actions - let the main event handler deal with them
|
||||
if Self::is_mode_transition_action(action_str) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let canvas_action = CanvasAction::from_string(&action_str);
|
||||
// Execute the config-mapped action
|
||||
let canvas_action = CanvasAction::from_string(action_str);
|
||||
match ActionDispatcher::dispatch(
|
||||
canvas_action,
|
||||
form_state,
|
||||
@@ -1187,54 +1158,7 @@ impl EventHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to automatic key handling for edit mode
|
||||
if is_edit_mode {
|
||||
if let Some(canvas_action) = CanvasAction::from_key(key_event.code) {
|
||||
match ActionDispatcher::dispatch(
|
||||
canvas_action,
|
||||
form_state,
|
||||
&mut self.ideal_cursor_column,
|
||||
).await {
|
||||
Ok(result) => {
|
||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
||||
}
|
||||
Err(_) => {
|
||||
return Ok(Some("Auto action failed".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// In read-only mode, only handle non-character keys
|
||||
let canvas_action = match key_event.code {
|
||||
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::Tab => Some(CanvasAction::NextField),
|
||||
KeyCode::BackTab => Some(CanvasAction::PrevField),
|
||||
KeyCode::Delete => Some(CanvasAction::DeleteForward),
|
||||
KeyCode::Backspace => Some(CanvasAction::DeleteBackward),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(canvas_action) = canvas_action {
|
||||
match ActionDispatcher::dispatch(
|
||||
canvas_action,
|
||||
form_state,
|
||||
&mut self.ideal_cursor_column,
|
||||
).await {
|
||||
Ok(result) => {
|
||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
||||
}
|
||||
Err(_) => {
|
||||
return Ok(Some("Action failed".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No action found
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/state/pages/add_logic.rs
|
||||
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
|
||||
use canvas::canvas::{CanvasState, ActionContext, CanvasAction}; // External library
|
||||
use canvas::canvas::{CanvasState, ActionContext, CanvasAction, AppMode};
|
||||
use crate::components::common::text_editor::{TextEditor, VimState};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
@@ -54,6 +54,7 @@ pub struct AddLogicState {
|
||||
// New fields for same-profile table names and column autocomplete
|
||||
pub same_profile_table_names: Vec<String>, // Tables from same profile only
|
||||
pub script_editor_awaiting_column_autocomplete: Option<String>, // Table name waiting for column fetch
|
||||
pub app_mode: AppMode,
|
||||
}
|
||||
|
||||
impl AddLogicState {
|
||||
@@ -91,6 +92,7 @@ impl AddLogicState {
|
||||
|
||||
same_profile_table_names: Vec::new(),
|
||||
script_editor_awaiting_column_autocomplete: None,
|
||||
app_mode: AppMode::Edit,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +271,9 @@ impl AddLogicState {
|
||||
|
||||
impl Default for AddLogicState {
|
||||
fn default() -> Self {
|
||||
Self::new(&EditorConfig::default())
|
||||
let mut state = Self::new(&EditorConfig::default());
|
||||
state.app_mode = AppMode::Edit;
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,4 +443,8 @@ impl CanvasState for AddLogicState {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_mode(&self) -> AppMode {
|
||||
self.app_mode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/state/pages/add_table.rs
|
||||
use canvas::canvas::{CanvasState, ActionContext, CanvasAction}; // External library
|
||||
use canvas::canvas::{CanvasState, ActionContext, CanvasAction, AppMode};
|
||||
use ratatui::widgets::TableState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -63,6 +63,7 @@ pub struct AddTableState {
|
||||
pub column_name_cursor_pos: usize,
|
||||
pub column_type_cursor_pos: usize,
|
||||
pub has_unsaved_changes: bool,
|
||||
pub app_mode: AppMode,
|
||||
}
|
||||
|
||||
impl Default for AddTableState {
|
||||
@@ -85,6 +86,7 @@ impl Default for AddTableState {
|
||||
column_name_cursor_pos: 0,
|
||||
column_type_cursor_pos: 0,
|
||||
has_unsaved_changes: false,
|
||||
app_mode: AppMode::Edit,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,4 +299,8 @@ impl CanvasState for AddTableState {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_mode(&self) -> AppMode {
|
||||
self.app_mode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/state/pages/auth.rs
|
||||
use canvas::canvas::{CanvasState, ActionContext, CanvasAction};
|
||||
use canvas::canvas::{CanvasState, ActionContext, CanvasAction, AppMode};
|
||||
use canvas::autocomplete::{AutocompleteCanvasState, AutocompleteState, SuggestionItem};
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
@@ -22,7 +22,6 @@ pub struct AuthState {
|
||||
}
|
||||
|
||||
/// Represents the state of the Login form UI
|
||||
#[derive(Default)]
|
||||
pub struct LoginState {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
@@ -31,10 +30,26 @@ pub struct LoginState {
|
||||
pub current_cursor_pos: usize,
|
||||
pub has_unsaved_changes: bool,
|
||||
pub login_request_pending: bool,
|
||||
pub app_mode: AppMode,
|
||||
}
|
||||
|
||||
impl Default for LoginState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
error_message: None,
|
||||
current_field: 0,
|
||||
current_cursor_pos: 0,
|
||||
has_unsaved_changes: false,
|
||||
login_request_pending: false,
|
||||
app_mode: AppMode::Edit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the state of the Registration form UI
|
||||
#[derive(Default, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct RegisterState {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
@@ -45,8 +60,26 @@ pub struct RegisterState {
|
||||
pub current_field: usize,
|
||||
pub current_cursor_pos: usize,
|
||||
pub has_unsaved_changes: bool,
|
||||
// NEW: Replace old autocomplete with external library's system
|
||||
pub autocomplete: AutocompleteState<String>,
|
||||
pub app_mode: AppMode,
|
||||
}
|
||||
|
||||
impl Default for RegisterState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
username: String::new(),
|
||||
email: String::new(),
|
||||
password: String::new(),
|
||||
password_confirmation: String::new(),
|
||||
role: String::new(),
|
||||
error_message: None,
|
||||
current_field: 0,
|
||||
current_cursor_pos: 0,
|
||||
has_unsaved_changes: false,
|
||||
autocomplete: AutocompleteState::new(),
|
||||
app_mode: AppMode::Edit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthState {
|
||||
@@ -57,7 +90,10 @@ impl AuthState {
|
||||
|
||||
impl LoginState {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
Self {
|
||||
app_mode: AppMode::Edit,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +101,7 @@ impl RegisterState {
|
||||
pub fn new() -> Self {
|
||||
let mut state = Self {
|
||||
autocomplete: AutocompleteState::new(),
|
||||
app_mode: AppMode::Edit,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -146,6 +183,10 @@ impl CanvasState for LoginState {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_mode(&self) -> AppMode {
|
||||
self.app_mode
|
||||
}
|
||||
}
|
||||
|
||||
// Implement external library's CanvasState for RegisterState
|
||||
@@ -237,6 +278,10 @@ impl CanvasState for RegisterState {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_mode(&self) -> AppMode {
|
||||
self.app_mode
|
||||
}
|
||||
}
|
||||
|
||||
// Add autocomplete support for RegisterState
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/state/pages/form.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use canvas::canvas::{CanvasState, CanvasAction, ActionContext, HighlightState};
|
||||
use canvas::canvas::{CanvasState, CanvasAction, ActionContext, HighlightState, AppMode};
|
||||
use common::proto::komp_ac::search::search_response::Hit;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::Frame;
|
||||
@@ -41,6 +41,7 @@ pub struct FormState {
|
||||
pub selected_suggestion_index: Option<usize>,
|
||||
pub autocomplete_loading: bool,
|
||||
pub link_display_map: HashMap<usize, String>,
|
||||
pub app_mode: AppMode,
|
||||
}
|
||||
|
||||
impl FormState {
|
||||
@@ -74,6 +75,7 @@ impl FormState {
|
||||
selected_suggestion_index: None,
|
||||
autocomplete_loading: false,
|
||||
link_display_map: HashMap::new(),
|
||||
app_mode: AppMode::Edit,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +233,15 @@ impl FormState {
|
||||
self.selected_suggestion_index = if self.autocomplete_active { Some(0) } else { None };
|
||||
self.autocomplete_loading = false;
|
||||
}
|
||||
|
||||
// NEW: Add these methods to change modes
|
||||
pub fn set_edit_mode(&mut self) {
|
||||
self.app_mode = AppMode::Edit;
|
||||
}
|
||||
|
||||
pub fn set_readonly_mode(&mut self) {
|
||||
self.app_mode = AppMode::ReadOnly;
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasState for FormState {
|
||||
@@ -320,4 +331,8 @@ impl CanvasState for FormState {
|
||||
fn has_display_override(&self, index: usize) -> bool {
|
||||
self.link_display_map.contains_key(&index)
|
||||
}
|
||||
|
||||
fn current_mode(&self) -> AppMode {
|
||||
self.app_mode
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user