Compare commits
6 Commits
a604d62d44
...
9672b9949c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9672b9949c | ||
|
|
e4e9594a9d | ||
|
|
6daa5202b1 | ||
|
|
cae47da5f2 | ||
|
|
85c7c89c28 | ||
|
|
0d80266e9b |
38
Cargo.lock
generated
38
Cargo.lock
generated
@@ -1955,6 +1955,15 @@ version = "0.11.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a"
|
checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchers"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
|
||||||
|
dependencies = [
|
||||||
|
"regex-automata 0.1.10",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchit"
|
name = "matchit"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
@@ -2747,8 +2756,17 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"memchr",
|
"memchr",
|
||||||
"regex-automata",
|
"regex-automata 0.4.9",
|
||||||
"regex-syntax",
|
"regex-syntax 0.8.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-automata"
|
||||||
|
version = "0.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
|
||||||
|
dependencies = [
|
||||||
|
"regex-syntax 0.6.29",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2759,9 +2777,15 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"memchr",
|
"memchr",
|
||||||
"regex-syntax",
|
"regex-syntax 0.8.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-syntax"
|
||||||
|
version = "0.6.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-syntax"
|
name = "regex-syntax"
|
||||||
version = "0.8.5"
|
version = "0.8.5"
|
||||||
@@ -3751,7 +3775,7 @@ dependencies = [
|
|||||||
"fnv",
|
"fnv",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"plist",
|
"plist",
|
||||||
"regex-syntax",
|
"regex-syntax 0.8.5",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -3857,7 +3881,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18"
|
checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"regex-syntax",
|
"regex-syntax 0.8.5",
|
||||||
"utf8-ranges",
|
"utf8-ranges",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4287,10 +4311,14 @@ version = "0.3.19"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
|
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"matchers",
|
||||||
"nu-ansi-term",
|
"nu-ansi-term",
|
||||||
|
"once_cell",
|
||||||
|
"regex",
|
||||||
"sharded-slab",
|
"sharded-slab",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thread_local",
|
"thread_local",
|
||||||
|
"tracing",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
"tracing-log",
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|||||||
1
client/.gitignore
vendored
1
client/.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
canvas_config.toml.txt
|
canvas_config.toml.txt
|
||||||
|
ui_debug.log
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ tokio = { version = "1.44.2", features = ["full", "macros"] }
|
|||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
tonic = "0.13.0"
|
tonic = "0.13.0"
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-subscriber = "0.3.19"
|
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||||
tui-textarea = { version = "0.7.0", features = ["crossterm", "ratatui", "search"] }
|
tui-textarea = { version = "0.7.0", features = ["crossterm", "ratatui", "search"] }
|
||||||
unicode-segmentation = "1.12.0"
|
unicode-segmentation = "1.12.0"
|
||||||
unicode-width.workspace = true
|
unicode-width.workspace = true
|
||||||
|
|||||||
@@ -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,107 +24,124 @@ 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) {
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if config.is_key_sequence_prefix(&sequence) {
|
pub fn reset_sequence(&mut self) {
|
||||||
return InputOutcome::Pending;
|
info!("InputEngine.reset_sequence() leader_seq_before={:?}", self.leader_seq.current_sequence);
|
||||||
}
|
|
||||||
|
|
||||||
// Not matched and not a prefix → reset and continue to single key
|
|
||||||
self.seq.reset();
|
self.seq.reset();
|
||||||
} else if key_event.code == space && config.is_key_sequence_prefix(&[space]) {
|
self.leader_seq.reset();
|
||||||
self.seq.reset();
|
|
||||||
self.seq.add_key(space);
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a key sequence is currently active
|
|
||||||
pub fn has_active_sequence(&self) -> bool {
|
pub fn has_active_sequence(&self) -> bool {
|
||||||
!self.seq.current_sequence.is_empty()
|
!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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
|
|||||||
@@ -4,26 +4,35 @@ use client::run_ui;
|
|||||||
use client::utils::debug_logger::UiDebugWriter;
|
use client::utils::debug_logger::UiDebugWriter;
|
||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use tracing_subscriber;
|
use tracing_subscriber::EnvFilter;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
#[cfg(feature = "ui-debug")]
|
#[cfg(feature = "ui-debug")]
|
||||||
{
|
{
|
||||||
// If ui-debug is on, set up our custom writer.
|
use std::sync::Once;
|
||||||
let writer = UiDebugWriter::new();
|
static INIT_LOGGER: Once = Once::new();
|
||||||
tracing_subscriber::fmt()
|
|
||||||
.with_level(false) // Don't show INFO, ERROR, etc.
|
INIT_LOGGER.call_once(|| {
|
||||||
.with_target(false) // Don't show the module path.
|
let writer = UiDebugWriter::new();
|
||||||
.without_time() // This is the correct and simpler method.
|
let _ = tracing_subscriber::fmt()
|
||||||
.with_writer(move || writer.clone())
|
.with_max_level(tracing::Level::DEBUG)
|
||||||
.init();
|
.with_target(false)
|
||||||
|
.without_time()
|
||||||
|
.with_writer(move || writer.clone())
|
||||||
|
// Filter out noisy grpc/h2 internals
|
||||||
|
.with_env_filter("client=debug,tonic=info,h2=info,tower=info")
|
||||||
|
.try_init();
|
||||||
|
|
||||||
|
client::utils::debug_logger::spawn_file_logger();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "ui-debug"))]
|
#[cfg(not(feature = "ui-debug"))]
|
||||||
{
|
{
|
||||||
if env::var("ENABLE_TRACING").is_ok() {
|
if env::var("ENABLE_TRACING").is_ok() {
|
||||||
tracing_subscriber::fmt::init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 +108,10 @@ 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::with_channel(
|
||||||
|
grpc_client.channel()
|
||||||
|
).await?,
|
||||||
grpc_client,
|
grpc_client,
|
||||||
login_result_sender,
|
login_result_sender,
|
||||||
register_result_sender,
|
register_result_sender,
|
||||||
@@ -227,6 +230,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 +306,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 +326,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 +383,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 +393,29 @@ 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.
|
||||||
|
info!("Form branch: has_active_seq={}", self.input_engine.has_active_sequence());
|
||||||
|
if self.input_engine.has_active_sequence() {
|
||||||
|
info!("Form branch suppressing key {:?}, leader in progress", key_event.code);
|
||||||
|
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
|
||||||
|
|||||||
@@ -14,12 +14,20 @@ pub struct AuthClient {
|
|||||||
|
|
||||||
impl AuthClient {
|
impl AuthClient {
|
||||||
pub async fn new() -> Result<Self> {
|
pub async fn new() -> Result<Self> {
|
||||||
|
// Kept for backward compatibility; opens a new connection.
|
||||||
let client = AuthServiceClient::connect("http://[::1]:50051")
|
let client = AuthServiceClient::connect("http://[::1]:50051")
|
||||||
.await
|
.await
|
||||||
.context("Failed to connect to auth service")?;
|
.context("Failed to connect to auth service")?;
|
||||||
Ok(Self { client })
|
Ok(Self { client })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Preferred: reuse an existing Channel (from GrpcClient).
|
||||||
|
pub async fn with_channel(channel: Channel) -> Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
client: AuthServiceClient::new(channel),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Login user via gRPC.
|
/// Login user via gRPC.
|
||||||
pub async fn login(&mut self, identifier: String, password: String) -> Result<LoginResponse> {
|
pub async fn login(&mut self, identifier: String, password: String) -> Result<LoginResponse> {
|
||||||
let request = tonic::Request::new(LoginRequest { identifier, password });
|
let request = tonic::Request::new(LoginRequest { identifier, password });
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
use common::proto::komp_ac::common::Empty;
|
use common::proto::komp_ac::common::Empty;
|
||||||
use common::proto::komp_ac::table_structure::table_structure_service_client::TableStructureServiceClient;
|
use common::proto::komp_ac::table_structure::table_structure_service_client::TableStructureServiceClient;
|
||||||
use common::proto::komp_ac::table_structure::{GetTableStructureRequest, TableStructureResponse};
|
use common::proto::komp_ac::table_structure::{
|
||||||
|
GetTableStructureRequest, TableStructureResponse,
|
||||||
|
};
|
||||||
use common::proto::komp_ac::table_definition::{
|
use common::proto::komp_ac::table_definition::{
|
||||||
table_definition_client::TableDefinitionClient,
|
table_definition_client::TableDefinitionClient,
|
||||||
PostTableDefinitionRequest, ProfileTreeResponse, TableDefinitionResponse,
|
PostTableDefinitionRequest, ProfileTreeResponse, TableDefinitionResponse,
|
||||||
@@ -26,11 +28,13 @@ use crate::search::SearchGrpc;
|
|||||||
use common::proto::komp_ac::search::SearchResponse;
|
use common::proto::komp_ac::search::SearchResponse;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tonic::transport::Channel;
|
use tonic::transport::{Channel, Endpoint};
|
||||||
use prost_types::Value;
|
use prost_types::Value;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct GrpcClient {
|
pub struct GrpcClient {
|
||||||
|
channel: Channel,
|
||||||
table_structure_client: TableStructureServiceClient<Channel>,
|
table_structure_client: TableStructureServiceClient<Channel>,
|
||||||
table_definition_client: TableDefinitionClient<Channel>,
|
table_definition_client: TableDefinitionClient<Channel>,
|
||||||
table_script_client: TableScriptClient<Channel>,
|
table_script_client: TableScriptClient<Channel>,
|
||||||
@@ -40,7 +44,14 @@ pub struct GrpcClient {
|
|||||||
|
|
||||||
impl GrpcClient {
|
impl GrpcClient {
|
||||||
pub async fn new() -> Result<Self> {
|
pub async fn new() -> Result<Self> {
|
||||||
let channel = Channel::from_static("http://[::1]:50051")
|
let endpoint = Endpoint::from_static("http://[::1]:50051")
|
||||||
|
.connect_timeout(Duration::from_secs(5))
|
||||||
|
.tcp_keepalive(Some(Duration::from_secs(30)))
|
||||||
|
.keep_alive_while_idle(true)
|
||||||
|
.http2_keep_alive_interval(Duration::from_secs(15))
|
||||||
|
.keep_alive_timeout(Duration::from_secs(5));
|
||||||
|
|
||||||
|
let channel = endpoint
|
||||||
.connect()
|
.connect()
|
||||||
.await
|
.await
|
||||||
.context("Failed to create gRPC channel")?;
|
.context("Failed to create gRPC channel")?;
|
||||||
@@ -54,6 +65,7 @@ impl GrpcClient {
|
|||||||
let search_client = SearchGrpc::new(channel.clone());
|
let search_client = SearchGrpc::new(channel.clone());
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
channel,
|
||||||
table_structure_client,
|
table_structure_client,
|
||||||
table_definition_client,
|
table_definition_client,
|
||||||
table_script_client,
|
table_script_client,
|
||||||
@@ -62,6 +74,11 @@ impl GrpcClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expose the shared channel so other typed clients can reuse it.
|
||||||
|
pub fn channel(&self) -> Channel {
|
||||||
|
self.channel.clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_table_structure(
|
pub async fn get_table_structure(
|
||||||
&mut self,
|
&mut self,
|
||||||
profile_name: String,
|
profile_name: String,
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ pub struct AppState {
|
|||||||
pub ui: UiState,
|
pub ui: UiState,
|
||||||
|
|
||||||
pub form_editor: HashMap<String, FormEditor<FormState>>, // key = "profile/table"
|
pub form_editor: HashMap<String, FormEditor<FormState>>, // key = "profile/table"
|
||||||
|
|
||||||
#[cfg(feature = "ui-debug")]
|
#[cfg(feature = "ui-debug")]
|
||||||
pub debug_state: Option<DebugState>,
|
pub debug_state: Option<DebugState>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use crate::buffer::state::AppView;
|
|||||||
use crate::state::app::state::AppState;
|
use crate::state::app::state::AppState;
|
||||||
use crate::tui::terminal::{EventReader, TerminalCore};
|
use crate::tui::terminal::{EventReader, TerminalCore};
|
||||||
use crate::ui::handlers::render::render_ui;
|
use crate::ui::handlers::render::render_ui;
|
||||||
|
use crate::input::leader::leader_has_any_start;
|
||||||
use crate::pages::login;
|
use crate::pages::login;
|
||||||
use crate::pages::register;
|
use crate::pages::register;
|
||||||
use crate::pages::login::LoginResult;
|
use crate::pages::login::LoginResult;
|
||||||
@@ -237,24 +238,46 @@ pub async fn run_ui() -> Result<()> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if inside_canvas {
|
if inside_canvas {
|
||||||
if let Page::Form(path) = &router.current {
|
// Do NOT forward to canvas while a leader is active or about to start.
|
||||||
if let Some(editor) = app_state.editor_for_path(path) {
|
// This prevents the canvas from stealing the second/third key (b/d/r).
|
||||||
match editor.handle_key_event(*key_event) {
|
let leader_in_progress = event_handler.input_engine.has_active_sequence();
|
||||||
KeyEventOutcome::Consumed(Some(msg)) => {
|
let is_space = matches!(key_event.code, crossterm_event::KeyCode::Char(' '));
|
||||||
event_handler.command_message = msg;
|
let can_start_leader = leader_has_any_start(&config);
|
||||||
needs_redraw = true;
|
let form_in_edit_mode = match &router.current {
|
||||||
continue;
|
Page::Form(path) => app_state
|
||||||
}
|
.editor_for_path_ref(path)
|
||||||
KeyEventOutcome::Consumed(None) => {
|
.map(|e| e.mode() == canvas::AppMode::Edit)
|
||||||
needs_redraw = true;
|
.unwrap_or(false),
|
||||||
continue;
|
_ => false,
|
||||||
}
|
};
|
||||||
KeyEventOutcome::Pending => {
|
|
||||||
needs_redraw = true;
|
let defer_to_engine_for_leader = leader_in_progress
|
||||||
continue;
|
|| (is_space && can_start_leader && !form_in_edit_mode);
|
||||||
}
|
|
||||||
KeyEventOutcome::NotMatched => {
|
if defer_to_engine_for_leader {
|
||||||
// fall through to client-level handling
|
info!(
|
||||||
|
"Skipping canvas pre-handle: leader sequence active or starting"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if let Page::Form(path) = &router.current {
|
||||||
|
if let Some(editor) = app_state.editor_for_path(path) {
|
||||||
|
match editor.handle_key_event(*key_event) {
|
||||||
|
KeyEventOutcome::Consumed(Some(msg)) => {
|
||||||
|
event_handler.command_message = msg;
|
||||||
|
needs_redraw = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
KeyEventOutcome::Consumed(None) => {
|
||||||
|
needs_redraw = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
KeyEventOutcome::Pending => {
|
||||||
|
needs_redraw = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
KeyEventOutcome::NotMatched => {
|
||||||
|
// fall through to client-level handling
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
// client/src/utils/debug_logger.rs
|
// client/src/utils/debug_logger.rs
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use std::collections::VecDeque; // <-- FIX: Import VecDeque
|
use std::collections::VecDeque; // <-- FIX: Import VecDeque
|
||||||
use std::io;
|
use std::io::{self, Write};
|
||||||
use std::sync::{Arc, Mutex}; // <-- FIX: Import Mutex
|
use std::sync::{Arc, Mutex}; // <-- FIX: Import Mutex
|
||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
static ref UI_DEBUG_BUFFER: Arc<Mutex<VecDeque<(String, bool)>>> =
|
static ref UI_DEBUG_BUFFER: Arc<Mutex<VecDeque<(String, bool)>>> =
|
||||||
@@ -27,11 +30,21 @@ impl UiDebugWriter {
|
|||||||
impl io::Write for UiDebugWriter {
|
impl io::Write for UiDebugWriter {
|
||||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||||
let mut buffer = UI_DEBUG_BUFFER.lock().unwrap();
|
let mut buffer = UI_DEBUG_BUFFER.lock().unwrap();
|
||||||
let message = String::from_utf8_lossy(buf);
|
let message = String::from_utf8_lossy(buf).trim().to_string();
|
||||||
let trimmed_message = message.trim().to_string();
|
let is_error = message.starts_with("ERROR");
|
||||||
let is_error = trimmed_message.starts_with("ERROR");
|
|
||||||
// Add the new message to the back of the queue
|
// Keep in memory for UI
|
||||||
buffer.push_back((trimmed_message, is_error));
|
buffer.push_back((message.clone(), is_error));
|
||||||
|
|
||||||
|
// ALSO log directly to file (non-blocking best effort)
|
||||||
|
if let Ok(mut file) = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open("ui_debug.log")
|
||||||
|
{
|
||||||
|
let _ = writeln!(file, "{message}");
|
||||||
|
}
|
||||||
|
|
||||||
Ok(buf.len())
|
Ok(buf.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,3 +57,22 @@ impl io::Write for UiDebugWriter {
|
|||||||
pub fn pop_next_debug_message() -> Option<(String, bool)> {
|
pub fn pop_next_debug_message() -> Option<(String, bool)> {
|
||||||
UI_DEBUG_BUFFER.lock().unwrap().pop_front()
|
UI_DEBUG_BUFFER.lock().unwrap().pop_front()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// spawn a background thread that keeps draining UI_DEBUG_BUFFER
|
||||||
|
/// and writes messages into ui_debug.log continuously
|
||||||
|
pub fn spawn_file_logger() {
|
||||||
|
thread::spawn(|| loop {
|
||||||
|
// pop one message if present
|
||||||
|
if let Some((msg, _)) = pop_next_debug_message() {
|
||||||
|
if let Ok(mut file) = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open("ui_debug.log")
|
||||||
|
{
|
||||||
|
let _ = writeln!(file, "{msg}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// small sleep to avoid burning CPU
|
||||||
|
thread::sleep(Duration::from_millis(50));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
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