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"
|
||||
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]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
@@ -2747,8 +2756,17 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
"regex-automata 0.4.9",
|
||||
"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]]
|
||||
@@ -2759,9 +2777,15 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"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]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.5"
|
||||
@@ -3751,7 +3775,7 @@ dependencies = [
|
||||
"fnv",
|
||||
"once_cell",
|
||||
"plist",
|
||||
"regex-syntax",
|
||||
"regex-syntax 0.8.5",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
@@ -3857,7 +3881,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"regex-syntax",
|
||||
"regex-syntax 0.8.5",
|
||||
"utf8-ranges",
|
||||
]
|
||||
|
||||
@@ -4287,10 +4311,14 @@ version = "0.3.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
1
client/.gitignore
vendored
1
client/.gitignore
vendored
@@ -1 +1,2 @@
|
||||
canvas_config.toml.txt
|
||||
ui_debug.log
|
||||
|
||||
@@ -24,7 +24,7 @@ tokio = { version = "1.44.2", features = ["full", "macros"] }
|
||||
toml = { workspace = true }
|
||||
tonic = "0.13.0"
|
||||
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"] }
|
||||
unicode-segmentation = "1.12.0"
|
||||
unicode-width.workspace = true
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
// Many terminals send uppercase Char without SHIFT bit.
|
||||
if binding_lc.starts_with("shift+") {
|
||||
|
||||
@@ -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<KeyCode> {
|
||||
pub fn parse_binding(binding: &str) -> Vec<ParsedKey> {
|
||||
let mut sequence = Vec::new();
|
||||
|
||||
// Handle different binding formats
|
||||
let parts: Vec<String> = 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,17 +24,26 @@ pub enum InputOutcome {
|
||||
|
||||
pub struct InputEngine {
|
||||
seq: KeySequenceTracker,
|
||||
leader_seq: KeySequenceTracker,
|
||||
}
|
||||
|
||||
impl InputEngine {
|
||||
pub fn new(timeout_ms: u64) -> Self {
|
||||
pub fn new(normal_timeout_ms: u64, leader_timeout_ms: u64) -> 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) {
|
||||
info!("InputEngine.reset_sequence() leader_seq_before={:?}", self.leader_seq.current_sequence);
|
||||
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(
|
||||
@@ -63,41 +74,54 @@ impl InputEngine {
|
||||
// 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)
|
||||
if ctx.allow_navigation_capture {
|
||||
let space = KeyCode::Char(' ');
|
||||
let seq_active = !self.seq.current_sequence.is_empty()
|
||||
&& self.seq.current_sequence[0] == space;
|
||||
let leader_active = !self.leader_seq.current_sequence.is_empty()
|
||||
&& self.leader_seq.current_sequence[0] == space;
|
||||
|
||||
if seq_active {
|
||||
self.seq.add_key(key_event.code);
|
||||
let sequence = self.seq.get_sequence();
|
||||
// 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) = 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) {
|
||||
self.seq.reset();
|
||||
self.leader_seq.reset();
|
||||
return InputOutcome::Action(app_action);
|
||||
}
|
||||
// A non-app action sequence (canvas stuff) → pass-through
|
||||
self.seq.reset();
|
||||
self.leader_seq.reset();
|
||||
return InputOutcome::PassThrough;
|
||||
}
|
||||
|
||||
if config.is_key_sequence_prefix(&sequence) {
|
||||
if leader_is_prefix(config, &sequence) {
|
||||
info!("Leader prefix continuing...");
|
||||
return InputOutcome::Pending;
|
||||
}
|
||||
|
||||
// Not matched and not a prefix → reset and continue to single key
|
||||
self.seq.reset();
|
||||
} else if key_event.code == space && config.is_key_sequence_prefix(&[space]) {
|
||||
self.seq.reset();
|
||||
self.seq.add_key(space);
|
||||
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) =
|
||||
@@ -119,11 +143,6 @@ impl InputEngine {
|
||||
|
||||
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> {
|
||||
|
||||
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
|
||||
pub mod action;
|
||||
pub mod engine;
|
||||
pub mod leader;
|
||||
|
||||
@@ -4,26 +4,35 @@ use client::run_ui;
|
||||
use client::utils::debug_logger::UiDebugWriter;
|
||||
use dotenvy::dotenv;
|
||||
use anyhow::Result;
|
||||
use tracing_subscriber;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use std::env;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
#[cfg(feature = "ui-debug")]
|
||||
{
|
||||
// If ui-debug is on, set up our custom writer.
|
||||
use std::sync::Once;
|
||||
static INIT_LOGGER: Once = Once::new();
|
||||
|
||||
INIT_LOGGER.call_once(|| {
|
||||
let writer = UiDebugWriter::new();
|
||||
tracing_subscriber::fmt()
|
||||
.with_level(false) // Don't show INFO, ERROR, etc.
|
||||
.with_target(false) // Don't show the module path.
|
||||
.without_time() // This is the correct and simpler method.
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::DEBUG)
|
||||
.with_target(false)
|
||||
.without_time()
|
||||
.with_writer(move || writer.clone())
|
||||
.init();
|
||||
// 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"))]
|
||||
{
|
||||
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 tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EventOutcome {
|
||||
@@ -107,8 +108,10 @@ impl EventHandler {
|
||||
command_message: String::new(),
|
||||
edit_mode_cooldown: false,
|
||||
ideal_cursor_column: 0,
|
||||
input_engine: InputEngine::new(1200),
|
||||
auth_client: AuthClient::new().await?,
|
||||
input_engine: InputEngine::new(400, 5000),
|
||||
auth_client: AuthClient::with_channel(
|
||||
grpc_client.channel()
|
||||
).await?,
|
||||
grpc_client,
|
||||
login_result_sender,
|
||||
register_result_sender,
|
||||
@@ -227,6 +230,12 @@ impl EventHandler {
|
||||
) -> Result<EventOutcome> {
|
||||
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 +306,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 +326,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 +383,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 +393,29 @@ 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.
|
||||
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(
|
||||
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
|
||||
|
||||
@@ -14,12 +14,20 @@ pub struct AuthClient {
|
||||
|
||||
impl AuthClient {
|
||||
pub async fn new() -> Result<Self> {
|
||||
// Kept for backward compatibility; opens a new connection.
|
||||
let client = AuthServiceClient::connect("http://[::1]:50051")
|
||||
.await
|
||||
.context("Failed to connect to auth service")?;
|
||||
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.
|
||||
pub async fn login(&mut self, identifier: String, password: String) -> Result<LoginResponse> {
|
||||
let request = tonic::Request::new(LoginRequest { identifier, password });
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
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::{GetTableStructureRequest, TableStructureResponse};
|
||||
use common::proto::komp_ac::table_structure::{
|
||||
GetTableStructureRequest, TableStructureResponse,
|
||||
};
|
||||
use common::proto::komp_ac::table_definition::{
|
||||
table_definition_client::TableDefinitionClient,
|
||||
PostTableDefinitionRequest, ProfileTreeResponse, TableDefinitionResponse,
|
||||
@@ -26,11 +28,13 @@ use crate::search::SearchGrpc;
|
||||
use common::proto::komp_ac::search::SearchResponse;
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use tonic::transport::Channel;
|
||||
use tonic::transport::{Channel, Endpoint};
|
||||
use prost_types::Value;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GrpcClient {
|
||||
channel: Channel,
|
||||
table_structure_client: TableStructureServiceClient<Channel>,
|
||||
table_definition_client: TableDefinitionClient<Channel>,
|
||||
table_script_client: TableScriptClient<Channel>,
|
||||
@@ -40,7 +44,14 @@ pub struct GrpcClient {
|
||||
|
||||
impl GrpcClient {
|
||||
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()
|
||||
.await
|
||||
.context("Failed to create gRPC channel")?;
|
||||
@@ -54,6 +65,7 @@ impl GrpcClient {
|
||||
let search_client = SearchGrpc::new(channel.clone());
|
||||
|
||||
Ok(Self {
|
||||
channel,
|
||||
table_structure_client,
|
||||
table_definition_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(
|
||||
&mut self,
|
||||
profile_name: String,
|
||||
|
||||
@@ -59,7 +59,6 @@ pub struct AppState {
|
||||
pub ui: UiState,
|
||||
|
||||
pub form_editor: HashMap<String, FormEditor<FormState>>, // key = "profile/table"
|
||||
|
||||
#[cfg(feature = "ui-debug")]
|
||||
pub debug_state: Option<DebugState>,
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::buffer::state::AppView;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::tui::terminal::{EventReader, TerminalCore};
|
||||
use crate::ui::handlers::render::render_ui;
|
||||
use crate::input::leader::leader_has_any_start;
|
||||
use crate::pages::login;
|
||||
use crate::pages::register;
|
||||
use crate::pages::login::LoginResult;
|
||||
@@ -237,6 +238,27 @@ pub async fn run_ui() -> Result<()> {
|
||||
};
|
||||
|
||||
if inside_canvas {
|
||||
// Do NOT forward to canvas while a leader is active or about to start.
|
||||
// This prevents the canvas from stealing the second/third key (b/d/r).
|
||||
let leader_in_progress = event_handler.input_engine.has_active_sequence();
|
||||
let is_space = matches!(key_event.code, crossterm_event::KeyCode::Char(' '));
|
||||
let can_start_leader = leader_has_any_start(&config);
|
||||
let form_in_edit_mode = match &router.current {
|
||||
Page::Form(path) => app_state
|
||||
.editor_for_path_ref(path)
|
||||
.map(|e| e.mode() == canvas::AppMode::Edit)
|
||||
.unwrap_or(false),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
let defer_to_engine_for_leader = leader_in_progress
|
||||
|| (is_space && can_start_leader && !form_in_edit_mode);
|
||||
|
||||
if defer_to_engine_for_leader {
|
||||
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) {
|
||||
@@ -262,6 +284,7 @@ pub async fn run_ui() -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call handle_event directly
|
||||
let event_outcome_result = event_handler.handle_event(
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// client/src/utils/debug_logger.rs
|
||||
use lazy_static::lazy_static;
|
||||
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::fs::OpenOptions;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
lazy_static! {
|
||||
static ref UI_DEBUG_BUFFER: Arc<Mutex<VecDeque<(String, bool)>>> =
|
||||
@@ -27,11 +30,21 @@ impl UiDebugWriter {
|
||||
impl io::Write for UiDebugWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let mut buffer = UI_DEBUG_BUFFER.lock().unwrap();
|
||||
let message = String::from_utf8_lossy(buf);
|
||||
let trimmed_message = message.trim().to_string();
|
||||
let is_error = trimmed_message.starts_with("ERROR");
|
||||
// Add the new message to the back of the queue
|
||||
buffer.push_back((trimmed_message, is_error));
|
||||
let message = String::from_utf8_lossy(buf).trim().to_string();
|
||||
let is_error = message.starts_with("ERROR");
|
||||
|
||||
// Keep in memory for UI
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -44,3 +57,22 @@ impl io::Write for UiDebugWriter {
|
||||
pub fn pop_next_debug_message() -> Option<(String, bool)> {
|
||||
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
|
||||
|
||||
pub mod form;
|
||||
// pub mod form;
|
||||
pub mod input;
|
||||
|
||||
Reference in New Issue
Block a user