Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ff3c59961 | ||
|
|
c5f22d7da1 | ||
|
|
3c62877757 | ||
|
|
cc19c61f37 | ||
|
|
ad82bd4302 | ||
|
|
d584a25fdb |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@
|
|||||||
server/tantivy_indexes
|
server/tantivy_indexes
|
||||||
steel_decimal/tests/property_tests.proptest-regressions
|
steel_decimal/tests/property_tests.proptest-regressions
|
||||||
.direnv/
|
.direnv/
|
||||||
|
canvas/*.toml
|
||||||
|
|||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -479,6 +479,7 @@ dependencies = [
|
|||||||
"crossterm",
|
"crossterm",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"serde",
|
"serde",
|
||||||
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-test",
|
"tokio-test",
|
||||||
"toml",
|
"toml",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ tokio = { workspace = true }
|
|||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
unicode-width.workspace = true
|
unicode-width.workspace = true
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-subscriber = "0.3.19"
|
tracing-subscriber = "0.3.19"
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
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::canvas::state::{CanvasState, ActionContext};
|
||||||
use crate::autocomplete::state::AutocompleteCanvasState;
|
use crate::autocomplete::state::AutocompleteCanvasState;
|
||||||
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
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 crate::config::CanvasConfig;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
@@ -26,9 +26,9 @@ pub async fn execute_canvas_action_with_autocomplete<S: CanvasState + Autocomple
|
|||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Handle generic actions and add auto-trigger logic
|
// 2. Handle generic actions using the new dispatcher directly
|
||||||
let result = handle_generic_canvas_action(action.clone(), state, ideal_cursor_column, config).await?;
|
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
|
// 3. AUTO-TRIGGER LOGIC: Check if we should activate/deactivate autocomplete
|
||||||
if let Some(cfg) = config {
|
if let Some(cfg) = config {
|
||||||
println!("{:?}, {}", action, cfg.should_auto_trigger_autocomplete());
|
println!("{:?}, {}", action, cfg.should_auto_trigger_autocomplete());
|
||||||
@@ -39,27 +39,27 @@ pub async fn execute_canvas_action_with_autocomplete<S: CanvasState + Autocomple
|
|||||||
println!("AUTO-T on Ins");
|
println!("AUTO-T on Ins");
|
||||||
let current_field = state.current_field();
|
let current_field = state.current_field();
|
||||||
let current_input = state.get_current_input();
|
let current_input = state.get_current_input();
|
||||||
|
|
||||||
if state.supports_autocomplete(current_field)
|
if state.supports_autocomplete(current_field)
|
||||||
&& !state.is_autocomplete_active()
|
&& !state.is_autocomplete_active()
|
||||||
&& current_input.len() >= 1
|
&& current_input.len() >= 1
|
||||||
{
|
{
|
||||||
println!("ACT AUTOC");
|
println!("ACT AUTOC");
|
||||||
state.activate_autocomplete();
|
state.activate_autocomplete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CanvasAction::NextField | CanvasAction::PrevField => {
|
CanvasAction::NextField | CanvasAction::PrevField => {
|
||||||
println!("AUTO-T on nav");
|
println!("AUTO-T on nav");
|
||||||
let current_field = state.current_field();
|
let current_field = state.current_field();
|
||||||
|
|
||||||
if state.supports_autocomplete(current_field) && !state.is_autocomplete_active() {
|
if state.supports_autocomplete(current_field) && !state.is_autocomplete_active() {
|
||||||
state.activate_autocomplete();
|
state.activate_autocomplete();
|
||||||
} else if !state.supports_autocomplete(current_field) && state.is_autocomplete_active() {
|
} else if !state.supports_autocomplete(current_field) && state.is_autocomplete_active() {
|
||||||
state.deactivate_autocomplete();
|
state.deactivate_autocomplete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ => {} // No auto-trigger for other actions
|
_ => {} // No auto-trigger for other actions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,15 @@
|
|||||||
// canvas/src/canvas/actions/edit.rs
|
// src/canvas/actions/handlers/edit.rs
|
||||||
|
|
||||||
use crate::canvas::state::{CanvasState, ActionContext};
|
|
||||||
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
||||||
|
use crate::canvas::actions::movement::*;
|
||||||
|
use crate::canvas::state::CanvasState;
|
||||||
use crate::config::CanvasConfig;
|
use crate::config::CanvasConfig;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
/// Execute a typed canvas action on any CanvasState implementation
|
const FOR_EDIT_MODE: bool = true; // Edit mode flag
|
||||||
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) {
|
/// Handle actions in edit mode with edit-specific cursor behavior
|
||||||
return Ok(ActionResult::HandledByFeature(result));
|
pub async fn handle_edit_action<S: CanvasState>(
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
action: CanvasAction,
|
||||||
state: &mut S,
|
state: &mut S,
|
||||||
ideal_cursor_column: &mut usize,
|
ideal_cursor_column: &mut usize,
|
||||||
@@ -44,34 +26,6 @@ pub async fn handle_generic_canvas_action<S: CanvasState>(
|
|||||||
Ok(ActionResult::success())
|
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 => {
|
CanvasAction::DeleteBackward => {
|
||||||
let cursor_pos = state.current_cursor_pos();
|
let cursor_pos = state.current_cursor_pos();
|
||||||
if cursor_pos > 0 {
|
if cursor_pos > 0 {
|
||||||
@@ -95,34 +49,18 @@ pub async fn handle_generic_canvas_action<S: CanvasState>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
CanvasAction::MoveLeft => {
|
CanvasAction::MoveLeft => {
|
||||||
let cursor_pos = state.current_cursor_pos();
|
let new_pos = move_left(state.current_cursor_pos());
|
||||||
if cursor_pos > 0 {
|
state.set_current_cursor_pos(new_pos);
|
||||||
state.set_current_cursor_pos(cursor_pos - 1);
|
*ideal_cursor_column = new_pos;
|
||||||
*ideal_cursor_column = cursor_pos - 1;
|
|
||||||
}
|
|
||||||
Ok(ActionResult::success())
|
Ok(ActionResult::success())
|
||||||
}
|
}
|
||||||
|
|
||||||
CanvasAction::MoveRight => {
|
CanvasAction::MoveRight => {
|
||||||
let cursor_pos = state.current_cursor_pos();
|
|
||||||
let current_input = state.get_current_input();
|
let current_input = state.get_current_input();
|
||||||
if cursor_pos < current_input.len() {
|
let current_pos = state.current_cursor_pos();
|
||||||
state.set_current_cursor_pos(cursor_pos + 1);
|
let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE);
|
||||||
*ideal_cursor_column = cursor_pos + 1;
|
state.set_current_cursor_pos(new_pos);
|
||||||
}
|
*ideal_cursor_column = new_pos;
|
||||||
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())
|
Ok(ActionResult::success())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +69,9 @@ pub async fn handle_generic_canvas_action<S: CanvasState>(
|
|||||||
let current_field = state.current_field();
|
let current_field = state.current_field();
|
||||||
if current_field > 0 {
|
if current_field > 0 {
|
||||||
state.set_current_field(current_field - 1);
|
state.set_current_field(current_field - 1);
|
||||||
*ideal_cursor_column = state.current_cursor_pos();
|
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())
|
Ok(ActionResult::success())
|
||||||
}
|
}
|
||||||
@@ -142,24 +82,44 @@ pub async fn handle_generic_canvas_action<S: CanvasState>(
|
|||||||
let total_fields = state.fields().len();
|
let total_fields = state.fields().len();
|
||||||
if current_field < total_fields - 1 {
|
if current_field < total_fields - 1 {
|
||||||
state.set_current_field(current_field + 1);
|
state.set_current_field(current_field + 1);
|
||||||
*ideal_cursor_column = state.current_cursor_pos();
|
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())
|
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::MoveFirstLine => {
|
CanvasAction::MoveFirstLine => {
|
||||||
state.set_current_field(0);
|
state.set_current_field(0);
|
||||||
state.set_current_cursor_pos(0);
|
let current_input = state.get_current_input();
|
||||||
*ideal_cursor_column = 0;
|
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())
|
Ok(ActionResult::success())
|
||||||
}
|
}
|
||||||
|
|
||||||
CanvasAction::MoveLastLine => {
|
CanvasAction::MoveLastLine => {
|
||||||
let last_field = state.fields().len() - 1;
|
let last_field = state.fields().len() - 1;
|
||||||
state.set_current_field(last_field);
|
state.set_current_field(last_field);
|
||||||
let end_pos = state.get_current_input().len();
|
let current_input = state.get_current_input();
|
||||||
state.set_current_cursor_pos(end_pos);
|
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
|
||||||
*ideal_cursor_column = end_pos;
|
state.set_current_cursor_pos(new_pos);
|
||||||
|
*ideal_cursor_column = new_pos;
|
||||||
Ok(ActionResult::success())
|
Ok(ActionResult::success())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,61 +153,51 @@ pub async fn handle_generic_canvas_action<S: CanvasState>(
|
|||||||
Ok(ActionResult::success())
|
Ok(ActionResult::success())
|
||||||
}
|
}
|
||||||
|
|
||||||
CanvasAction::Custom(action_str) => {
|
CanvasAction::MoveWordEndPrev => {
|
||||||
Ok(ActionResult::success_with_message(&format!("Custom action: {}", action_str)))
|
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())
|
||||||
}
|
}
|
||||||
|
|
||||||
_ => Ok(ActionResult::success_with_message("Action not implemented")),
|
CanvasAction::NextField | CanvasAction::PrevField => {
|
||||||
}
|
let current_field = state.current_field();
|
||||||
}
|
let total_fields = state.fields().len();
|
||||||
|
|
||||||
// Helper functions for word navigation
|
let new_field = match action {
|
||||||
fn find_next_word_start(text: &str, cursor_pos: usize) -> usize {
|
CanvasAction::NextField => {
|
||||||
let chars: Vec<char> = text.chars().collect();
|
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
|
||||||
let mut pos = cursor_pos;
|
(current_field + 1) % total_fields
|
||||||
|
} else {
|
||||||
// Skip current word
|
(current_field + 1).min(total_fields - 1)
|
||||||
while pos < chars.len() && chars[pos].is_alphanumeric() {
|
}
|
||||||
pos += 1;
|
}
|
||||||
}
|
CanvasAction::PrevField => {
|
||||||
|
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
|
||||||
// Skip whitespace
|
if current_field == 0 { total_fields - 1 } else { current_field - 1 }
|
||||||
while pos < chars.len() && chars[pos].is_whitespace() {
|
} else {
|
||||||
pos += 1;
|
current_field.saturating_sub(1)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
pos
|
_ => unreachable!(),
|
||||||
}
|
};
|
||||||
|
|
||||||
fn find_word_end(text: &str, cursor_pos: usize) -> usize {
|
state.set_current_field(new_field);
|
||||||
let chars: Vec<char> = text.chars().collect();
|
let current_input = state.get_current_input();
|
||||||
let mut pos = cursor_pos;
|
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
||||||
|
state.set_current_cursor_pos(new_pos);
|
||||||
// Move to end of current word
|
Ok(ActionResult::success())
|
||||||
while pos < chars.len() && chars[pos].is_alphanumeric() {
|
}
|
||||||
pos += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
pos
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_prev_word_start(text: &str, cursor_pos: usize) -> usize {
|
CanvasAction::Custom(action_str) => {
|
||||||
if cursor_pos == 0 {
|
Ok(ActionResult::success_with_message(&format!("Custom edit action: {}", action_str)))
|
||||||
return 0;
|
}
|
||||||
|
|
||||||
|
_ => {
|
||||||
|
Ok(ActionResult::success_with_message("Action not implemented for edit mode"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
106
canvas/src/canvas/actions/handlers/highlight.rs
Normal file
106
canvas/src/canvas/actions/handlers/highlight.rs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// src/canvas/actions/handlers/highlight.rs
|
||||||
|
|
||||||
|
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
||||||
|
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
|
||||||
|
|
||||||
|
/// 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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
193
canvas/src/canvas/actions/handlers/readonly.rs
Normal file
193
canvas/src/canvas/actions/handlers/readonly.rs
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
// src/canvas/actions/handlers/readonly.rs
|
||||||
|
|
||||||
|
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
||||||
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
// canvas/src/canvas/actions/mod.rs
|
// src/canvas/actions/mod.rs
|
||||||
pub mod types;
|
|
||||||
pub mod edit;
|
|
||||||
|
|
||||||
// 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 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,
|
MoveWordNext,
|
||||||
MoveWordEnd,
|
MoveWordEnd,
|
||||||
MoveWordPrev,
|
MoveWordPrev,
|
||||||
|
MoveWordEndPrev,
|
||||||
|
|
||||||
// Field navigation
|
// Field navigation
|
||||||
NextField,
|
NextField,
|
||||||
@@ -42,24 +43,7 @@ pub enum CanvasAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CanvasAction {
|
impl CanvasAction {
|
||||||
pub fn from_key(key: crossterm::event::KeyCode) -> Option<Self> {
|
/// Convert string action name to CanvasAction enum (config-driven)
|
||||||
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
|
|
||||||
pub fn from_string(action: &str) -> Self {
|
pub fn from_string(action: &str) -> Self {
|
||||||
match action {
|
match action {
|
||||||
"delete_char_backward" => Self::DeleteBackward,
|
"delete_char_backward" => Self::DeleteBackward,
|
||||||
@@ -75,6 +59,7 @@ impl CanvasAction {
|
|||||||
"move_word_next" => Self::MoveWordNext,
|
"move_word_next" => Self::MoveWordNext,
|
||||||
"move_word_end" => Self::MoveWordEnd,
|
"move_word_end" => Self::MoveWordEnd,
|
||||||
"move_word_prev" => Self::MoveWordPrev,
|
"move_word_prev" => Self::MoveWordPrev,
|
||||||
|
"move_word_end_prev" => Self::MoveWordEndPrev,
|
||||||
"next_field" => Self::NextField,
|
"next_field" => Self::NextField,
|
||||||
"prev_field" => Self::PrevField,
|
"prev_field" => Self::PrevField,
|
||||||
"trigger_autocomplete" => Self::TriggerAutocomplete,
|
"trigger_autocomplete" => Self::TriggerAutocomplete,
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
// src/canvas/mod.rs
|
// src/canvas/mod.rs
|
||||||
pub mod actions;
|
pub mod actions;
|
||||||
pub mod modes;
|
|
||||||
pub mod gui;
|
pub mod gui;
|
||||||
pub mod theme;
|
pub mod modes;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
pub mod theme;
|
||||||
|
|
||||||
// Re-export commonly used canvas types
|
// Re-export commonly used canvas types
|
||||||
pub use actions::{CanvasAction, ActionResult};
|
pub use actions::{CanvasAction, ActionResult};
|
||||||
pub use modes::{AppMode, ModeManager, HighlightState};
|
pub use modes::{AppMode, ModeManager, HighlightState};
|
||||||
pub use state::{CanvasState, ActionContext};
|
pub use state::{CanvasState, ActionContext};
|
||||||
|
|
||||||
|
// Re-export the main entry point
|
||||||
|
pub use crate::dispatcher::execute_canvas_action;
|
||||||
|
|
||||||
#[cfg(feature = "gui")]
|
#[cfg(feature = "gui")]
|
||||||
pub use theme::CanvasTheme;
|
pub use theme::CanvasTheme;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// canvas/src/state.rs
|
// src/canvas/state.rs
|
||||||
|
|
||||||
use crate::canvas::actions::CanvasAction;
|
use crate::canvas::actions::CanvasAction;
|
||||||
|
use crate::canvas::modes::AppMode;
|
||||||
|
|
||||||
/// Context passed to feature-specific action handlers
|
/// Context passed to feature-specific action handlers
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -21,6 +22,9 @@ pub trait CanvasState {
|
|||||||
fn set_current_field(&mut self, index: usize);
|
fn set_current_field(&mut self, index: usize);
|
||||||
fn set_current_cursor_pos(&mut self, pos: usize);
|
fn set_current_cursor_pos(&mut self, pos: usize);
|
||||||
|
|
||||||
|
// --- Mode Information ---
|
||||||
|
fn current_mode(&self) -> AppMode;
|
||||||
|
|
||||||
// --- Data Access ---
|
// --- Data Access ---
|
||||||
fn get_current_input(&self) -> &str;
|
fn get_current_input(&self) -> &str;
|
||||||
fn get_current_input_mut(&mut self) -> &mut String;
|
fn get_current_input_mut(&mut self) -> &mut String;
|
||||||
@@ -33,7 +37,7 @@ pub trait CanvasState {
|
|||||||
|
|
||||||
// --- Feature-specific action handling ---
|
// --- Feature-specific action handling ---
|
||||||
|
|
||||||
/// Feature-specific action handling (NEW: Type-safe)
|
/// Feature-specific action handling (Type-safe)
|
||||||
fn handle_feature_action(&mut self, _action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
fn handle_feature_action(&mut self, _action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||||
None // Default: no feature-specific handling
|
None // Default: no feature-specific handling
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,494 +0,0 @@
|
|||||||
// canvas/src/config.rs
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use crossterm::event::{KeyCode, KeyModifiers};
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct CanvasConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub keybindings: CanvasKeybindings,
|
|
||||||
#[serde(default)]
|
|
||||||
pub behavior: CanvasBehavior,
|
|
||||||
#[serde(default)]
|
|
||||||
pub appearance: CanvasAppearance,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct CanvasBehavior {
|
|
||||||
#[serde(default = "default_wrap_around")]
|
|
||||||
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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for CanvasConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
keybindings: CanvasKeybindings::with_vim_defaults(),
|
|
||||||
behavior: CanvasBehavior::default(),
|
|
||||||
appearance: CanvasAppearance::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CanvasKeybindings {
|
|
||||||
pub fn with_vim_defaults() -> Self {
|
|
||||||
let mut keybindings = Self::default();
|
|
||||||
|
|
||||||
// Read-only mode (vim-style navigation)
|
|
||||||
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();
|
|
||||||
|
|
||||||
// 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()]);
|
|
||||||
|
|
||||||
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()]);
|
|
||||||
|
|
||||||
keybindings
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CanvasConfig {
|
|
||||||
/// Load from canvas_config.toml or fallback to vim defaults
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to vim defaults
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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))?;
|
|
||||||
Self::from_toml(&contents)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// NEW: 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
|
|
||||||
pub fn has_trigger_autocomplete_keybinding(&self) -> bool {
|
|
||||||
self.keybindings.edit.contains_key("trigger_autocomplete") ||
|
|
||||||
self.keybindings.read_only.contains_key("trigger_autocomplete") ||
|
|
||||||
self.keybindings.global.contains_key("trigger_autocomplete")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get action for key in read-only mode
|
|
||||||
pub fn get_read_only_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
|
||||||
self.get_action_in_mode(&self.keybindings.read_only, key, modifiers)
|
|
||||||
.or_else(|| self.get_action_in_mode(&self.keybindings.global, key, modifiers))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get action for key in edit mode
|
|
||||||
pub fn get_edit_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
|
||||||
self.get_action_in_mode(&self.keybindings.edit, key, modifiers)
|
|
||||||
.or_else(|| self.get_action_in_mode(&self.keybindings.global, key, modifiers))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get action for key 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
|
|
||||||
if is_edit_mode {
|
|
||||||
self.get_edit_action(key, modifiers)
|
|
||||||
} else {
|
|
||||||
self.get_read_only_action(key, modifiers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_action_in_mode<'a>(&self, mode_bindings: &'a HashMap<String, Vec<String>>, key: KeyCode, modifiers: KeyModifiers) -> Option<&'a str> {
|
|
||||||
for (action, bindings) in mode_bindings {
|
|
||||||
for binding in bindings {
|
|
||||||
if self.matches_keybinding(binding, key, modifiers) {
|
|
||||||
return Some(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn matches_keybinding(&self, binding: &str, key: KeyCode, modifiers: KeyModifiers) -> bool {
|
|
||||||
// Special handling for shift+character combinations
|
|
||||||
if binding.to_lowercase().starts_with("shift+") {
|
|
||||||
let parts: Vec<&str> = binding.split('+').collect();
|
|
||||||
if parts.len() == 2 && parts[1].len() == 1 {
|
|
||||||
let expected_lowercase = parts[1].chars().next().unwrap().to_lowercase().next().unwrap();
|
|
||||||
let expected_uppercase = expected_lowercase.to_uppercase().next().unwrap();
|
|
||||||
if let KeyCode::Char(actual_char) = key {
|
|
||||||
if actual_char == expected_uppercase && modifiers.contains(KeyModifiers::SHIFT) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Shift+Tab -> BackTab
|
|
||||||
if binding.to_lowercase() == "shift+tab" && key == KeyCode::BackTab && modifiers.is_empty() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle multi-character bindings (all standard keys without modifiers)
|
|
||||||
if binding.len() > 1 && !binding.contains('+') {
|
|
||||||
return match binding.to_lowercase().as_str() {
|
|
||||||
// Navigation keys
|
|
||||||
"left" => key == KeyCode::Left,
|
|
||||||
"right" => key == KeyCode::Right,
|
|
||||||
"up" => key == KeyCode::Up,
|
|
||||||
"down" => key == KeyCode::Down,
|
|
||||||
"home" => key == KeyCode::Home,
|
|
||||||
"end" => key == KeyCode::End,
|
|
||||||
"pageup" | "pgup" => key == KeyCode::PageUp,
|
|
||||||
"pagedown" | "pgdn" => key == KeyCode::PageDown,
|
|
||||||
|
|
||||||
// Editing keys
|
|
||||||
"insert" | "ins" => key == KeyCode::Insert,
|
|
||||||
"delete" | "del" => key == KeyCode::Delete,
|
|
||||||
"backspace" => key == KeyCode::Backspace,
|
|
||||||
|
|
||||||
// Tab keys
|
|
||||||
"tab" => key == KeyCode::Tab,
|
|
||||||
"backtab" => key == KeyCode::BackTab,
|
|
||||||
|
|
||||||
// Special keys
|
|
||||||
"enter" | "return" => key == KeyCode::Enter,
|
|
||||||
"escape" | "esc" => key == KeyCode::Esc,
|
|
||||||
"space" => key == KeyCode::Char(' '),
|
|
||||||
|
|
||||||
// Function keys F1-F24
|
|
||||||
"f1" => key == KeyCode::F(1),
|
|
||||||
"f2" => key == KeyCode::F(2),
|
|
||||||
"f3" => key == KeyCode::F(3),
|
|
||||||
"f4" => key == KeyCode::F(4),
|
|
||||||
"f5" => key == KeyCode::F(5),
|
|
||||||
"f6" => key == KeyCode::F(6),
|
|
||||||
"f7" => key == KeyCode::F(7),
|
|
||||||
"f8" => key == KeyCode::F(8),
|
|
||||||
"f9" => key == KeyCode::F(9),
|
|
||||||
"f10" => key == KeyCode::F(10),
|
|
||||||
"f11" => key == KeyCode::F(11),
|
|
||||||
"f12" => key == KeyCode::F(12),
|
|
||||||
"f13" => key == KeyCode::F(13),
|
|
||||||
"f14" => key == KeyCode::F(14),
|
|
||||||
"f15" => key == KeyCode::F(15),
|
|
||||||
"f16" => key == KeyCode::F(16),
|
|
||||||
"f17" => key == KeyCode::F(17),
|
|
||||||
"f18" => key == KeyCode::F(18),
|
|
||||||
"f19" => key == KeyCode::F(19),
|
|
||||||
"f20" => key == KeyCode::F(20),
|
|
||||||
"f21" => key == KeyCode::F(21),
|
|
||||||
"f22" => key == KeyCode::F(22),
|
|
||||||
"f23" => key == KeyCode::F(23),
|
|
||||||
"f24" => key == KeyCode::F(24),
|
|
||||||
|
|
||||||
// Lock keys (may not work reliably in all terminals)
|
|
||||||
"capslock" => key == KeyCode::CapsLock,
|
|
||||||
"scrolllock" => key == KeyCode::ScrollLock,
|
|
||||||
"numlock" => key == KeyCode::NumLock,
|
|
||||||
|
|
||||||
// System keys
|
|
||||||
"printscreen" => key == KeyCode::PrintScreen,
|
|
||||||
"pause" => key == KeyCode::Pause,
|
|
||||||
"menu" => key == KeyCode::Menu,
|
|
||||||
"keypadbegin" => key == KeyCode::KeypadBegin,
|
|
||||||
|
|
||||||
// Media keys (rarely supported but included for completeness)
|
|
||||||
"mediaplay" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Play),
|
|
||||||
"mediapause" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Pause),
|
|
||||||
"mediaplaypause" => key == KeyCode::Media(crossterm::event::MediaKeyCode::PlayPause),
|
|
||||||
"mediareverse" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Reverse),
|
|
||||||
"mediastop" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Stop),
|
|
||||||
"mediafastforward" => key == KeyCode::Media(crossterm::event::MediaKeyCode::FastForward),
|
|
||||||
"mediarewind" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Rewind),
|
|
||||||
"mediatracknext" => key == KeyCode::Media(crossterm::event::MediaKeyCode::TrackNext),
|
|
||||||
"mediatrackprevious" => key == KeyCode::Media(crossterm::event::MediaKeyCode::TrackPrevious),
|
|
||||||
"mediarecord" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Record),
|
|
||||||
"medialowervolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::LowerVolume),
|
|
||||||
"mediaraisevolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::RaiseVolume),
|
|
||||||
"mediamutevolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::MuteVolume),
|
|
||||||
|
|
||||||
// Modifier keys (these work better as part of combinations)
|
|
||||||
"leftshift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftShift),
|
|
||||||
"leftcontrol" | "leftctrl" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftControl),
|
|
||||||
"leftalt" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftAlt),
|
|
||||||
"leftsuper" | "leftwindows" | "leftcmd" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftSuper),
|
|
||||||
"lefthyper" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftHyper),
|
|
||||||
"leftmeta" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftMeta),
|
|
||||||
"rightshift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightShift),
|
|
||||||
"rightcontrol" | "rightctrl" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightControl),
|
|
||||||
"rightalt" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightAlt),
|
|
||||||
"rightsuper" | "rightwindows" | "rightcmd" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightSuper),
|
|
||||||
"righthyper" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightHyper),
|
|
||||||
"rightmeta" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightMeta),
|
|
||||||
"isolevel3shift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::IsoLevel3Shift),
|
|
||||||
"isolevel5shift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::IsoLevel5Shift),
|
|
||||||
|
|
||||||
// Multi-key sequences need special handling
|
|
||||||
"gg" => false, // This needs sequence handling
|
|
||||||
_ => {
|
|
||||||
// Handle single characters and punctuation
|
|
||||||
if binding.len() == 1 {
|
|
||||||
if let Some(c) = binding.chars().next() {
|
|
||||||
key == KeyCode::Char(c)
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle modifier combinations (like "Ctrl+F5", "Alt+Shift+A")
|
|
||||||
let parts: Vec<&str> = binding.split('+').collect();
|
|
||||||
let mut expected_modifiers = KeyModifiers::empty();
|
|
||||||
let mut expected_key = None;
|
|
||||||
|
|
||||||
for part in parts {
|
|
||||||
match part.to_lowercase().as_str() {
|
|
||||||
// Modifiers
|
|
||||||
"ctrl" | "control" => expected_modifiers |= KeyModifiers::CONTROL,
|
|
||||||
"shift" => expected_modifiers |= KeyModifiers::SHIFT,
|
|
||||||
"alt" => expected_modifiers |= KeyModifiers::ALT,
|
|
||||||
"super" | "windows" | "cmd" => expected_modifiers |= KeyModifiers::SUPER,
|
|
||||||
"hyper" => expected_modifiers |= KeyModifiers::HYPER,
|
|
||||||
"meta" => expected_modifiers |= KeyModifiers::META,
|
|
||||||
|
|
||||||
// Navigation keys
|
|
||||||
"left" => expected_key = Some(KeyCode::Left),
|
|
||||||
"right" => expected_key = Some(KeyCode::Right),
|
|
||||||
"up" => expected_key = Some(KeyCode::Up),
|
|
||||||
"down" => expected_key = Some(KeyCode::Down),
|
|
||||||
"home" => expected_key = Some(KeyCode::Home),
|
|
||||||
"end" => expected_key = Some(KeyCode::End),
|
|
||||||
"pageup" | "pgup" => expected_key = Some(KeyCode::PageUp),
|
|
||||||
"pagedown" | "pgdn" => expected_key = Some(KeyCode::PageDown),
|
|
||||||
|
|
||||||
// Editing keys
|
|
||||||
"insert" | "ins" => expected_key = Some(KeyCode::Insert),
|
|
||||||
"delete" | "del" => expected_key = Some(KeyCode::Delete),
|
|
||||||
"backspace" => expected_key = Some(KeyCode::Backspace),
|
|
||||||
|
|
||||||
// Tab keys
|
|
||||||
"tab" => expected_key = Some(KeyCode::Tab),
|
|
||||||
"backtab" => expected_key = Some(KeyCode::BackTab),
|
|
||||||
|
|
||||||
// Special keys
|
|
||||||
"enter" | "return" => expected_key = Some(KeyCode::Enter),
|
|
||||||
"escape" | "esc" => expected_key = Some(KeyCode::Esc),
|
|
||||||
"space" => expected_key = Some(KeyCode::Char(' ')),
|
|
||||||
|
|
||||||
// Function keys
|
|
||||||
"f1" => expected_key = Some(KeyCode::F(1)),
|
|
||||||
"f2" => expected_key = Some(KeyCode::F(2)),
|
|
||||||
"f3" => expected_key = Some(KeyCode::F(3)),
|
|
||||||
"f4" => expected_key = Some(KeyCode::F(4)),
|
|
||||||
"f5" => expected_key = Some(KeyCode::F(5)),
|
|
||||||
"f6" => expected_key = Some(KeyCode::F(6)),
|
|
||||||
"f7" => expected_key = Some(KeyCode::F(7)),
|
|
||||||
"f8" => expected_key = Some(KeyCode::F(8)),
|
|
||||||
"f9" => expected_key = Some(KeyCode::F(9)),
|
|
||||||
"f10" => expected_key = Some(KeyCode::F(10)),
|
|
||||||
"f11" => expected_key = Some(KeyCode::F(11)),
|
|
||||||
"f12" => expected_key = Some(KeyCode::F(12)),
|
|
||||||
"f13" => expected_key = Some(KeyCode::F(13)),
|
|
||||||
"f14" => expected_key = Some(KeyCode::F(14)),
|
|
||||||
"f15" => expected_key = Some(KeyCode::F(15)),
|
|
||||||
"f16" => expected_key = Some(KeyCode::F(16)),
|
|
||||||
"f17" => expected_key = Some(KeyCode::F(17)),
|
|
||||||
"f18" => expected_key = Some(KeyCode::F(18)),
|
|
||||||
"f19" => expected_key = Some(KeyCode::F(19)),
|
|
||||||
"f20" => expected_key = Some(KeyCode::F(20)),
|
|
||||||
"f21" => expected_key = Some(KeyCode::F(21)),
|
|
||||||
"f22" => expected_key = Some(KeyCode::F(22)),
|
|
||||||
"f23" => expected_key = Some(KeyCode::F(23)),
|
|
||||||
"f24" => expected_key = Some(KeyCode::F(24)),
|
|
||||||
|
|
||||||
// Lock keys
|
|
||||||
"capslock" => expected_key = Some(KeyCode::CapsLock),
|
|
||||||
"scrolllock" => expected_key = Some(KeyCode::ScrollLock),
|
|
||||||
"numlock" => expected_key = Some(KeyCode::NumLock),
|
|
||||||
|
|
||||||
// System keys
|
|
||||||
"printscreen" => expected_key = Some(KeyCode::PrintScreen),
|
|
||||||
"pause" => expected_key = Some(KeyCode::Pause),
|
|
||||||
"menu" => expected_key = Some(KeyCode::Menu),
|
|
||||||
"keypadbegin" => expected_key = Some(KeyCode::KeypadBegin),
|
|
||||||
|
|
||||||
// Single character (letters, numbers, punctuation)
|
|
||||||
part => {
|
|
||||||
if part.len() == 1 {
|
|
||||||
if let Some(c) = part.chars().next() {
|
|
||||||
expected_key = Some(KeyCode::Char(c));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
modifiers == expected_modifiers && Some(key) == expected_key
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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;
|
|
||||||
363
canvas/src/config/config.rs
Normal file
363
canvas/src/config/config.rs
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
// canvas/src/config.rs
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use crossterm::event::{KeyCode, KeyModifiers};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
use super::registry::{ActionRegistry, ActionSpec, ModeRegistry};
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CanvasBehavior {
|
||||||
|
#[serde(default = "default_wrap_around")]
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CanvasConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
keybindings: CanvasKeybindings::with_vim_defaults(),
|
||||||
|
behavior: CanvasBehavior::default(),
|
||||||
|
appearance: CanvasAppearance::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CanvasKeybindings {
|
||||||
|
pub fn with_vim_defaults() -> Self {
|
||||||
|
let mut keybindings = Self::default();
|
||||||
|
|
||||||
|
// Read-only mode (vim-style navigation)
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 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()]);
|
||||||
|
|
||||||
|
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()]);
|
||||||
|
|
||||||
|
keybindings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CanvasConfig {
|
||||||
|
/// NEW: Load and validate configuration
|
||||||
|
pub fn load() -> Self {
|
||||||
|
match Self::load_and_validate() {
|
||||||
|
Ok(config) => config,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("⚠️ Canvas config validation failed: {}", e);
|
||||||
|
eprintln!(" Using vim defaults. Run CanvasConfig::generate_template() for help.");
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NEW: Load configuration with validation
|
||||||
|
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 {
|
||||||
|
// Fallback to vim defaults
|
||||||
|
Self::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate the configuration
|
||||||
|
let validator = ConfigValidator::new();
|
||||||
|
let validation_result = validator.validate_keybindings(&config.keybindings);
|
||||||
|
|
||||||
|
if !validation_result.is_valid {
|
||||||
|
// Print validation errors
|
||||||
|
validator.print_validation_result(&validation_result);
|
||||||
|
|
||||||
|
// Create error with suggestions
|
||||||
|
let error_msg = format!(
|
||||||
|
"Configuration validation failed with {} errors",
|
||||||
|
validation_result.errors.len()
|
||||||
|
);
|
||||||
|
return Err(anyhow::anyhow!(error_msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print warnings if any
|
||||||
|
if !validation_result.warnings.is_empty() {
|
||||||
|
validator.print_validation_result(&validation_result);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NEW: Generate a complete configuration template
|
||||||
|
pub fn generate_template() -> String {
|
||||||
|
let registry = ActionRegistry::new();
|
||||||
|
registry.generate_config_template()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NEW: Generate a clean, minimal configuration template
|
||||||
|
pub fn generate_clean_template() -> String {
|
||||||
|
let registry = ActionRegistry::new();
|
||||||
|
registry.generate_clean_template()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NEW: Validate current configuration
|
||||||
|
pub fn validate(&self) -> ValidationResult {
|
||||||
|
let validator = ConfigValidator::new();
|
||||||
|
validator.validate_keybindings(&self.keybindings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NEW: Print validation results for current config
|
||||||
|
pub fn print_validation(&self) {
|
||||||
|
let validator = ConfigValidator::new();
|
||||||
|
let result = validator.validate_keybindings(&self.keybindings);
|
||||||
|
validator.print_validation_result(&result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NEW: Generate config for missing required actions
|
||||||
|
pub fn generate_missing_config(&self) -> String {
|
||||||
|
let validator = ConfigValidator::new();
|
||||||
|
validator.generate_missing_config(&self.keybindings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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))?;
|
||||||
|
Self::from_toml(&contents)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NEW: 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
|
||||||
|
pub fn has_trigger_autocomplete_keybinding(&self) -> bool {
|
||||||
|
self.keybindings.edit.contains_key("trigger_autocomplete") ||
|
||||||
|
self.keybindings.read_only.contains_key("trigger_autocomplete") ||
|
||||||
|
self.keybindings.global.contains_key("trigger_autocomplete")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... rest of your existing methods stay the same ...
|
||||||
|
|
||||||
|
/// Get action for key in read-only mode
|
||||||
|
pub fn get_read_only_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
||||||
|
self.get_action_in_mode(&self.keybindings.read_only, key, modifiers)
|
||||||
|
.or_else(|| self.get_action_in_mode(&self.keybindings.global, key, modifiers))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get action for key in edit mode
|
||||||
|
pub fn get_edit_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
||||||
|
self.get_action_in_mode(&self.keybindings.edit, key, modifiers)
|
||||||
|
.or_else(|| self.get_action_in_mode(&self.keybindings.global, key, modifiers))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get action for key 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
|
||||||
|
if is_edit_mode {
|
||||||
|
self.get_edit_action(key, modifiers)
|
||||||
|
} else {
|
||||||
|
self.get_read_only_action(key, modifiers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... keep all your existing private methods ...
|
||||||
|
fn get_action_in_mode<'a>(&self, mode_bindings: &'a HashMap<String, Vec<String>>, key: KeyCode, modifiers: KeyModifiers) -> Option<&'a str> {
|
||||||
|
for (action, bindings) in mode_bindings {
|
||||||
|
for binding in bindings {
|
||||||
|
if self.matches_keybinding(binding, key, modifiers) {
|
||||||
|
return Some(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches_keybinding(&self, binding: &str, key: KeyCode, modifiers: KeyModifiers) -> bool {
|
||||||
|
// ... keep all your existing key matching logic ...
|
||||||
|
// (This is a very long method, so I'm just indicating to keep it as-is)
|
||||||
|
|
||||||
|
// Your existing implementation here...
|
||||||
|
true // placeholder - use your actual implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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());
|
||||||
|
|
||||||
|
// NEW: Show validation status
|
||||||
|
let validation = self.validate();
|
||||||
|
if validation.is_valid {
|
||||||
|
println!(" ✅ Configuration is valid");
|
||||||
|
} else {
|
||||||
|
println!(" ❌ Configuration has {} errors", validation.errors.len());
|
||||||
|
}
|
||||||
|
if !validation.warnings.is_empty() {
|
||||||
|
println!(" ⚠️ Configuration has {} warnings", validation.warnings.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export for convenience
|
||||||
|
pub use crate::canvas::actions::CanvasAction;
|
||||||
|
pub use crate::dispatcher::ActionDispatcher;
|
||||||
10
canvas/src/config/mod.rs
Normal file
10
canvas/src/config/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// src/config/mod.rs
|
||||||
|
|
||||||
|
mod registry;
|
||||||
|
mod config;
|
||||||
|
mod validation;
|
||||||
|
|
||||||
|
// Re-export everything from the main config module
|
||||||
|
pub use registry::*;
|
||||||
|
pub use validation::*;
|
||||||
|
pub use config::*;
|
||||||
451
canvas/src/config/registry.rs
Normal file
451
canvas/src/config/registry.rs
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
// src/config/registry.rs
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use crate::canvas::modes::AppMode;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ActionSpec {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub examples: Vec<String>,
|
||||||
|
pub mode_specific: bool, // true if different behavior per mode
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ModeRegistry {
|
||||||
|
pub required: HashMap<String, ActionSpec>,
|
||||||
|
pub optional: HashMap<String, ActionSpec>,
|
||||||
|
pub auto_handled: Vec<String>, // Never appear in config
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ActionRegistry {
|
||||||
|
pub edit_mode: ModeRegistry,
|
||||||
|
pub readonly_mode: ModeRegistry,
|
||||||
|
pub suggestions: ModeRegistry,
|
||||||
|
pub global: ModeRegistry,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionRegistry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
edit_mode: Self::edit_mode_registry(),
|
||||||
|
readonly_mode: Self::readonly_mode_registry(),
|
||||||
|
suggestions: Self::suggestions_registry(),
|
||||||
|
global: Self::global_registry(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_mode_registry() -> ModeRegistry {
|
||||||
|
let mut required = HashMap::new();
|
||||||
|
let mut optional = HashMap::new();
|
||||||
|
|
||||||
|
// REQUIRED - These MUST be configured
|
||||||
|
required.insert("move_left".to_string(), ActionSpec {
|
||||||
|
name: "move_left".to_string(),
|
||||||
|
description: "Move cursor one position to the left".to_string(),
|
||||||
|
examples: vec!["Left".to_string(), "h".to_string()],
|
||||||
|
mode_specific: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
required.insert("move_right".to_string(), ActionSpec {
|
||||||
|
name: "move_right".to_string(),
|
||||||
|
description: "Move cursor one position to the right".to_string(),
|
||||||
|
examples: vec!["Right".to_string(), "l".to_string()],
|
||||||
|
mode_specific: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
required.insert("move_up".to_string(), ActionSpec {
|
||||||
|
name: "move_up".to_string(),
|
||||||
|
description: "Move to previous field or line".to_string(),
|
||||||
|
examples: vec!["Up".to_string(), "k".to_string()],
|
||||||
|
mode_specific: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
required.insert("move_down".to_string(), ActionSpec {
|
||||||
|
name: "move_down".to_string(),
|
||||||
|
description: "Move to next field or line".to_string(),
|
||||||
|
examples: vec!["Down".to_string(), "j".to_string()],
|
||||||
|
mode_specific: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
required.insert("delete_char_backward".to_string(), ActionSpec {
|
||||||
|
name: "delete_char_backward".to_string(),
|
||||||
|
description: "Delete character before cursor".to_string(),
|
||||||
|
examples: vec!["Backspace".to_string()],
|
||||||
|
mode_specific: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
required.insert("next_field".to_string(), ActionSpec {
|
||||||
|
name: "next_field".to_string(),
|
||||||
|
description: "Move to next input field".to_string(),
|
||||||
|
examples: vec!["Tab".to_string(), "Enter".to_string()],
|
||||||
|
mode_specific: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
required.insert("prev_field".to_string(), ActionSpec {
|
||||||
|
name: "prev_field".to_string(),
|
||||||
|
description: "Move to previous input field".to_string(),
|
||||||
|
examples: vec!["Shift+Tab".to_string()],
|
||||||
|
mode_specific: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// OPTIONAL - These can be configured or omitted
|
||||||
|
optional.insert("move_word_next".to_string(), 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()],
|
||||||
|
mode_specific: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
optional.insert("move_word_prev".to_string(), 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()],
|
||||||
|
mode_specific: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
optional.insert("move_word_end".to_string(), ActionSpec {
|
||||||
|
name: "move_word_end".to_string(),
|
||||||
|
description: "Move cursor to end of current/next word".to_string(),
|
||||||
|
examples: vec!["e".to_string()],
|
||||||
|
mode_specific: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
optional.insert("move_word_end_prev".to_string(), ActionSpec {
|
||||||
|
name: "move_word_end_prev".to_string(),
|
||||||
|
description: "Move cursor to end of previous word".to_string(),
|
||||||
|
examples: vec!["ge".to_string()],
|
||||||
|
mode_specific: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
optional.insert("move_line_start".to_string(), ActionSpec {
|
||||||
|
name: "move_line_start".to_string(),
|
||||||
|
description: "Move cursor to beginning of line".to_string(),
|
||||||
|
examples: vec!["Home".to_string(), "0".to_string()],
|
||||||
|
mode_specific: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
optional.insert("move_line_end".to_string(), ActionSpec {
|
||||||
|
name: "move_line_end".to_string(),
|
||||||
|
description: "Move cursor to end of line".to_string(),
|
||||||
|
examples: vec!["End".to_string(), "$".to_string()],
|
||||||
|
mode_specific: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
optional.insert("move_first_line".to_string(), ActionSpec {
|
||||||
|
name: "move_first_line".to_string(),
|
||||||
|
description: "Move to first field".to_string(),
|
||||||
|
examples: vec!["Ctrl+Home".to_string(), "gg".to_string()],
|
||||||
|
mode_specific: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
optional.insert("move_last_line".to_string(), ActionSpec {
|
||||||
|
name: "move_last_line".to_string(),
|
||||||
|
description: "Move to last field".to_string(),
|
||||||
|
examples: vec!["Ctrl+End".to_string(), "G".to_string()],
|
||||||
|
mode_specific: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
optional.insert("delete_char_forward".to_string(), ActionSpec {
|
||||||
|
name: "delete_char_forward".to_string(),
|
||||||
|
description: "Delete character after cursor".to_string(),
|
||||||
|
examples: vec!["Delete".to_string()],
|
||||||
|
mode_specific: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
ModeRegistry {
|
||||||
|
required,
|
||||||
|
optional,
|
||||||
|
auto_handled: vec![
|
||||||
|
"insert_char".to_string(), // Any printable character
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn readonly_mode_registry() -> ModeRegistry {
|
||||||
|
let mut required = HashMap::new();
|
||||||
|
let mut optional = HashMap::new();
|
||||||
|
|
||||||
|
// REQUIRED - Navigation is essential in read-only mode
|
||||||
|
required.insert("move_left".to_string(), ActionSpec {
|
||||||
|
name: "move_left".to_string(),
|
||||||
|
description: "Move cursor one position to the left".to_string(),
|
||||||
|
examples: vec!["h".to_string(), "Left".to_string()],
|
||||||
|
mode_specific: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
required.insert("move_right".to_string(), ActionSpec {
|
||||||
|
name: "move_right".to_string(),
|
||||||
|
description: "Move cursor one position to the right".to_string(),
|
||||||
|
examples: vec!["l".to_string(), "Right".to_string()],
|
||||||
|
mode_specific: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
required.insert("move_up".to_string(), ActionSpec {
|
||||||
|
name: "move_up".to_string(),
|
||||||
|
description: "Move to previous field".to_string(),
|
||||||
|
examples: vec!["k".to_string(), "Up".to_string()],
|
||||||
|
mode_specific: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
required.insert("move_down".to_string(), ActionSpec {
|
||||||
|
name: "move_down".to_string(),
|
||||||
|
description: "Move to next field".to_string(),
|
||||||
|
examples: vec!["j".to_string(), "Down".to_string()],
|
||||||
|
mode_specific: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// OPTIONAL - Advanced navigation
|
||||||
|
optional.insert("move_word_next".to_string(), ActionSpec {
|
||||||
|
name: "move_word_next".to_string(),
|
||||||
|
description: "Move cursor to start of next word".to_string(),
|
||||||
|
examples: vec!["w".to_string()],
|
||||||
|
mode_specific: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
optional.insert("move_word_prev".to_string(), ActionSpec {
|
||||||
|
name: "move_word_prev".to_string(),
|
||||||
|
description: "Move cursor to start of previous word".to_string(),
|
||||||
|
examples: vec!["b".to_string()],
|
||||||
|
mode_specific: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
optional.insert("move_word_end".to_string(), ActionSpec {
|
||||||
|
name: "move_word_end".to_string(),
|
||||||
|
description: "Move cursor to end of current/next word".to_string(),
|
||||||
|
examples: vec!["e".to_string()],
|
||||||
|
mode_specific: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
optional.insert("move_word_end_prev".to_string(), ActionSpec {
|
||||||
|
name: "move_word_end_prev".to_string(),
|
||||||
|
description: "Move cursor to end of previous word".to_string(),
|
||||||
|
examples: vec!["ge".to_string()],
|
||||||
|
mode_specific: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
optional.insert("move_line_start".to_string(), ActionSpec {
|
||||||
|
name: "move_line_start".to_string(),
|
||||||
|
description: "Move cursor to beginning of line".to_string(),
|
||||||
|
examples: vec!["0".to_string()],
|
||||||
|
mode_specific: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
optional.insert("move_line_end".to_string(), ActionSpec {
|
||||||
|
name: "move_line_end".to_string(),
|
||||||
|
description: "Move cursor to end of line".to_string(),
|
||||||
|
examples: vec!["$".to_string()],
|
||||||
|
mode_specific: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
optional.insert("move_first_line".to_string(), ActionSpec {
|
||||||
|
name: "move_first_line".to_string(),
|
||||||
|
description: "Move to first field".to_string(),
|
||||||
|
examples: vec!["gg".to_string()],
|
||||||
|
mode_specific: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
optional.insert("move_last_line".to_string(), ActionSpec {
|
||||||
|
name: "move_last_line".to_string(),
|
||||||
|
description: "Move to last field".to_string(),
|
||||||
|
examples: vec!["G".to_string()],
|
||||||
|
mode_specific: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
optional.insert("next_field".to_string(), ActionSpec {
|
||||||
|
name: "next_field".to_string(),
|
||||||
|
description: "Move to next input field".to_string(),
|
||||||
|
examples: vec!["Tab".to_string()],
|
||||||
|
mode_specific: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
optional.insert("prev_field".to_string(), ActionSpec {
|
||||||
|
name: "prev_field".to_string(),
|
||||||
|
description: "Move to previous input field".to_string(),
|
||||||
|
examples: vec!["Shift+Tab".to_string()],
|
||||||
|
mode_specific: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
ModeRegistry {
|
||||||
|
required,
|
||||||
|
optional,
|
||||||
|
auto_handled: vec![], // Read-only mode has no auto-handled actions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn suggestions_registry() -> ModeRegistry {
|
||||||
|
let mut required = HashMap::new();
|
||||||
|
|
||||||
|
// REQUIRED - Essential for suggestion navigation
|
||||||
|
required.insert("suggestion_up".to_string(), ActionSpec {
|
||||||
|
name: "suggestion_up".to_string(),
|
||||||
|
description: "Move selection to previous suggestion".to_string(),
|
||||||
|
examples: vec!["Up".to_string(), "Ctrl+p".to_string()],
|
||||||
|
mode_specific: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
required.insert("suggestion_down".to_string(), ActionSpec {
|
||||||
|
name: "suggestion_down".to_string(),
|
||||||
|
description: "Move selection to next suggestion".to_string(),
|
||||||
|
examples: vec!["Down".to_string(), "Ctrl+n".to_string()],
|
||||||
|
mode_specific: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
required.insert("select_suggestion".to_string(), ActionSpec {
|
||||||
|
name: "select_suggestion".to_string(),
|
||||||
|
description: "Select the currently highlighted suggestion".to_string(),
|
||||||
|
examples: vec!["Enter".to_string(), "Tab".to_string()],
|
||||||
|
mode_specific: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
required.insert("exit_suggestions".to_string(), ActionSpec {
|
||||||
|
name: "exit_suggestions".to_string(),
|
||||||
|
description: "Close suggestions without selecting".to_string(),
|
||||||
|
examples: vec!["Esc".to_string()],
|
||||||
|
mode_specific: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
ModeRegistry {
|
||||||
|
required,
|
||||||
|
optional: HashMap::new(),
|
||||||
|
auto_handled: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn global_registry() -> ModeRegistry {
|
||||||
|
let mut optional = HashMap::new();
|
||||||
|
|
||||||
|
// OPTIONAL - Global overrides
|
||||||
|
optional.insert("move_up".to_string(), ActionSpec {
|
||||||
|
name: "move_up".to_string(),
|
||||||
|
description: "Global override for up movement".to_string(),
|
||||||
|
examples: vec!["Up".to_string()],
|
||||||
|
mode_specific: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
optional.insert("move_down".to_string(), ActionSpec {
|
||||||
|
name: "move_down".to_string(),
|
||||||
|
description: "Global override for down movement".to_string(),
|
||||||
|
examples: vec!["Down".to_string()],
|
||||||
|
mode_specific: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
ModeRegistry {
|
||||||
|
required: HashMap::new(),
|
||||||
|
optional,
|
||||||
|
auto_handled: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_mode_registry(&self, mode: &str) -> &ModeRegistry {
|
||||||
|
match mode {
|
||||||
|
"edit" => &self.edit_mode,
|
||||||
|
"read_only" => &self.readonly_mode,
|
||||||
|
"suggestions" => &self.suggestions,
|
||||||
|
"global" => &self.global,
|
||||||
|
_ => &self.global, // fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn all_known_actions(&self) -> Vec<String> {
|
||||||
|
let mut actions = Vec::new();
|
||||||
|
|
||||||
|
for registry in [&self.edit_mode, &self.readonly_mode, &self.suggestions, &self.global] {
|
||||||
|
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 - customize as needed\n\n");
|
||||||
|
|
||||||
|
template.push_str("[keybindings.edit]\n");
|
||||||
|
template.push_str("# REQUIRED ACTIONS - These must be configured\n");
|
||||||
|
for (name, spec) in &self.edit_mode.required {
|
||||||
|
template.push_str(&format!("# {}\n", spec.description));
|
||||||
|
template.push_str(&format!("{} = {:?}\n\n", name, spec.examples));
|
||||||
|
}
|
||||||
|
|
||||||
|
template.push_str("# OPTIONAL ACTIONS - Configure these if you want them enabled\n");
|
||||||
|
for (name, spec) in &self.edit_mode.optional {
|
||||||
|
template.push_str(&format!("# {}\n", spec.description));
|
||||||
|
template.push_str(&format!("# {} = {:?}\n\n", name, spec.examples));
|
||||||
|
}
|
||||||
|
|
||||||
|
template.push_str("[keybindings.read_only]\n");
|
||||||
|
template.push_str("# REQUIRED ACTIONS - These must be configured\n");
|
||||||
|
for (name, spec) in &self.readonly_mode.required {
|
||||||
|
template.push_str(&format!("# {}\n", spec.description));
|
||||||
|
template.push_str(&format!("{} = {:?}\n\n", name, spec.examples));
|
||||||
|
}
|
||||||
|
|
||||||
|
template.push_str("# OPTIONAL ACTIONS - Configure these if you want them enabled\n");
|
||||||
|
for (name, spec) in &self.readonly_mode.optional {
|
||||||
|
template.push_str(&format!("# {}\n", spec.description));
|
||||||
|
template.push_str(&format!("# {} = {:?}\n\n", name, spec.examples));
|
||||||
|
}
|
||||||
|
|
||||||
|
template.push_str("[keybindings.suggestions]\n");
|
||||||
|
template.push_str("# REQUIRED ACTIONS - These must be configured\n");
|
||||||
|
for (name, spec) in &self.suggestions.required {
|
||||||
|
template.push_str(&format!("# {}\n", spec.description));
|
||||||
|
template.push_str(&format!("{} = {:?}\n\n", name, spec.examples));
|
||||||
|
}
|
||||||
|
|
||||||
|
template
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_clean_template(&self) -> String {
|
||||||
|
let mut template = String::new();
|
||||||
|
|
||||||
|
// Edit Mode
|
||||||
|
template.push_str("[keybindings.edit]\n");
|
||||||
|
template.push_str("# Required\n");
|
||||||
|
for (name, spec) in &self.edit_mode.required {
|
||||||
|
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
|
||||||
|
}
|
||||||
|
template.push_str("# Optional\n");
|
||||||
|
for (name, spec) in &self.edit_mode.optional {
|
||||||
|
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
|
||||||
|
}
|
||||||
|
template.push('\n');
|
||||||
|
|
||||||
|
// Read-Only Mode
|
||||||
|
template.push_str("[keybindings.read_only]\n");
|
||||||
|
template.push_str("# Required\n");
|
||||||
|
for (name, spec) in &self.readonly_mode.required {
|
||||||
|
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
|
||||||
|
}
|
||||||
|
template.push_str("# Optional\n");
|
||||||
|
for (name, spec) in &self.readonly_mode.optional {
|
||||||
|
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
|
||||||
|
}
|
||||||
|
template.push('\n');
|
||||||
|
|
||||||
|
// Suggestions Mode
|
||||||
|
template.push_str("[keybindings.suggestions]\n");
|
||||||
|
template.push_str("# Required\n");
|
||||||
|
for (name, spec) in &self.suggestions.required {
|
||||||
|
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
|
||||||
|
}
|
||||||
|
template.push('\n');
|
||||||
|
|
||||||
|
// Global (all optional)
|
||||||
|
if !self.global.optional.is_empty() {
|
||||||
|
template.push_str("[keybindings.global]\n");
|
||||||
|
template.push_str("# Optional\n");
|
||||||
|
for (name, spec) in &self.global.optional {
|
||||||
|
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
template
|
||||||
|
}
|
||||||
|
}
|
||||||
279
canvas/src/config/validation.rs
Normal file
279
canvas/src/config/validation.rs
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
// 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 {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
registry: ActionRegistry::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_keybindings(&self, keybindings: &CanvasKeybindings) -> ValidationResult {
|
||||||
|
let mut result = ValidationResult::new();
|
||||||
|
|
||||||
|
// Validate each mode
|
||||||
|
result.merge(self.validate_mode_bindings(
|
||||||
|
"edit",
|
||||||
|
&keybindings.edit,
|
||||||
|
self.registry.get_mode_registry("edit")
|
||||||
|
));
|
||||||
|
|
||||||
|
result.merge(self.validate_mode_bindings(
|
||||||
|
"read_only",
|
||||||
|
&keybindings.read_only,
|
||||||
|
self.registry.get_mode_registry("read_only")
|
||||||
|
));
|
||||||
|
|
||||||
|
result.merge(self.validate_mode_bindings(
|
||||||
|
"suggestions",
|
||||||
|
&keybindings.suggestions,
|
||||||
|
self.registry.get_mode_registry("suggestions")
|
||||||
|
));
|
||||||
|
|
||||||
|
result.merge(self.validate_mode_bindings(
|
||||||
|
"global",
|
||||||
|
&keybindings.global,
|
||||||
|
self.registry.get_mode_registry("global")
|
||||||
|
));
|
||||||
|
|
||||||
|
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::state::{CanvasState, ActionContext};
|
||||||
use crate::canvas::actions::{CanvasAction, ActionResult, execute_canvas_action};
|
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 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;
|
pub struct ActionDispatcher;
|
||||||
|
|
||||||
impl ActionDispatcher {
|
impl ActionDispatcher {
|
||||||
/// Dispatch any action to the appropriate handler
|
/// Dispatch any action to the appropriate mode handler
|
||||||
pub async fn dispatch<S: CanvasState>(
|
pub async fn dispatch<S: CanvasState>(
|
||||||
action: CanvasAction,
|
action: CanvasAction,
|
||||||
state: &mut S,
|
state: &mut S,
|
||||||
ideal_cursor_column: &mut usize,
|
ideal_cursor_column: &mut usize,
|
||||||
) -> anyhow::Result<ActionResult> {
|
) -> anyhow::Result<ActionResult> {
|
||||||
|
let config = CanvasConfig::load();
|
||||||
// Load config once here instead of threading it everywhere
|
Self::dispatch_with_config(action, state, ideal_cursor_column, Some(&config)).await
|
||||||
execute_canvas_action(action, state, ideal_cursor_column, Some(&CanvasConfig::load())).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Quick action dispatch from KeyCode
|
/// Dispatch action with provided config
|
||||||
pub async fn dispatch_key<S: CanvasState>(
|
pub async fn dispatch_with_config<S: CanvasState>(
|
||||||
key: crossterm::event::KeyCode,
|
action: CanvasAction,
|
||||||
state: &mut S,
|
state: &mut S,
|
||||||
ideal_cursor_column: &mut usize,
|
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>> {
|
) -> anyhow::Result<Option<ActionResult>> {
|
||||||
if let Some(action) = CanvasAction::from_key(key) {
|
let config = CanvasConfig::load();
|
||||||
let result = Self::dispatch(action, state, ideal_cursor_column).await?;
|
|
||||||
|
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))
|
Ok(Some(result))
|
||||||
} else {
|
} else {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
@@ -42,7 +97,7 @@ impl ActionDispatcher {
|
|||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
for action in actions {
|
for action in actions {
|
||||||
let result = Self::dispatch(action, state, ideal_cursor_column).await?;
|
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);
|
results.push(result);
|
||||||
|
|
||||||
// Stop on first error
|
// Stop on first error
|
||||||
@@ -53,131 +108,3 @@ impl ActionDispatcher {
|
|||||||
Ok(results)
|
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 autocomplete;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod dispatcher;
|
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};
|
||||||
|
|||||||
@@ -39,18 +39,6 @@ enter_edit_mode_after = ["a"]
|
|||||||
previous_entry = ["left","q"]
|
previous_entry = ["left","q"]
|
||||||
next_entry = ["right","1"]
|
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 = ["v"]
|
||||||
enter_highlight_mode_linewise = ["ctrl+v"]
|
enter_highlight_mode_linewise = ["ctrl+v"]
|
||||||
|
|
||||||
@@ -69,8 +57,6 @@ prev_field = ["shift+enter"]
|
|||||||
exit = ["esc", "ctrl+e"]
|
exit = ["esc", "ctrl+e"]
|
||||||
delete_char_forward = ["delete"]
|
delete_char_forward = ["delete"]
|
||||||
delete_char_backward = ["backspace"]
|
delete_char_backward = ["backspace"]
|
||||||
move_left = ["left"]
|
|
||||||
move_right = ["right"]
|
|
||||||
suggestion_down = ["ctrl+n", "tab"]
|
suggestion_down = ["ctrl+n", "tab"]
|
||||||
suggestion_up = ["ctrl+p", "shift+tab"]
|
suggestion_up = ["ctrl+p", "shift+tab"]
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ pub async fn handle_form_edit_with_canvas(
|
|||||||
ideal_cursor_column: &mut usize,
|
ideal_cursor_column: &mut usize,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
// Try canvas action from key first
|
// 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 {
|
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
|
||||||
Ok(ActionResult::Success(msg)) => {
|
Ok(ActionResult::Success(msg)) => {
|
||||||
return Ok(msg.unwrap_or_default());
|
return Ok(msg.unwrap_or_default());
|
||||||
@@ -143,14 +145,23 @@ async fn execute_canvas_action(
|
|||||||
|
|
||||||
/// NEW: Unified canvas action handler for any CanvasState (LoginState, RegisterState, etc.)
|
/// 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
|
/// This replaces the old auth_e::execute_edit_action calls with the new canvas library
|
||||||
|
/// NEW: Unified canvas action handler for any CanvasState with character fallback
|
||||||
|
/// Complete canvas action handler with fallbacks for common keys
|
||||||
|
/// Debug version to see what's happening
|
||||||
async fn handle_canvas_state_edit<S: CanvasState>(
|
async fn handle_canvas_state_edit<S: CanvasState>(
|
||||||
key: KeyEvent,
|
key: KeyEvent,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
state: &mut S,
|
state: &mut S,
|
||||||
ideal_cursor_column: &mut usize,
|
ideal_cursor_column: &mut usize,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
|
println!("DEBUG: Key pressed: {:?}", key); // DEBUG
|
||||||
|
|
||||||
// Try direct key mapping first (same pattern as FormState)
|
// Try direct key mapping first (same pattern as FormState)
|
||||||
if let Some(canvas_action) = CanvasAction::from_key(key.code) {
|
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 {
|
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
|
||||||
Ok(ActionResult::Success(msg)) => {
|
Ok(ActionResult::Success(msg)) => {
|
||||||
return Ok(msg.unwrap_or_default());
|
return Ok(msg.unwrap_or_default());
|
||||||
@@ -165,13 +176,16 @@ async fn handle_canvas_state_edit<S: CanvasState>(
|
|||||||
return Ok(format!("Context needed: {}", msg));
|
return Ok(format!("Context needed: {}", msg));
|
||||||
}
|
}
|
||||||
Err(_) => {
|
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)
|
// Try config-mapped action (same pattern as FormState)
|
||||||
if let Some(action_str) = config.get_edit_action_for_key(key.code, key.modifiers) {
|
if let Some(action_str) = config.get_edit_action_for_key(key.code, key.modifiers) {
|
||||||
|
println!("DEBUG: Client config mapped to: {}", action_str); // DEBUG
|
||||||
let canvas_action = CanvasAction::from_string(&action_str);
|
let canvas_action = CanvasAction::from_string(&action_str);
|
||||||
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
|
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
|
||||||
Ok(ActionResult::Success(msg)) => {
|
Ok(ActionResult::Success(msg)) => {
|
||||||
@@ -190,8 +204,34 @@ async fn handle_canvas_state_edit<S: CanvasState>(
|
|||||||
return Ok(format!("Action failed: {}", e));
|
return Ok(format!("Action failed: {}", e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
println!("DEBUG: No client config mapping found"); // DEBUG
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Character insertion fallback
|
||||||
|
if let KeyCode::Char(c) = key.code {
|
||||||
|
println!("DEBUG: Using character fallback 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) => {
|
||||||
|
return Ok(format!("Character insertion failed: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("DEBUG: No action taken for key: {:?}", key); // DEBUG
|
||||||
Ok(String::new())
|
Ok(String::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ pub async fn handle_form_readonly_with_canvas(
|
|||||||
ideal_cursor_column: &mut usize,
|
ideal_cursor_column: &mut usize,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
// Try canvas action from key first
|
// 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 {
|
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
|
||||||
Ok(ActionResult::Success(msg)) => {
|
Ok(ActionResult::Success(msg)) => {
|
||||||
return Ok(msg.unwrap_or_default());
|
return Ok(msg.unwrap_or_default());
|
||||||
|
|||||||
@@ -1108,58 +1108,7 @@ impl EventHandler {
|
|||||||
) -> Result<Option<String>> {
|
) -> Result<Option<String>> {
|
||||||
let canvas_config = canvas::config::CanvasConfig::load();
|
let canvas_config = canvas::config::CanvasConfig::load();
|
||||||
|
|
||||||
// Handle suggestion actions first if suggestions are active
|
// Get action from config - handles all modes (edit/read-only/suggestions)
|
||||||
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,
|
|
||||||
form_state,
|
|
||||||
&mut self.ideal_cursor_column,
|
|
||||||
).await {
|
|
||||||
return Ok(Some(result.message().unwrap_or("").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()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let action_str = canvas_config.get_action_for_key(
|
let action_str = canvas_config.get_action_for_key(
|
||||||
key_event.code,
|
key_event.code,
|
||||||
key_event.modifiers,
|
key_event.modifiers,
|
||||||
@@ -1168,11 +1117,13 @@ impl EventHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if let Some(action_str) = action_str {
|
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) {
|
if Self::is_mode_transition_action(action_str) {
|
||||||
return Ok(None);
|
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(
|
match ActionDispatcher::dispatch(
|
||||||
canvas_action,
|
canvas_action,
|
||||||
form_state,
|
form_state,
|
||||||
@@ -1187,9 +1138,10 @@ impl EventHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to automatic key handling for edit mode
|
// Handle character insertion for edit mode (not in config)
|
||||||
if is_edit_mode {
|
if is_edit_mode {
|
||||||
if let Some(canvas_action) = CanvasAction::from_key(key_event.code) {
|
if let KeyCode::Char(c) = key_event.code {
|
||||||
|
let canvas_action = CanvasAction::InsertChar(c);
|
||||||
match ActionDispatcher::dispatch(
|
match ActionDispatcher::dispatch(
|
||||||
canvas_action,
|
canvas_action,
|
||||||
form_state,
|
form_state,
|
||||||
@@ -1199,42 +1151,13 @@ impl EventHandler {
|
|||||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
return Ok(Some(result.message().unwrap_or("").to_string()));
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return Ok(Some("Auto action failed".to_string()));
|
return Ok(Some("Character insertion 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)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// src/state/pages/add_logic.rs
|
// src/state/pages/add_logic.rs
|
||||||
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
|
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 crate::components::common::text_editor::{TextEditor, VimState};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
@@ -54,6 +54,7 @@ pub struct AddLogicState {
|
|||||||
// New fields for same-profile table names and column autocomplete
|
// New fields for same-profile table names and column autocomplete
|
||||||
pub same_profile_table_names: Vec<String>, // Tables from same profile only
|
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 script_editor_awaiting_column_autocomplete: Option<String>, // Table name waiting for column fetch
|
||||||
|
pub app_mode: AppMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AddLogicState {
|
impl AddLogicState {
|
||||||
@@ -91,6 +92,7 @@ impl AddLogicState {
|
|||||||
|
|
||||||
same_profile_table_names: Vec::new(),
|
same_profile_table_names: Vec::new(),
|
||||||
script_editor_awaiting_column_autocomplete: None,
|
script_editor_awaiting_column_autocomplete: None,
|
||||||
|
app_mode: AppMode::Edit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +271,9 @@ impl AddLogicState {
|
|||||||
|
|
||||||
impl Default for AddLogicState {
|
impl Default for AddLogicState {
|
||||||
fn default() -> Self {
|
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,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn current_mode(&self) -> AppMode {
|
||||||
|
self.app_mode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// src/state/pages/add_table.rs
|
// 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 ratatui::widgets::TableState;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -63,6 +63,7 @@ pub struct AddTableState {
|
|||||||
pub column_name_cursor_pos: usize,
|
pub column_name_cursor_pos: usize,
|
||||||
pub column_type_cursor_pos: usize,
|
pub column_type_cursor_pos: usize,
|
||||||
pub has_unsaved_changes: bool,
|
pub has_unsaved_changes: bool,
|
||||||
|
pub app_mode: AppMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AddTableState {
|
impl Default for AddTableState {
|
||||||
@@ -85,6 +86,7 @@ impl Default for AddTableState {
|
|||||||
column_name_cursor_pos: 0,
|
column_name_cursor_pos: 0,
|
||||||
column_type_cursor_pos: 0,
|
column_type_cursor_pos: 0,
|
||||||
has_unsaved_changes: false,
|
has_unsaved_changes: false,
|
||||||
|
app_mode: AppMode::Edit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,4 +299,8 @@ impl CanvasState for AddTableState {
|
|||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn current_mode(&self) -> AppMode {
|
||||||
|
self.app_mode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// src/state/pages/auth.rs
|
// 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 canvas::autocomplete::{AutocompleteCanvasState, AutocompleteState, SuggestionItem};
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
|
|
||||||
@@ -22,7 +22,6 @@ pub struct AuthState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Represents the state of the Login form UI
|
/// Represents the state of the Login form UI
|
||||||
#[derive(Default)]
|
|
||||||
pub struct LoginState {
|
pub struct LoginState {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
@@ -31,10 +30,26 @@ pub struct LoginState {
|
|||||||
pub current_cursor_pos: usize,
|
pub current_cursor_pos: usize,
|
||||||
pub has_unsaved_changes: bool,
|
pub has_unsaved_changes: bool,
|
||||||
pub login_request_pending: 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
|
/// Represents the state of the Registration form UI
|
||||||
#[derive(Default, Clone)]
|
#[derive(Clone)]
|
||||||
pub struct RegisterState {
|
pub struct RegisterState {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
@@ -45,8 +60,26 @@ pub struct RegisterState {
|
|||||||
pub current_field: usize,
|
pub current_field: usize,
|
||||||
pub current_cursor_pos: usize,
|
pub current_cursor_pos: usize,
|
||||||
pub has_unsaved_changes: bool,
|
pub has_unsaved_changes: bool,
|
||||||
// NEW: Replace old autocomplete with external library's system
|
|
||||||
pub autocomplete: AutocompleteState<String>,
|
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 {
|
impl AuthState {
|
||||||
@@ -57,7 +90,10 @@ impl AuthState {
|
|||||||
|
|
||||||
impl LoginState {
|
impl LoginState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
Self {
|
||||||
|
app_mode: AppMode::Edit,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +101,7 @@ impl RegisterState {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let mut state = Self {
|
let mut state = Self {
|
||||||
autocomplete: AutocompleteState::new(),
|
autocomplete: AutocompleteState::new(),
|
||||||
|
app_mode: AppMode::Edit,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -146,6 +183,10 @@ impl CanvasState for LoginState {
|
|||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn current_mode(&self) -> AppMode {
|
||||||
|
self.app_mode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implement external library's CanvasState for RegisterState
|
// Implement external library's CanvasState for RegisterState
|
||||||
@@ -237,6 +278,10 @@ impl CanvasState for RegisterState {
|
|||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn current_mode(&self) -> AppMode {
|
||||||
|
self.app_mode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add autocomplete support for RegisterState
|
// Add autocomplete support for RegisterState
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/state/pages/form.rs
|
// src/state/pages/form.rs
|
||||||
|
|
||||||
use crate::config::colors::themes::Theme;
|
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 common::proto::komp_ac::search::search_response::Hit;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
@@ -41,6 +41,7 @@ pub struct FormState {
|
|||||||
pub selected_suggestion_index: Option<usize>,
|
pub selected_suggestion_index: Option<usize>,
|
||||||
pub autocomplete_loading: bool,
|
pub autocomplete_loading: bool,
|
||||||
pub link_display_map: HashMap<usize, String>,
|
pub link_display_map: HashMap<usize, String>,
|
||||||
|
pub app_mode: AppMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FormState {
|
impl FormState {
|
||||||
@@ -74,6 +75,7 @@ impl FormState {
|
|||||||
selected_suggestion_index: None,
|
selected_suggestion_index: None,
|
||||||
autocomplete_loading: false,
|
autocomplete_loading: false,
|
||||||
link_display_map: HashMap::new(),
|
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.selected_suggestion_index = if self.autocomplete_active { Some(0) } else { None };
|
||||||
self.autocomplete_loading = false;
|
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 {
|
impl CanvasState for FormState {
|
||||||
@@ -320,4 +331,8 @@ impl CanvasState for FormState {
|
|||||||
fn has_display_override(&self, index: usize) -> bool {
|
fn has_display_override(&self, index: usize) -> bool {
|
||||||
self.link_display_map.contains_key(&index)
|
self.link_display_map.contains_key(&index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn current_mode(&self) -> AppMode {
|
||||||
|
self.app_mode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user