space commands here we go again
This commit is contained in:
@@ -2,14 +2,10 @@
|
|||||||
[keybindings]
|
[keybindings]
|
||||||
|
|
||||||
enter_command_mode = [":", "ctrl+;"]
|
enter_command_mode = [":", "ctrl+;"]
|
||||||
next_buffer = ["ctrl+b+n"]
|
next_buffer = ["space+b+n"]
|
||||||
previous_buffer = ["ctrl+b+p"]
|
previous_buffer = ["space+b+p"]
|
||||||
close_buffer = ["ctrl+b+d"]
|
close_buffer = ["space+b+d"]
|
||||||
# SPACE NOT WORKING, NEEDS REDESIGN
|
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]
|
[keybindings.general]
|
||||||
up = ["k", "Up"]
|
up = ["k", "Up"]
|
||||||
|
|||||||
@@ -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+<char> (letters)
|
// Robust handling for shift+<char> (letters)
|
||||||
// Many terminals send uppercase Char without SHIFT bit.
|
// Many terminals send uppercase Char without SHIFT bit.
|
||||||
if binding_lc.starts_with("shift+") {
|
if binding_lc.starts_with("shift+") {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// client/src/config/key_sequences.rs
|
// client/src/config/key_sequences.rs
|
||||||
use crossterm::event::{KeyCode, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyModifiers};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct ParsedKey {
|
pub struct ParsedKey {
|
||||||
@@ -25,19 +26,21 @@ impl KeySequenceTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn reset(&mut self) {
|
pub fn reset(&mut self) {
|
||||||
|
info!("KeySequenceTracker.reset() from {:?}", self.current_sequence);
|
||||||
self.current_sequence.clear();
|
self.current_sequence.clear();
|
||||||
self.last_key_time = Instant::now();
|
self.last_key_time = Instant::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_key(&mut self, key: KeyCode) -> bool {
|
pub fn add_key(&mut self, key: KeyCode) -> bool {
|
||||||
// Check if timeout has expired
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
if now.duration_since(self.last_key_time) > self.timeout {
|
if now.duration_since(self.last_key_time) > self.timeout {
|
||||||
|
info!("KeySequenceTracker timeout — reset before adding {:?}", key);
|
||||||
self.reset();
|
self.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
self.current_sequence.push(key);
|
self.current_sequence.push(key);
|
||||||
self.last_key_time = now;
|
self.last_key_time = now;
|
||||||
|
info!("KeySequenceTracker state after add: {:?}", self.current_sequence);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,26 +118,21 @@ pub fn string_to_keycode(s: &str) -> Option<KeyCode> {
|
|||||||
pub fn parse_binding(binding: &str) -> Vec<ParsedKey> {
|
pub fn parse_binding(binding: &str) -> Vec<ParsedKey> {
|
||||||
let mut sequence = Vec::new();
|
let mut sequence = Vec::new();
|
||||||
|
|
||||||
// Handle different binding formats
|
// Split into multi-key sequence:
|
||||||
let parts: Vec<String> = if binding.contains('+') {
|
// - If contains space → sequence split by space
|
||||||
// Format with explicit '+' separators like "g+left"
|
// - Else split by '+'
|
||||||
binding.split('+').map(|s| s.to_string()).collect()
|
let parts: Vec<&str> = if binding.contains(' ') {
|
||||||
} else if binding.contains(' ') {
|
binding.split(' ').collect()
|
||||||
// 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()]
|
|
||||||
} else {
|
} else {
|
||||||
// Simple character sequence like "gg"
|
binding.split('+').collect()
|
||||||
binding.chars().map(|c| c.to_string()).collect()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
for part in &parts {
|
for part in parts {
|
||||||
if let Some(key) = parse_key_part(part) {
|
if let Some(parsed) = parse_key_part(part) {
|
||||||
sequence.push(key);
|
sequence.push(parsed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sequence
|
sequence
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ use crate::input::action::{AppAction, BufferAction, CoreAction};
|
|||||||
use crate::movement::MovementAction;
|
use crate::movement::MovementAction;
|
||||||
use crate::modes::handlers::mode_manager::AppMode;
|
use crate::modes::handlers::mode_manager::AppMode;
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
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)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct InputContext {
|
pub struct InputContext {
|
||||||
@@ -22,17 +24,26 @@ pub enum InputOutcome {
|
|||||||
|
|
||||||
pub struct InputEngine {
|
pub struct InputEngine {
|
||||||
seq: KeySequenceTracker,
|
seq: KeySequenceTracker,
|
||||||
|
leader_seq: KeySequenceTracker,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InputEngine {
|
impl InputEngine {
|
||||||
pub fn new(timeout_ms: u64) -> Self {
|
pub fn new(normal_timeout_ms: u64, leader_timeout_ms: u64) -> Self {
|
||||||
Self {
|
Self {
|
||||||
seq: KeySequenceTracker::new(timeout_ms),
|
seq: KeySequenceTracker::new(normal_timeout_ms),
|
||||||
|
leader_seq: KeySequenceTracker::new(leader_timeout_ms),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reset_sequence(&mut self) {
|
pub fn reset_sequence(&mut self) {
|
||||||
|
info!("InputEngine.reset_sequence() leader_seq_before={:?}", self.leader_seq.current_sequence);
|
||||||
self.seq.reset();
|
self.seq.reset();
|
||||||
|
self.leader_seq.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_active_sequence(&self) -> bool {
|
||||||
|
!self.seq.current_sequence.is_empty()
|
||||||
|
|| !self.leader_seq.current_sequence.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn process_key(
|
pub fn process_key(
|
||||||
@@ -63,41 +74,54 @@ impl InputEngine {
|
|||||||
// If overlays are active, do not intercept (palette, navigation, etc.)
|
// If overlays are active, do not intercept (palette, navigation, etc.)
|
||||||
if ctx.overlay_active {
|
if ctx.overlay_active {
|
||||||
self.seq.reset();
|
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;
|
return InputOutcome::PassThrough;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Space-led multi-key sequences (leader = space)
|
// Space-led multi-key sequences (leader = space)
|
||||||
if ctx.allow_navigation_capture {
|
|
||||||
let space = KeyCode::Char(' ');
|
let space = KeyCode::Char(' ');
|
||||||
let seq_active = !self.seq.current_sequence.is_empty()
|
let leader_active = !self.leader_seq.current_sequence.is_empty()
|
||||||
&& self.seq.current_sequence[0] == space;
|
&& self.leader_seq.current_sequence[0] == space;
|
||||||
|
|
||||||
if seq_active {
|
// Keep collecting leader sequence even if allow_navigation_capture is false.
|
||||||
self.seq.add_key(key_event.code);
|
if leader_active {
|
||||||
let sequence = self.seq.get_sequence();
|
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) = config.matches_key_sequence_generalized(&sequence) {
|
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) {
|
if let Some(app_action) = map_action_string(action_str, ctx) {
|
||||||
self.seq.reset();
|
self.leader_seq.reset();
|
||||||
return InputOutcome::Action(app_action);
|
return InputOutcome::Action(app_action);
|
||||||
}
|
}
|
||||||
// A non-app action sequence (canvas stuff) → pass-through
|
self.leader_seq.reset();
|
||||||
self.seq.reset();
|
|
||||||
return InputOutcome::PassThrough;
|
return InputOutcome::PassThrough;
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.is_key_sequence_prefix(&sequence) {
|
if leader_is_prefix(config, &sequence) {
|
||||||
|
info!("Leader prefix continuing...");
|
||||||
return InputOutcome::Pending;
|
return InputOutcome::Pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not matched and not a prefix → reset and continue to single key
|
info!("Leader sequence reset (no match/prefix).");
|
||||||
self.seq.reset();
|
self.leader_seq.reset();
|
||||||
} else if key_event.code == space && config.is_key_sequence_prefix(&[space]) {
|
// fall through to regular handling of this key
|
||||||
self.seq.reset();
|
} else if ctx.allow_navigation_capture
|
||||||
self.seq.add_key(space);
|
&& 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;
|
return InputOutcome::Pending;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Single-key mapping: try general binds first (arrows, open_search, enter_command_mode)
|
// Single-key mapping: try general binds first (arrows, open_search, enter_command_mode)
|
||||||
if let Some(action_str) =
|
if let Some(action_str) =
|
||||||
@@ -119,11 +143,6 @@ impl InputEngine {
|
|||||||
|
|
||||||
InputOutcome::PassThrough
|
InputOutcome::PassThrough
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a key sequence is currently active
|
|
||||||
pub fn has_active_sequence(&self) -> bool {
|
|
||||||
!self.seq.current_sequence.is_empty()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn str_to_movement(s: &str) -> Option<MovementAction> {
|
fn str_to_movement(s: &str) -> Option<MovementAction> {
|
||||||
|
|||||||
74
client/src/input/leader.rs
Normal file
74
client/src/input/leader.rs
Normal file
@@ -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<KeyCode>)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
|
||||||
|
// Include all keybinding maps, not just global
|
||||||
|
let all_modes: Vec<&std::collections::HashMap<String, Vec<String>>> = 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::<Vec<_>>();
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
// src/input/mod.rs
|
// src/input/mod.rs
|
||||||
pub mod action;
|
pub mod action;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
|
pub mod leader;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ use common::proto::komp_ac::search::search_response::Hit;
|
|||||||
use crossterm::event::{Event, KeyCode};
|
use crossterm::event::{Event, KeyCode};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::sync::mpsc::unbounded_channel;
|
use tokio::sync::mpsc::unbounded_channel;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum EventOutcome {
|
pub enum EventOutcome {
|
||||||
@@ -107,7 +108,7 @@ impl EventHandler {
|
|||||||
command_message: String::new(),
|
command_message: String::new(),
|
||||||
edit_mode_cooldown: false,
|
edit_mode_cooldown: false,
|
||||||
ideal_cursor_column: 0,
|
ideal_cursor_column: 0,
|
||||||
input_engine: InputEngine::new(1200),
|
input_engine: InputEngine::new(400, 5000),
|
||||||
auth_client: AuthClient::new().await?,
|
auth_client: AuthClient::new().await?,
|
||||||
grpc_client,
|
grpc_client,
|
||||||
login_result_sender,
|
login_result_sender,
|
||||||
@@ -227,6 +228,12 @@ impl EventHandler {
|
|||||||
) -> Result<EventOutcome> {
|
) -> Result<EventOutcome> {
|
||||||
if app_state.ui.show_search_palette {
|
if app_state.ui.show_search_palette {
|
||||||
if let Event::Key(key_event) = event {
|
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(
|
if let Some(message) = handle_search_palette_event(
|
||||||
key_event,
|
key_event,
|
||||||
app_state,
|
app_state,
|
||||||
@@ -297,6 +304,10 @@ impl EventHandler {
|
|||||||
if let Event::Key(key_event) = event {
|
if let Event::Key(key_event) = event {
|
||||||
let key_code = key_event.code;
|
let key_code = key_event.code;
|
||||||
let modifiers = key_event.modifiers;
|
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
|
let overlay_active = self.command_mode
|
||||||
|| app_state.ui.show_search_palette
|
|| app_state.ui.show_search_palette
|
||||||
@@ -313,12 +324,24 @@ impl EventHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Centralized key -> action resolution
|
// Centralized key -> action resolution
|
||||||
|
let allow_nav = self.input_engine.has_active_sequence()
|
||||||
|
|| (!in_form_edit_mode && !overlay_active);
|
||||||
let input_ctx = InputContext {
|
let input_ctx = InputContext {
|
||||||
app_mode: current_mode,
|
app_mode: current_mode,
|
||||||
overlay_active,
|
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) => {
|
InputOutcome::Action(action) => {
|
||||||
if let Some(outcome) = self
|
if let Some(outcome) = self
|
||||||
.handle_app_action(
|
.handle_app_action(
|
||||||
@@ -358,21 +381,6 @@ impl EventHandler {
|
|||||||
if !outcome.get_message_if_ok().is_empty() {
|
if !outcome.get_message_if_ok().is_empty() {
|
||||||
return Ok(outcome);
|
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 {
|
} else if let Page::Register(register_page) = &mut router.current {
|
||||||
let outcome = crate::pages::register::event::handle_register_event(
|
let outcome = crate::pages::register::event::handle_register_event(
|
||||||
event,
|
event,
|
||||||
@@ -383,21 +391,27 @@ impl EventHandler {
|
|||||||
if !outcome.get_message_if_ok().is_empty() {
|
if !outcome.get_message_if_ok().is_empty() {
|
||||||
return Ok(outcome);
|
return Ok(outcome);
|
||||||
}
|
}
|
||||||
} else if let Page::Form(path) = &router.current {
|
} else if let Page::Form(path_str) = &router.current {
|
||||||
// If a space-led sequence is in progress or has just begun, do not forward to editor
|
let path = path_str.clone();
|
||||||
if !self.input_engine.has_active_sequence() {
|
|
||||||
|
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(
|
let outcome = forms::event::handle_form_event(
|
||||||
event,
|
event,
|
||||||
app_state,
|
app_state,
|
||||||
path,
|
&path,
|
||||||
&mut self.ideal_cursor_column,
|
&mut self.ideal_cursor_column,
|
||||||
)?;
|
)?;
|
||||||
if !outcome.get_message_if_ok().is_empty() {
|
if !outcome.get_message_if_ok().is_empty() {
|
||||||
return Ok(outcome);
|
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 {
|
} else if let Page::AddLogic(add_logic_page) = &mut router.current {
|
||||||
// Allow ":" (enter_command_mode) even when inside AddLogic canvas
|
// Allow ":" (enter_command_mode) even when inside AddLogic canvas
|
||||||
|
|||||||
39
client/tests/input/engine_leader_e2e.rs
Normal file
39
client/tests/input/engine_leader_e2e.rs
Normal file
@@ -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(_)));
|
||||||
|
}
|
||||||
25
client/tests/input/leader_sequences.rs
Normal file
25
client/tests/input/leader_sequences.rs
Normal file
@@ -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<KeyCode> = seq.iter().map(|p| p.code).collect();
|
||||||
|
assert_eq!(codes, vec![KeyCode::Char(' '), KeyCode::Char('b'), KeyCode::Char('r')]);
|
||||||
|
}
|
||||||
4
client/tests/input/mod.rs
Normal file
4
client/tests/input/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// tests/input/mod.rs
|
||||||
|
|
||||||
|
pub mod engine_leader_e2e;
|
||||||
|
pub mod leader_sequences;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
// tests/mod.rs
|
// tests/mod.rs
|
||||||
|
|
||||||
pub mod form;
|
// pub mod form;
|
||||||
|
pub mod input;
|
||||||
|
|||||||
Reference in New Issue
Block a user