From 306cb956a095041c259fb3a035c8c2c384009357 Mon Sep 17 00:00:00 2001 From: Priec Date: Tue, 29 Jul 2025 17:16:03 +0200 Subject: [PATCH] canvas has now its own config for keybindings, lets use that from the client --- Cargo.lock | 2 + Cargo.toml | 1 + canvas/Cargo.toml | 2 + canvas/canvas_config.toml | 56 +++++++ canvas/src/config.rs | 311 ++++++++++++++++++++++++++++++++++++++ canvas/src/lib.rs | 1 + client/Cargo.toml | 2 +- client/canvas_config.toml | 56 +++++++ 8 files changed, 430 insertions(+), 1 deletion(-) create mode 100644 canvas/canvas_config.toml create mode 100644 canvas/src/config.rs create mode 100644 client/canvas_config.toml diff --git a/Cargo.lock b/Cargo.lock index 75b44da..1587dc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -478,8 +478,10 @@ dependencies = [ "common", "crossterm", "ratatui", + "serde", "tokio", "tokio-test", + "toml", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 69e461d..ada3cf1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,5 +49,6 @@ regex = "1.11.1" # Canvas crate ratatui = { version = "0.29.0", features = ["crossterm"] } crossterm = "0.28.1" +toml = "0.8.20" common = { path = "./common" } diff --git a/canvas/Cargo.toml b/canvas/Cargo.toml index b020283..e4a4998 100644 --- a/canvas/Cargo.toml +++ b/canvas/Cargo.toml @@ -15,6 +15,8 @@ ratatui = { workspace = true } crossterm = { workspace = true } anyhow = { workspace = true } tokio = { workspace = true } +toml = { workspace = true } +serde = { workspace = true } [dev-dependencies] tokio-test = "0.4.4" diff --git a/canvas/canvas_config.toml b/canvas/canvas_config.toml new file mode 100644 index 0000000..8f5ce4d --- /dev/null +++ b/canvas/canvas_config.toml @@ -0,0 +1,56 @@ +# 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 = ["p"] +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 = ["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"] + +# 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"] diff --git a/canvas/src/config.rs b/canvas/src/config.rs new file mode 100644 index 0000000..4509b76 --- /dev/null +++ b/canvas/src/config.rs @@ -0,0 +1,311 @@ +// 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>, + #[serde(default)] + pub edit: HashMap>, + #[serde(default)] + pub suggestions: HashMap>, + #[serde(default)] + pub global: HashMap>, +} + +#[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 { + 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 { + let contents = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read config file: {:?}", path))?; + Self::from_toml(&contents) + } + + /// 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>, 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 { + // Handle multi-character bindings (like "gg") + if binding.len() > 1 && !binding.contains('+') { + return match binding.to_lowercase().as_str() { + "left" => key == KeyCode::Left, + "right" => key == KeyCode::Right, + "up" => key == KeyCode::Up, + "down" => key == KeyCode::Down, + "esc" => key == KeyCode::Esc, + "enter" => key == KeyCode::Enter, + "delete" => key == KeyCode::Delete, + "backspace" => key == KeyCode::Backspace, + "tab" => key == KeyCode::Tab, + "home" => key == KeyCode::Home, + "end" => key == KeyCode::End, + "gg" => false, // Multi-key sequences need special handling + _ => false, + }; + } + + // Handle modifier combinations (like "Ctrl+p") + 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() { + "ctrl" => expected_modifiers |= KeyModifiers::CONTROL, + "shift" => expected_modifiers |= KeyModifiers::SHIFT, + "alt" => expected_modifiers |= KeyModifiers::ALT, + "left" => expected_key = Some(KeyCode::Left), + "right" => expected_key = Some(KeyCode::Right), + "up" => expected_key = Some(KeyCode::Up), + "down" => expected_key = Some(KeyCode::Down), + "esc" => expected_key = Some(KeyCode::Esc), + "enter" => expected_key = Some(KeyCode::Enter), + "delete" => expected_key = Some(KeyCode::Delete), + "backspace" => expected_key = Some(KeyCode::Backspace), + "tab" => expected_key = Some(KeyCode::Tab), + "home" => expected_key = Some(KeyCode::Home), + "end" => expected_key = Some(KeyCode::End), + part => { + if part.len() == 1 { + let c = part.chars().next().unwrap(); + 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::actions::CanvasAction; +pub use crate::dispatcher::ActionDispatcher; diff --git a/canvas/src/lib.rs b/canvas/src/lib.rs index e9a7544..5695f32 100644 --- a/canvas/src/lib.rs +++ b/canvas/src/lib.rs @@ -7,6 +7,7 @@ pub mod state; pub mod actions; pub mod modes; +pub mod config; pub mod suggestions; pub mod dispatcher; diff --git a/client/Cargo.toml b/client/Cargo.toml index 5d0ae51..366ec12 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -21,7 +21,7 @@ serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" time = "0.3.41" tokio = { version = "1.44.2", features = ["full", "macros"] } -toml = "0.8.20" +toml = { workspace = true } tonic = "0.13.0" tracing = "0.1.41" tracing-subscriber = "0.3.19" diff --git a/client/canvas_config.toml b/client/canvas_config.toml new file mode 100644 index 0000000..af918b1 --- /dev/null +++ b/client/canvas_config.toml @@ -0,0 +1,56 @@ +# 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 = ["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"] + +# 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"]