Compare commits

..

19 Commits

Author SHA1 Message Date
Priec
9672b9949c finally a working space 2025-09-12 19:14:21 +02:00
Priec
e4e9594a9d minor changes 2025-09-12 19:05:17 +02:00
Priec
6daa5202b1 debug is now running properly in the background without any issues 2025-09-12 18:17:52 +02:00
Priec
cae47da5f2 reused grpc connections, not a constant refreshes anymore, all fixed now, keep on fixing other bugs 2025-09-12 18:15:46 +02:00
filipriec
85c7c89c28 space2 is now debugging better 2025-09-12 15:46:14 +02:00
Priec
0d80266e9b space commands here we go again 2025-09-11 22:36:40 +02:00
Priec
a604d62d44 inputs from keyboard are now decoupled 2025-09-10 22:12:22 +02:00
Priec
2cbbfd21aa revert works on login, now do the same for other pages as well 2025-09-08 22:11:53 +02:00
Priec
1c17d07497 space and revert working properly well, also shift 2025-09-08 20:05:39 +02:00
Priec
ad15becd7a doing key sequencing via space 2025-09-08 12:56:03 +02:00
Priec
c2a6272413 buttons in add_logic and add_table works properly well now 2025-09-04 18:56:21 +02:00
Priec
c51af13fb1 intro buttons fixed 2025-09-04 17:46:32 +02:00
Priec
d9d8562539 moving the state from general to each page owning its own state of button or canvas focus 2025-09-04 17:36:13 +02:00
Priec
6891631b8d validation of exact strings 2025-09-02 13:52:36 +02:00
Priec
738d58b5f1 moving add_table to add_logic modern architecture3 2025-09-02 11:46:35 +02:00
Priec
3081125716 moving add_table to add_logic modern architecture2 2025-09-02 00:36:49 +02:00
Priec
6073c7ab43 moving add_table to add_logic modern architecture 2025-09-02 00:23:50 +02:00
filipriec
8157dc7a60 add_table working properly well 2025-09-01 16:37:43 +02:00
filipriec
3b130e9208 add_table fixing 2025-09-01 16:30:57 +02:00
48 changed files with 1785 additions and 904 deletions

38
Cargo.lock generated
View File

@@ -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",
]

View File

@@ -6,6 +6,49 @@ use crate::validation::{CharacterLimits, PatternFilters, DisplayMask};
use crate::validation::{CustomFormatter, FormattingResult, PositionMapper};
use std::sync::Arc;
/// Whitelist of allowed exact values for a field.
/// If configured, the field is valid when it is empty (by default) or when the
/// content exactly matches one of the allowed values. This does not block field
/// switching (unlike minimum length in CharacterLimits).
#[derive(Clone, Debug)]
pub struct AllowedValues {
allowed: Vec<String>,
allow_empty: bool,
case_insensitive: bool,
}
impl AllowedValues {
pub fn new(allowed: Vec<String>) -> Self {
Self {
allowed,
allow_empty: true,
case_insensitive: false,
}
}
/// Allow or disallow empty value to be considered valid (default: true).
pub fn allow_empty(mut self, allow: bool) -> Self {
self.allow_empty = allow;
self
}
/// Enable/disable ASCII case-insensitive matching (default: false).
pub fn case_insensitive(mut self, ci: bool) -> Self {
self.case_insensitive = ci;
self
}
fn matches(&self, text: &str) -> bool {
if self.case_insensitive {
self.allowed
.iter()
.any(|s| s.eq_ignore_ascii_case(text))
} else {
self.allowed.iter().any(|s| s == text)
}
}
}
/// Main validation configuration for a field
#[derive(Clone, Default)]
pub struct ValidationConfig {
@@ -22,6 +65,9 @@ pub struct ValidationConfig {
#[cfg(feature = "validation")]
pub custom_formatter: Option<Arc<dyn CustomFormatter + Send + Sync>>,
/// Optional: restrict the field to one of exact allowed values (or empty)
pub allowed_values: Option<AllowedValues>,
/// Enable external validation indicator UI (feature 5)
pub external_validation_enabled: bool,
@@ -50,6 +96,7 @@ impl std::fmt::Debug for ValidationConfig {
}
},
)
.field("allowed_values", &self.allowed_values)
.field("external_validation_enabled", &self.external_validation_enabled)
.field("external_validation", &self.external_validation)
.finish()
@@ -167,6 +214,18 @@ impl ValidationConfig {
}
}
// Allowed values (whitelist) validation
if let Some(ref allowed) = self.allowed_values {
// Empty value is allowed (default) or required (if allow_empty is false)
if text.is_empty() {
if !allowed.allow_empty {
return ValidationResult::warning("Value required");
}
} else if !allowed.matches(text) {
return ValidationResult::error("Value must be one of the allowed options");
}
}
// Future: Add other validation types here
ValidationResult::Valid
@@ -183,6 +242,12 @@ impl ValidationConfig {
#[cfg(not(feature = "validation"))]
{ false }
}
|| self.allowed_values.is_some()
}
/// Check if whitelist is configured
pub fn has_allowed_values(&self) -> bool {
self.allowed_values.is_some()
}
pub fn allows_field_switch(&self, text: &str) -> bool {
@@ -289,6 +354,41 @@ impl ValidationConfigBuilder {
self
}
/// Restrict content to one of the provided exact values (or empty).
/// - Empty is considered valid by default.
/// - Matching is case-sensitive by default.
pub fn with_allowed_values<S>(mut self, values: Vec<S>) -> Self
where
S: Into<String>,
{
let vals: Vec<String> = values.into_iter().map(Into::into).collect();
self.config.allowed_values = Some(AllowedValues::new(vals));
self
}
/// Same as with_allowed_values, but case-insensitive (ASCII).
pub fn with_allowed_values_ci<S>(mut self, values: Vec<S>) -> Self
where
S: Into<String>,
{
let vals: Vec<String> = values.into_iter().map(Into::into).collect();
self.config.allowed_values = Some(AllowedValues::new(vals).case_insensitive(true));
self
}
/// Configure whether empty value should be allowed when using AllowedValues.
pub fn with_allowed_values_allow_empty(mut self, allow_empty: bool) -> Self {
if let Some(av) = self.config.allowed_values.take() {
self.config.allowed_values = Some(AllowedValues {
allow_empty,
..av
});
} else {
self.config.allowed_values = Some(AllowedValues::new(vec![]).allow_empty(allow_empty));
}
self
}
/// Enable or disable external validation indicator UI (feature 5)
pub fn with_external_validation_enabled(mut self, enabled: bool) -> Self {
self.config.external_validation_enabled = enabled;
@@ -391,6 +491,47 @@ mod tests {
assert!(config.display_mask.is_some());
}
#[test]
fn test_allowed_values() {
let config = ValidationConfigBuilder::new()
.with_allowed_values(vec!["alpha", "beta", "gamma", "delta", "epsilon"])
.build();
// Empty should be valid by default
let result = config.validate_content("");
assert!(result.is_acceptable());
// Exact allowed values are valid
assert!(config.validate_content("alpha").is_acceptable());
assert!(config.validate_content("beta").is_acceptable());
// Anything else is an error
let res = config.validate_content("alph");
assert!(res.is_error());
let res = config.validate_content("ALPHA");
assert!(res.is_error()); // case-sensitive by default
}
#[test]
fn test_allowed_values_case_insensitive_and_required() {
let config = ValidationConfigBuilder::new()
.with_allowed_values_ci(vec!["Yes", "No"])
.with_allowed_values_allow_empty(false)
.build();
// Empty is not allowed now (warning so it's still acceptable for typing)
let res = config.validate_content("");
assert!(res.is_acceptable());
// Case-insensitive matches
assert!(config.validate_content("yes").is_acceptable());
assert!(config.validate_content("NO").is_acceptable());
// Random text is an error
let res = config.validate_content("maybe");
assert!(res.is_error());
}
#[test]
fn test_validation_result() {
let valid = ValidationResult::Valid;

1
client/.gitignore vendored
View File

@@ -1 +1,2 @@
canvas_config.toml.txt
ui_debug.log

View File

@@ -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

View File

@@ -5,6 +5,7 @@ enter_command_mode = [":", "ctrl+;"]
next_buffer = ["space+b+n"]
previous_buffer = ["space+b+p"]
close_buffer = ["space+b+d"]
revert = ["space+b+r"]
[keybindings.general]
up = ["k", "Up"]
@@ -27,7 +28,6 @@ move_up = ["Up"]
move_down = ["Down"]
toggle_sidebar = ["ctrl+t"]
toggle_buffer_list = ["ctrl+b"]
revert = ["space+b+r"]
# MODE SPECIFIC
# READ ONLY MODE
@@ -60,7 +60,7 @@ prev_field = ["Shift+Tab"]
[keybindings.highlight]
exit_highlight_mode = ["esc"]
enter_highlight_mode_linewise = ["ctrl+v"]
enter_highlight_mode_linewise = ["shift+v"]
### AUTOGENERATED CANVAS CONFIG
# Required

View File

@@ -250,28 +250,63 @@ impl Config {
key: KeyCode,
modifiers: KeyModifiers,
) -> bool {
// Special handling for shift+character combinations
if binding.to_lowercase().starts_with("shift+") {
// Normalize binding once
let binding_lc = binding.to_lowercase();
// Robust handling for Shift+Tab
// Accept either BackTab (with or without SHIFT flagged) or Tab+SHIFT
if binding_lc == "shift+tab" || binding_lc == "backtab" {
return match key {
KeyCode::BackTab => true,
KeyCode::Tab => modifiers.contains(KeyModifiers::SHIFT),
_ => false,
};
}
// 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+") {
let parts: Vec<&str> = binding.split('+').collect();
if parts.len() == 2 && parts[1].len() == 1 {
let expected_lowercase = parts[1].chars().next().unwrap().to_lowercase().next().unwrap();
let expected_uppercase = expected_lowercase.to_uppercase().next().unwrap();
if let KeyCode::Char(actual_char) = key {
if actual_char == expected_uppercase && modifiers.contains(KeyModifiers::SHIFT) {
if parts.len() == 2 && parts[1].chars().count() == 1 {
let base = parts[1].chars().next().unwrap();
let upper = base.to_ascii_uppercase();
let lower = base.to_ascii_lowercase();
if let KeyCode::Char(actual) = key {
// Accept uppercase char regardless of SHIFT bit
if actual == upper {
return true;
}
// Also accept lowercase char with SHIFT flagged (some terms do this)
if actual == lower && modifiers.contains(KeyModifiers::SHIFT) {
return true;
}
}
}
}
// Handle Shift+Tab -> BackTab
if binding.to_lowercase() == "shift+tab" && key == KeyCode::BackTab && modifiers.is_empty() {
return true;
}
// Handle multi-character bindings (all standard keys without modifiers)
if binding.len() > 1 && !binding.contains('+') {
return match binding.to_lowercase().as_str() {
return match binding_lc.as_str() {
// Navigation keys
"left" => key == KeyCode::Left,
"right" => key == KeyCode::Right,
@@ -371,6 +406,7 @@ impl Config {
let mut expected_key = None;
for part in parts {
let part_lc = part.to_lowercase();
match part.to_lowercase().as_str() {
// Modifiers
"ctrl" | "control" => expected_modifiers |= KeyModifiers::CONTROL,
@@ -789,12 +825,43 @@ impl Config {
None
}
// Normalize bindings for canvas consumption:
// - "shift+<char>" -> also add "<CHAR>"
// - "shift+tab" -> also add "backtab"
// This keeps your config human-friendly while making the canvas happy.
fn normalize_for_canvas(
map: &HashMap<String, Vec<String>>,
) -> HashMap<String, Vec<String>> {
let mut out: HashMap<String, Vec<String>> = HashMap::new();
for (action, bindings) in map {
let mut new_list: Vec<String> = Vec::new();
for b in bindings {
new_list.push(b.clone());
let blc = b.to_lowercase();
if blc.starts_with("shift+") {
let parts: Vec<&str> = b.split('+').collect();
if parts.len() == 2 && parts[1].chars().count() == 1 {
let ch = parts[1].chars().next().unwrap();
new_list.push(ch.to_ascii_uppercase().to_string());
}
if blc == "shift+tab" {
new_list.push("backtab".to_string());
}
}
if blc == "shift+tab" {
new_list.push("backtab".to_string());
}
}
out.insert(action.clone(), new_list);
}
out
}
pub fn build_canvas_keymap(&self) -> CanvasKeyMap {
CanvasKeyMap::from_mode_maps(
&self.keybindings.read_only,
&self.keybindings.edit,
&self.keybindings.highlight,
)
let ro = Self::normalize_for_canvas(&self.keybindings.read_only);
let ed = Self::normalize_for_canvas(&self.keybindings.edit);
let hl = Self::normalize_for_canvas(&self.keybindings.highlight);
CanvasKeyMap::from_mode_maps(&ro, &ed, &hl)
}
}

View File

@@ -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
}
@@ -67,6 +70,7 @@ impl KeySequenceTracker {
// Helper function to convert any KeyCode to a string representation
pub fn key_to_string(key: &KeyCode) -> String {
match key {
KeyCode::Char(' ') => "space".to_string(),
KeyCode::Char(c) => c.to_string(),
KeyCode::Left => "left".to_string(),
KeyCode::Right => "right".to_string(),
@@ -90,6 +94,7 @@ pub fn key_to_string(key: &KeyCode) -> String {
// Helper function to convert a string to a KeyCode
pub fn string_to_keycode(s: &str) -> Option<KeyCode> {
match s.to_lowercase().as_str() {
"space" => Some(KeyCode::Char(' ')),
"left" => Some(KeyCode::Left),
"right" => Some(KeyCode::Right),
"up" => Some(KeyCode::Up),
@@ -113,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
}
@@ -140,7 +140,7 @@ fn is_compound_key(part: &str) -> bool {
matches!(part.to_lowercase().as_str(),
"esc" | "up" | "down" | "left" | "right" | "enter" |
"backspace" | "delete" | "tab" | "backtab" | "home" |
"end" | "pageup" | "pagedown" | "insert"
"end" | "pageup" | "pagedown" | "insert" | "space"
)
}

View File

@@ -19,7 +19,6 @@ impl AppState {
self.ui.dialog.purpose = Some(purpose);
self.ui.dialog.is_loading = false;
self.ui.dialog.dialog_show = true;
self.ui.focus_outside_canvas = true;
}
pub fn show_loading_dialog(&mut self, title: &str, message: &str) {
@@ -30,7 +29,6 @@ impl AppState {
self.ui.dialog.purpose = None;
self.ui.dialog.is_loading = true;
self.ui.dialog.dialog_show = true;
self.ui.focus_outside_canvas = true;
}
pub fn update_dialog_content(
@@ -55,7 +53,6 @@ impl AppState {
self.ui.dialog.dialog_buttons.clear();
self.ui.dialog.dialog_active_button_index = 0;
self.ui.dialog.purpose = None;
self.ui.focus_outside_canvas = false;
self.ui.dialog.is_loading = false;
}

View File

@@ -154,15 +154,14 @@ pub async fn handle_dialog_event(
DialogPurpose::ConfirmDeleteColumns => match selected_index {
0 => {
// "Confirm" button selected
if let Page::Admin(state) = &mut router.current {
let outcome_message =
handle_delete_selected_columns(&mut state.add_table_state);
if let Page::AddTable(page) = &mut router.current {
let outcome_message = handle_delete_selected_columns(&mut page.state);
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok(outcome_message)));
}
return Some(Ok(EventOutcome::Ok(
"Admin state not active".to_string(),
)));
"AddTable page not active".to_string(),
)));
}
1 => {
// "Cancel" button selected

View File

@@ -0,0 +1,41 @@
// src/input/action.rs
use crate::movement::MovementAction;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BufferAction {
Next,
Previous,
Close,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CoreAction {
Save,
ForceQuit,
SaveAndQuit,
Revert,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppAction {
// Global/UI
ToggleSidebar,
ToggleBufferList,
OpenSearch,
FindFilePaletteToggle,
// Buffers
Buffer(BufferAction),
// Command mode
EnterCommandMode,
ExitCommandMode,
CommandExecute,
CommandBackspace,
// Navigation across UI
Navigate(MovementAction),
// Core actions
Core(CoreAction),
}

195
client/src/input/engine.rs Normal file
View File

@@ -0,0 +1,195 @@
// src/input/engine.rs
use crate::config::binds::config::Config;
use crate::config::binds::key_sequences::KeySequenceTracker;
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 {
pub app_mode: AppMode,
pub overlay_active: bool,
pub allow_navigation_capture: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InputOutcome {
Action(AppAction),
Pending, // sequence in progress
PassThrough, // let page/canvas handle it
}
pub struct InputEngine {
seq: KeySequenceTracker,
leader_seq: KeySequenceTracker,
}
impl InputEngine {
pub fn new(normal_timeout_ms: u64, leader_timeout_ms: u64) -> Self {
Self {
seq: KeySequenceTracker::new(normal_timeout_ms),
leader_seq: KeySequenceTracker::new(leader_timeout_ms),
}
}
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(
&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
}
}
fn str_to_movement(s: &str) -> Option<MovementAction> {
match s {
"up" => Some(MovementAction::Up),
"down" => Some(MovementAction::Down),
"left" => Some(MovementAction::Left),
"right" => Some(MovementAction::Right),
"next" => Some(MovementAction::Next),
"previous" => Some(MovementAction::Previous),
"select" => Some(MovementAction::Select),
"esc" => Some(MovementAction::Esc),
_ => None,
}
}
fn map_action_string(action: &str, ctx: &InputContext) -> Option<AppAction> {
match action {
// Global/UI
"toggle_sidebar" => Some(AppAction::ToggleSidebar),
"toggle_buffer_list" => Some(AppAction::ToggleBufferList),
"open_search" => Some(AppAction::OpenSearch),
"find_file_palette_toggle" => Some(AppAction::FindFilePaletteToggle),
// Buffers
"next_buffer" => Some(AppAction::Buffer(BufferAction::Next)),
"previous_buffer" => Some(AppAction::Buffer(BufferAction::Previous)),
"close_buffer" => Some(AppAction::Buffer(BufferAction::Close)),
// Command mode
"enter_command_mode" => Some(AppAction::EnterCommandMode),
"exit_command_mode" => Some(AppAction::ExitCommandMode),
"command_execute" => Some(AppAction::CommandExecute),
"command_backspace" => Some(AppAction::CommandBackspace),
// Navigation across UI (only if allowed)
s if str_to_movement(s).is_some() && ctx.allow_navigation_capture => {
Some(AppAction::Navigate(str_to_movement(s).unwrap()))
}
// Core actions
"save" => Some(AppAction::Core(CoreAction::Save)),
"force_quit" => Some(AppAction::Core(CoreAction::ForceQuit)),
"save_and_quit" => Some(AppAction::Core(CoreAction::SaveAndQuit)),
"revert" => Some(AppAction::Core(CoreAction::Revert)),
// Unknown to app layer: ignore (canvas-specific actions, etc.)
_ => None,
}
}

View 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
}

4
client/src/input/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
// src/input/mod.rs
pub mod action;
pub mod engine;
pub mod leader;

View File

@@ -14,6 +14,7 @@ pub mod search;
pub mod bottom_panel;
pub mod pages;
pub mod movement;
pub mod input;
pub use ui::run_ui;

View File

@@ -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.
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.
.with_writer(move || writer.clone())
.init();
use std::sync::Once;
static INIT_LOGGER: Once = Once::new();
INIT_LOGGER.call_once(|| {
let writer = UiDebugWriter::new();
let _ = tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.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"))]
{
if env::var("ENABLE_TRACING").is_ok() {
tracing_subscriber::fmt::init();
let _ = tracing_subscriber::fmt::try_init();
}
}

View File

@@ -66,12 +66,12 @@ pub async fn handle_navigation_event(
}
"select" => {
let (context, index) = match &router.current {
Page::Intro(state) => (UiContext::Intro, state.selected_option),
Page::Login(_) if app_state.ui.focus_outside_canvas => {
(UiContext::Login, app_state.focused_button_index)
Page::Intro(state) => (UiContext::Intro, state.focused_button_index),
Page::Login(state) if state.focus_outside_canvas => {
(UiContext::Login, state.focused_button_index)
}
Page::Register(_) if app_state.ui.focus_outside_canvas => {
(UiContext::Register, app_state.focused_button_index)
Page::Register(state) if state.focus_outside_canvas => {
(UiContext::Register, state.focused_button_index)
}
Page::Admin(state) => {
(UiContext::Admin, state.get_selected_index().unwrap_or(0))
@@ -91,24 +91,24 @@ pub async fn handle_navigation_event(
pub fn up(app_state: &mut AppState, router: &mut Router) {
match &mut router.current {
Page::Login(page) if app_state.ui.focus_outside_canvas => {
if app_state.focused_button_index == 0 {
app_state.ui.focus_outside_canvas = false;
Page::Login(page) if page.focus_outside_canvas => {
if page.focused_button_index == 0 {
page.focus_outside_canvas = false;
let last_field_index = page.state.field_count().saturating_sub(1);
page.state.set_current_field(last_field_index);
} else {
app_state.focused_button_index =
app_state.focused_button_index.saturating_sub(1);
page.focused_button_index =
page.focused_button_index.saturating_sub(1);
}
}
Page::Register(state) if app_state.ui.focus_outside_canvas => {
if app_state.focused_button_index == 0 {
app_state.ui.focus_outside_canvas = false;
Page::Register(state) if state.focus_outside_canvas => {
if state.focused_button_index == 0 {
state.focus_outside_canvas = false;
let last_field_index = state.state.field_count().saturating_sub(1);
state.set_current_field(last_field_index);
} else {
app_state.focused_button_index =
app_state.focused_button_index.saturating_sub(1);
state.focused_button_index =
state.focused_button_index.saturating_sub(1);
}
}
Page::Intro(state) => state.previous_option(),
@@ -119,10 +119,16 @@ pub fn up(app_state: &mut AppState, router: &mut Router) {
pub fn down(app_state: &mut AppState, router: &mut Router) {
match &mut router.current {
Page::Login(_) | Page::Register(_) if app_state.ui.focus_outside_canvas => {
Page::Login(state) if state.focus_outside_canvas => {
let num_general_elements = 2;
if app_state.focused_button_index < num_general_elements - 1 {
app_state.focused_button_index += 1;
if state.focused_button_index < num_general_elements - 1 {
state.focused_button_index += 1;
}
}
Page::Register(state) if state.focus_outside_canvas => {
let num_general_elements = 2;
if state.focused_button_index < num_general_elements - 1 {
state.focused_button_index += 1;
}
}
Page::Intro(state) => state.next_option(),
@@ -134,11 +140,11 @@ pub fn down(app_state: &mut AppState, router: &mut Router) {
pub fn next_option(app_state: &mut AppState, router: &mut Router) {
match &mut router.current {
Page::Intro(state) => state.next_option(),
Page::Admin(_) => {
Page::Admin(state) => {
let option_count = app_state.profile_tree.profiles.len();
if option_count > 0 {
app_state.focused_button_index =
(app_state.focused_button_index + 1) % option_count;
state.focused_button_index =
(state.focused_button_index + 1) % option_count;
}
}
_ => {}
@@ -148,13 +154,13 @@ pub fn next_option(app_state: &mut AppState, router: &mut Router) {
pub fn previous_option(app_state: &mut AppState, router: &mut Router) {
match &mut router.current {
Page::Intro(state) => state.previous_option(),
Page::Admin(_) => {
Page::Admin(state) => {
let option_count = app_state.profile_tree.profiles.len();
if option_count > 0 {
app_state.focused_button_index = if app_state.focused_button_index == 0 {
state.focused_button_index = if state.focused_button_index == 0 {
option_count.saturating_sub(1)
} else {
app_state.focused_button_index - 1
state.focused_button_index - 1
};
}
}

View File

@@ -1,6 +1,7 @@
// src/modes/handlers/event.rs
use crate::config::binds::config::Config;
use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::input::engine::{InputContext, InputEngine, InputOutcome};
use crate::input::action::{AppAction, BufferAction, CoreAction};
use crate::buffer::{AppView, BufferState, switch_buffer, toggle_buffer_list};
use crate::sidebar::toggle_sidebar;
use crate::search::event::handle_search_palette_event;
@@ -51,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 {
@@ -76,7 +78,7 @@ pub struct EventHandler {
pub command_message: String,
pub edit_mode_cooldown: bool,
pub ideal_cursor_column: usize,
pub key_sequence_tracker: KeySequenceTracker,
pub input_engine: InputEngine,
pub auth_client: AuthClient,
pub grpc_client: GrpcClient,
pub login_result_sender: mpsc::Sender<LoginResult>,
@@ -106,8 +108,10 @@ impl EventHandler {
command_message: String::new(),
edit_mode_cooldown: false,
ideal_cursor_column: 0,
key_sequence_tracker: KeySequenceTracker::new(400),
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,
@@ -226,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,
@@ -296,17 +306,79 @@ 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()
);
// LOGIN: canvas <-> buttons focus handoff
// Do not let Login canvas receive keys when overlays/palettes are active
let overlay_active = self.command_mode
|| app_state.ui.show_search_palette
|| self.navigation_state.active;
// Determine if canvas is in edit mode (we avoid capturing navigation then)
let in_form_edit_mode = matches!(
&router.current,
Page::Form(path) if {
if let Some(editor) = app_state.editor_for_path_ref(path) {
editor.mode() == CanvasMode::Edit
} else { false }
}
);
// 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: allow_nav,
};
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(
action,
key_event, // pass original key
config,
terminal,
command_handler,
auth_state,
buffer_state,
app_state,
router,
)
.await?
{
return Ok(outcome);
}
// No early return on None (e.g., Navigate) — fall through
}
InputOutcome::Pending => {
// waiting for more keys in a sequence
return Ok(EventOutcome::Ok(String::new()));
}
InputOutcome::PassThrough => {
// fall through to page/canvas handlers
}
}
// LOGIN: canvas <-> buttons focus handoff
// Do not let Login canvas receive keys when overlays/palettes are active
if !overlay_active {
if let Page::Login(login_page) = &mut router.current {
let outcome =
login::event::handle_login_event(event, app_state, login_page)?;
login::event::handle_login_event(event.clone(), app_state, login_page)?;
// Only return if the login page actually consumed the key
if !outcome.get_message_if_ok().is_empty() {
return Ok(outcome);
@@ -321,16 +393,29 @@ impl EventHandler {
if !outcome.get_message_if_ok().is_empty() {
return Ok(outcome);
}
} else if let Page::Form(path) = &router.current {
let outcome = forms::event::handle_form_event(
event,
app_state,
path,
&mut self.ideal_cursor_column,
)?;
// Only return if the form page actually consumed the key
if !outcome.get_message_if_ok().is_empty() {
return Ok(outcome);
} 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,
&mut self.ideal_cursor_column,
)?;
if !outcome.get_message_if_ok().is_empty() {
return Ok(outcome);
}
}
} else if let Page::AddLogic(add_logic_page) = &mut router.current {
// Allow ":" (enter_command_mode) even when inside AddLogic canvas
@@ -345,8 +430,8 @@ impl EventHandler {
self.command_mode = true;
self.command_input.clear();
self.command_message.clear();
self.key_sequence_tracker.reset();
app_state.ui.focus_outside_canvas = true;
self.input_engine.reset_sequence();
self.set_focus_outside(router, true);
return Ok(EventOutcome::Ok(String::new()));
}
}
@@ -378,6 +463,58 @@ impl EventHandler {
if !outcome.get_message_if_ok().is_empty() {
return Ok(outcome);
}
} else if let Page::AddTable(add_table_page) = &mut router.current {
// Allow ":" (enter_command_mode) even when inside AddTable canvas
if let Some(action) =
config.get_general_action(key_event.code, key_event.modifiers)
{
if action == "enter_command_mode"
&& !self.command_mode
&& !app_state.ui.show_search_palette
&& !self.navigation_state.active
{
self.command_mode = true;
self.command_input.clear();
self.command_message.clear();
self.input_engine.reset_sequence();
self.set_focus_outside(router, true);
return Ok(EventOutcome::Ok(String::new()));
}
}
// Handle AddTable before global actions so canvas gets first shot at keys.
// Map keys to MovementAction (same as AddLogic early handler)
let movement_action_early = if let Some(act) =
config.get_general_action(key_event.code, key_event.modifiers)
{
match act {
"up" => Some(MovementAction::Up),
"down" => Some(MovementAction::Down),
"left" => Some(MovementAction::Left),
"right" => Some(MovementAction::Right),
"next" => Some(MovementAction::Next),
"previous" => Some(MovementAction::Previous),
"select" => Some(MovementAction::Select),
"esc" => Some(MovementAction::Esc),
_ => None,
}
} else {
None
};
let outcome = add_table::event::handle_add_table_event(
key_event,
movement_action_early,
config,
app_state,
add_table_page,
self.grpc_client.clone(),
self.save_table_result_sender.clone(),
)?;
// Only stop if the page consumed the key; else let global handling proceed.
if !outcome.get_message_if_ok().is_empty() {
return Ok(outcome);
}
} else if let Page::Admin(admin_state) = &mut router.current {
if matches!(auth_state.role, Some(UserRole::Admin)) {
if let Event::Key(key_event) = event {
@@ -395,106 +532,11 @@ impl EventHandler {
}
}
}
if toggle_sidebar(
&mut app_state.ui,
config,
key_code,
modifiers,
) {
let message = format!(
"Sidebar {}",
if app_state.ui.show_sidebar {
"shown"
} else {
"hidden"
}
);
return Ok(EventOutcome::Ok(message));
}
if toggle_buffer_list(
&mut app_state.ui,
config,
key_code,
modifiers,
) {
let message = format!(
"Buffer {}",
if app_state.ui.show_buffer_list {
"shown"
} else {
"hidden"
}
);
return Ok(EventOutcome::Ok(message));
}
// Sidebar/buffer toggles now handled via AppAction in the engine
if current_mode == AppMode::General {
if let Some(action) = config.get_action_for_key_in_mode(
&config.keybindings.global,
key_code,
modifiers,
) {
match action {
"next_buffer" => {
if switch_buffer(buffer_state, true) {
return Ok(EventOutcome::Ok(
"Switched to next buffer".to_string(),
));
}
}
"previous_buffer" => {
if switch_buffer(buffer_state, false) {
return Ok(EventOutcome::Ok(
"Switched to previous buffer".to_string(),
));
}
}
"close_buffer" => {
let current_table_name =
app_state.current_view_table_name.as_deref();
let message = buffer_state
.close_buffer_with_intro_fallback(
current_table_name,
);
return Ok(EventOutcome::Ok(message));
}
_ => {}
}
}
if let Some(action) = config.get_general_action(key_code, modifiers) {
if action == "open_search" {
if let Page::Form(_) = &router.current {
if let Some(table_name) =
app_state.current_view_table_name.clone()
{
app_state.ui.show_search_palette = true;
app_state.search_state =
Some(SearchState::new(table_name));
app_state.ui.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(
"Search palette opened".to_string(),
));
}
}
}
// Allow ":" / ctrl+; to enter command mode only when outside canvas.
if action == "enter_command_mode" {
if app_state.ui.focus_outside_canvas
&& !self.command_mode
&& !app_state.ui.show_search_palette
&& !self.navigation_state.active
{
self.command_mode = true;
self.command_input.clear();
self.command_message.clear();
self.key_sequence_tracker.reset();
// Keep focus outside so canvas won't receive keys
app_state.ui.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
}
}
// General mode specific key mapping now handled via AppAction
}
match current_mode {
@@ -531,23 +573,6 @@ impl EventHandler {
}
}
if let Page::AddTable(add_table_state) = &mut router.current {
let client_clone = self.grpc_client.clone();
let sender_clone = self.save_table_result_sender.clone();
let outcome = crate::pages::admin_panel::add_table::event::handle_add_table_event(
key_event,
movement_action,
config,
app_state,
add_table_state,
client_clone,
sender_clone,
)?;
if !outcome.get_message_if_ok().is_empty() {
return Ok(outcome);
}
}
// Generic navigation for the rest (Intro/Login/Register/Form)
let nav_outcome = if matches!(&router.current, Page::AddTable(_) | Page::AddLogic(_)) {
// Skip generic navigation for AddTable/AddLogic (they have their own handlers)
@@ -643,144 +668,18 @@ impl EventHandler {
}
AppMode::Command => {
if config.is_exit_command_mode(key_code, modifiers) {
self.command_input.clear();
self.command_message.clear();
self.command_mode = false;
self.key_sequence_tracker.reset();
if let Page::Form(path) = &router.current {
if let Some(editor) = app_state.editor_for_path(path) {
editor.set_mode(CanvasMode::ReadOnly);
}
// Command-mode keys already handled by the engine.
// Collect characters not handled (typed command input).
match key_code {
KeyCode::Char(c) => {
self.command_input.push(c);
return Ok(EventOutcome::Ok(String::new()));
}
return Ok(EventOutcome::Ok(
"Exited command mode".to_string(),
));
}
if config.is_command_execute(key_code, modifiers) {
let (mut current_position, total_count) =
if let Page::Form(path) = &router.current {
if let Some(fs) =
app_state.form_state_for_path_ref(path)
{
(fs.current_position, fs.total_count)
} else {
(1, 0)
}
} else {
(1, 0)
};
let outcome = command_mode::handle_command_event(
key_event,
config,
app_state,
router,
&mut self.command_input,
&mut self.command_message,
&mut self.grpc_client,
command_handler,
terminal,
&mut current_position,
total_count,
).await?;
if let Page::Form(path) = &router.current {
if let Some(fs) = app_state.form_state_for_path(path) {
fs.current_position = current_position;
}
_ => {
self.input_engine.reset_sequence();
return Ok(EventOutcome::Ok(String::new()));
}
self.command_mode = false;
self.key_sequence_tracker.reset();
let new_mode = ModeManager::derive_mode(
app_state,
self,
router,
);
app_state.update_mode(new_mode);
return Ok(outcome);
}
if key_code == KeyCode::Backspace {
self.command_input.pop();
self.key_sequence_tracker.reset();
return Ok(EventOutcome::Ok(String::new()));
}
if let KeyCode::Char(c) = key_code {
if c == 'f' {
self.key_sequence_tracker.add_key(key_code);
let sequence =
self.key_sequence_tracker.get_sequence();
if config.matches_key_sequence_generalized(
&sequence,
) == Some("find_file_palette_toggle")
{
if matches!(&router.current, Page::Form(_) | Page::Intro(_)) {
let mut all_table_paths: Vec<String> =
app_state
.profile_tree
.profiles
.iter()
.flat_map(|profile| {
profile.tables.iter().map(
move |table| {
format!(
"{}/{}",
profile.name,
table.name
)
},
)
})
.collect();
all_table_paths.sort();
self.navigation_state
.activate_find_file(all_table_paths);
self.command_mode = false;
self.command_input.clear();
self.command_message.clear();
self.key_sequence_tracker.reset();
return Ok(EventOutcome::Ok(
"Table selection palette activated"
.to_string(),
));
} else {
self.key_sequence_tracker.reset();
self.command_input.push('f');
if sequence.len() > 1
&& sequence[0] == KeyCode::Char('f')
{
self.command_input.push('f');
}
self.command_message = "Find File not available in this view."
.to_string();
return Ok(EventOutcome::Ok(
self.command_message.clone(),
));
}
}
if config.is_key_sequence_prefix(&sequence) {
return Ok(EventOutcome::Ok(String::new()));
}
}
if c != 'f'
&& !self.key_sequence_tracker.current_sequence.is_empty()
{
self.key_sequence_tracker.reset();
}
self.command_input.push(c);
return Ok(EventOutcome::Ok(String::new()));
}
self.key_sequence_tracker.reset();
return Ok(EventOutcome::Ok(String::new()));
}
}
} else if let Event::Resize(_, _) = event {
@@ -920,4 +819,275 @@ impl EventHandler {
"find_file_palette_toggle"
)
}
fn set_focus_outside(&mut self, router: &mut Router, outside: bool) {
match &mut router.current {
Page::Login(state) => state.focus_outside_canvas = outside,
Page::Register(state) => state.focus_outside_canvas = outside,
Page::Intro(state) => state.focus_outside_canvas = outside,
Page::Admin(state) => state.focus_outside_canvas = outside,
Page::AddLogic(state) => state.focus_outside_canvas = outside,
Page::AddTable(state) => state.focus_outside_canvas = outside,
_ => {}
}
}
fn set_focused_button(&mut self, router: &mut Router, index: usize) {
match &mut router.current {
Page::Login(state) => state.focused_button_index = index,
Page::Register(state) => state.focused_button_index = index,
Page::Intro(state) => state.focused_button_index = index,
Page::Admin(state) => state.focused_button_index = index,
Page::AddLogic(state) => state.focused_button_index = index,
Page::AddTable(state) => state.focused_button_index = index,
_ => {}
}
}
fn is_focus_outside(&self, router: &Router) -> bool {
match &router.current {
Page::Login(state) => state.focus_outside_canvas,
Page::Register(state) => state.focus_outside_canvas,
Page::Intro(state) => state.focus_outside_canvas,
Page::Admin(state) => state.focus_outside_canvas,
Page::AddLogic(state) => state.focus_outside_canvas,
Page::AddTable(state) => state.focus_outside_canvas,
_ => false,
}
}
fn focused_button(&self, router: &Router) -> usize {
match &router.current {
Page::Login(state) => state.focused_button_index,
Page::Register(state) => state.focused_button_index,
Page::Intro(state) => state.focused_button_index,
Page::Admin(state) => state.focused_button_index,
Page::AddLogic(state) => state.focused_button_index,
Page::AddTable(state) => state.focused_button_index,
_ => 0,
}
}
async fn execute_command(
&mut self,
key_event: crossterm::event::KeyEvent,
config: &Config,
terminal: &mut TerminalCore,
command_handler: &mut CommandHandler,
app_state: &mut AppState,
router: &mut Router,
) -> Result<EventOutcome> {
let (mut current_position, total_count) = if let Page::Form(path) = &router.current {
if let Some(fs) = app_state.form_state_for_path_ref(path) {
(fs.current_position, fs.total_count)
} else {
(1, 0)
}
} else {
(1, 0)
};
let outcome = command_mode::handle_command_event(
key_event,
config,
app_state,
router,
&mut self.command_input,
&mut self.command_message,
&mut self.grpc_client,
command_handler,
terminal,
&mut current_position,
total_count,
)
.await?;
if let Page::Form(path) = &router.current {
if let Some(fs) = app_state.form_state_for_path(path) {
fs.current_position = current_position;
}
}
self.command_mode = false;
self.input_engine.reset_sequence();
let new_mode = ModeManager::derive_mode(app_state, self, router);
app_state.update_mode(new_mode);
Ok(outcome)
}
#[allow(clippy::too_many_arguments)]
async fn handle_app_action(
&mut self,
action: AppAction,
key_event: crossterm::event::KeyEvent,
config: &Config,
terminal: &mut TerminalCore,
command_handler: &mut CommandHandler,
auth_state: &mut AuthState,
buffer_state: &mut BufferState,
app_state: &mut AppState,
router: &mut Router,
) -> Result<Option<EventOutcome>> {
match action {
AppAction::ToggleSidebar => {
app_state.ui.show_sidebar = !app_state.ui.show_sidebar;
let message = format!(
"Sidebar {}",
if app_state.ui.show_sidebar {
"shown"
} else {
"hidden"
}
);
Ok(Some(EventOutcome::Ok(message)))
}
AppAction::ToggleBufferList => {
app_state.ui.show_buffer_list = !app_state.ui.show_buffer_list;
let message = format!(
"Buffer {}",
if app_state.ui.show_buffer_list {
"shown"
} else {
"hidden"
}
);
Ok(Some(EventOutcome::Ok(message)))
}
AppAction::Buffer(BufferAction::Next) => {
if switch_buffer(buffer_state, true) {
return Ok(Some(EventOutcome::Ok(
"Switched to next buffer".to_string(),
)));
}
Ok(Some(EventOutcome::Ok(String::new())))
}
AppAction::Buffer(BufferAction::Previous) => {
if switch_buffer(buffer_state, false) {
return Ok(Some(EventOutcome::Ok(
"Switched to previous buffer".to_string(),
)));
}
Ok(Some(EventOutcome::Ok(String::new())))
}
AppAction::Buffer(BufferAction::Close) => {
let current_table_name = app_state.current_view_table_name.as_deref();
let message = buffer_state
.close_buffer_with_intro_fallback(current_table_name);
Ok(Some(EventOutcome::Ok(message)))
}
AppAction::OpenSearch => {
if let Page::Form(_) = &router.current {
if let Some(table_name) = app_state.current_view_table_name.clone() {
app_state.ui.show_search_palette = true;
app_state.search_state = Some(SearchState::new(table_name));
self.set_focus_outside(router, true);
return Ok(Some(EventOutcome::Ok(
"Search palette opened".to_string(),
)));
}
}
Ok(Some(EventOutcome::Ok(String::new())))
}
AppAction::FindFilePaletteToggle => {
if matches!(&router.current, Page::Form(_) | Page::Intro(_)) {
let mut all_table_paths: Vec<String> = app_state
.profile_tree
.profiles
.iter()
.flat_map(|profile| {
profile.tables.iter().map(move |table| {
format!("{}/{}", profile.name, table.name)
})
})
.collect();
all_table_paths.sort();
self.navigation_state.activate_find_file(all_table_paths);
self.command_mode = false;
self.command_input.clear();
self.command_message.clear();
self.input_engine.reset_sequence();
return Ok(Some(EventOutcome::Ok(
"Table selection palette activated".to_string(),
)));
}
Ok(Some(EventOutcome::Ok(String::new())))
}
AppAction::EnterCommandMode => {
if !self.is_in_form_edit_mode(router, app_state)
&& !self.command_mode
&& !app_state.ui.show_search_palette
&& !self.navigation_state.active
{
self.command_mode = true;
self.command_input.clear();
self.command_message.clear();
self.input_engine.reset_sequence();
// Keep focus outside so canvas wont consume keystrokes
self.set_focus_outside(router, true);
}
Ok(Some(EventOutcome::Ok(String::new())))
}
AppAction::ExitCommandMode => {
self.command_input.clear();
self.command_message.clear();
self.command_mode = false;
self.input_engine.reset_sequence();
if let Page::Form(path) = &router.current {
if let Some(editor) = app_state.editor_for_path(path) {
editor.set_mode(CanvasMode::ReadOnly);
}
}
Ok(Some(EventOutcome::Ok(
"Exited command mode".to_string(),
)))
}
AppAction::CommandExecute => {
// Execute using the actual configured key that triggered the action
let out = self
.execute_command(
key_event,
config,
terminal,
command_handler,
app_state,
router,
)
.await?;
Ok(Some(out))
}
AppAction::CommandBackspace => {
self.command_input.pop();
self.input_engine.reset_sequence();
Ok(Some(EventOutcome::Ok(String::new())))
}
AppAction::Core(core) => {
let s = match core {
CoreAction::Save => "save",
CoreAction::ForceQuit => "force_quit",
CoreAction::SaveAndQuit => "save_and_quit",
CoreAction::Revert => "revert",
};
let out = self
.handle_core_action(s, auth_state, terminal, app_state, router)
.await?;
Ok(Some(out))
}
AppAction::Navigate(_ma) => {
// Movement is still handled by page/nav code paths that
// follow after PassThrough. We return None here to keep flow.
Ok(None)
}
}
}
fn is_in_form_edit_mode(&self, router: &Router, app_state: &AppState) -> bool {
if let Page::Form(path) = &router.current {
if let Some(editor) = app_state.editor_for_path_ref(path) {
return editor.mode() == CanvasMode::Edit;
}
}
false
}
}

View File

@@ -40,14 +40,11 @@ impl ModeManager {
// If focus is inside a canvas, we don't duplicate canvas modes here.
// Canvas crate owns ReadOnly/Edit/Highlight internally.
match &router.current {
Page::Form(_)
| Page::Login(_)
| Page::Register(_)
| Page::AddTable(_)
| Page::AddLogic(_) if !app_state.ui.focus_outside_canvas => {
// Canvas active → let canvas handle its own AppMode
AppMode::General
}
Page::Form(_) => AppMode::General, // Form always has its own canvas
Page::Login(state) if !state.focus_outside_canvas => AppMode::General,
Page::Register(state) if !state.focus_outside_canvas => AppMode::General,
Page::AddTable(state) if !state.focus_outside_canvas => AppMode::General,
Page::AddLogic(state) if !state.focus_outside_canvas => AppMode::General,
_ => AppMode::General,
}
}

View File

@@ -1,6 +1,5 @@
// src/pages/admin/admin/state.rs
use ratatui::widgets::ListState;
use crate::pages::admin_panel::add_table::state::AddTableState;
use crate::movement::{move_focus, MovementAction};
use crate::state::app::state::AppState;
@@ -26,7 +25,8 @@ pub struct AdminState {
pub selected_profile_index: Option<usize>,
pub selected_table_index: Option<usize>,
pub current_focus: AdminFocus,
pub add_table_state: AddTableState,
pub focus_outside_canvas: bool,
pub focused_button_index: usize,
}
impl AdminState {

View File

@@ -3,8 +3,8 @@ use crate::pages::admin::{AdminFocus, AdminState};
use crate::state::app::state::AppState;
use crate::config::binds::config::Config;
use crate::buffer::state::{BufferState, AppView};
use crate::pages::admin_panel::add_table::state::{AddTableState, LinkDefinition};
use ratatui::widgets::ListState;
use crate::pages::admin_panel::add_table::state::{AddTableFormState, LinkDefinition};
use crate::pages::admin_panel::add_logic::state::{AddLogicState, AddLogicFocus, AddLogicFormState};
use crate::pages::routing::{Page, Router};
@@ -43,15 +43,29 @@ pub fn handle_admin_navigation(
) -> bool {
let action = config.get_general_action(key.code, key.modifiers).map(String::from);
let Page::Admin(admin_state) = &mut router.current else {
// Check if we're in admin page, but don't borrow mutably yet
let is_admin = matches!(&router.current, Page::Admin(_));
if !is_admin {
return false;
}
// Get the current focus without borrowing mutably
let current_focus = if let Page::Admin(admin_state) = &router.current {
admin_state.current_focus
} else {
return false;
};
let current_focus = admin_state.current_focus;
let profile_count = app_state.profile_tree.profiles.len();
let mut handled = false;
match current_focus {
AdminFocus::ProfilesPane => {
// Now we can borrow mutably since we're not reassigning router.current
let Page::Admin(admin_state) = &mut router.current else {
return false;
};
match action.as_deref() {
Some("select") => {
admin_state.current_focus = AdminFocus::InsideProfilesList;
@@ -69,7 +83,6 @@ pub fn handle_admin_navigation(
handled = true;
}
Some("previous_option") | Some("move_up") => {
// No wrap-around: Stay on ProfilesPane if trying to go "before" it
*command_message = "At first focusable pane.".to_string();
handled = true;
}
@@ -78,6 +91,10 @@ pub fn handle_admin_navigation(
}
AdminFocus::InsideProfilesList => {
let Page::Admin(admin_state) = &mut router.current else {
return false;
};
match action.as_deref() {
Some("move_up") => {
if profile_count > 0 {
@@ -95,11 +112,11 @@ pub fn handle_admin_navigation(
}
Some("select") => {
admin_state.selected_profile_index = admin_state.profile_list_state.selected();
admin_state.selected_table_index = None; // Deselect table when profile changes
admin_state.selected_table_index = None;
if let Some(profile_idx) = admin_state.selected_profile_index {
if let Some(profile) = app_state.profile_tree.profiles.get(profile_idx) {
if !profile.tables.is_empty() {
admin_state.table_list_state.select(Some(0)); // Auto-select first table for nav
admin_state.table_list_state.select(Some(0));
} else {
admin_state.table_list_state.select(None);
}
@@ -123,6 +140,10 @@ pub fn handle_admin_navigation(
}
AdminFocus::Tables => {
let Page::Admin(admin_state) = &mut router.current else {
return false;
};
match action.as_deref() {
Some("select") => {
admin_state.current_focus = AdminFocus::InsideTablesList;
@@ -152,7 +173,7 @@ pub fn handle_admin_navigation(
} else {
*command_message = "No tables in selected profile.".to_string();
}
admin_state.current_focus = AdminFocus::Tables; // Stay in Tables pane if no tables to enter
admin_state.current_focus = AdminFocus::Tables;
}
handled = true;
}
@@ -171,6 +192,10 @@ pub fn handle_admin_navigation(
}
AdminFocus::InsideTablesList => {
let Page::Admin(admin_state) = &mut router.current else {
return false;
};
match action.as_deref() {
Some("move_up") => {
let current_profile_idx = admin_state.selected_profile_index
@@ -210,7 +235,7 @@ pub fn handle_admin_navigation(
handled = true;
}
}
Some("select") => { // This is for persistently selecting a table with [*]
Some("select") => {
admin_state.selected_table_index = admin_state.table_list_state.selected();
let table_name = admin_state.selected_profile_index
.and_then(|p_idx| app_state.profile_tree.profiles.get(p_idx))
@@ -230,10 +255,17 @@ pub fn handle_admin_navigation(
AdminFocus::Button1 => { // Add Logic Button
match action.as_deref() {
Some("select") => { // Typically "Enter" key
if let Some(p_idx) = admin_state.selected_profile_index {
Some("select") => {
// Extract needed data first, before any router reassignment
let (selected_profile_idx, selected_table_idx) = if let Page::Admin(admin_state) = &router.current {
(admin_state.selected_profile_index, admin_state.selected_table_index)
} else {
return false;
};
if let Some(p_idx) = selected_profile_idx {
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
if let Some(t_idx) = admin_state.selected_table_index {
if let Some(t_idx) = selected_table_idx {
if let Some(table) = profile.tables.get(t_idx) {
// Create AddLogic page with selected profile & table
let add_logic_form = AddLogicFormState::new_with_table(
@@ -243,16 +275,16 @@ pub fn handle_admin_navigation(
table.name.clone(),
);
// Route to AddLogic
router.current = Page::AddLogic(add_logic_form);
// Store table info for later fetching
app_state.pending_table_structure_fetch = Some((
profile.name.clone(),
table.name.clone(),
));
// Now it's safe to reassign router.current
router.current = Page::AddLogic(add_logic_form);
buffer_state.update_history(AppView::AddLogic);
app_state.ui.focus_outside_canvas = false;
*command_message = format!(
"Opening Add Logic for table '{}' in profile '{}'...",
table.name, profile.name
@@ -272,11 +304,17 @@ pub fn handle_admin_navigation(
handled = true;
}
Some("previous_option") | Some("move_up") => {
let Page::Admin(admin_state) = &mut router.current else {
return false;
};
admin_state.current_focus = AdminFocus::Tables;
*command_message = "Focus: Tables Pane".to_string();
handled = true;
}
Some("next_option") | Some("move_down") => {
let Page::Admin(admin_state) = &mut router.current else {
return false;
};
admin_state.current_focus = AdminFocus::Button2;
*command_message = "Focus: Add Table Button".to_string();
handled = true;
@@ -288,25 +326,36 @@ pub fn handle_admin_navigation(
AdminFocus::Button2 => { // Add Table Button
match action.as_deref() {
Some("select") => {
if let Some(p_idx) = admin_state.selected_profile_index {
// Extract needed data first
let selected_profile_idx = if let Page::Admin(admin_state) = &router.current {
admin_state.selected_profile_index
} else {
return false;
};
if let Some(p_idx) = selected_profile_idx {
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
let selected_profile_name = profile.name.clone();
// Prepare links from the selected profile's existing tables
let available_links: Vec<LinkDefinition> = profile.tables.iter()
.map(|table| LinkDefinition {
linked_table_name: table.name.clone(),
is_required: false, // Default, can be changed in AddTable screen
is_required: false,
selected: false,
}).collect();
admin_state.add_table_state = AddTableState {
profile_name: selected_profile_name,
links: available_links,
..AddTableState::default() // Reset other fields
};
// Build decoupled AddTable page and route into it
let mut page = AddTableFormState::new(selected_profile_name.clone());
page.state.links = available_links;
// Now safe to reassign router.current
router.current = Page::AddTable(page);
buffer_state.update_history(AppView::AddTable);
app_state.ui.focus_outside_canvas = false;
*command_message = format!("Opening Add Table for profile '{}'...", admin_state.add_table_state.profile_name);
*command_message = format!(
"Opening Add Table for profile '{}'...",
selected_profile_name
);
handled = true;
} else {
*command_message = "Error: Selected profile index out of bounds.".to_string();
@@ -318,11 +367,17 @@ pub fn handle_admin_navigation(
}
}
Some("previous_option") | Some("move_up") => {
let Page::Admin(admin_state) = &mut router.current else {
return false;
};
admin_state.current_focus = AdminFocus::Button1;
*command_message = "Focus: Add Logic Button".to_string();
handled = true;
}
Some("next_option") | Some("move_down") => {
let Page::Admin(admin_state) = &mut router.current else {
return false;
};
admin_state.current_focus = AdminFocus::Button3;
*command_message = "Focus: Change Table Button".to_string();
handled = true;
@@ -334,17 +389,18 @@ pub fn handle_admin_navigation(
AdminFocus::Button3 => { // Change Table Button
match action.as_deref() {
Some("select") => {
// Future: Logic to load selected table into AddTableState for editing
*command_message = "Action: Change Table (Not Implemented)".to_string();
handled = true;
}
Some("previous_option") | Some("move_up") => {
let Page::Admin(admin_state) = &mut router.current else {
return false;
};
admin_state.current_focus = AdminFocus::Button2;
*command_message = "Focus: Add Table Button".to_string();
handled = true;
}
Some("next_option") | Some("move_down") => {
// No wrap-around: Stay on Button3 if trying to go "after" it
*command_message = "At last focusable button.".to_string();
handled = true;
}

View File

@@ -39,7 +39,7 @@ pub fn handle_add_logic_event(
match key_event.code {
crossterm::event::KeyCode::Esc => {
add_logic_page.state.current_focus = AddLogicFocus::ScriptContentPreview;
app_state.ui.focus_outside_canvas = true;
add_logic_page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok("Exited script editing.".to_string()));
}
_ => {
@@ -85,7 +85,7 @@ pub fn handle_add_logic_event(
if at_last && matches!(ma, MovementAction::Down | MovementAction::Next) {
add_logic_page.state.last_canvas_field = last_idx;
add_logic_page.state.current_focus = AddLogicFocus::ScriptContentPreview;
app_state.ui.focus_outside_canvas = true;
add_logic_page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok("Moved to Script Preview".to_string()));
}
}
@@ -114,7 +114,7 @@ pub fn handle_add_logic_event(
let mut current = add_logic_page.state.current_focus;
if move_focus(&ADD_LOGIC_FOCUS_ORDER, &mut current, ma) {
add_logic_page.state.current_focus = current;
app_state.ui.focus_outside_canvas = !matches!(
add_logic_page.focus_outside_canvas = !matches!(
add_logic_page.state.current_focus,
AddLogicFocus::InputLogicName
| AddLogicFocus::InputTargetColumn
@@ -127,7 +127,7 @@ pub fn handle_add_logic_event(
MovementAction::Select => match add_logic_page.state.current_focus {
AddLogicFocus::ScriptContentPreview => {
add_logic_page.state.current_focus = AddLogicFocus::InsideScriptContent;
app_state.ui.focus_outside_canvas = false;
add_logic_page.focus_outside_canvas = false;
return Ok(EventOutcome::Ok(
"Fullscreen script editing. Esc to exit.".to_string(),
));
@@ -147,7 +147,7 @@ pub fn handle_add_logic_event(
MovementAction::Esc => {
if add_logic_page.state.current_focus == AddLogicFocus::ScriptContentPreview {
add_logic_page.state.current_focus = AddLogicFocus::InputDescription;
app_state.ui.focus_outside_canvas = false;
add_logic_page.focus_outside_canvas = false;
return Ok(EventOutcome::Ok("Back to Description".to_string()));
}
}

View File

@@ -335,6 +335,7 @@ pub struct AddLogicFormState {
pub state: AddLogicState,
pub editor: FormEditor<AddLogicState>,
pub focus_outside_canvas: bool,
pub focused_button_index: usize,
}
// manual Debug because FormEditor may not implement Debug
@@ -343,6 +344,7 @@ impl std::fmt::Debug for AddLogicFormState {
f.debug_struct("AddLogicFormState")
.field("state", &self.state)
.field("focus_outside_canvas", &self.focus_outside_canvas)
.field("focused_button_index", &self.focused_button_index)
.finish()
}
}
@@ -355,6 +357,7 @@ impl AddLogicFormState {
state,
editor,
focus_outside_canvas: false,
focused_button_index: 0,
}
}
@@ -373,6 +376,7 @@ impl AddLogicFormState {
state,
editor,
focus_outside_canvas: false,
focused_button_index: 0,
}
}
@@ -382,6 +386,7 @@ impl AddLogicFormState {
state,
editor,
focus_outside_canvas: false,
focused_button_index: 0,
}
}

View File

@@ -4,31 +4,38 @@ use anyhow::Result;
use crate::config::binds::config::Config;
use crate::movement::{move_focus, MovementAction};
use crate::pages::admin_panel::add_table::logic::{
handle_add_column_action, handle_delete_selected_columns, handle_save_table_action,
handle_add_column_action, handle_delete_selected_columns,
};
use crate::pages::admin_panel::add_table::loader::handle_save_table_action;
use crate::pages::admin_panel::add_table::nav::SaveTableResultSender;
use crate::pages::admin_panel::add_table::state::{AddTableFocus, AddTableFormState};
use crate::services::GrpcClient;
use crate::services::grpc_client::GrpcClient;
use crate::state::app::state::AppState;
use crate::modes::handlers::event::EventOutcome;
use canvas::{AppMode as CanvasMode, DataProvider};
use crossterm::event::KeyEvent;
/// Focus traversal order for AddTable (outside canvas)
const ADD_TABLE_FOCUS_ORDER: [AddTableFocus; 7] = [
const ADD_TABLE_FOCUS_ORDER: [AddTableFocus; 10] = [
AddTableFocus::InputTableName,
AddTableFocus::InputColumnName,
AddTableFocus::InputColumnType,
AddTableFocus::AddColumnButton,
AddTableFocus::ColumnsTable,
AddTableFocus::IndexesTable,
AddTableFocus::LinksTable,
AddTableFocus::SaveButton,
AddTableFocus::DeleteSelectedButton,
AddTableFocus::CancelButton,
];
/// Unified AddTable event handler (like AddLogic)
/// Handles all AddTable page-specific events.
/// Return a non-empty Ok(message) only when the page actually consumed the key,
/// otherwise return Ok("") to let global handling proceed.
pub fn handle_add_table_event(
key_event: KeyEvent,
movement: Option<MovementAction>,
_config: &Config,
config: &Config,
app_state: &mut AppState,
page: &mut AddTableFormState,
mut grpc_client: GrpcClient,
@@ -43,6 +50,25 @@ pub fn handle_add_table_event(
);
if inside_canvas_inputs {
// Disable global shortcuts while typing
page.focus_outside_canvas = false;
// Only allow leaving the canvas with Down/Next when in ReadOnly mode
let in_edit_mode = page.editor.mode() == CanvasMode::Edit;
if !in_edit_mode {
if let Some(ma) = movement {
let last_idx = page.editor.data_provider().field_count().saturating_sub(1);
let at_last = page.editor.current_field() >= last_idx;
if at_last && matches!(ma, MovementAction::Down | MovementAction::Next) {
page.state.last_canvas_field = last_idx;
page.set_current_focus(AddTableFocus::AddColumnButton);
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok("Moved to Add button".to_string()));
}
}
}
// Let the FormEditor handle typing
match page.editor.handle_key_event(key_event) {
canvas::keymap::KeyEventOutcome::Consumed(Some(msg)) => {
page.sync_from_editor();
@@ -61,16 +87,157 @@ pub fn handle_add_table_event(
}
}
// 2) Movement outside canvas
// 2) Outside canvas
if let Some(ma) = movement {
// Block outer moves when "inside" any table and handle locally
match page.current_focus() {
AddTableFocus::InsideColumnsTable => {
match ma {
MovementAction::Up => {
if let Some(i) = page.state.column_table_state.selected() {
let next = i.saturating_sub(1);
page.state.column_table_state.select(Some(next));
} else if !page.state.columns.is_empty() {
page.state.column_table_state.select(Some(0));
}
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Down => {
if let Some(i) = page.state.column_table_state.selected() {
let last = page.state.columns.len().saturating_sub(1);
let next = if i < last { i + 1 } else { i };
page.state.column_table_state.select(Some(next));
} else if !page.state.columns.is_empty() {
page.state.column_table_state.select(Some(0));
}
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Select => {
if let Some(i) = page.state.column_table_state.selected() {
if let Some(col) = page.state.columns.get_mut(i) {
col.selected = !col.selected;
page.state.has_unsaved_changes = true;
}
}
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Esc => {
page.state.column_table_state.select(None);
page.set_current_focus(AddTableFocus::ColumnsTable);
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Next | MovementAction::Previous => {
// Block outer movement while inside
return Ok(EventOutcome::Ok(String::new()));
}
_ => {}
}
}
AddTableFocus::InsideIndexesTable => {
match ma {
MovementAction::Up => {
if let Some(i) = page.state.index_table_state.selected() {
let next = i.saturating_sub(1);
page.state.index_table_state.select(Some(next));
} else if !page.state.indexes.is_empty() {
page.state.index_table_state.select(Some(0));
}
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Down => {
if let Some(i) = page.state.index_table_state.selected() {
let last = page.state.indexes.len().saturating_sub(1);
let next = if i < last { i + 1 } else { i };
page.state.index_table_state.select(Some(next));
} else if !page.state.indexes.is_empty() {
page.state.index_table_state.select(Some(0));
}
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Select => {
if let Some(i) = page.state.index_table_state.selected() {
if let Some(ix) = page.state.indexes.get_mut(i) {
ix.selected = !ix.selected;
page.state.has_unsaved_changes = true;
}
}
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Esc => {
page.state.index_table_state.select(None);
page.set_current_focus(AddTableFocus::IndexesTable);
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Next | MovementAction::Previous => {
return Ok(EventOutcome::Ok(String::new()));
}
_ => {}
}
}
AddTableFocus::InsideLinksTable => {
match ma {
MovementAction::Up => {
if let Some(i) = page.state.link_table_state.selected() {
let next = i.saturating_sub(1);
page.state.link_table_state.select(Some(next));
} else if !page.state.links.is_empty() {
page.state.link_table_state.select(Some(0));
}
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Down => {
if let Some(i) = page.state.link_table_state.selected() {
let last = page.state.links.len().saturating_sub(1);
let next = if i < last { i + 1 } else { i };
page.state.link_table_state.select(Some(next));
} else if !page.state.links.is_empty() {
page.state.link_table_state.select(Some(0));
}
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Select => {
if let Some(i) = page.state.link_table_state.selected() {
if let Some(link) = page.state.links.get_mut(i) {
link.selected = !link.selected;
page.state.has_unsaved_changes = true;
}
}
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Esc => {
page.state.link_table_state.select(None);
page.set_current_focus(AddTableFocus::LinksTable);
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Next | MovementAction::Previous => {
return Ok(EventOutcome::Ok(String::new()));
}
_ => {}
}
}
_ => {}
}
let mut current = page.current_focus();
if move_focus(&ADD_TABLE_FOCUS_ORDER, &mut current, ma) {
page.set_current_focus(current);
app_state.ui.focus_outside_canvas = !matches!(
page.focus_outside_canvas = !matches!(
page.current_focus(),
AddTableFocus::InputTableName
| AddTableFocus::InputColumnName
| AddTableFocus::InputColumnType
| AddTableFocus::InputColumnName
| AddTableFocus::InputColumnType
);
return Ok(EventOutcome::Ok(String::new()));
}
@@ -79,14 +246,9 @@ pub fn handle_add_table_event(
match ma {
MovementAction::Select => match page.current_focus() {
AddTableFocus::AddColumnButton => {
if let Some(focus_after_add) =
crate::pages::admin_panel::add_table::logic::handle_add_column_action(
&mut page.state,
&mut String::new(),
)
{
page.set_current_focus(focus_after_add);
return Ok(EventOutcome::Ok("Column added".into()));
if let Some(msg) = page.state.add_column_from_inputs() {
// Focus is set by the state method; just bubble message
return Ok(EventOutcome::Ok(msg));
}
}
AddTableFocus::SaveButton => {
@@ -100,25 +262,20 @@ pub fn handle_add_table_event(
let state_clone = page.state.clone();
let sender_clone = save_result_sender.clone();
tokio::spawn(async move {
let result =
crate::pages::admin_panel::add_table::logic::handle_save_table_action(
&mut grpc_client,
&state_clone,
)
.await;
let result = handle_save_table_action(&mut grpc_client, &state_clone).await;
let _ = sender_clone.send(result).await;
});
return Ok(EventOutcome::Ok("Saving table...".into()));
}
AddTableFocus::DeleteSelectedButton => {
let msg =
crate::pages::admin_panel::add_table::logic::handle_delete_selected_columns(
&mut page.state,
);
let msg = page
.state
.delete_selected_items()
.unwrap_or_else(|| "No items selected for deletion".to_string());
return Ok(EventOutcome::Ok(msg));
}
AddTableFocus::CancelButton => {
return Ok(EventOutcome::Ok("Cancelled Add Table".into()));
return Ok(EventOutcome::Ok("Cancelled Add Table".to_string()));
}
_ => {}
},

View File

@@ -0,0 +1,78 @@
// src/pages/admin_panel/add_table/loader.rs
use anyhow::{anyhow, Result};
use tracing::debug;
use crate::pages::admin_panel::add_table::state::AddTableState;
use crate::services::grpc_client::GrpcClient;
use common::proto::komp_ac::table_definition::{
ColumnDefinition as ProtoColumnDefinition, PostTableDefinitionRequest, TableLink as ProtoTableLink,
};
/// Prepares and sends the request to save the new table definition via gRPC.
pub async fn handle_save_table_action(
grpc_client: &mut GrpcClient,
add_table_state: &AddTableState,
) -> Result<String> {
if add_table_state.table_name.is_empty() {
return Err(anyhow!("Table name cannot be empty."));
}
if add_table_state.columns.is_empty() {
return Err(anyhow!("Table must have at least one column."));
}
let proto_columns: Vec<ProtoColumnDefinition> = add_table_state
.columns
.iter()
.map(|col| ProtoColumnDefinition {
name: col.name.clone(),
field_type: col.data_type.clone(),
})
.collect();
let proto_indexes: Vec<String> = add_table_state
.indexes
.iter()
.filter(|idx| idx.selected)
.map(|idx| idx.name.clone())
.collect();
let proto_links: Vec<ProtoTableLink> = add_table_state
.links
.iter()
.filter(|link| link.selected)
.map(|link| ProtoTableLink {
linked_table_name: link.linked_table_name.clone(),
required: false,
})
.collect();
let request = PostTableDefinitionRequest {
table_name: add_table_state.table_name.clone(),
columns: proto_columns,
indexes: proto_indexes,
links: proto_links,
profile_name: add_table_state.profile_name.clone(),
};
debug!("Sending PostTableDefinitionRequest: {:?}", request);
match grpc_client.post_table_definition(request).await {
Ok(response) => {
if response.success {
Ok(format!(
"Table '{}' saved successfully.",
add_table_state.table_name
))
} else {
let error_message = if !response.sql.is_empty() {
format!("Server failed to save table: {}", response.sql)
} else {
"Server failed to save table (unknown reason).".to_string()
};
Err(anyhow!(error_message))
}
}
Err(e) => Err(anyhow!("gRPC call failed: {}", e)),
}
}

View File

@@ -1,197 +1,24 @@
// src/pages/admin_panel/add_table/logic.rs
use crate::pages::admin_panel::add_table::state;
use crate::pages::admin_panel::add_table::state::{AddTableState, AddTableFocus, IndexDefinition, ColumnDefinition};
use crate::services::GrpcClient;
use anyhow::{anyhow, Result};
use common::proto::komp_ac::table_definition::{
PostTableDefinitionRequest,
ColumnDefinition as ProtoColumnDefinition,
TableLink as ProtoTableLink,
};
use tracing::debug;
/// Handles the logic for adding a column when the "Add" button is activated.
///
/// Takes the mutable state and command message string.
/// Returns `Some(AddTableFocus)` indicating the desired focus state after a successful add,
/// or `None` if the action failed (e.g., validation error).
use crate::pages::admin_panel::add_table::state::{AddTableState, AddTableFocus};
/// Thin wrapper around AddTableState::add_column_from_inputs
/// Returns Some(AddTableFocus) for compatibility with old call sites.
pub fn handle_add_column_action(
add_table_state: &mut AddTableState,
command_message: &mut String,
) -> Option<AddTableFocus> {
// Trim and create owned Strings from inputs
let table_name_in = add_table_state.table_name_input.trim();
let column_name_in = add_table_state.column_name_input.trim();
let column_type_in = add_table_state.column_type_input.trim();
// Validate all inputs needed for this combined action
let has_table_name = !table_name_in.is_empty();
let has_column_name = !column_name_in.is_empty();
let has_column_type = !column_type_in.is_empty();
match (has_table_name, has_column_name, has_column_type) {
// Case 1: Both column fields have input (Table name is optional here)
(_, true, true) => {
let mut msg = String::new();
// Optionally update table name if provided
if has_table_name {
add_table_state.table_name = table_name_in.to_string();
msg.push_str(&format!("Table name set to '{}'. ", add_table_state.table_name));
}
// Add the column
let new_column = ColumnDefinition {
name: column_name_in.to_string(),
data_type: column_type_in.to_string(),
selected: false,
};
add_table_state.columns.push(new_column.clone()); // Clone for msg
msg.push_str(&format!("Column '{}' added.", new_column.name));
// Add corresponding index definition (initially unselected)
let new_index = IndexDefinition {
name: column_name_in.to_string(),
selected: false,
};
add_table_state.indexes.push(new_index);
*command_message = msg;
// Clear all inputs and reset cursors
add_table_state.table_name_input.clear();
add_table_state.column_name_input.clear();
add_table_state.column_type_input.clear();
add_table_state.table_name_cursor_pos = 0;
add_table_state.column_name_cursor_pos = 0;
add_table_state.column_type_cursor_pos = 0;
add_table_state.has_unsaved_changes = true;
Some(AddTableFocus::InputColumnName) // Focus for next column
}
// Case 2: Only one column field has input (Error)
(_, true, false) | (_, false, true) => {
*command_message = "Both Column Name and Type are required to add a column.".to_string();
None // Indicate validation failure
}
// Case 3: Only Table name has input (No column input)
(true, false, false) => {
add_table_state.table_name = table_name_in.to_string();
*command_message = format!("Table name set to '{}'.", add_table_state.table_name);
// Clear only table name input
add_table_state.table_name_input.clear();
add_table_state.table_name_cursor_pos = 0;
add_table_state.has_unsaved_changes = true;
Some(AddTableFocus::InputTableName) // Keep focus here
}
// Case 4: All fields are empty
(false, false, false) => {
*command_message = "No input provided.".to_string();
None
}
if let Some(msg) = add_table_state.add_column_from_inputs() {
*command_message = msg;
// State sets focus internally; return it explicitly for old call sites
return Some(add_table_state.current_focus);
}
None
}
/// Handles deleting columns marked as selected in the AddTableState.
pub fn handle_delete_selected_columns(
add_table_state: &mut AddTableState,
) -> String {
let initial_count = add_table_state.columns.len();
// Keep only the columns that are NOT selected
let initial_selected_indices: std::collections::HashSet<String> = add_table_state
.columns
.iter()
.filter(|col| col.selected)
.map(|col| col.name.clone())
.collect();
add_table_state.columns.retain(|col| !col.selected);
let deleted_count = initial_count - add_table_state.columns.len();
if deleted_count > 0 {
add_table_state.indexes.retain(|index| !initial_selected_indices.contains(&index.name));
add_table_state.has_unsaved_changes = true;
// Reset selection highlight as indices have changed
add_table_state.column_table_state.select(None);
// Optionally, select the first item if the list is not empty
// if !add_table_state.columns.is_empty() {
// add_table_state.column_table_state.select(Some(0));
// }
add_table_state.index_table_state.select(None);
format!("Deleted {} selected column(s).", deleted_count)
} else {
"No columns marked for deletion.".to_string()
}
}
/// Prepares and sends the request to save the new table definition via gRPC.
pub async fn handle_save_table_action(
grpc_client: &mut GrpcClient,
add_table_state: &AddTableState,
) -> Result<String> {
// --- Basic Validation ---
if add_table_state.table_name.is_empty() {
return Err(anyhow!("Table name cannot be empty."));
}
if add_table_state.columns.is_empty() {
return Err(anyhow!("Table must have at least one column."));
}
// --- Prepare Proto Data ---
let proto_columns: Vec<ProtoColumnDefinition> = add_table_state
.columns
.iter()
.map(|col| ProtoColumnDefinition {
name: col.name.clone(),
field_type: col.data_type.clone(), // Assuming data_type maps directly
})
.collect();
let proto_indexes: Vec<String> = add_table_state
.indexes
.iter()
.filter(|idx| idx.selected) // Only include selected indexes
.map(|idx| idx.name.clone())
.collect();
let proto_links: Vec<ProtoTableLink> = add_table_state
.links
.iter()
.filter(|link| link.selected) // Only include selected links
.map(|link| ProtoTableLink {
linked_table_name: link.linked_table_name.clone(),
// Assuming 'required' maps directly, adjust if needed
// For now, the proto only seems to use linked_table_name based on example
// If your proto evolves, map link.is_required here.
required: false, // Set based on your proto definition/needs
})
.collect();
// --- Create Request ---
let request = PostTableDefinitionRequest {
table_name: add_table_state.table_name.clone(),
columns: proto_columns,
indexes: proto_indexes,
links: proto_links,
profile_name: add_table_state.profile_name.clone(),
};
debug!("Sending PostTableDefinitionRequest: {:?}", request);
// --- Call gRPC Service ---
match grpc_client.post_table_definition(request).await {
Ok(response) => {
if response.success {
Ok(format!(
"Table '{}' saved successfully.",
add_table_state.table_name
))
} else {
// Use the SQL message from the response if available, otherwise generic error
let error_message = if !response.sql.is_empty() {
format!("Server failed to save table: {}", response.sql)
} else {
"Server failed to save table (unknown reason).".to_string()
};
Err(anyhow!(error_message))
}
}
Err(e) => Err(anyhow!("gRPC call failed: {}", e)),
}
/// Thin wrapper around AddTableState::delete_selected_items
pub fn handle_delete_selected_columns(add_table_state: &mut AddTableState) -> String {
add_table_state
.delete_selected_items()
.unwrap_or_else(|| "No items selected for deletion".to_string())
}

View File

@@ -5,3 +5,4 @@ pub mod nav;
pub mod state;
pub mod logic;
pub mod event;
pub mod loader;

View File

@@ -4,7 +4,6 @@ use canvas::{DataProvider, AppMode};
use canvas::FormEditor;
use ratatui::widgets::TableState;
use serde::{Deserialize, Serialize};
use crate::movement::{move_focus, MovementAction};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColumnDefinition {
@@ -99,23 +98,48 @@ impl AddTableState {
/// Helper method to add a column from current inputs
pub fn add_column_from_inputs(&mut self) -> Option<String> {
if self.column_name_input.trim().is_empty() || self.column_type_input.trim().is_empty() {
return Some("Both column name and type are required".to_string());
let table_name_in = self.table_name_input.trim().to_string();
let column_name_in = self.column_name_input.trim().to_string();
let column_type_in = self.column_type_input.trim().to_string();
// Case: "only table name" provided → set it and stay on TableName
if !table_name_in.is_empty() && column_name_in.is_empty() && column_type_in.is_empty() {
self.table_name = table_name_in;
self.table_name_input.clear();
self.table_name_cursor_pos = 0;
self.current_focus = AddTableFocus::InputTableName;
self.has_unsaved_changes = true;
return Some(format!("Table name set to '{}'.", self.table_name));
}
// Check for duplicate column names
if self.columns.iter().any(|col| col.name == self.column_name_input.trim()) {
// Column validation
if column_name_in.is_empty() || column_type_in.is_empty() {
return Some("Both column name and type are required".to_string());
}
if self.columns.iter().any(|col| col.name == column_name_in) {
return Some("Column name already exists".to_string());
}
// If table_name input present while adding first column, apply it too
if !table_name_in.is_empty() {
self.table_name = table_name_in;
self.table_name_input.clear();
self.table_name_cursor_pos = 0;
}
// Add the column
self.columns.push(ColumnDefinition {
name: self.column_name_input.trim().to_string(),
data_type: self.column_type_input.trim().to_string(),
name: column_name_in.clone(),
data_type: column_type_in.clone(),
selected: false,
});
// Add a corresponding (unselected) index with the same name
self.indexes.push(IndexDefinition {
name: column_name_in.clone(),
selected: false,
});
// Clear inputs and reset focus to column name for next entry
// Clear column inputs and set focus for next entry
self.column_name_input.clear();
self.column_type_input.clear();
self.column_name_cursor_pos = 0;
@@ -124,23 +148,33 @@ impl AddTableState {
self.last_canvas_field = 1;
self.has_unsaved_changes = true;
Some(format!("Column '{}' added successfully", self.columns.last().unwrap().name))
Some(format!("Column '{}' added successfully", column_name_in))
}
/// Helper method to delete selected items
pub fn delete_selected_items(&mut self) -> Option<String> {
let mut deleted_items = Vec::new();
let mut deleted_items: Vec<String> = Vec::new();
// Remove selected columns
let initial_column_count = self.columns.len();
self.columns.retain(|col| {
if col.selected {
deleted_items.push(format!("column '{}'", col.name));
false
} else {
true
}
});
let selected_col_names: std::collections::HashSet<String> = self
.columns
.iter()
.filter(|c| c.selected)
.map(|c| c.name.clone())
.collect();
if !selected_col_names.is_empty() {
self.columns.retain(|col| {
if selected_col_names.contains(&col.name) {
deleted_items.push(format!("column '{}'", col.name));
false
} else {
true
}
});
// Also purge indexes for deleted columns
self.indexes
.retain(|idx| !selected_col_names.contains(&idx.name));
}
// Remove selected indexes
let initial_index_count = self.indexes.len();
@@ -168,6 +202,8 @@ impl AddTableState {
Some("No items selected for deletion".to_string())
} else {
self.has_unsaved_changes = true;
self.column_table_state.select(None);
self.index_table_state.select(None);
Some(format!("Deleted: {}", deleted_items.join(", ")))
}
}
@@ -211,182 +247,11 @@ impl DataProvider for AddTableState {
}
}
impl AddTableState {
pub fn handle_movement(&mut self, action: MovementAction) -> bool {
use AddTableFocus::*;
// Linear outer focus order
const ORDER: [AddTableFocus; 10] = [
InputTableName,
InputColumnName,
InputColumnType,
AddColumnButton,
ColumnsTable,
IndexesTable,
LinksTable,
SaveButton,
DeleteSelectedButton,
CancelButton,
];
// Enter "inside" on Select from outer panes
match (self.current_focus, action) {
(ColumnsTable, MovementAction::Select) => {
if !self.columns.is_empty() && self.column_table_state.selected().is_none() {
self.column_table_state.select(Some(0));
}
self.current_focus = InsideColumnsTable;
return true;
}
(IndexesTable, MovementAction::Select) => {
if !self.indexes.is_empty() && self.index_table_state.selected().is_none() {
self.index_table_state.select(Some(0));
}
self.current_focus = InsideIndexesTable;
return true;
}
(LinksTable, MovementAction::Select) => {
if !self.links.is_empty() && self.link_table_state.selected().is_none() {
self.link_table_state.select(Some(0));
}
self.current_focus = InsideLinksTable;
return true;
}
_ => {}
}
// Handle "inside" states: Up/Down/Select/Esc; block outer movement keys
match self.current_focus {
InsideColumnsTable => {
match action {
MovementAction::Up => {
if let Some(i) = self.column_table_state.selected() {
let next = i.saturating_sub(1);
self.column_table_state.select(Some(next));
} else if !self.columns.is_empty() {
self.column_table_state.select(Some(0));
}
return true;
}
MovementAction::Down => {
if let Some(i) = self.column_table_state.selected() {
let last = self.columns.len().saturating_sub(1);
let next = if i < last { i + 1 } else { i };
self.column_table_state.select(Some(next));
} else if !self.columns.is_empty() {
self.column_table_state.select(Some(0));
}
return true;
}
MovementAction::Select => {
if let Some(i) = self.column_table_state.selected() {
if let Some(col) = self.columns.get_mut(i) {
col.selected = !col.selected;
self.has_unsaved_changes = true;
}
}
return true;
}
MovementAction::Esc => {
self.column_table_state.select(None);
self.current_focus = ColumnsTable;
return true;
}
MovementAction::Next | MovementAction::Previous => return true, // block outer moves
_ => {}
}
}
InsideIndexesTable => {
match action {
MovementAction::Up => {
if let Some(i) = self.index_table_state.selected() {
let next = i.saturating_sub(1);
self.index_table_state.select(Some(next));
} else if !self.indexes.is_empty() {
self.index_table_state.select(Some(0));
}
return true;
}
MovementAction::Down => {
if let Some(i) = self.index_table_state.selected() {
let last = self.indexes.len().saturating_sub(1);
let next = if i < last { i + 1 } else { i };
self.index_table_state.select(Some(next));
} else if !self.indexes.is_empty() {
self.index_table_state.select(Some(0));
}
return true;
}
MovementAction::Select => {
if let Some(i) = self.index_table_state.selected() {
if let Some(ix) = self.indexes.get_mut(i) {
ix.selected = !ix.selected;
self.has_unsaved_changes = true;
}
}
return true;
}
MovementAction::Esc => {
self.index_table_state.select(None);
self.current_focus = IndexesTable;
return true;
}
MovementAction::Next | MovementAction::Previous => return true, // block outer moves
_ => {}
}
}
InsideLinksTable => {
match action {
MovementAction::Up => {
if let Some(i) = self.link_table_state.selected() {
let next = i.saturating_sub(1);
self.link_table_state.select(Some(next));
} else if !self.links.is_empty() {
self.link_table_state.select(Some(0));
}
return true;
}
MovementAction::Down => {
if let Some(i) = self.link_table_state.selected() {
let last = self.links.len().saturating_sub(1);
let next = if i < last { i + 1 } else { i };
self.link_table_state.select(Some(next));
} else if !self.links.is_empty() {
self.link_table_state.select(Some(0));
}
return true;
}
MovementAction::Select => {
if let Some(i) = self.link_table_state.selected() {
if let Some(link) = self.links.get_mut(i) {
link.selected = !link.selected;
self.has_unsaved_changes = true;
}
}
return true;
}
MovementAction::Esc => {
self.link_table_state.select(None);
self.current_focus = LinksTable;
return true;
}
MovementAction::Next | MovementAction::Previous => return true, // block outer moves
_ => {}
}
}
_ => {}
}
// Default: outer navigation via helper
move_focus(&ORDER, &mut self.current_focus, action)
}
}
pub struct AddTableFormState {
pub state: AddTableState,
pub editor: FormEditor<AddTableState>,
pub focus_outside_canvas: bool,
pub focused_button_index: usize,
}
impl std::fmt::Debug for AddTableFormState {
@@ -394,6 +259,7 @@ impl std::fmt::Debug for AddTableFormState {
f.debug_struct("AddTableFormState")
.field("state", &self.state)
.field("focus_outside_canvas", &self.focus_outside_canvas)
.field("focused_button_index", &self.focused_button_index)
.finish()
}
}
@@ -407,6 +273,7 @@ impl AddTableFormState {
state,
editor,
focus_outside_canvas: false,
focused_button_index: 0,
}
}
@@ -416,6 +283,7 @@ impl AddTableFormState {
state,
editor,
focus_outside_canvas: false,
focused_button_index: 0,
}
}
@@ -455,4 +323,10 @@ impl AddTableFormState {
pub fn link_table_state(&mut self) -> &mut TableState {
&mut self.state.link_table_state
}
pub fn set_focused_button(&mut self, index: usize) {
self.focused_button_index = index;
}
pub fn focused_button(&self) -> usize {
self.focused_button_index
}
}

View File

@@ -490,10 +490,11 @@ pub fn render_add_table(
.split(bottom_buttons_area);
let save_button = Paragraph::new(" Save table ")
.style(get_button_style(
AddTableFocus::SaveButton,
add_table_state.current_focus(),
))
.style(if add_table_state.current_focus() == AddTableFocus::SaveButton {
Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.secondary)
})
.alignment(Alignment::Center)
.block(
Block::default()
@@ -507,34 +508,36 @@ pub fn render_add_table(
f.render_widget(save_button, bottom_button_chunks[0]);
let delete_button = Paragraph::new(" Delete Selected ")
.style(get_button_style(
AddTableFocus::DeleteSelectedButton,
add_table_state.current_focus(),
))
.style(if add_table_state.current_focus() == AddTableFocus::DeleteSelectedButton {
Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.secondary)
})
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_button_border_style(
add_table_state.current_focus() == AddTableFocus::DeleteSelectedButton, // Pass bool
add_table_state.current_focus() == AddTableFocus::DeleteSelectedButton,
theme,
)),
);
f.render_widget(delete_button, bottom_button_chunks[1]);
let cancel_button = Paragraph::new(" Cancel ")
.style(get_button_style(
AddTableFocus::CancelButton,
add_table_state.current_focus(),
))
.style(if add_table_state.current_focus() == AddTableFocus::CancelButton {
Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.secondary)
})
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_button_border_style(
add_table_state.current_focus() == AddTableFocus::CancelButton, // Pass bool
add_table_state.current_focus() == AddTableFocus::CancelButton,
theme,
)),
);

View File

@@ -67,9 +67,6 @@ pub fn handle_intro_selection(
}
3 => {
buffer_state.update_history(AppView::Register);
// Register view requires focus reset
app_state.ui.focus_outside_canvas = false;
app_state.focused_button_index = 0;
}
_ => return,
}

View File

@@ -3,23 +3,27 @@ use crate::movement::MovementAction;
#[derive(Default, Clone, Debug)]
pub struct IntroState {
pub selected_option: usize,
pub focus_outside_canvas: bool,
pub focused_button_index: usize,
}
impl IntroState {
pub fn new() -> Self {
Self::default()
Self {
focus_outside_canvas: true,
focused_button_index: 0,
}
}
pub fn next_option(&mut self) {
if self.selected_option < 3 {
self.selected_option += 1;
if self.focused_button_index < 3 {
self.focused_button_index += 1;
}
}
pub fn previous_option(&mut self) {
if self.selected_option > 0 {
self.selected_option -= 1
if self.focused_button_index > 0 {
self.focused_button_index -= 1;
}
}
}

View File

@@ -56,7 +56,8 @@ pub fn render_intro(f: &mut Frame, intro_state: &IntroState, area: Rect, theme:
let buttons = ["Continue", "Admin", "Login", "Register"];
for (i, &text) in buttons.iter().enumerate() {
render_button(f, button_area[i], text, intro_state.selected_option == i, theme);
let active = intro_state.focused_button_index == i;
render_button(f, button_area[i], text, active, theme);
}
}

View File

@@ -26,7 +26,6 @@ pub fn handle_login_event(
&& modifiers.is_empty()
{
login_page.focus_outside_canvas = false;
app_state.ui.focus_outside_canvas = false; // 🔑 keep global in sync
login_page.editor.set_mode(CanvasMode::ReadOnly);
return Ok(EventOutcome::Ok(String::new()));
}
@@ -43,9 +42,7 @@ pub fn handle_login_event(
)
{
login_page.focus_outside_canvas = true;
login_page.focused_button_index = 0; // focus "Login" button
app_state.ui.focus_outside_canvas = true;
app_state.focused_button_index = 0;
login_page.focused_button_index = 0;
login_page.editor.set_mode(CanvasMode::ReadOnly);
return Ok(EventOutcome::Ok("Focus moved to buttons".into()));
}

View File

@@ -9,6 +9,7 @@ use crate::ui::handlers::context::DialogPurpose;
use common::proto::komp_ac::auth::LoginResponse;
use crate::pages::login::LoginFormState;
use crate::state::pages::auth::UserRole;
use canvas::DataProvider;
use anyhow::{Context, Result, anyhow};
use tokio::spawn;
use tokio::sync::mpsc;
@@ -108,7 +109,19 @@ pub async fn revert(
login_state: &mut LoginFormState,
app_state: &mut AppState,
) -> String {
// Clear the underlying state
login_state.clear();
// Also clear values inside the editors data provider
{
let dp = login_state.editor.data_provider_mut();
dp.set_field_value(0, "".to_string());
dp.set_field_value(1, "".to_string());
dp.set_current_field(0);
dp.set_current_cursor_pos(0);
dp.set_has_unsaved_changes(false);
}
app_state.hide_dialog();
"Login reverted".to_string()
}
@@ -125,9 +138,6 @@ pub async fn back_to_main(
buffer_state.close_active_buffer();
buffer_state.update_history(AppView::Intro);
app_state.ui.focus_outside_canvas = false;
app_state.focused_button_index = 0;
"Returned to main menu".to_string()
}

View File

@@ -80,11 +80,8 @@ pub fn render_login(
// Login Button
let login_button_index = 0;
let login_active = if login_page.focus_outside_canvas {
app_state.focused_button_index == login_button_index
} else {
false
};
let login_active = login_page.focus_outside_canvas
&& login_page.focused_button_index == login_button_index;
let mut login_style = Style::default().fg(theme.fg);
let mut login_border = Style::default().fg(theme.border);
if login_active {
@@ -107,11 +104,8 @@ pub fn render_login(
// Return Button
let return_button_index = 1;
let return_active = if app_state.ui.focus_outside_canvas {
app_state.focused_button_index == return_button_index
} else {
false
};
let return_active = login_page.focus_outside_canvas
&& login_page.focused_button_index == return_button_index;
let mut return_style = Style::default().fg(theme.fg);
let mut return_border = Style::default().fg(theme.border);
if return_active {

View File

@@ -28,8 +28,6 @@ pub fn handle_register_event(
&& modifiers.is_empty()
{
register_page.focus_outside_canvas = false;
// Keep global in sync for now (cursor styling elsewhere still reads it)
app_state.ui.focus_outside_canvas = false;
register_page.editor.set_mode(CanvasMode::ReadOnly);
return Ok(EventOutcome::Ok(String::new()));
}
@@ -47,9 +45,6 @@ pub fn handle_register_event(
{
register_page.focus_outside_canvas = true;
register_page.focused_button_index = 0; // focus "Register" button
// Keep global in sync for now
app_state.ui.focus_outside_canvas = true;
app_state.focused_button_index = 0;
register_page.editor.set_mode(CanvasMode::ReadOnly);
return Ok(EventOutcome::Ok("Focus moved to buttons".into()));
}

View File

@@ -56,8 +56,6 @@ pub async fn back_to_login(
// Reset focus state
register_state.focus_outside_canvas = false;
register_state.focused_button_index = 0;
app_state.ui.focus_outside_canvas = false;
app_state.focused_button_index = 0;
"Returned to main menu".to_string()
}

View File

@@ -106,7 +106,6 @@ pub async fn handle_search_palette_event(
if should_close {
app_state.search_state = None;
app_state.ui.show_search_palette = false;
app_state.ui.focus_outside_canvas = false;
}
Ok(outcome_message)

View File

@@ -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 });

View File

@@ -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,

View File

@@ -28,7 +28,6 @@ pub struct UiState {
pub show_login: bool,
pub show_register: bool,
pub show_search_palette: bool,
pub focus_outside_canvas: bool,
pub dialog: DialogState,
}
@@ -52,7 +51,6 @@ pub struct AppState {
// NEW: The "Rulebook" cache. We use Arc for efficient sharing.
pub schema_cache: HashMap<String, Arc<TableStructureResponse>>,
pub focused_button_index: usize,
pub pending_table_structure_fetch: Option<(String, String)>,
pub search_state: Option<SearchState>,
@@ -61,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>,
}
@@ -77,7 +74,6 @@ impl AppState {
current_view_table_name: None,
current_mode: AppMode::General,
schema_cache: HashMap::new(), // NEW: Initialize the cache
focused_button_index: 0,
pending_table_structure_fetch: None,
search_state: None,
ui: UiState::default(),
@@ -166,8 +162,7 @@ impl Default for UiState {
show_login: false,
show_register: false,
show_buffer_list: true,
show_search_palette: false, // ADDED
focus_outside_canvas: false,
show_search_palette: false,
dialog: DialogState::default(),
}
}

View File

@@ -16,32 +16,27 @@ pub fn logout(
auth_state.user_id = None;
auth_state.role = None;
auth_state.decoded_username = None;
// Delete stored auth data
if let Err(e) = delete_auth_data() {
error!("Failed to delete stored auth data: {}", e);
// Continue anyway - user is logged out in memory
}
// Navigate to intro screen
buffer_state.history = vec![AppView::Intro];
buffer_state.active_index = 0;
// Reset UI state
app_state.ui.focus_outside_canvas = false;
app_state.focused_button_index = 0;
// Hide any open dialogs
app_state.hide_dialog();
// Show logout confirmation dialog
app_state.show_dialog(
"Logged Out",
"You have been successfully logged out.",
vec!["OK".to_string()],
DialogPurpose::LoginSuccess, // Reuse or create a new purpose
DialogPurpose::LoginSuccess,
);
info!("User logged out successfully.");
"Logged out successfully".to_string()
}

View File

@@ -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;
@@ -227,25 +228,56 @@ pub async fn run_ui() -> Result<()> {
|| app_state.ui.show_search_palette
|| event_handler.navigation_state.active;
if !overlay_active {
if let Page::Form(path) = &router.current {
if !app_state.ui.focus_outside_canvas {
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
let inside_canvas = match &router.current {
Page::Form(_) => true,
Page::Login(state) => !state.focus_outside_canvas,
Page::Register(state) => !state.focus_outside_canvas,
Page::AddTable(state) => !state.focus_outside_canvas,
Page::AddLogic(state) => !state.focus_outside_canvas,
_ => false,
};
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) {
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
}
}
}
}
@@ -366,7 +398,9 @@ pub async fn run_ui() -> Result<()> {
vec!["OK".to_string()],
DialogPurpose::SaveTableSuccess,
);
admin_state.add_table_state.has_unsaved_changes = false;
if let Page::AddTable(page) = &mut router.current {
page.state.has_unsaved_changes = false;
}
}
Err(e) => {
event_handler.command_message = format!("Save failed: {}", e);
@@ -426,14 +460,11 @@ pub async fn run_ui() -> Result<()> {
router.navigate(Page::Admin(admin_state.clone()));
}
AppView::AddTable => {
router.navigate(Page::AddTable(
add_table::state::AddTableFormState::from_state(
admin_state.add_table_state.clone(),
),
));
if let Page::AddTable(page) = &mut router.current {
// Ensure keymap is set once
// Ensure keymap is set once (same as AddLogic)
page.editor.set_keymap(config.build_canvas_keymap());
} else {
// Page is created by admin navigation (Button2). No-op here.
}
}
AppView::AddLogic => {
@@ -634,7 +665,15 @@ pub async fn run_ui() -> Result<()> {
match current_mode {
AppMode::General => {
if app_state.ui.focus_outside_canvas {
let outside_canvas = match &router.current {
Page::Login(state) => state.focus_outside_canvas,
Page::Register(state) => state.focus_outside_canvas,
Page::AddTable(state) => state.focus_outside_canvas,
Page::AddLogic(state) => state.focus_outside_canvas,
_ => false, // Form and Admin dont use this flag
};
if outside_canvas {
// Outside canvas → app decides
terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?;
terminal.show_cursor()?;

View File

@@ -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));
});
}

View 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(_)));
}

View 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')]);
}

View File

@@ -0,0 +1,4 @@
// tests/input/mod.rs
pub mod engine_leader_e2e;
pub mod leader_sequences;

View File

@@ -1,3 +1,4 @@
// tests/mod.rs
pub mod form;
// pub mod form;
pub mod input;

View File