From f9e0833bcfd771cc9fda9b5131d95047d592da79 Mon Sep 17 00:00:00 2001 From: Priec Date: Thu, 21 Aug 2025 12:32:36 +0200 Subject: [PATCH] working keymap --- canvas/Cargo.toml | 9 +- canvas/examples/canvas_keymap.rs | 376 +++++++++++++++++++++++++++++++ canvas/src/editor/core.rs | 35 +++ canvas/src/editor/key_input.rs | 228 +++++++++++++++++++ canvas/src/editor/mod.rs | 3 + canvas/src/keymap/mod.rs | 344 ++++++++++++++++++++++++++++ canvas/src/lib.rs | 9 +- 7 files changed, 999 insertions(+), 5 deletions(-) create mode 100644 canvas/examples/canvas_keymap.rs create mode 100644 canvas/src/editor/key_input.rs create mode 100644 canvas/src/keymap/mod.rs diff --git a/canvas/Cargo.toml b/canvas/Cargo.toml index 713378f..4edc45c 100644 --- a/canvas/Cargo.toml +++ b/canvas/Cargo.toml @@ -39,6 +39,7 @@ validation = ["regex"] computed = [] textarea = ["dep:ropey","gui"] syntect = ["dep:syntect", "gui", "textarea"] +keymap = ["gui"] # text modes (mutually exclusive; default to vim) textmode-vim = [] @@ -50,7 +51,8 @@ all-nontextmodes = [ "cursor-style", "validation", "computed", - "textarea" + "textarea", + "keymap" ] [[example]] @@ -106,3 +108,8 @@ path = "examples/textarea_normal.rs" name = "textarea_syntax" required-features = ["gui", "cursor-style", "textarea", "textmode-normal", "syntect"] path = "examples/textarea_syntax.rs" + +[[example]] +name = "canvas_keymap" +required-features = ["gui", "keymap", "cursor-style"] +path = "examples/canvas_keymap.rs" diff --git a/canvas/examples/canvas_keymap.rs b/canvas/examples/canvas_keymap.rs new file mode 100644 index 0000000..88ca361 --- /dev/null +++ b/canvas/examples/canvas_keymap.rs @@ -0,0 +1,376 @@ +// examples/canvas_keymap.rs +//! Demonstrates the centralized keymap system for canvas interactions +//! +//! This example shows how to use the canvas-keymap feature to delegate +//! all canvas key handling to the library, supporting complex sequences +//! like "gg", "ge", etc. +//! +//! Run with: +//! cargo run --example canvas_keymap --features "gui,keymap,cursor-style" + +#[cfg(not(feature = "keymap"))] +compile_error!( + "This example requires the 'keymap' feature. \ + Run with: cargo run --example canvas_keymap --features \"gui,keymap,cursor-style\"" +); + +use std::collections::HashMap; +use std::io; +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEvent}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::{Backend, CrosstermBackend}, + layout::{Constraint, Direction, Layout}, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, Terminal, +}; + +use canvas::{ + canvas::{gui::render_canvas_default, modes::AppMode}, + keymap::{CanvasKeyMap, KeyEventOutcome}, + DataProvider, FormEditor, +}; + +/// Demo application using centralized keymap system +struct KeymapDemoApp { + editor: FormEditor, + message: String, + quit: bool, +} + +impl KeymapDemoApp { + fn new() -> Self { + let data = DemoData::new(); + let mut editor = FormEditor::new(data); + + // Build and inject the keymap from our config + let keymap = Self::build_demo_keymap(); + editor.set_keymap(keymap); + + Self { + editor, + message: "đŸŽ¯ Keymap system loaded! Try: gg, ge, hjkl, w/b/e, v, i, etc.".to_string(), + quit: false, + } + } + + /// Build a comprehensive keymap configuration + fn build_demo_keymap() -> CanvasKeyMap { + let mut read_only = HashMap::new(); + let mut edit = HashMap::new(); + let mut highlight = HashMap::new(); + + // === READ-ONLY MODE KEYBINDINGS === + + // Basic movement + read_only.insert("move_left".to_string(), vec!["h".to_string(), "Left".to_string()]); + read_only.insert("move_right".to_string(), vec!["l".to_string(), "Right".to_string()]); + read_only.insert("move_up".to_string(), vec!["k".to_string(), "Up".to_string()]); + read_only.insert("move_down".to_string(), vec!["j".to_string(), "Down".to_string()]); + + // Word movement + read_only.insert("move_word_next".to_string(), vec!["w".to_string()]); + read_only.insert("move_word_prev".to_string(), vec!["b".to_string()]); + read_only.insert("move_word_end".to_string(), vec!["e".to_string()]); + read_only.insert("move_word_end_prev".to_string(), vec!["ge".to_string()]); // Multi-key! + + // Big word movement + read_only.insert("move_big_word_next".to_string(), vec!["W".to_string()]); + read_only.insert("move_big_word_prev".to_string(), vec!["B".to_string()]); + read_only.insert("move_big_word_end".to_string(), vec!["E".to_string()]); + read_only.insert("move_big_word_end_prev".to_string(), vec!["gE".to_string()]); // Multi-key! + + // Line movement + read_only.insert("move_line_start".to_string(), vec!["0".to_string(), "Home".to_string()]); + read_only.insert("move_line_end".to_string(), vec!["$".to_string(), "End".to_string()]); + + // Field movement + read_only.insert("move_first_line".to_string(), vec!["gg".to_string()]); // Multi-key! + read_only.insert("move_last_line".to_string(), vec!["G".to_string()]); + read_only.insert("next_field".to_string(), vec!["Tab".to_string()]); + read_only.insert("prev_field".to_string(), vec!["Shift+Tab".to_string()]); + + // Mode transitions + read_only.insert("enter_edit_mode_before".to_string(), vec!["i".to_string()]); + read_only.insert("enter_edit_mode_after".to_string(), vec!["a".to_string()]); + read_only.insert("enter_highlight_mode".to_string(), vec!["v".to_string()]); + read_only.insert("enter_highlight_mode_linewise".to_string(), vec!["V".to_string()]); + + // Editing actions in normal mode + read_only.insert("delete_char_forward".to_string(), vec!["x".to_string()]); + read_only.insert("delete_char_backward".to_string(), vec!["X".to_string()]); + read_only.insert("open_line_below".to_string(), vec!["o".to_string()]); + read_only.insert("open_line_above".to_string(), vec!["O".to_string()]); + + // === EDIT MODE KEYBINDINGS === + + edit.insert("exit_edit_mode".to_string(), vec!["esc".to_string()]); + edit.insert("move_left".to_string(), vec!["Left".to_string()]); + edit.insert("move_right".to_string(), vec!["Right".to_string()]); + edit.insert("move_up".to_string(), vec!["Up".to_string()]); + edit.insert("move_down".to_string(), vec!["Down".to_string()]); + edit.insert("move_line_start".to_string(), vec!["Home".to_string()]); + edit.insert("move_line_end".to_string(), vec!["End".to_string()]); + edit.insert("move_word_next".to_string(), vec!["Ctrl+Right".to_string()]); + edit.insert("move_word_prev".to_string(), vec!["Ctrl+Left".to_string()]); + edit.insert("next_field".to_string(), vec!["Tab".to_string()]); + edit.insert("prev_field".to_string(), vec!["Shift+Tab".to_string()]); + edit.insert("delete_char_backward".to_string(), vec!["Backspace".to_string()]); + edit.insert("delete_char_forward".to_string(), vec!["Delete".to_string()]); + + // === HIGHLIGHT MODE KEYBINDINGS === + + highlight.insert("exit_highlight_mode".to_string(), vec!["esc".to_string()]); + highlight.insert("enter_highlight_mode_linewise".to_string(), vec!["V".to_string()]); + + // Movement (extends selection) + highlight.insert("move_left".to_string(), vec!["h".to_string(), "Left".to_string()]); + highlight.insert("move_right".to_string(), vec!["l".to_string(), "Right".to_string()]); + highlight.insert("move_up".to_string(), vec!["k".to_string(), "Up".to_string()]); + highlight.insert("move_down".to_string(), vec!["j".to_string(), "Down".to_string()]); + highlight.insert("move_word_next".to_string(), vec!["w".to_string()]); + highlight.insert("move_word_prev".to_string(), vec!["b".to_string()]); + highlight.insert("move_word_end".to_string(), vec!["e".to_string()]); + highlight.insert("move_word_end_prev".to_string(), vec!["ge".to_string()]); + highlight.insert("move_line_start".to_string(), vec!["0".to_string()]); + highlight.insert("move_line_end".to_string(), vec!["$".to_string()]); + highlight.insert("move_first_line".to_string(), vec!["gg".to_string()]); + highlight.insert("move_last_line".to_string(), vec!["G".to_string()]); + + CanvasKeyMap::from_mode_maps(&read_only, &edit, &highlight) + } + + fn handle_key_event(&mut self, key_event: KeyEvent) -> io::Result<()> { + // First, try canvas keymap + match self.editor.handle_key_event(key_event) { + KeyEventOutcome::Consumed(Some(msg)) => { + self.message = format!("đŸŽ¯ Canvas: {}", msg); + return Ok(()); + } + KeyEventOutcome::Consumed(None) => { + self.message = "đŸŽ¯ Canvas action executed".to_string(); + return Ok(()); + } + KeyEventOutcome::Pending => { + self.message = "âŗ Waiting for next key in sequence...".to_string(); + return Ok(()); + } + KeyEventOutcome::NotMatched => { + // Fall through to client actions + } + } + + // Handle client-specific actions (non-canvas) + use crossterm::event::{KeyCode, KeyModifiers}; + match (key_event.code, key_event.modifiers) { + (KeyCode::Char('q'), KeyModifiers::CONTROL) | + (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + self.quit = true; + self.message = "👋 Goodbye!".to_string(); + } + (KeyCode::F(1), _) => { + self.message = "â„šī¸ F1: This is a client action (not handled by canvas keymap)".to_string(); + } + (KeyCode::F(2), _) => { + // Demonstrate saving + self.message = "💾 F2: Save action (client-side)".to_string(); + } + (KeyCode::Char('?'), _) if self.editor.mode() == AppMode::ReadOnly => { + self.show_help(); + } + _ => { + // Unknown key + self.message = format!( + "❓ Unhandled key: {:?} (mode: {:?})", + key_event.code, + self.editor.mode() + ); + } + } + + Ok(()) + } + + fn show_help(&mut self) { + self.message = "📖 Help: Multi-key sequences work! Try gg, ge, gE. Also: hjkl, w/b/e, v/V, i/a/o".to_string(); + } + + fn should_quit(&self) -> bool { + self.quit + } + + fn editor(&self) -> &FormEditor { + &self.editor + } + + fn message(&self) -> &str { + &self.message + } +} + +/// Demo form data with interesting examples for keymap testing +struct DemoData { + fields: Vec<(String, String)>, +} + +impl DemoData { + fn new() -> Self { + Self { + fields: vec![ + ("đŸŽ¯ Name".to_string(), "John-Paul McDonald-Smith".to_string()), + ("📧 Email".to_string(), "user@long-domain-name.example.com".to_string()), + ("📱 Phone".to_string(), "+1 (555) 123-4567 ext. 890".to_string()), + ("🏠 Address".to_string(), "123 Main Street, Apartment 4B, Suite 100".to_string()), + ("đŸˇī¸ Tags".to_string(), "urgent,important,follow-up,high-priority".to_string()), + ("📝 Notes".to_string(), "Test word movements: w=next-word, b=prev-word, e=word-end, ge=prev-word-end".to_string()), + ("đŸ”Ĩ Multi-key".to_string(), "Try multi-key sequences: gg=first-field, ge=prev-word-end, gE=prev-WORD-end".to_string()), + ("⚡ Vim Actions".to_string(), "Normal mode: x=delete-char, o=open-line-below, v=visual, i=insert".to_string()), + ], + } + } +} + +impl DataProvider for DemoData { + fn field_count(&self) -> usize { + self.fields.len() + } + + fn field_name(&self, index: usize) -> &str { + &self.fields[index].0 + } + + fn field_value(&self, index: usize) -> &str { + &self.fields[index].1 + } + + fn set_field_value(&mut self, index: usize, value: String) { + self.fields[index].1 = value; + } +} + +fn run_app(terminal: &mut Terminal, mut app: KeymapDemoApp) -> io::Result<()> { + loop { + terminal.draw(|f| ui(f, &app))?; + + if let Event::Key(key) = event::read()? { + app.handle_key_event(key)?; + if app.should_quit() { + break; + } + } + } + Ok(()) +} + +fn ui(f: &mut Frame, app: &KeymapDemoApp) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(8), Constraint::Length(12)]) + .split(f.area()); + + // Render the canvas + render_canvas_default(f, chunks[0], app.editor()); + + // Render status and help + render_status_and_help(f, chunks[1], app); +} + +fn render_status_and_help(f: &mut Frame, area: ratatui::layout::Rect, app: &KeymapDemoApp) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(9)]) + .split(area); + + // Status message + let status_text = format!( + "Mode: {:?} | Field: {}/{} | Pos: {} | {}", + app.editor().mode(), + app.editor().current_field() + 1, + app.editor().data_provider().field_count(), + app.editor().cursor_position(), + app.message() + ); + + let status = Paragraph::new(Line::from(Span::raw(status_text))) + .block(Block::default().borders(Borders::ALL).title("đŸŽ¯ Keymap Demo Status")); + + f.render_widget(status, chunks[0]); + + // Help text based on current mode + let help_text = match app.editor().mode() { + AppMode::ReadOnly => { + "đŸŽ¯ KEYMAP DEMO - All keys handled by centralized keymap system!\n\ + \n\ + 📍 MOVEMENT: hjkl(basic) | w/b/e(words) | W/B/E(WORDS) | 0/$(line) | gg/G(fields)\n\ + đŸ”Ĩ MULTI-KEY: gg=first-field, ge=prev-word-end, gE=prev-WORD-end\n\ + âœī¸ MODES: i/a(insert) | v/V(visual) | o/O(open-line)\n\ + đŸ—‘ī¸ DELETE: x/X(delete-char)\n\ + 📂 FIELDS: Tab/Shift+Tab\n\ + \n\ + 💡 Try multi-key sequences like 'gg' or 'ge' - watch the status for 'Waiting...'\n\ + đŸšĒ Ctrl+C=quit | ?=help | F1/F2=client actions (not canvas)" + } + AppMode::Edit => { + "âœī¸ INSERT MODE - Keys handled by keymap system\n\ + \n\ + 🔄 NAVIGATION: arrows | Ctrl+arrows(words) | Home/End(line) | Tab/Shift+Tab(fields)\n\ + đŸ—‘ī¸ DELETE: Backspace/Delete\n\ + đŸšĒ EXIT: Esc=normal\n\ + \n\ + 💡 Type text normally - the keymap handles navigation!" + } + AppMode::Highlight => { + "đŸŽ¯ VISUAL MODE - Selection extended by keymap movements\n\ + \n\ + 📍 EXTEND: hjkl(basic) | w/b/e(words) | 0/$(line) | gg/G(fields)\n\ + 🔄 SWITCH: V=toggle-line-mode\n\ + đŸšĒ EXIT: Esc=normal\n\ + \n\ + 💡 All movements extend the selection automatically!" + } + _ => "đŸŽ¯ Keymap system active!" + }; + + let help = Paragraph::new(help_text) + .block(Block::default().borders(Borders::ALL).title("🚀 Centralized Keymap System")) + .style(Style::default().fg(Color::Gray)); + + f.render_widget(help, chunks[1]); +} + +fn main() -> Result<(), Box> { + println!("đŸŽ¯ Canvas Keymap Demo"); + println!("✅ canvas-keymap feature: ENABLED"); + println!("🚀 Centralized key handling: ACTIVE"); + println!("📖 Multi-key sequences: SUPPORTED (gg, ge, gE, etc.)"); + println!(); + + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let app = KeymapDemoApp::new(); + let res = run_app(&mut terminal, app); + + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + if let Err(err) = res { + println!("{err:?}"); + } + + println!("đŸŽ¯ Keymap demo completed!"); + Ok(()) +} diff --git a/canvas/src/editor/core.rs b/canvas/src/editor/core.rs index 35ecdac..1c15618 100644 --- a/canvas/src/editor/core.rs +++ b/canvas/src/editor/core.rs @@ -9,6 +9,10 @@ use crate::DataProvider; #[cfg(feature = "suggestions")] use crate::SuggestionItem; +// NEW: Import keymap types when keymap feature is enabled +#[cfg(feature = "keymap")] +use crate::keymap::{CanvasKeyMap, KeySequenceTracker}; + pub struct FormEditor { pub(crate) ui_state: EditorState, pub(crate) data_provider: D, @@ -23,6 +27,12 @@ pub struct FormEditor { + Sync, >, >, + + // NEW: Injected keymap and sequence tracker (keymap feature only) + #[cfg(feature = "keymap")] + pub(crate) keymap: Option, + #[cfg(feature = "keymap")] + pub(crate) seq_tracker: KeySequenceTracker, } impl FormEditor { @@ -47,6 +57,11 @@ impl FormEditor { suggestions: Vec::new(), #[cfg(feature = "validation")] external_validation_callback: None, + // NEW: Initialize keymap fields + #[cfg(feature = "keymap")] + keymap: None, + #[cfg(feature = "keymap")] + seq_tracker: KeySequenceTracker::new(400), // 400ms default timeout }; #[cfg(feature = "validation")] @@ -70,6 +85,26 @@ impl FormEditor { } } + // NEW: Keymap management methods (keymap feature only) + + /// Set the keymap for this editor instance + #[cfg(feature = "keymap")] + pub fn set_keymap(&mut self, keymap: CanvasKeyMap) { + self.keymap = Some(keymap); + } + + /// Check if this editor has a keymap configured + #[cfg(feature = "keymap")] + pub fn has_keymap(&self) -> bool { + self.keymap.is_some() + } + + /// Set the timeout for multi-key sequences (in milliseconds) + #[cfg(feature = "keymap")] + pub fn set_key_sequence_timeout_ms(&mut self, timeout_ms: u64) { + self.seq_tracker = KeySequenceTracker::new(timeout_ms); + } + // Library-internal, used by multiple modules pub(crate) fn current_text(&self) -> &str { let field_index = self.ui_state.current_field; diff --git a/canvas/src/editor/key_input.rs b/canvas/src/editor/key_input.rs new file mode 100644 index 0000000..ba92667 --- /dev/null +++ b/canvas/src/editor/key_input.rs @@ -0,0 +1,228 @@ +// src/editor/key_input.rs +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::canvas::modes::AppMode; +use crate::editor::FormEditor; +use crate::DataProvider; + +#[cfg(feature = "keymap")] +use crate::keymap::{KeyEventOutcome, KeyStroke}; + +impl FormEditor { + #[cfg(feature = "keymap")] + pub fn handle_key_event(&mut self, evt: KeyEvent) -> KeyEventOutcome { + // Check if keymap exists first + if self.keymap.is_none() { + return KeyEventOutcome::NotMatched; + } + + let mode = self.ui_state.current_mode; + + // Convert event to normalized stroke + let stroke = KeyStroke { + code: evt.code, + modifiers: evt.modifiers, + }; + + // Add key to sequence tracker + self.seq_tracker.add_key(stroke); + + // Look up the action in keymap + let (matched, is_prefix) = { + let km = self.keymap.as_ref().unwrap(); + km.lookup(mode, self.seq_tracker.sequence()) + }; + + if let Some(action) = matched { + // Clone the action string to avoid borrow checker issues + let action_owned = action.to_string(); + let msg = self.dispatch_canvas_action(&action_owned); + self.seq_tracker.reset(); + return KeyEventOutcome::Consumed(msg); + } + + if is_prefix { + // Wait for more keys + return KeyEventOutcome::Pending; + } + + // No match: reset sequence and try insert-char fallback in Edit + self.seq_tracker.reset(); + + if mode == AppMode::Edit { + if let KeyCode::Char(c) = evt.code { + // Skip control/alt combos + let m = evt.modifiers; + let is_plain = + m.is_empty() || m == KeyModifiers::SHIFT; + if is_plain { + if self.insert_char(c).is_ok() { + return KeyEventOutcome::Consumed(None); + } + } + } + } + + KeyEventOutcome::NotMatched + } + + #[cfg(feature = "keymap")] + fn dispatch_canvas_action(&mut self, action: &str) -> Option { + match action { + // Movement + "move_left" => { + let _ = self.move_left(); + None + } + "move_right" => { + let _ = self.move_right(); + None + } + "move_up" => { + let _ = self.move_up(); + None + } + "move_down" => { + let _ = self.move_down(); + None + } + "next_field" => { + let _ = self.next_field(); + None + } + "prev_field" => { + let _ = self.prev_field(); + None + } + "move_line_start" => { + self.move_line_start(); + None + } + "move_line_end" => { + self.move_line_end(); + None + } + "move_first_line" => { + let _ = self.move_first_line(); + None + } + "move_last_line" => { + let _ = self.move_last_line(); + None + } + + // Word/big-word movement (cross-field aware) + "move_word_next" => { + self.move_word_next(); + None + } + "move_word_prev" => { + self.move_word_prev(); + None + } + "move_word_end" => { + self.move_word_end(); + None + } + "move_word_end_prev" => { + self.move_word_end_prev(); + None + } + "move_big_word_next" => { + self.move_big_word_next(); + None + } + "move_big_word_prev" => { + self.move_big_word_prev(); + None + } + "move_big_word_end" => { + self.move_big_word_end(); + None + } + "move_big_word_end_prev" => { + self.move_big_word_end_prev(); + None + } + + // Editing + "delete_char_backward" => { + let _ = self.delete_backward(); + None + } + "delete_char_forward" => { + let _ = self.delete_forward(); + None + } + "open_line_below" => { + let _ = self.open_line_below(); + None + } + "open_line_above" => { + let _ = self.open_line_above(); + None + } + + // Suggestions (only when feature is enabled) + #[cfg(feature = "suggestions")] + "open_suggestions" => { + let idx = self.current_field(); + self.open_suggestions(idx); + None + } + #[cfg(feature = "suggestions")] + "apply_suggestion" | "enter_decider" => { + if let Some(_applied) = self.apply_suggestion() { + None + } else { + None + } + } + #[cfg(feature = "suggestions")] + "suggestion_down" => { + self.suggestions_next(); + None + } + #[cfg(feature = "suggestions")] + "suggestion_up" => { + self.suggestions_prev(); + None + } + + // Mode transitions (vim-like) + "enter_edit_mode_before" => { + self.enter_edit_mode(); + None + } + "enter_edit_mode_after" => { + // Move forward 1 char if possible (vim 'a'), then enter insert + let txt_len = self.current_text().chars().count(); + let pos = self.ui_state.cursor_pos; + if pos < txt_len { + self.ui_state.cursor_pos = pos + 1; + self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos; + } + self.enter_edit_mode(); + None + } + "exit" | "exit_edit_mode" => { + let _ = self.exit_edit_mode(); + None + } + "enter_highlight_mode" => { + self.enter_highlight_mode(); + None + } + "enter_highlight_mode_linewise" => { + self.enter_highlight_line_mode(); + None + } + "exit_highlight_mode" => { + self.exit_highlight_mode(); + None + } + + _ => None, + } + } +} diff --git a/canvas/src/editor/mod.rs b/canvas/src/editor/mod.rs index 024e25a..7f15884 100644 --- a/canvas/src/editor/mod.rs +++ b/canvas/src/editor/mod.rs @@ -21,5 +21,8 @@ pub mod validation_helpers; #[cfg(feature = "computed")] pub mod computed_helpers; +#[cfg(feature = "keymap")] +pub mod key_input; + // Re-export the main type pub use core::FormEditor; diff --git a/canvas/src/keymap/mod.rs b/canvas/src/keymap/mod.rs new file mode 100644 index 0000000..5d72457 --- /dev/null +++ b/canvas/src/keymap/mod.rs @@ -0,0 +1,344 @@ +// src/keymap/mod.rs +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use crossterm::event::{KeyCode, KeyModifiers}; + +use crate::canvas::modes::AppMode; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct KeyStroke { + pub code: KeyCode, + pub modifiers: KeyModifiers, +} + +#[derive(Clone, Debug)] +struct Binding { + action: String, + sequence: Vec, +} + +#[derive(Clone, Debug, Default)] +pub struct CanvasKeyMap { + ro: Vec, + edit: Vec, + hl: Vec, +} + +// FIXED: Removed Copy because Option is not Copy +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KeyEventOutcome { + Consumed(Option), + Pending, + NotMatched, +} + +#[derive(Debug, Clone)] +pub struct KeySequenceTracker { + sequence: Vec, + last_key_time: Instant, + timeout: Duration, +} + +impl KeySequenceTracker { + pub fn new(timeout_ms: u64) -> Self { + Self { + sequence: Vec::new(), + last_key_time: Instant::now(), + timeout: Duration::from_millis(timeout_ms), + } + } + + pub fn reset(&mut self) { + self.sequence.clear(); + self.last_key_time = Instant::now(); + } + + pub fn add_key(&mut self, stroke: KeyStroke) { + let now = Instant::now(); + if now.duration_since(self.last_key_time) > self.timeout { + self.reset(); + } + self.sequence.push(normalize_stroke(stroke)); + self.last_key_time = now; + } + + pub fn sequence(&self) -> &[KeyStroke] { + &self.sequence + } +} + +fn normalize_stroke(mut s: KeyStroke) -> KeyStroke { + // Normalize Shift+Tab to BackTab + let is_shift_tab = + s.code == KeyCode::Tab && s.modifiers.contains(KeyModifiers::SHIFT); + if is_shift_tab { + s.code = KeyCode::BackTab; + s.modifiers.remove(KeyModifiers::SHIFT); + return s; + } + + // Normalize Shift+char to uppercase char without SHIFT when possible + if let KeyCode::Char(c) = s.code { + if s.modifiers.contains(KeyModifiers::SHIFT) { + let mut up = c; + // Only letters transform meaningfully + if c.is_ascii_alphabetic() { + up = c.to_ascii_uppercase(); + } + s.code = KeyCode::Char(up); + s.modifiers.remove(KeyModifiers::SHIFT); + return s; + } + } + s +} + +impl CanvasKeyMap { + pub fn from_mode_maps( + read_only: &HashMap>, + edit: &HashMap>, + highlight: &HashMap>, + ) -> Self { + let mut km = Self::default(); + km.ro = collect_bindings(read_only); + km.edit = collect_bindings(edit); + km.hl = collect_bindings(highlight); + km + } + + pub fn lookup( + &self, + mode: AppMode, + seq: &[KeyStroke], + ) -> (Option<&str>, bool) { + let bindings = match mode { + AppMode::ReadOnly => &self.ro, + AppMode::Edit => &self.edit, + AppMode::Highlight => &self.hl, + _ => return (None, false), + }; + + if seq.is_empty() { + return (None, false); + } + + // Exact match + for b in bindings { + if sequences_equal(&b.sequence, seq) { + return (Some(b.action.as_str()), false); + } + } + + // Prefix match + for b in bindings { + if is_prefix(&b.sequence, seq) { + return (None, true); + } + } + + (None, false) + } +} + +fn sequences_equal(a: &[KeyStroke], b: &[KeyStroke]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter().zip(b.iter()).all(|(x, y)| strokes_equal(x, y)) +} + +fn strokes_equal(a: &KeyStroke, b: &KeyStroke) -> bool { + // Both KeyStroke are already normalized + a.code == b.code && a.modifiers == b.modifiers +} + +fn is_prefix(binding: &[KeyStroke], seq: &[KeyStroke]) -> bool { + if seq.len() >= binding.len() { + return false; + } + binding + .iter() + .zip(seq.iter()) + .all(|(b, s)| strokes_equal(b, s)) +} + +fn collect_bindings( + mode_map: &HashMap>, +) -> Vec { + let mut out = Vec::new(); + for (action, list) in mode_map { + for binding_str in list { + if let Some(seq) = parse_binding_to_sequence(binding_str) { + out.push(Binding { + action: action.to_string(), + sequence: seq, + }); + } + } + } + out +} + +fn parse_binding_to_sequence(input: &str) -> Option> { + let s = input.trim(); + if s.is_empty() { + return None; + } + + let has_space = s.contains(' '); + let has_plus = s.contains('+'); + + if has_space { + let mut seq = Vec::new(); + for part in s.split_whitespace() { + if let Some(mut strokes) = parse_part_to_sequence(part) { + seq.append(&mut strokes); + } else { + return None; + } + } + return Some(seq); + } + + if has_plus { + if contains_modifier_token(s) { + if let Some(k) = parse_chord_with_modifiers(s) { + return Some(vec![k]); + } + return None; + } else { + let mut seq = Vec::new(); + for t in s.split('+') { + if let Some(mut strokes) = parse_part_to_sequence(t) { + seq.append(&mut strokes); + } else { + return None; + } + } + return Some(seq); + } + } + + if is_compound_key(s) { + if let Some(k) = parse_simple_key(s) { + return Some(vec![k]); + } + return None; + } + + if s.len() > 1 { + let mut seq = Vec::new(); + for ch in s.chars() { + seq.push(KeyStroke { + code: KeyCode::Char(ch), + modifiers: KeyModifiers::empty(), + }); + } + return Some(seq); + } + + if let Some(k) = parse_simple_key(s) { + return Some(vec![k]); + } + None +} + +fn parse_part_to_sequence(part: &str) -> Option> { + let p = part.trim(); + if p.is_empty() { + return None; + } + + if p.contains('+') && contains_modifier_token(p) { + if let Some(k) = parse_chord_with_modifiers(p) { + return Some(vec![k]); + } + return None; + } + + if is_compound_key(p) { + if let Some(k) = parse_simple_key(p) { + return Some(vec![k]); + } + return None; + } + + if p.len() > 1 { + let mut seq = Vec::new(); + for ch in p.chars() { + seq.push(KeyStroke { + code: KeyCode::Char(ch), + modifiers: KeyModifiers::empty(), + }); + } + return Some(seq); + } + + parse_simple_key(p).map(|k| vec![k]) +} + +fn contains_modifier_token(s: &str) -> bool { + let low = s.to_lowercase(); + low.contains("ctrl") || low.contains("shift") || low.contains("alt") || + low.contains("super") || low.contains("cmd") || low.contains("meta") +} + +fn parse_chord_with_modifiers(s: &str) -> Option { + let mut mods = KeyModifiers::empty(); + let mut key: Option = None; + + for comp in s.split('+') { + match comp.to_lowercase().as_str() { + "ctrl" => mods |= KeyModifiers::CONTROL, + "shift" => mods |= KeyModifiers::SHIFT, + "alt" => mods |= KeyModifiers::ALT, + "super" | "cmd" => mods |= KeyModifiers::SUPER, + "meta" => mods |= KeyModifiers::META, + other => { + key = string_to_keycode(other); + } + } + } + + key.map(|k| normalize_stroke(KeyStroke { code: k, modifiers: mods })) +} + +fn is_compound_key(s: &str) -> bool { + matches!(s.to_lowercase().as_str(), + "left" | "right" | "up" | "down" | "esc" | "enter" | "backspace" | + "delete" | "tab" | "home" | "end" | "$" | "0" + ) +} + +fn parse_simple_key(s: &str) -> Option { + if let Some(kc) = string_to_keycode(&s.to_lowercase()) { + return Some(KeyStroke { code: kc, modifiers: KeyModifiers::empty() }); + } + + if s.chars().count() == 1 { + let ch = s.chars().next().unwrap(); + return Some(KeyStroke { code: KeyCode::Char(ch), modifiers: KeyModifiers::empty() }); + } + + None +} + +fn string_to_keycode(s: &str) -> Option { + Some(match s { + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "esc" => KeyCode::Esc, + "enter" => KeyCode::Enter, + "backspace" => KeyCode::Backspace, + "delete" => KeyCode::Delete, + "tab" => KeyCode::Tab, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "$" => KeyCode::Char('$'), + "0" => KeyCode::Char('0'), + _ => return None, + }) +} diff --git a/canvas/src/lib.rs b/canvas/src/lib.rs index bfb3a60..2a649e2 100644 --- a/canvas/src/lib.rs +++ b/canvas/src/lib.rs @@ -4,22 +4,21 @@ pub mod canvas; pub mod editor; pub mod data_provider; -// Only include suggestions module if feature is enabled #[cfg(feature = "suggestions")] pub mod suggestions; -// Only include validation module if feature is enabled #[cfg(feature = "validation")] pub mod validation; -// First-class textarea module and exports #[cfg(feature = "textarea")] pub mod textarea; -// Only include computed module if feature is enabled #[cfg(feature = "computed")] pub mod computed; +#[cfg(feature = "keymap")] +pub mod keymap; + #[cfg(feature = "cursor-style")] pub use canvas::CursorManager; @@ -71,6 +70,8 @@ pub use canvas::gui::{CanvasDisplayOptions, OverflowMode}; #[cfg(all(feature = "gui", feature = "suggestions"))] pub use suggestions::gui::render_suggestions_dropdown; +#[cfg(feature = "keymap")] +pub use keymap::{CanvasKeyMap, KeyEventOutcome}; #[cfg(feature = "textarea")] pub use textarea::{TextArea, TextAreaProvider, TextAreaState, TextAreaEditor};