Compare commits
110 Commits
0.4.27
...
492f1f1e55
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
492f1f1e55 | ||
|
|
241ab99584 | ||
|
|
8bd5b5c62f | ||
|
|
7e21258d2e | ||
|
|
49277cfdd4 | ||
|
|
1f6dc3cd75 | ||
|
|
7350b0985c | ||
|
|
73bc6dc99c | ||
|
|
095645a209 | ||
|
|
532977056d | ||
|
|
2435f58256 | ||
|
|
ceb560c658 | ||
|
|
d88c239bf6 | ||
|
|
01c4ff2e14 | ||
|
|
c2890e1f3d | ||
|
|
e1ea44c68c | ||
|
|
cec2361b00 | ||
|
|
9672b9949c | ||
|
|
e4e9594a9d | ||
|
|
6daa5202b1 | ||
|
|
cae47da5f2 | ||
|
|
85c7c89c28 | ||
|
|
0d80266e9b | ||
|
|
a604d62d44 | ||
|
|
2cbbfd21aa | ||
|
|
1c17d07497 | ||
|
|
ad15becd7a | ||
|
|
c2a6272413 | ||
|
|
c51af13fb1 | ||
|
|
d9d8562539 | ||
|
|
6891631b8d | ||
|
|
738d58b5f1 | ||
|
|
3081125716 | ||
|
|
6073c7ab43 | ||
|
|
8157dc7a60 | ||
|
|
3b130e9208 | ||
|
|
ab81434c4e | ||
|
|
62c54dc1eb | ||
|
|
347802b2a4 | ||
|
|
a5a8d98984 | ||
|
|
5b42da8290 | ||
|
|
4e041f36ce | ||
|
|
22926b7266 | ||
|
|
0a7f032028 | ||
|
|
4edec5e72d | ||
|
|
c7d524c76a | ||
|
|
9ed558562b | ||
|
|
43f5c1a764 | ||
|
|
46149c09db | ||
|
|
a0757efe8b | ||
|
|
10f4b9d8e2 | ||
|
|
42db496ad7 | ||
|
|
d6fd672409 | ||
|
|
60eb1c9f51 | ||
|
|
a09c804595 | ||
|
|
a17f73fd54 | ||
|
|
2373ae4b8c | ||
|
|
16dd460469 | ||
|
|
58f109ca91 | ||
|
|
75da9c0f4b | ||
|
|
833b918c5b | ||
|
|
72c2691a17 | ||
|
|
cf79bc7bd5 | ||
|
|
f5f2f2cdef | ||
|
|
19a9bab8c2 | ||
|
|
6e221ef8c1 | ||
|
|
e142f56706 | ||
|
|
a794f22366 | ||
|
|
cfe4903c79 | ||
|
|
a0a473f96c | ||
|
|
9e4dd3b4c7 | ||
|
|
e5db0334c0 | ||
|
|
d641ad1bbb | ||
|
|
18393ff661 | ||
|
|
b2a82fba30 | ||
|
|
f6c2fd627f | ||
|
|
15d9b31cb6 | ||
|
|
06cc1663b3 | ||
|
|
88a4b2d69c | ||
|
|
e6072d25c5 | ||
|
|
fc2b65601e | ||
|
|
597bdde7e1 | ||
|
|
f56092e86c | ||
|
|
d5cfe59f47 | ||
|
|
f281eaa662 | ||
|
|
cbb3ed7c48 | ||
|
|
41a0b85376 | ||
|
|
b5a31ee81c | ||
|
|
dceb031822 | ||
|
|
78bc9fc432 | ||
|
|
b9072e4d7c | ||
|
|
5d97e63f93 | ||
|
|
957f5bf9f0 | ||
|
|
6833ac5fad | ||
|
|
3dff2ced6c | ||
|
|
ea7ff3796f | ||
|
|
310617d62b | ||
|
|
1d94e82f4b | ||
|
|
00dad5d673 | ||
|
|
414c6957e7 | ||
|
|
f127298e5a | ||
|
|
f49899e66d | ||
|
|
5717c88857 | ||
|
|
ae8aa16208 | ||
|
|
4ed8e7b421 | ||
|
|
3dd6808ea2 | ||
|
|
f2b426851b | ||
|
|
f9e0833bcf | ||
|
|
11b073c2fd | ||
|
|
1320884409 |
135
Cargo.lock
generated
135
Cargo.lock
generated
@@ -493,7 +493,7 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
||||
|
||||
[[package]]
|
||||
name = "canvas"
|
||||
version = "0.4.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -584,7 +584,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "client"
|
||||
version = "0.4.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -595,8 +595,8 @@ dependencies = [
|
||||
"dotenvy",
|
||||
"futures",
|
||||
"lazy_static",
|
||||
"prost",
|
||||
"prost-types",
|
||||
"prost 0.13.5",
|
||||
"prost-types 0.13.5",
|
||||
"ratatui",
|
||||
"rstest",
|
||||
"serde",
|
||||
@@ -635,11 +635,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "common"
|
||||
version = "0.4.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"prost",
|
||||
"prost-types",
|
||||
"prost 0.13.5",
|
||||
"prost-build 0.14.1",
|
||||
"prost-types 0.13.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tantivy",
|
||||
"tonic",
|
||||
"tonic-build",
|
||||
@@ -1955,6 +1957,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"
|
||||
@@ -2495,7 +2506,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost-derive",
|
||||
"prost-derive 0.13.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost-derive 0.14.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2511,8 +2532,28 @@ dependencies = [
|
||||
"once_cell",
|
||||
"petgraph",
|
||||
"prettyplease",
|
||||
"prost",
|
||||
"prost-types",
|
||||
"prost 0.13.5",
|
||||
"prost-types 0.13.5",
|
||||
"regex",
|
||||
"syn 2.0.104",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-build"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"itertools 0.14.0",
|
||||
"log",
|
||||
"multimap",
|
||||
"once_cell",
|
||||
"petgraph",
|
||||
"prettyplease",
|
||||
"prost 0.14.1",
|
||||
"prost-types 0.14.1",
|
||||
"regex",
|
||||
"syn 2.0.104",
|
||||
"tempfile",
|
||||
@@ -2531,13 +2572,35 @@ dependencies = [
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-derive"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-types"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
|
||||
dependencies = [
|
||||
"prost",
|
||||
"prost 0.13.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-types"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72"
|
||||
dependencies = [
|
||||
"prost 0.14.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2747,8 +2810,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 +2831,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"
|
||||
@@ -3022,11 +3100,11 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
||||
|
||||
[[package]]
|
||||
name = "search"
|
||||
version = "0.4.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"common",
|
||||
"prost",
|
||||
"prost 0.13.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -3121,7 +3199,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "server"
|
||||
version = "0.4.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bcrypt",
|
||||
@@ -3132,8 +3210,9 @@ dependencies = [
|
||||
"futures",
|
||||
"jsonwebtoken",
|
||||
"lazy_static",
|
||||
"prost",
|
||||
"prost-types",
|
||||
"prost 0.13.5",
|
||||
"prost-build 0.14.1",
|
||||
"prost-types 0.13.5",
|
||||
"rand 0.9.2",
|
||||
"regex",
|
||||
"rstest",
|
||||
@@ -3751,7 +3830,7 @@ dependencies = [
|
||||
"fnv",
|
||||
"once_cell",
|
||||
"plist",
|
||||
"regex-syntax",
|
||||
"regex-syntax 0.8.5",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
@@ -3857,7 +3936,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"regex-syntax",
|
||||
"regex-syntax 0.8.5",
|
||||
"utf8-ranges",
|
||||
]
|
||||
|
||||
@@ -4169,7 +4248,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"prost",
|
||||
"prost 0.13.5",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
@@ -4187,8 +4266,8 @@ checksum = "eac6f67be712d12f0b41328db3137e0d0757645d8904b4cb7d51cd9c2279e847"
|
||||
dependencies = [
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"prost-build",
|
||||
"prost-types",
|
||||
"prost-build 0.13.5",
|
||||
"prost-types 0.13.5",
|
||||
"quote",
|
||||
"syn 2.0.104",
|
||||
]
|
||||
@@ -4199,8 +4278,8 @@ version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9687bd5bfeafebdded2356950f278bba8226f0b32109537c4253406e09aafe1"
|
||||
dependencies = [
|
||||
"prost",
|
||||
"prost-types",
|
||||
"prost 0.13.5",
|
||||
"prost-types 0.13.5",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tonic",
|
||||
@@ -4287,10 +4366,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",
|
||||
]
|
||||
|
||||
@@ -5,7 +5,7 @@ resolver = "2"
|
||||
[workspace.package]
|
||||
# TODO: idk how to do the name, fix later
|
||||
# name = "komp_ac"
|
||||
version = "0.4.2"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
license = "GPL-3.0-or-later"
|
||||
authors = ["Filip Priečinský <filippriec@gmail.com>"]
|
||||
|
||||
@@ -39,6 +39,7 @@ validation = ["regex"]
|
||||
computed = []
|
||||
textarea = ["dep:ropey","gui"]
|
||||
syntect = ["dep:syntect", "gui", "textarea"]
|
||||
keymap = ["gui"]
|
||||
|
||||
# text modes (mutually exclusive; default to vim)
|
||||
textmode-vim = []
|
||||
@@ -50,7 +51,8 @@ all-nontextmodes = [
|
||||
"cursor-style",
|
||||
"validation",
|
||||
"computed",
|
||||
"textarea"
|
||||
"textarea",
|
||||
"keymap"
|
||||
]
|
||||
|
||||
[[example]]
|
||||
@@ -106,3 +108,8 @@ path = "examples/textarea_normal.rs"
|
||||
name = "textarea_syntax"
|
||||
required-features = ["gui", "cursor-style", "textarea", "textmode-normal", "syntect"]
|
||||
path = "examples/textarea_syntax.rs"
|
||||
|
||||
[[example]]
|
||||
name = "canvas_keymap"
|
||||
required-features = ["gui", "keymap", "cursor-style"]
|
||||
path = "examples/canvas_keymap.rs"
|
||||
|
||||
376
canvas/examples/canvas_keymap.rs
Normal file
376
canvas/examples/canvas_keymap.rs
Normal file
@@ -0,0 +1,376 @@
|
||||
// examples/canvas_keymap.rs
|
||||
//! Demonstrates the centralized keymap system for canvas interactions
|
||||
//!
|
||||
//! This example shows how to use the canvas-keymap feature to delegate
|
||||
//! all canvas key handling to the library, supporting complex sequences
|
||||
//! like "gg", "ge", etc.
|
||||
//!
|
||||
//! Run with:
|
||||
//! cargo run --example canvas_keymap --features "gui,keymap,cursor-style"
|
||||
|
||||
#[cfg(not(feature = "keymap"))]
|
||||
compile_error!(
|
||||
"This example requires the 'keymap' feature. \
|
||||
Run with: cargo run --example canvas_keymap --features \"gui,keymap,cursor-style\""
|
||||
);
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEvent},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{
|
||||
backend::{Backend, CrosstermBackend},
|
||||
layout::{Constraint, Direction, Layout},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use canvas::{
|
||||
canvas::{gui::render_canvas_default, modes::AppMode},
|
||||
keymap::{CanvasKeyMap, KeyEventOutcome},
|
||||
DataProvider, FormEditor,
|
||||
};
|
||||
|
||||
/// Demo application using centralized keymap system
|
||||
struct KeymapDemoApp {
|
||||
editor: FormEditor<DemoData>,
|
||||
message: String,
|
||||
quit: bool,
|
||||
}
|
||||
|
||||
impl KeymapDemoApp {
|
||||
fn new() -> Self {
|
||||
let data = DemoData::new();
|
||||
let mut editor = FormEditor::new(data);
|
||||
|
||||
// Build and inject the keymap from our config
|
||||
let keymap = Self::build_demo_keymap();
|
||||
editor.set_keymap(keymap);
|
||||
|
||||
Self {
|
||||
editor,
|
||||
message: "🎯 Keymap system loaded! Try: gg, ge, hjkl, w/b/e, v, i, etc.".to_string(),
|
||||
quit: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a comprehensive keymap configuration
|
||||
fn build_demo_keymap() -> CanvasKeyMap {
|
||||
let mut read_only = HashMap::new();
|
||||
let mut edit = HashMap::new();
|
||||
let mut highlight = HashMap::new();
|
||||
|
||||
// === READ-ONLY MODE KEYBINDINGS ===
|
||||
|
||||
// Basic movement
|
||||
read_only.insert("move_left".to_string(), vec!["h".to_string(), "Left".to_string()]);
|
||||
read_only.insert("move_right".to_string(), vec!["l".to_string(), "Right".to_string()]);
|
||||
read_only.insert("move_up".to_string(), vec!["k".to_string(), "Up".to_string()]);
|
||||
read_only.insert("move_down".to_string(), vec!["j".to_string(), "Down".to_string()]);
|
||||
|
||||
// Word movement
|
||||
read_only.insert("move_word_next".to_string(), vec!["w".to_string()]);
|
||||
read_only.insert("move_word_prev".to_string(), vec!["b".to_string()]);
|
||||
read_only.insert("move_word_end".to_string(), vec!["e".to_string()]);
|
||||
read_only.insert("move_word_end_prev".to_string(), vec!["ge".to_string()]); // Multi-key!
|
||||
|
||||
// Big word movement
|
||||
read_only.insert("move_big_word_next".to_string(), vec!["W".to_string()]);
|
||||
read_only.insert("move_big_word_prev".to_string(), vec!["B".to_string()]);
|
||||
read_only.insert("move_big_word_end".to_string(), vec!["E".to_string()]);
|
||||
read_only.insert("move_big_word_end_prev".to_string(), vec!["gE".to_string()]); // Multi-key!
|
||||
|
||||
// Line movement
|
||||
read_only.insert("move_line_start".to_string(), vec!["0".to_string(), "Home".to_string()]);
|
||||
read_only.insert("move_line_end".to_string(), vec!["$".to_string(), "End".to_string()]);
|
||||
|
||||
// Field movement
|
||||
read_only.insert("move_first_line".to_string(), vec!["gg".to_string()]); // Multi-key!
|
||||
read_only.insert("move_last_line".to_string(), vec!["G".to_string()]);
|
||||
read_only.insert("next_field".to_string(), vec!["Tab".to_string()]);
|
||||
read_only.insert("prev_field".to_string(), vec!["Shift+Tab".to_string()]);
|
||||
|
||||
// Mode transitions
|
||||
read_only.insert("enter_edit_mode_before".to_string(), vec!["i".to_string()]);
|
||||
read_only.insert("enter_edit_mode_after".to_string(), vec!["a".to_string()]);
|
||||
read_only.insert("enter_highlight_mode".to_string(), vec!["v".to_string()]);
|
||||
read_only.insert("enter_highlight_mode_linewise".to_string(), vec!["V".to_string()]);
|
||||
|
||||
// Editing actions in normal mode
|
||||
read_only.insert("delete_char_forward".to_string(), vec!["x".to_string()]);
|
||||
read_only.insert("delete_char_backward".to_string(), vec!["X".to_string()]);
|
||||
read_only.insert("open_line_below".to_string(), vec!["o".to_string()]);
|
||||
read_only.insert("open_line_above".to_string(), vec!["O".to_string()]);
|
||||
|
||||
// === EDIT MODE KEYBINDINGS ===
|
||||
|
||||
edit.insert("exit_edit_mode".to_string(), vec!["esc".to_string()]);
|
||||
edit.insert("move_left".to_string(), vec!["Left".to_string()]);
|
||||
edit.insert("move_right".to_string(), vec!["Right".to_string()]);
|
||||
edit.insert("move_up".to_string(), vec!["Up".to_string()]);
|
||||
edit.insert("move_down".to_string(), vec!["Down".to_string()]);
|
||||
edit.insert("move_line_start".to_string(), vec!["Home".to_string()]);
|
||||
edit.insert("move_line_end".to_string(), vec!["End".to_string()]);
|
||||
edit.insert("move_word_next".to_string(), vec!["Ctrl+Right".to_string()]);
|
||||
edit.insert("move_word_prev".to_string(), vec!["Ctrl+Left".to_string()]);
|
||||
edit.insert("next_field".to_string(), vec!["Tab".to_string()]);
|
||||
edit.insert("prev_field".to_string(), vec!["Shift+Tab".to_string()]);
|
||||
edit.insert("delete_char_backward".to_string(), vec!["Backspace".to_string()]);
|
||||
edit.insert("delete_char_forward".to_string(), vec!["Delete".to_string()]);
|
||||
|
||||
// === HIGHLIGHT MODE KEYBINDINGS ===
|
||||
|
||||
highlight.insert("exit_highlight_mode".to_string(), vec!["esc".to_string()]);
|
||||
highlight.insert("enter_highlight_mode_linewise".to_string(), vec!["V".to_string()]);
|
||||
|
||||
// Movement (extends selection)
|
||||
highlight.insert("move_left".to_string(), vec!["h".to_string(), "Left".to_string()]);
|
||||
highlight.insert("move_right".to_string(), vec!["l".to_string(), "Right".to_string()]);
|
||||
highlight.insert("move_up".to_string(), vec!["k".to_string(), "Up".to_string()]);
|
||||
highlight.insert("move_down".to_string(), vec!["j".to_string(), "Down".to_string()]);
|
||||
highlight.insert("move_word_next".to_string(), vec!["w".to_string()]);
|
||||
highlight.insert("move_word_prev".to_string(), vec!["b".to_string()]);
|
||||
highlight.insert("move_word_end".to_string(), vec!["e".to_string()]);
|
||||
highlight.insert("move_word_end_prev".to_string(), vec!["ge".to_string()]);
|
||||
highlight.insert("move_line_start".to_string(), vec!["0".to_string()]);
|
||||
highlight.insert("move_line_end".to_string(), vec!["$".to_string()]);
|
||||
highlight.insert("move_first_line".to_string(), vec!["gg".to_string()]);
|
||||
highlight.insert("move_last_line".to_string(), vec!["G".to_string()]);
|
||||
|
||||
CanvasKeyMap::from_mode_maps(&read_only, &edit, &highlight)
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) -> io::Result<()> {
|
||||
// First, try canvas keymap
|
||||
match self.editor.handle_key_event(key_event) {
|
||||
KeyEventOutcome::Consumed(Some(msg)) => {
|
||||
self.message = format!("🎯 Canvas: {}", msg);
|
||||
return Ok(());
|
||||
}
|
||||
KeyEventOutcome::Consumed(None) => {
|
||||
self.message = "🎯 Canvas action executed".to_string();
|
||||
return Ok(());
|
||||
}
|
||||
KeyEventOutcome::Pending => {
|
||||
self.message = "⏳ Waiting for next key in sequence...".to_string();
|
||||
return Ok(());
|
||||
}
|
||||
KeyEventOutcome::NotMatched => {
|
||||
// Fall through to client actions
|
||||
}
|
||||
}
|
||||
|
||||
// Handle client-specific actions (non-canvas)
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
match (key_event.code, key_event.modifiers) {
|
||||
(KeyCode::Char('q'), KeyModifiers::CONTROL) |
|
||||
(KeyCode::Char('c'), KeyModifiers::CONTROL) => {
|
||||
self.quit = true;
|
||||
self.message = "👋 Goodbye!".to_string();
|
||||
}
|
||||
(KeyCode::F(1), _) => {
|
||||
self.message = "ℹ️ F1: This is a client action (not handled by canvas keymap)".to_string();
|
||||
}
|
||||
(KeyCode::F(2), _) => {
|
||||
// Demonstrate saving
|
||||
self.message = "💾 F2: Save action (client-side)".to_string();
|
||||
}
|
||||
(KeyCode::Char('?'), _) if self.editor.mode() == AppMode::ReadOnly => {
|
||||
self.show_help();
|
||||
}
|
||||
_ => {
|
||||
// Unknown key
|
||||
self.message = format!(
|
||||
"❓ Unhandled key: {:?} (mode: {:?})",
|
||||
key_event.code,
|
||||
self.editor.mode()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn show_help(&mut self) {
|
||||
self.message = "📖 Help: Multi-key sequences work! Try gg, ge, gE. Also: hjkl, w/b/e, v/V, i/a/o".to_string();
|
||||
}
|
||||
|
||||
fn should_quit(&self) -> bool {
|
||||
self.quit
|
||||
}
|
||||
|
||||
fn editor(&self) -> &FormEditor<DemoData> {
|
||||
&self.editor
|
||||
}
|
||||
|
||||
fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
}
|
||||
|
||||
/// Demo form data with interesting examples for keymap testing
|
||||
struct DemoData {
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl DemoData {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
fields: vec![
|
||||
("🎯 Name".to_string(), "John-Paul McDonald-Smith".to_string()),
|
||||
("📧 Email".to_string(), "user@long-domain-name.example.com".to_string()),
|
||||
("📱 Phone".to_string(), "+1 (555) 123-4567 ext. 890".to_string()),
|
||||
("🏠 Address".to_string(), "123 Main Street, Apartment 4B, Suite 100".to_string()),
|
||||
("🏷️ Tags".to_string(), "urgent,important,follow-up,high-priority".to_string()),
|
||||
("📝 Notes".to_string(), "Test word movements: w=next-word, b=prev-word, e=word-end, ge=prev-word-end".to_string()),
|
||||
("🔥 Multi-key".to_string(), "Try multi-key sequences: gg=first-field, ge=prev-word-end, gE=prev-WORD-end".to_string()),
|
||||
("⚡ Vim Actions".to_string(), "Normal mode: x=delete-char, o=open-line-below, v=visual, i=insert".to_string()),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DataProvider for DemoData {
|
||||
fn field_count(&self) -> usize {
|
||||
self.fields.len()
|
||||
}
|
||||
|
||||
fn field_name(&self, index: usize) -> &str {
|
||||
&self.fields[index].0
|
||||
}
|
||||
|
||||
fn field_value(&self, index: usize) -> &str {
|
||||
&self.fields[index].1
|
||||
}
|
||||
|
||||
fn set_field_value(&mut self, index: usize, value: String) {
|
||||
self.fields[index].1 = value;
|
||||
}
|
||||
}
|
||||
|
||||
fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: KeymapDemoApp) -> io::Result<()> {
|
||||
loop {
|
||||
terminal.draw(|f| ui(f, &app))?;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
app.handle_key_event(key)?;
|
||||
if app.should_quit() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ui(f: &mut Frame, app: &KeymapDemoApp) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(8), Constraint::Length(12)])
|
||||
.split(f.area());
|
||||
|
||||
// Render the canvas
|
||||
render_canvas_default(f, chunks[0], app.editor());
|
||||
|
||||
// Render status and help
|
||||
render_status_and_help(f, chunks[1], app);
|
||||
}
|
||||
|
||||
fn render_status_and_help(f: &mut Frame, area: ratatui::layout::Rect, app: &KeymapDemoApp) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Min(9)])
|
||||
.split(area);
|
||||
|
||||
// Status message
|
||||
let status_text = format!(
|
||||
"Mode: {:?} | Field: {}/{} | Pos: {} | {}",
|
||||
app.editor().mode(),
|
||||
app.editor().current_field() + 1,
|
||||
app.editor().data_provider().field_count(),
|
||||
app.editor().cursor_position(),
|
||||
app.message()
|
||||
);
|
||||
|
||||
let status = Paragraph::new(Line::from(Span::raw(status_text)))
|
||||
.block(Block::default().borders(Borders::ALL).title("🎯 Keymap Demo Status"));
|
||||
|
||||
f.render_widget(status, chunks[0]);
|
||||
|
||||
// Help text based on current mode
|
||||
let help_text = match app.editor().mode() {
|
||||
AppMode::ReadOnly => {
|
||||
"🎯 KEYMAP DEMO - All keys handled by centralized keymap system!\n\
|
||||
\n\
|
||||
📍 MOVEMENT: hjkl(basic) | w/b/e(words) | W/B/E(WORDS) | 0/$(line) | gg/G(fields)\n\
|
||||
🔥 MULTI-KEY: gg=first-field, ge=prev-word-end, gE=prev-WORD-end\n\
|
||||
✏️ MODES: i/a(insert) | v/V(visual) | o/O(open-line)\n\
|
||||
🗑️ DELETE: x/X(delete-char)\n\
|
||||
📂 FIELDS: Tab/Shift+Tab\n\
|
||||
\n\
|
||||
💡 Try multi-key sequences like 'gg' or 'ge' - watch the status for 'Waiting...'\n\
|
||||
🚪 Ctrl+C=quit | ?=help | F1/F2=client actions (not canvas)"
|
||||
}
|
||||
AppMode::Edit => {
|
||||
"✏️ INSERT MODE - Keys handled by keymap system\n\
|
||||
\n\
|
||||
🔄 NAVIGATION: arrows | Ctrl+arrows(words) | Home/End(line) | Tab/Shift+Tab(fields)\n\
|
||||
🗑️ DELETE: Backspace/Delete\n\
|
||||
🚪 EXIT: Esc=normal\n\
|
||||
\n\
|
||||
💡 Type text normally - the keymap handles navigation!"
|
||||
}
|
||||
AppMode::Highlight => {
|
||||
"🎯 VISUAL MODE - Selection extended by keymap movements\n\
|
||||
\n\
|
||||
📍 EXTEND: hjkl(basic) | w/b/e(words) | 0/$(line) | gg/G(fields)\n\
|
||||
🔄 SWITCH: V=toggle-line-mode\n\
|
||||
🚪 EXIT: Esc=normal\n\
|
||||
\n\
|
||||
💡 All movements extend the selection automatically!"
|
||||
}
|
||||
_ => "🎯 Keymap system active!"
|
||||
};
|
||||
|
||||
let help = Paragraph::new(help_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("🚀 Centralized Keymap System"))
|
||||
.style(Style::default().fg(Color::Gray));
|
||||
|
||||
f.render_widget(help, chunks[1]);
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("🎯 Canvas Keymap Demo");
|
||||
println!("✅ canvas-keymap feature: ENABLED");
|
||||
println!("🚀 Centralized key handling: ACTIVE");
|
||||
println!("📖 Multi-key sequences: SUPPORTED (gg, ge, gE, etc.)");
|
||||
println!();
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let app = KeymapDemoApp::new();
|
||||
let res = run_app(&mut terminal, app);
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(err) = res {
|
||||
println!("{err:?}");
|
||||
}
|
||||
|
||||
println!("🎯 Keymap demo completed!");
|
||||
Ok(())
|
||||
}
|
||||
@@ -9,6 +9,10 @@ use crate::DataProvider;
|
||||
#[cfg(feature = "suggestions")]
|
||||
use crate::SuggestionItem;
|
||||
|
||||
// NEW: Import keymap types when keymap feature is enabled
|
||||
#[cfg(feature = "keymap")]
|
||||
use crate::keymap::{CanvasKeyMap, KeySequenceTracker};
|
||||
|
||||
pub struct FormEditor<D: DataProvider> {
|
||||
pub(crate) ui_state: EditorState,
|
||||
pub(crate) data_provider: D,
|
||||
@@ -23,6 +27,12 @@ pub struct FormEditor<D: DataProvider> {
|
||||
+ Sync,
|
||||
>,
|
||||
>,
|
||||
|
||||
// NEW: Injected keymap and sequence tracker (keymap feature only)
|
||||
#[cfg(feature = "keymap")]
|
||||
pub(crate) keymap: Option<CanvasKeyMap>,
|
||||
#[cfg(feature = "keymap")]
|
||||
pub(crate) seq_tracker: KeySequenceTracker,
|
||||
}
|
||||
|
||||
impl<D: DataProvider> FormEditor<D> {
|
||||
@@ -47,6 +57,11 @@ impl<D: DataProvider> FormEditor<D> {
|
||||
suggestions: Vec::new(),
|
||||
#[cfg(feature = "validation")]
|
||||
external_validation_callback: None,
|
||||
// NEW: Initialize keymap fields
|
||||
#[cfg(feature = "keymap")]
|
||||
keymap: None,
|
||||
#[cfg(feature = "keymap")]
|
||||
seq_tracker: KeySequenceTracker::new(400), // 400ms default timeout
|
||||
};
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
@@ -70,6 +85,26 @@ impl<D: DataProvider> FormEditor<D> {
|
||||
}
|
||||
}
|
||||
|
||||
// NEW: Keymap management methods (keymap feature only)
|
||||
|
||||
/// Set the keymap for this editor instance
|
||||
#[cfg(feature = "keymap")]
|
||||
pub fn set_keymap(&mut self, keymap: CanvasKeyMap) {
|
||||
self.keymap = Some(keymap);
|
||||
}
|
||||
|
||||
/// Check if this editor has a keymap configured
|
||||
#[cfg(feature = "keymap")]
|
||||
pub fn has_keymap(&self) -> bool {
|
||||
self.keymap.is_some()
|
||||
}
|
||||
|
||||
/// Set the timeout for multi-key sequences (in milliseconds)
|
||||
#[cfg(feature = "keymap")]
|
||||
pub fn set_key_sequence_timeout_ms(&mut self, timeout_ms: u64) {
|
||||
self.seq_tracker = KeySequenceTracker::new(timeout_ms);
|
||||
}
|
||||
|
||||
// Library-internal, used by multiple modules
|
||||
pub(crate) fn current_text(&self) -> &str {
|
||||
let field_index = self.ui_state.current_field;
|
||||
|
||||
228
canvas/src/editor/key_input.rs
Normal file
228
canvas/src/editor/key_input.rs
Normal file
@@ -0,0 +1,228 @@
|
||||
// src/editor/key_input.rs
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::canvas::modes::AppMode;
|
||||
use crate::editor::FormEditor;
|
||||
use crate::DataProvider;
|
||||
|
||||
#[cfg(feature = "keymap")]
|
||||
use crate::keymap::{KeyEventOutcome, KeyStroke};
|
||||
|
||||
impl<D: DataProvider> FormEditor<D> {
|
||||
#[cfg(feature = "keymap")]
|
||||
pub fn handle_key_event(&mut self, evt: KeyEvent) -> KeyEventOutcome {
|
||||
// Check if keymap exists first
|
||||
if self.keymap.is_none() {
|
||||
return KeyEventOutcome::NotMatched;
|
||||
}
|
||||
|
||||
let mode = self.ui_state.current_mode;
|
||||
|
||||
// Convert event to normalized stroke
|
||||
let stroke = KeyStroke {
|
||||
code: evt.code,
|
||||
modifiers: evt.modifiers,
|
||||
};
|
||||
|
||||
// Add key to sequence tracker
|
||||
self.seq_tracker.add_key(stroke);
|
||||
|
||||
// Look up the action in keymap
|
||||
let (matched, is_prefix) = {
|
||||
let km = self.keymap.as_ref().unwrap();
|
||||
km.lookup(mode, self.seq_tracker.sequence())
|
||||
};
|
||||
|
||||
if let Some(action) = matched {
|
||||
// Clone the action string to avoid borrow checker issues
|
||||
let action_owned = action.to_string();
|
||||
let msg = self.dispatch_canvas_action(&action_owned);
|
||||
self.seq_tracker.reset();
|
||||
return KeyEventOutcome::Consumed(msg);
|
||||
}
|
||||
|
||||
if is_prefix {
|
||||
// Wait for more keys
|
||||
return KeyEventOutcome::Pending;
|
||||
}
|
||||
|
||||
// No match: reset sequence and try insert-char fallback in Edit
|
||||
self.seq_tracker.reset();
|
||||
|
||||
if mode == AppMode::Edit {
|
||||
if let KeyCode::Char(c) = evt.code {
|
||||
// Skip control/alt combos
|
||||
let m = evt.modifiers;
|
||||
let is_plain =
|
||||
m.is_empty() || m == KeyModifiers::SHIFT;
|
||||
if is_plain {
|
||||
if self.insert_char(c).is_ok() {
|
||||
return KeyEventOutcome::Consumed(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventOutcome::NotMatched
|
||||
}
|
||||
|
||||
#[cfg(feature = "keymap")]
|
||||
fn dispatch_canvas_action(&mut self, action: &str) -> Option<String> {
|
||||
match action {
|
||||
// Movement
|
||||
"move_left" => {
|
||||
let _ = self.move_left();
|
||||
None
|
||||
}
|
||||
"move_right" => {
|
||||
let _ = self.move_right();
|
||||
None
|
||||
}
|
||||
"move_up" => {
|
||||
let _ = self.move_up();
|
||||
None
|
||||
}
|
||||
"move_down" => {
|
||||
let _ = self.move_down();
|
||||
None
|
||||
}
|
||||
"next_field" => {
|
||||
let _ = self.next_field();
|
||||
None
|
||||
}
|
||||
"prev_field" => {
|
||||
let _ = self.prev_field();
|
||||
None
|
||||
}
|
||||
"move_line_start" => {
|
||||
self.move_line_start();
|
||||
None
|
||||
}
|
||||
"move_line_end" => {
|
||||
self.move_line_end();
|
||||
None
|
||||
}
|
||||
"move_first_line" => {
|
||||
let _ = self.move_first_line();
|
||||
None
|
||||
}
|
||||
"move_last_line" => {
|
||||
let _ = self.move_last_line();
|
||||
None
|
||||
}
|
||||
|
||||
// Word/big-word movement (cross-field aware)
|
||||
"move_word_next" => {
|
||||
self.move_word_next();
|
||||
None
|
||||
}
|
||||
"move_word_prev" => {
|
||||
self.move_word_prev();
|
||||
None
|
||||
}
|
||||
"move_word_end" => {
|
||||
self.move_word_end();
|
||||
None
|
||||
}
|
||||
"move_word_end_prev" => {
|
||||
self.move_word_end_prev();
|
||||
None
|
||||
}
|
||||
"move_big_word_next" => {
|
||||
self.move_big_word_next();
|
||||
None
|
||||
}
|
||||
"move_big_word_prev" => {
|
||||
self.move_big_word_prev();
|
||||
None
|
||||
}
|
||||
"move_big_word_end" => {
|
||||
self.move_big_word_end();
|
||||
None
|
||||
}
|
||||
"move_big_word_end_prev" => {
|
||||
self.move_big_word_end_prev();
|
||||
None
|
||||
}
|
||||
|
||||
// Editing
|
||||
"delete_char_backward" => {
|
||||
let _ = self.delete_backward();
|
||||
None
|
||||
}
|
||||
"delete_char_forward" => {
|
||||
let _ = self.delete_forward();
|
||||
None
|
||||
}
|
||||
"open_line_below" => {
|
||||
let _ = self.open_line_below();
|
||||
None
|
||||
}
|
||||
"open_line_above" => {
|
||||
let _ = self.open_line_above();
|
||||
None
|
||||
}
|
||||
|
||||
// Suggestions (only when feature is enabled)
|
||||
#[cfg(feature = "suggestions")]
|
||||
"open_suggestions" => {
|
||||
let idx = self.current_field();
|
||||
self.open_suggestions(idx);
|
||||
None
|
||||
}
|
||||
#[cfg(feature = "suggestions")]
|
||||
"apply_suggestion" | "enter_decider" => {
|
||||
if let Some(_applied) = self.apply_suggestion() {
|
||||
None
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "suggestions")]
|
||||
"suggestion_down" => {
|
||||
self.suggestions_next();
|
||||
None
|
||||
}
|
||||
#[cfg(feature = "suggestions")]
|
||||
"suggestion_up" => {
|
||||
self.suggestions_prev();
|
||||
None
|
||||
}
|
||||
|
||||
// Mode transitions (vim-like)
|
||||
"enter_edit_mode_before" => {
|
||||
self.enter_edit_mode();
|
||||
None
|
||||
}
|
||||
"enter_edit_mode_after" => {
|
||||
// Move forward 1 char if possible (vim 'a'), then enter insert
|
||||
let txt_len = self.current_text().chars().count();
|
||||
let pos = self.ui_state.cursor_pos;
|
||||
if pos < txt_len {
|
||||
self.ui_state.cursor_pos = pos + 1;
|
||||
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||
}
|
||||
self.enter_edit_mode();
|
||||
None
|
||||
}
|
||||
"exit" | "exit_edit_mode" => {
|
||||
let _ = self.exit_edit_mode();
|
||||
None
|
||||
}
|
||||
"enter_highlight_mode" => {
|
||||
self.enter_highlight_mode();
|
||||
None
|
||||
}
|
||||
"enter_highlight_mode_linewise" => {
|
||||
self.enter_highlight_line_mode();
|
||||
None
|
||||
}
|
||||
"exit_highlight_mode" => {
|
||||
self.exit_highlight_mode();
|
||||
None
|
||||
}
|
||||
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,5 +21,8 @@ pub mod validation_helpers;
|
||||
#[cfg(feature = "computed")]
|
||||
pub mod computed_helpers;
|
||||
|
||||
#[cfg(feature = "keymap")]
|
||||
pub mod key_input;
|
||||
|
||||
// Re-export the main type
|
||||
pub use core::FormEditor;
|
||||
|
||||
@@ -133,6 +133,21 @@ impl<D: DataProvider> FormEditor<D> {
|
||||
self.update_inline_completion();
|
||||
}
|
||||
|
||||
pub fn suggestions_prev(&mut self) {
|
||||
if !self.ui_state.suggestions.is_active || self.suggestions.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let current = self.ui_state.suggestions.selected_index.unwrap_or(0);
|
||||
let prev = if current == 0 {
|
||||
self.suggestions.len() - 1
|
||||
} else {
|
||||
current - 1
|
||||
};
|
||||
self.ui_state.suggestions.selected_index = Some(prev);
|
||||
self.update_inline_completion();
|
||||
}
|
||||
|
||||
pub fn apply_suggestion(&mut self) -> Option<String> {
|
||||
if let Some(selected_index) = self.ui_state.suggestions.selected_index {
|
||||
if let Some(suggestion) = self.suggestions.get(selected_index).cloned()
|
||||
|
||||
344
canvas/src/keymap/mod.rs
Normal file
344
canvas/src/keymap/mod.rs
Normal file
@@ -0,0 +1,344 @@
|
||||
// src/keymap/mod.rs
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
|
||||
use crate::canvas::modes::AppMode;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct KeyStroke {
|
||||
pub code: KeyCode,
|
||||
pub modifiers: KeyModifiers,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Binding {
|
||||
action: String,
|
||||
sequence: Vec<KeyStroke>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CanvasKeyMap {
|
||||
ro: Vec<Binding>,
|
||||
edit: Vec<Binding>,
|
||||
hl: Vec<Binding>,
|
||||
}
|
||||
|
||||
// FIXED: Removed Copy because Option<String> is not Copy
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum KeyEventOutcome {
|
||||
Consumed(Option<String>),
|
||||
Pending,
|
||||
NotMatched,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeySequenceTracker {
|
||||
sequence: Vec<KeyStroke>,
|
||||
last_key_time: Instant,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl KeySequenceTracker {
|
||||
pub fn new(timeout_ms: u64) -> Self {
|
||||
Self {
|
||||
sequence: Vec::new(),
|
||||
last_key_time: Instant::now(),
|
||||
timeout: Duration::from_millis(timeout_ms),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.sequence.clear();
|
||||
self.last_key_time = Instant::now();
|
||||
}
|
||||
|
||||
pub fn add_key(&mut self, stroke: KeyStroke) {
|
||||
let now = Instant::now();
|
||||
if now.duration_since(self.last_key_time) > self.timeout {
|
||||
self.reset();
|
||||
}
|
||||
self.sequence.push(normalize_stroke(stroke));
|
||||
self.last_key_time = now;
|
||||
}
|
||||
|
||||
pub fn sequence(&self) -> &[KeyStroke] {
|
||||
&self.sequence
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_stroke(mut s: KeyStroke) -> KeyStroke {
|
||||
// Normalize Shift+Tab to BackTab
|
||||
let is_shift_tab =
|
||||
s.code == KeyCode::Tab && s.modifiers.contains(KeyModifiers::SHIFT);
|
||||
if is_shift_tab {
|
||||
s.code = KeyCode::BackTab;
|
||||
s.modifiers.remove(KeyModifiers::SHIFT);
|
||||
return s;
|
||||
}
|
||||
|
||||
// Normalize Shift+char to uppercase char without SHIFT when possible
|
||||
if let KeyCode::Char(c) = s.code {
|
||||
if s.modifiers.contains(KeyModifiers::SHIFT) {
|
||||
let mut up = c;
|
||||
// Only letters transform meaningfully
|
||||
if c.is_ascii_alphabetic() {
|
||||
up = c.to_ascii_uppercase();
|
||||
}
|
||||
s.code = KeyCode::Char(up);
|
||||
s.modifiers.remove(KeyModifiers::SHIFT);
|
||||
return s;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
impl CanvasKeyMap {
|
||||
pub fn from_mode_maps(
|
||||
read_only: &HashMap<String, Vec<String>>,
|
||||
edit: &HashMap<String, Vec<String>>,
|
||||
highlight: &HashMap<String, Vec<String>>,
|
||||
) -> Self {
|
||||
let mut km = Self::default();
|
||||
km.ro = collect_bindings(read_only);
|
||||
km.edit = collect_bindings(edit);
|
||||
km.hl = collect_bindings(highlight);
|
||||
km
|
||||
}
|
||||
|
||||
pub fn lookup(
|
||||
&self,
|
||||
mode: AppMode,
|
||||
seq: &[KeyStroke],
|
||||
) -> (Option<&str>, bool) {
|
||||
let bindings = match mode {
|
||||
AppMode::ReadOnly => &self.ro,
|
||||
AppMode::Edit => &self.edit,
|
||||
AppMode::Highlight => &self.hl,
|
||||
_ => return (None, false),
|
||||
};
|
||||
|
||||
if seq.is_empty() {
|
||||
return (None, false);
|
||||
}
|
||||
|
||||
// Exact match
|
||||
for b in bindings {
|
||||
if sequences_equal(&b.sequence, seq) {
|
||||
return (Some(b.action.as_str()), false);
|
||||
}
|
||||
}
|
||||
|
||||
// Prefix match
|
||||
for b in bindings {
|
||||
if is_prefix(&b.sequence, seq) {
|
||||
return (None, true);
|
||||
}
|
||||
}
|
||||
|
||||
(None, false)
|
||||
}
|
||||
}
|
||||
|
||||
fn sequences_equal(a: &[KeyStroke], b: &[KeyStroke]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.iter().zip(b.iter()).all(|(x, y)| strokes_equal(x, y))
|
||||
}
|
||||
|
||||
fn strokes_equal(a: &KeyStroke, b: &KeyStroke) -> bool {
|
||||
// Both KeyStroke are already normalized
|
||||
a.code == b.code && a.modifiers == b.modifiers
|
||||
}
|
||||
|
||||
fn is_prefix(binding: &[KeyStroke], seq: &[KeyStroke]) -> bool {
|
||||
if seq.len() >= binding.len() {
|
||||
return false;
|
||||
}
|
||||
binding
|
||||
.iter()
|
||||
.zip(seq.iter())
|
||||
.all(|(b, s)| strokes_equal(b, s))
|
||||
}
|
||||
|
||||
fn collect_bindings(
|
||||
mode_map: &HashMap<String, Vec<String>>,
|
||||
) -> Vec<Binding> {
|
||||
let mut out = Vec::new();
|
||||
for (action, list) in mode_map {
|
||||
for binding_str in list {
|
||||
if let Some(seq) = parse_binding_to_sequence(binding_str) {
|
||||
out.push(Binding {
|
||||
action: action.to_string(),
|
||||
sequence: seq,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_binding_to_sequence(input: &str) -> Option<Vec<KeyStroke>> {
|
||||
let s = input.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let has_space = s.contains(' ');
|
||||
let has_plus = s.contains('+');
|
||||
|
||||
if has_space {
|
||||
let mut seq = Vec::new();
|
||||
for part in s.split_whitespace() {
|
||||
if let Some(mut strokes) = parse_part_to_sequence(part) {
|
||||
seq.append(&mut strokes);
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
return Some(seq);
|
||||
}
|
||||
|
||||
if has_plus {
|
||||
if contains_modifier_token(s) {
|
||||
if let Some(k) = parse_chord_with_modifiers(s) {
|
||||
return Some(vec![k]);
|
||||
}
|
||||
return None;
|
||||
} else {
|
||||
let mut seq = Vec::new();
|
||||
for t in s.split('+') {
|
||||
if let Some(mut strokes) = parse_part_to_sequence(t) {
|
||||
seq.append(&mut strokes);
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
return Some(seq);
|
||||
}
|
||||
}
|
||||
|
||||
if is_compound_key(s) {
|
||||
if let Some(k) = parse_simple_key(s) {
|
||||
return Some(vec![k]);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
if s.len() > 1 {
|
||||
let mut seq = Vec::new();
|
||||
for ch in s.chars() {
|
||||
seq.push(KeyStroke {
|
||||
code: KeyCode::Char(ch),
|
||||
modifiers: KeyModifiers::empty(),
|
||||
});
|
||||
}
|
||||
return Some(seq);
|
||||
}
|
||||
|
||||
if let Some(k) = parse_simple_key(s) {
|
||||
return Some(vec![k]);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_part_to_sequence(part: &str) -> Option<Vec<KeyStroke>> {
|
||||
let p = part.trim();
|
||||
if p.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if p.contains('+') && contains_modifier_token(p) {
|
||||
if let Some(k) = parse_chord_with_modifiers(p) {
|
||||
return Some(vec![k]);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
if is_compound_key(p) {
|
||||
if let Some(k) = parse_simple_key(p) {
|
||||
return Some(vec![k]);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
if p.len() > 1 {
|
||||
let mut seq = Vec::new();
|
||||
for ch in p.chars() {
|
||||
seq.push(KeyStroke {
|
||||
code: KeyCode::Char(ch),
|
||||
modifiers: KeyModifiers::empty(),
|
||||
});
|
||||
}
|
||||
return Some(seq);
|
||||
}
|
||||
|
||||
parse_simple_key(p).map(|k| vec![k])
|
||||
}
|
||||
|
||||
fn contains_modifier_token(s: &str) -> bool {
|
||||
let low = s.to_lowercase();
|
||||
low.contains("ctrl") || low.contains("shift") || low.contains("alt") ||
|
||||
low.contains("super") || low.contains("cmd") || low.contains("meta")
|
||||
}
|
||||
|
||||
fn parse_chord_with_modifiers(s: &str) -> Option<KeyStroke> {
|
||||
let mut mods = KeyModifiers::empty();
|
||||
let mut key: Option<KeyCode> = None;
|
||||
|
||||
for comp in s.split('+') {
|
||||
match comp.to_lowercase().as_str() {
|
||||
"ctrl" => mods |= KeyModifiers::CONTROL,
|
||||
"shift" => mods |= KeyModifiers::SHIFT,
|
||||
"alt" => mods |= KeyModifiers::ALT,
|
||||
"super" | "cmd" => mods |= KeyModifiers::SUPER,
|
||||
"meta" => mods |= KeyModifiers::META,
|
||||
other => {
|
||||
key = string_to_keycode(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
key.map(|k| normalize_stroke(KeyStroke { code: k, modifiers: mods }))
|
||||
}
|
||||
|
||||
fn is_compound_key(s: &str) -> bool {
|
||||
matches!(s.to_lowercase().as_str(),
|
||||
"left" | "right" | "up" | "down" | "esc" | "enter" | "backspace" |
|
||||
"delete" | "tab" | "home" | "end" | "$" | "0"
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_simple_key(s: &str) -> Option<KeyStroke> {
|
||||
if let Some(kc) = string_to_keycode(&s.to_lowercase()) {
|
||||
return Some(KeyStroke { code: kc, modifiers: KeyModifiers::empty() });
|
||||
}
|
||||
|
||||
if s.chars().count() == 1 {
|
||||
let ch = s.chars().next().unwrap();
|
||||
return Some(KeyStroke { code: KeyCode::Char(ch), modifiers: KeyModifiers::empty() });
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn string_to_keycode(s: &str) -> Option<KeyCode> {
|
||||
Some(match s {
|
||||
"left" => KeyCode::Left,
|
||||
"right" => KeyCode::Right,
|
||||
"up" => KeyCode::Up,
|
||||
"down" => KeyCode::Down,
|
||||
"esc" => KeyCode::Esc,
|
||||
"enter" => KeyCode::Enter,
|
||||
"backspace" => KeyCode::Backspace,
|
||||
"delete" => KeyCode::Delete,
|
||||
"tab" => KeyCode::Tab,
|
||||
"home" => KeyCode::Home,
|
||||
"end" => KeyCode::End,
|
||||
"$" => KeyCode::Char('$'),
|
||||
"0" => KeyCode::Char('0'),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -4,22 +4,21 @@ pub mod canvas;
|
||||
pub mod editor;
|
||||
pub mod data_provider;
|
||||
|
||||
// Only include suggestions module if feature is enabled
|
||||
#[cfg(feature = "suggestions")]
|
||||
pub mod suggestions;
|
||||
|
||||
// Only include validation module if feature is enabled
|
||||
#[cfg(feature = "validation")]
|
||||
pub mod validation;
|
||||
|
||||
// First-class textarea module and exports
|
||||
#[cfg(feature = "textarea")]
|
||||
pub mod textarea;
|
||||
|
||||
// Only include computed module if feature is enabled
|
||||
#[cfg(feature = "computed")]
|
||||
pub mod computed;
|
||||
|
||||
#[cfg(feature = "keymap")]
|
||||
pub mod keymap;
|
||||
|
||||
#[cfg(feature = "cursor-style")]
|
||||
pub use canvas::CursorManager;
|
||||
|
||||
@@ -71,6 +70,8 @@ pub use canvas::gui::{CanvasDisplayOptions, OverflowMode};
|
||||
#[cfg(all(feature = "gui", feature = "suggestions"))]
|
||||
pub use suggestions::gui::render_suggestions_dropdown;
|
||||
|
||||
#[cfg(feature = "keymap")]
|
||||
pub use keymap::{CanvasKeyMap, KeyEventOutcome};
|
||||
|
||||
#[cfg(feature = "textarea")]
|
||||
pub use textarea::{TextArea, TextAreaProvider, TextAreaState, TextAreaEditor};
|
||||
|
||||
@@ -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
1
client/.gitignore
vendored
@@ -1 +1,2 @@
|
||||
canvas_config.toml.txt
|
||||
ui_debug.log
|
||||
|
||||
@@ -8,7 +8,7 @@ license.workspace = true
|
||||
anyhow = { workspace = true }
|
||||
async-trait = "0.1.88"
|
||||
common = { path = "../common" }
|
||||
canvas = { path = "../canvas", features = ["gui", "suggestions"] }
|
||||
canvas = { path = "../canvas", features = ["gui", "suggestions", "cursor-style", "keymap", "validation"] }
|
||||
|
||||
ratatui = { workspace = true }
|
||||
crossterm = { workspace = true }
|
||||
@@ -24,14 +24,15 @@ 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
|
||||
|
||||
[features]
|
||||
default = []
|
||||
default = ["validation"]
|
||||
ui-debug = []
|
||||
validation = []
|
||||
|
||||
[dev-dependencies]
|
||||
rstest = "0.25.0"
|
||||
|
||||
@@ -5,18 +5,17 @@ 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]
|
||||
move_up = ["k", "Up"]
|
||||
move_down = ["j", "Down"]
|
||||
next_option = ["l", "Right"]
|
||||
previous_option = ["h", "Left"]
|
||||
up = ["k", "Up"]
|
||||
down = ["j", "Down"]
|
||||
left = ["h", "Left"]
|
||||
right = ["l", "Right"]
|
||||
next = ["Tab"]
|
||||
previous = ["Shift+Tab"]
|
||||
select = ["Enter"]
|
||||
toggle_sidebar = ["ctrl+t"]
|
||||
toggle_buffer_list = ["ctrl+b"]
|
||||
next_field = ["Tab"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
exit_table_scroll = ["esc"]
|
||||
esc = ["esc"]
|
||||
open_search = ["ctrl+f"]
|
||||
|
||||
[keybindings.common]
|
||||
@@ -29,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
|
||||
@@ -40,7 +38,7 @@ previous_entry = ["left","q"]
|
||||
next_entry = ["right","1"]
|
||||
|
||||
enter_highlight_mode = ["v"]
|
||||
enter_highlight_mode_linewise = ["ctrl+v"]
|
||||
enter_highlight_mode_linewise = ["shift+v"]
|
||||
|
||||
### AUTOGENERATED CANVAS CONFIG
|
||||
# Required
|
||||
@@ -50,7 +48,7 @@ move_right = ["l", "Right"]
|
||||
move_down = ["j", "Down"]
|
||||
# Optional
|
||||
move_line_end = ["$"]
|
||||
# move_word_next = ["w"]
|
||||
move_word_next = ["w"]
|
||||
next_field = ["Tab"]
|
||||
move_word_prev = ["b"]
|
||||
move_word_end = ["e"]
|
||||
@@ -62,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
|
||||
@@ -91,23 +89,23 @@ suggestion_up = ["ctrl+p", "shift+tab"]
|
||||
|
||||
### AUTOGENERATED CANVAS CONFIG
|
||||
# Required
|
||||
move_right = ["Right", "l"]
|
||||
move_right = ["Right"]
|
||||
delete_char_backward = ["Backspace"]
|
||||
next_field = ["Tab", "Enter"]
|
||||
move_up = ["Up", "k"]
|
||||
move_down = ["Down", "j"]
|
||||
move_up = ["Up"]
|
||||
move_down = ["Down"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
move_left = ["Left", "h"]
|
||||
move_left = ["Left"]
|
||||
# Optional
|
||||
move_last_line = ["Ctrl+End", "G"]
|
||||
move_last_line = ["Ctrl+End"]
|
||||
delete_char_forward = ["Delete"]
|
||||
move_word_prev = ["Ctrl+Left", "b"]
|
||||
move_word_end = ["e"]
|
||||
move_word_end_prev = ["ge"]
|
||||
move_first_line = ["Ctrl+Home", "gg"]
|
||||
move_word_next = ["Ctrl+Right", "w"]
|
||||
move_line_start = ["Home", "0"]
|
||||
move_line_end = ["End", "$"]
|
||||
move_word_prev = ["Ctrl+Left"]
|
||||
# move_word_end = ["e"]
|
||||
# move_word_end_prev = ["ge"]
|
||||
move_first_line = ["Ctrl+Home"]
|
||||
move_word_next = ["Ctrl+Right"]
|
||||
move_line_start = ["Home"]
|
||||
move_line_end = ["End"]
|
||||
|
||||
[keybindings.command]
|
||||
exit_command_mode = ["ctrl+g", "esc"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// src/components/common/find_file_palette.rs
|
||||
// src/bottom_panel/find_file_palette.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::modes::general::command_navigation::NavigationState; // Corrected path
|
||||
98
client/src/bottom_panel/layout.rs
Normal file
98
client/src/bottom_panel/layout.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
// src/bottom_panel/layout.rs
|
||||
|
||||
use ratatui::{layout::Constraint, layout::Rect, Frame};
|
||||
use crate::bottom_panel::{status_line::render_status_line, command_line::render_command_line};
|
||||
use crate::bottom_panel::find_file_palette;
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::modes::general::command_navigation::NavigationState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::pages::routing::Router;
|
||||
|
||||
/// Calculate the layout constraints for the bottom panel (status line + command line/palette).
|
||||
pub fn bottom_panel_constraints(
|
||||
app_state: &AppState,
|
||||
navigation_state: &NavigationState,
|
||||
event_handler_command_mode_active: bool,
|
||||
) -> Vec<Constraint> {
|
||||
let mut status_line_height = 1;
|
||||
#[cfg(feature = "ui-debug")]
|
||||
{
|
||||
if let Some(debug_state) = &app_state.debug_state {
|
||||
if debug_state.is_error {
|
||||
status_line_height = 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT: u16 = 15;
|
||||
let command_palette_area_height = if navigation_state.active {
|
||||
1 + PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT
|
||||
} else if event_handler_command_mode_active {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let mut constraints = vec![Constraint::Length(status_line_height)];
|
||||
if command_palette_area_height > 0 {
|
||||
constraints.push(Constraint::Length(command_palette_area_height));
|
||||
}
|
||||
constraints
|
||||
}
|
||||
|
||||
/// Render the bottom panel (status line + command line/palette).
|
||||
pub fn render_bottom_panel(
|
||||
f: &mut Frame,
|
||||
root_chunks: &[Rect],
|
||||
chunk_idx: &mut usize,
|
||||
current_dir: &str,
|
||||
theme: &Theme,
|
||||
current_fps: f64,
|
||||
app_state: &AppState,
|
||||
router: &Router,
|
||||
navigation_state: &NavigationState,
|
||||
event_handler_command_input: &str,
|
||||
event_handler_command_mode_active: bool,
|
||||
event_handler_command_message: &str,
|
||||
) {
|
||||
// --- Status line area ---
|
||||
let status_line_area = root_chunks[*chunk_idx];
|
||||
*chunk_idx += 1;
|
||||
|
||||
// --- Command line / palette area ---
|
||||
let command_render_area = if root_chunks.len() > *chunk_idx {
|
||||
Some(root_chunks[*chunk_idx])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if command_render_area.is_some() {
|
||||
*chunk_idx += 1;
|
||||
}
|
||||
|
||||
// --- Render status line ---
|
||||
render_status_line(
|
||||
f,
|
||||
status_line_area,
|
||||
current_dir,
|
||||
theme,
|
||||
current_fps,
|
||||
app_state,
|
||||
router,
|
||||
);
|
||||
|
||||
// --- Render command line or palette ---
|
||||
if let Some(area) = command_render_area {
|
||||
if navigation_state.active {
|
||||
find_file_palette::render_find_file_palette(f, area, theme, navigation_state);
|
||||
} else if event_handler_command_mode_active {
|
||||
render_command_line(
|
||||
f,
|
||||
area,
|
||||
event_handler_command_input,
|
||||
true,
|
||||
theme,
|
||||
event_handler_command_message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
6
client/src/bottom_panel/mod.rs
Normal file
6
client/src/bottom_panel/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// src/bottom_panel/mod.rs
|
||||
|
||||
pub mod status_line;
|
||||
pub mod command_line;
|
||||
pub mod layout;
|
||||
pub mod find_file_palette;
|
||||
@@ -5,10 +5,11 @@ use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span, Text},
|
||||
widgets::Paragraph,
|
||||
widgets::{Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
use ratatui::widgets::Wrap;
|
||||
use crate::pages::routing::Page;
|
||||
use crate::pages::routing::Router;
|
||||
use std::path::Path;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
@@ -17,9 +18,9 @@ pub fn render_status_line(
|
||||
area: Rect,
|
||||
current_dir: &str,
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool,
|
||||
current_fps: f64,
|
||||
app_state: &AppState,
|
||||
router: &Router,
|
||||
) {
|
||||
#[cfg(feature = "ui-debug")]
|
||||
{
|
||||
@@ -49,7 +50,20 @@ pub fn render_status_line(
|
||||
|
||||
// --- The normal status line rendering logic (unchanged) ---
|
||||
let program_info = format!("komp_ac v{}", env!("CARGO_PKG_VERSION"));
|
||||
let mode_text = if is_edit_mode { "[EDIT]" } else { "[READ-ONLY]" };
|
||||
let mode_text = if let Page::Form(path) = &router.current {
|
||||
if let Some(editor) = app_state.editor_for_path_ref(path) {
|
||||
match editor.mode() {
|
||||
canvas::AppMode::Edit => "[EDIT]",
|
||||
canvas::AppMode::ReadOnly => "[READ-ONLY]",
|
||||
canvas::AppMode::Highlight => "[VISUAL]",
|
||||
_ => "",
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} else {
|
||||
"" // No canvas active
|
||||
};
|
||||
|
||||
let home_dir = dirs::home_dir()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
@@ -1,13 +1,13 @@
|
||||
// src/functions/common/buffer.rs
|
||||
// src/buffer/functions/buffer.rs
|
||||
|
||||
use crate::state::app::buffer::BufferState;
|
||||
use crate::state::app::buffer::AppView;
|
||||
use crate::buffer::state::BufferState;
|
||||
use crate::buffer::state::AppView;
|
||||
|
||||
pub fn get_view_layer(view: &AppView) -> u8 {
|
||||
match view {
|
||||
AppView::Intro => 1,
|
||||
AppView::Login | AppView::Register | AppView::Admin | AppView::AddTable | AppView::AddLogic => 2,
|
||||
AppView::Form | AppView::Scratch => 3,
|
||||
AppView::Form(_) | AppView::Scratch => 3,
|
||||
}
|
||||
}
|
||||
|
||||
20
client/src/buffer/logic.rs
Normal file
20
client/src/buffer/logic.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
// src/buffer/logic.rs
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::state::app::state::UiState;
|
||||
|
||||
/// Toggle the buffer list visibility based on keybindings.
|
||||
pub fn toggle_buffer_list(
|
||||
ui_state: &mut UiState,
|
||||
config: &Config,
|
||||
key: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
) -> bool {
|
||||
if let Some(action) = config.get_common_action(key, modifiers) {
|
||||
if action == "toggle_buffer_list" {
|
||||
ui_state.show_buffer_list = !ui_state.show_buffer_list;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
11
client/src/buffer/mod.rs
Normal file
11
client/src/buffer/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
// src/buffer/mod.rs
|
||||
|
||||
pub mod state;
|
||||
pub mod functions;
|
||||
pub mod ui;
|
||||
pub mod logic;
|
||||
|
||||
pub use state::{AppView, BufferState};
|
||||
pub use functions::*;
|
||||
pub use ui::render_buffer_list;
|
||||
pub use logic::toggle_buffer_list;
|
||||
@@ -1,4 +1,4 @@
|
||||
// src/state/app/buffer.rs
|
||||
// src/buffer/state/buffer.rs
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AppView {
|
||||
@@ -8,7 +8,7 @@ pub enum AppView {
|
||||
Admin,
|
||||
AddTable,
|
||||
AddLogic,
|
||||
Form,
|
||||
Form(String),
|
||||
Scratch,
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ impl AppView {
|
||||
AppView::Admin => "Admin_Panel",
|
||||
AppView::AddTable => "Add_Table",
|
||||
AppView::AddLogic => "Add_Logic",
|
||||
AppView::Form => "Form",
|
||||
AppView::Form(_) => "Form",
|
||||
AppView::Scratch => "*scratch*",
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,14 @@ impl AppView {
|
||||
/// Returns the display name with dynamic context (for Form buffers)
|
||||
pub fn display_name_with_context(&self, current_table_name: Option<&str>) -> String {
|
||||
match self {
|
||||
AppView::Form => {
|
||||
current_table_name
|
||||
.unwrap_or("Data Form")
|
||||
.to_string()
|
||||
AppView::Form(path) => {
|
||||
// Derive table name from "profile/table" path
|
||||
let table = path.split('/').nth(1).unwrap_or("");
|
||||
if !table.is_empty() {
|
||||
table.to_string()
|
||||
} else {
|
||||
current_table_name.unwrap_or("Data Form").to_string()
|
||||
}
|
||||
}
|
||||
_ => self.display_name().to_string(),
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/components/handlers/buffer_list.rs
|
||||
// src/buffer/ui.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::buffer::BufferState;
|
||||
use crate::buffer::state::BufferState;
|
||||
use crate::state::app::state::AppState; // Add this import
|
||||
use ratatui::{
|
||||
layout::{Alignment, Rect},
|
||||
@@ -11,7 +11,7 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
use crate::functions::common::buffer::get_view_layer;
|
||||
use crate::buffer::functions::get_view_layer;
|
||||
|
||||
pub fn render_buffer_list(
|
||||
f: &mut Frame,
|
||||
@@ -1,10 +0,0 @@
|
||||
// src/components/admin.rs
|
||||
pub mod admin_panel;
|
||||
pub mod admin_panel_admin;
|
||||
pub mod add_table;
|
||||
pub mod add_logic;
|
||||
|
||||
pub use admin_panel::*;
|
||||
pub use admin_panel_admin::*;
|
||||
pub use add_table::*;
|
||||
pub use add_logic::*;
|
||||
@@ -1,6 +0,0 @@
|
||||
// src/components/form.rs
|
||||
pub mod login;
|
||||
pub mod register;
|
||||
|
||||
pub use login::*;
|
||||
pub use register::*;
|
||||
@@ -1,18 +1,9 @@
|
||||
// src/components/common.rs
|
||||
pub mod command_line;
|
||||
pub mod status_line;
|
||||
|
||||
pub mod text_editor;
|
||||
pub mod background;
|
||||
pub mod dialog;
|
||||
pub mod autocomplete;
|
||||
pub mod search_palette;
|
||||
pub mod find_file_palette;
|
||||
|
||||
pub use command_line::*;
|
||||
pub use status_line::*;
|
||||
pub use text_editor::*;
|
||||
pub use background::*;
|
||||
pub use dialog::*;
|
||||
pub use autocomplete::*;
|
||||
pub use search_palette::*;
|
||||
pub use find_file_palette::*;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// src/components/common/autocomplete.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::pages::form::FormState;
|
||||
use common::proto::komp_ac::search::search_response::Hit;
|
||||
use crate::pages::forms::FormState;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
// src/components/form.rs
|
||||
pub mod form;
|
||||
|
||||
pub use form::*;
|
||||
@@ -1,6 +0,0 @@
|
||||
// src/components/handlers.rs
|
||||
pub mod sidebar;
|
||||
pub mod buffer_list;
|
||||
|
||||
pub use sidebar::*;
|
||||
pub use buffer_list::*;
|
||||
@@ -1,4 +0,0 @@
|
||||
// src/components/intro.rs
|
||||
pub mod intro;
|
||||
|
||||
pub use intro::*;
|
||||
@@ -1,16 +1,7 @@
|
||||
// src/components/mod.rs
|
||||
pub mod handlers;
|
||||
pub mod intro;
|
||||
pub mod admin;
|
||||
|
||||
pub mod common;
|
||||
pub mod form;
|
||||
pub mod auth;
|
||||
pub mod utils;
|
||||
|
||||
pub use handlers::*;
|
||||
pub use intro::*;
|
||||
pub use admin::*;
|
||||
pub use common::*;
|
||||
pub use form::*;
|
||||
pub use auth::*;
|
||||
pub use utils::*;
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use anyhow::{Context, Result};
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
use canvas::CanvasKeyMap;
|
||||
|
||||
// NEW: Editor Keybinding Mode Enum
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -147,19 +148,17 @@ impl Config {
|
||||
/// Context-aware keybinding resolution
|
||||
pub fn get_action_for_current_context(
|
||||
&self,
|
||||
is_edit_mode: bool,
|
||||
command_mode: bool,
|
||||
key: KeyCode,
|
||||
modifiers: KeyModifiers
|
||||
) -> Option<&str> {
|
||||
match (command_mode, is_edit_mode) {
|
||||
(true, _) => self.get_command_action_for_key(key, modifiers),
|
||||
(_, true) => self.get_edit_action_for_key(key, modifiers)
|
||||
.or_else(|| self.get_common_action(key, modifiers)),
|
||||
_ => self.get_read_only_action_for_key(key, modifiers)
|
||||
if command_mode {
|
||||
self.get_command_action_for_key(key, modifiers)
|
||||
} else {
|
||||
// fallback: read-only + common + global
|
||||
self.get_read_only_action_for_key(key, modifiers)
|
||||
.or_else(|| self.get_common_action(key, modifiers))
|
||||
// Add global bindings check for read-only mode
|
||||
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers)),
|
||||
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,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,
|
||||
@@ -372,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,
|
||||
@@ -760,4 +795,74 @@ impl Config {
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Unified action resolver for app-level actions
|
||||
pub fn get_app_action(
|
||||
&self,
|
||||
key_code: crossterm::event::KeyCode,
|
||||
modifiers: crossterm::event::KeyModifiers,
|
||||
) -> Option<&str> {
|
||||
// First check common actions
|
||||
if let Some(action) = self.get_common_action(key_code, modifiers) {
|
||||
return Some(action);
|
||||
}
|
||||
|
||||
// Then check read-only mode actions
|
||||
if let Some(action) = self.get_read_only_action_for_key(key_code, modifiers) {
|
||||
return Some(action);
|
||||
}
|
||||
|
||||
// Then check highlight mode actions
|
||||
if let Some(action) = self.get_highlight_action_for_key(key_code, modifiers) {
|
||||
return Some(action);
|
||||
}
|
||||
|
||||
// Then check edit mode actions
|
||||
if let Some(action) = self.get_edit_action_for_key(key_code, modifiers) {
|
||||
return Some(action);
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
82
client/src/dialog/functions.rs
Normal file
82
client/src/dialog/functions.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
// src/dialog/functions.rs
|
||||
|
||||
use crate::dialog::DialogState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
|
||||
impl AppState {
|
||||
pub fn show_dialog(
|
||||
&mut self,
|
||||
title: &str,
|
||||
message: &str,
|
||||
buttons: Vec<String>,
|
||||
purpose: DialogPurpose,
|
||||
) {
|
||||
self.ui.dialog.dialog_title = title.to_string();
|
||||
self.ui.dialog.dialog_message = message.to_string();
|
||||
self.ui.dialog.dialog_buttons = buttons;
|
||||
self.ui.dialog.dialog_active_button_index = 0;
|
||||
self.ui.dialog.purpose = Some(purpose);
|
||||
self.ui.dialog.is_loading = false;
|
||||
self.ui.dialog.dialog_show = true;
|
||||
}
|
||||
|
||||
pub fn show_loading_dialog(&mut self, title: &str, message: &str) {
|
||||
self.ui.dialog.dialog_title = title.to_string();
|
||||
self.ui.dialog.dialog_message = message.to_string();
|
||||
self.ui.dialog.dialog_buttons.clear();
|
||||
self.ui.dialog.dialog_active_button_index = 0;
|
||||
self.ui.dialog.purpose = None;
|
||||
self.ui.dialog.is_loading = true;
|
||||
self.ui.dialog.dialog_show = true;
|
||||
}
|
||||
|
||||
pub fn update_dialog_content(
|
||||
&mut self,
|
||||
message: &str,
|
||||
buttons: Vec<String>,
|
||||
purpose: DialogPurpose,
|
||||
) {
|
||||
if self.ui.dialog.dialog_show {
|
||||
self.ui.dialog.dialog_message = message.to_string();
|
||||
self.ui.dialog.dialog_buttons = buttons;
|
||||
self.ui.dialog.dialog_active_button_index = 0;
|
||||
self.ui.dialog.purpose = Some(purpose);
|
||||
self.ui.dialog.is_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hide_dialog(&mut self) {
|
||||
self.ui.dialog.dialog_show = false;
|
||||
self.ui.dialog.dialog_title.clear();
|
||||
self.ui.dialog.dialog_message.clear();
|
||||
self.ui.dialog.dialog_buttons.clear();
|
||||
self.ui.dialog.dialog_active_button_index = 0;
|
||||
self.ui.dialog.purpose = None;
|
||||
self.ui.dialog.is_loading = false;
|
||||
}
|
||||
|
||||
pub fn next_dialog_button(&mut self) {
|
||||
if !self.ui.dialog.dialog_buttons.is_empty() {
|
||||
let next_index = (self.ui.dialog.dialog_active_button_index + 1)
|
||||
% self.ui.dialog.dialog_buttons.len();
|
||||
self.ui.dialog.dialog_active_button_index = next_index;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn previous_dialog_button(&mut self) {
|
||||
if !self.ui.dialog.dialog_buttons.is_empty() {
|
||||
let len = self.ui.dialog.dialog_buttons.len();
|
||||
let prev_index =
|
||||
(self.ui.dialog.dialog_active_button_index + len - 1) % len;
|
||||
self.ui.dialog.dialog_active_button_index = prev_index;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_active_dialog_button_label(&self) -> Option<&str> {
|
||||
self.ui.dialog
|
||||
.dialog_buttons
|
||||
.get(self.ui.dialog.dialog_active_button_index)
|
||||
.map(|s| s.as_str())
|
||||
}
|
||||
}
|
||||
206
client/src/dialog/logic.rs
Normal file
206
client/src/dialog/logic.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
// src/dialog/logic.rs
|
||||
|
||||
// TODO(dialog-refactor):
|
||||
// Currently this module (`handle_dialog_event`) contains page-specific logic
|
||||
// (e.g. Login, Register, Admin, SaveTable). This couples the dialog crate
|
||||
// to application pages and business logic.
|
||||
//
|
||||
// Refactor plan:
|
||||
// 1. Keep dialog generic: only handle navigation (next/prev/select) and return
|
||||
// a `DialogResult` (Dismissed | Selected { purpose, index }).
|
||||
// 2. Move all page-specific actions (e.g. login::back_to_main, register::back_to_login,
|
||||
// handle_delete_selected_columns, buffer_state.update_history) into the
|
||||
// respective page or event handler (e.g. modes/handlers/event.rs).
|
||||
// 3. Dialog crate should only provide state, rendering, and generic navigation.
|
||||
// Pages decide what to do when a dialog button is pressed.
|
||||
|
||||
use crossterm::event::{Event, KeyCode};
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::buffer::AppView;
|
||||
use crate::buffer::state::BufferState;
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use crate::pages::register;
|
||||
use crate::pages::login;
|
||||
use crate::pages::admin_panel::add_table::logic::handle_delete_selected_columns;
|
||||
use crate::pages::routing::{Router, Page};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Handles key events specifically when a dialog is active.
|
||||
/// Returns Some(Result<EventOutcome, Error>) if the event was handled (consumed),
|
||||
/// otherwise returns None.
|
||||
pub async fn handle_dialog_event(
|
||||
event: &Event,
|
||||
config: &Config,
|
||||
app_state: &mut AppState,
|
||||
buffer_state: &mut BufferState,
|
||||
router: &mut Router,
|
||||
) -> Option<Result<EventOutcome>> {
|
||||
if let Event::Key(key) = event {
|
||||
// Always allow Esc to dismiss
|
||||
if key.code == KeyCode::Esc {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Dialog dismissed".to_string())));
|
||||
}
|
||||
|
||||
// Check general bindings for dialog actions
|
||||
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
|
||||
match action {
|
||||
"move_down" | "next_option" => {
|
||||
let current_index = app_state.ui.dialog.dialog_active_button_index;
|
||||
let num_buttons = app_state.ui.dialog.dialog_buttons.len();
|
||||
if num_buttons > 0 && current_index < num_buttons - 1 {
|
||||
app_state.ui.dialog.dialog_active_button_index += 1;
|
||||
}
|
||||
return Some(Ok(EventOutcome::Ok(String::new())));
|
||||
}
|
||||
"move_up" | "previous_option" => {
|
||||
let current_index = app_state.ui.dialog.dialog_active_button_index;
|
||||
if current_index > 0 {
|
||||
app_state.ui.dialog.dialog_active_button_index -= 1;
|
||||
}
|
||||
return Some(Ok(EventOutcome::Ok(String::new())));
|
||||
}
|
||||
"select" => {
|
||||
let selected_index = app_state.ui.dialog.dialog_active_button_index;
|
||||
let purpose = match app_state.ui.dialog.purpose {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Internal Error: Dialog context lost".to_string(),
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Dialog Actions Directly Here
|
||||
match purpose {
|
||||
DialogPurpose::LoginSuccess => match selected_index {
|
||||
0 => {
|
||||
// "Menu" button selected
|
||||
app_state.hide_dialog();
|
||||
if let Page::Login(state) = &mut router.current {
|
||||
let message =
|
||||
login::back_to_main(state, app_state, buffer_state).await;
|
||||
return Some(Ok(EventOutcome::Ok(message)));
|
||||
}
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Login state not active".to_string(),
|
||||
)));
|
||||
}
|
||||
1 => {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Exiting dialog".to_string())));
|
||||
}
|
||||
_ => {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Unknown dialog button selected".to_string(),
|
||||
)));
|
||||
}
|
||||
},
|
||||
DialogPurpose::LoginFailed => match selected_index {
|
||||
0 => {
|
||||
// "OK" button selected
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Login failed dialog dismissed".to_string(),
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Unknown dialog button selected".to_string(),
|
||||
)));
|
||||
}
|
||||
},
|
||||
DialogPurpose::RegisterSuccess => match selected_index {
|
||||
0 => {
|
||||
// "OK" button for RegisterSuccess
|
||||
app_state.hide_dialog();
|
||||
if let Page::Register(state) = &mut router.current {
|
||||
let message =
|
||||
register::back_to_login(state, app_state, buffer_state)
|
||||
.await;
|
||||
return Some(Ok(EventOutcome::Ok(message)));
|
||||
}
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Register state not active".to_string(),
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Unknown dialog button selected".to_string(),
|
||||
)));
|
||||
}
|
||||
},
|
||||
DialogPurpose::RegisterFailed => match selected_index {
|
||||
0 => {
|
||||
// "OK" button for RegisterFailed
|
||||
app_state.hide_dialog(); // Just dismiss
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Register failed dialog dismissed".to_string(),
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Unknown dialog button selected".to_string(),
|
||||
)));
|
||||
}
|
||||
},
|
||||
DialogPurpose::ConfirmDeleteColumns => match selected_index {
|
||||
0 => {
|
||||
// "Confirm" button selected
|
||||
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(
|
||||
"AddTable page not active".to_string(),
|
||||
)));
|
||||
}
|
||||
1 => {
|
||||
// "Cancel" button selected
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Deletion cancelled.".to_string())));
|
||||
}
|
||||
_ => { /* Handle unexpected index */ }
|
||||
},
|
||||
DialogPurpose::SaveTableSuccess => match selected_index {
|
||||
0 => {
|
||||
// "OK" button selected
|
||||
app_state.hide_dialog();
|
||||
buffer_state.update_history(AppView::Admin); // Navigate back
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Save success dialog dismissed.".to_string(),
|
||||
)));
|
||||
}
|
||||
_ => { /* Handle unexpected index */ }
|
||||
},
|
||||
DialogPurpose::SaveLogicSuccess => match selected_index {
|
||||
0 => {
|
||||
// "OK" button selected
|
||||
app_state.hide_dialog();
|
||||
buffer_state.update_history(AppView::Admin);
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Save success dialog dismissed.".to_string(),
|
||||
)));
|
||||
}
|
||||
_ => { /* Handle unexpected index */ }
|
||||
},
|
||||
}
|
||||
}
|
||||
_ => {} // Ignore other general actions when dialog is shown
|
||||
}
|
||||
}
|
||||
// If it was a key event but not handled above, consume it
|
||||
Some(Ok(EventOutcome::Ok(String::new())))
|
||||
} else {
|
||||
// If it wasn't a key event, consume it too while dialog is active
|
||||
Some(Ok(EventOutcome::Ok(String::new())))
|
||||
}
|
||||
}
|
||||
10
client/src/dialog/mod.rs
Normal file
10
client/src/dialog/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
// src/dialog/mod.rs
|
||||
|
||||
pub mod ui;
|
||||
pub mod logic;
|
||||
pub mod state;
|
||||
pub mod functions;
|
||||
|
||||
pub use ui::render_dialog;
|
||||
pub use logic::handle_dialog_event;
|
||||
pub use state::DialogState;
|
||||
26
client/src/dialog/state.rs
Normal file
26
client/src/dialog/state.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
// src/dialog/state.rs
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
|
||||
pub struct DialogState {
|
||||
pub dialog_show: bool,
|
||||
pub dialog_title: String,
|
||||
pub dialog_message: String,
|
||||
pub dialog_buttons: Vec<String>,
|
||||
pub dialog_active_button_index: usize,
|
||||
pub purpose: Option<DialogPurpose>,
|
||||
pub is_loading: bool,
|
||||
}
|
||||
|
||||
impl Default for DialogState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dialog_show: false,
|
||||
dialog_title: String::new(),
|
||||
dialog_message: String::new(),
|
||||
dialog_buttons: Vec::new(),
|
||||
dialog_active_button_index: 0,
|
||||
purpose: None,
|
||||
is_loading: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// src/dialog/ui.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Margin, Rect},
|
||||
@@ -1,5 +0,0 @@
|
||||
// src/functions/common.rs
|
||||
|
||||
pub mod buffer;
|
||||
|
||||
pub use buffer::*;
|
||||
@@ -1,6 +0,0 @@
|
||||
// src/functions/mod.rs
|
||||
|
||||
pub mod common;
|
||||
pub mod modes;
|
||||
|
||||
pub use modes::*;
|
||||
@@ -1,5 +0,0 @@
|
||||
// src/functions/modes.rs
|
||||
|
||||
pub mod navigation;
|
||||
|
||||
pub use navigation::*;
|
||||
@@ -1,5 +0,0 @@
|
||||
// src/functions/modes/navigation.rs
|
||||
|
||||
pub mod admin_nav;
|
||||
pub mod add_table_nav;
|
||||
pub mod add_logic_nav;
|
||||
@@ -1,440 +0,0 @@
|
||||
// src/functions/modes/navigation/add_logic_nav.rs
|
||||
use crate::config::binds::config::{Config, EditorKeybindingMode};
|
||||
use crate::state::{
|
||||
app::state::AppState,
|
||||
pages::add_logic::{AddLogicFocus, AddLogicState},
|
||||
app::buffer::AppView,
|
||||
app::buffer::BufferState,
|
||||
};
|
||||
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
|
||||
use crate::services::GrpcClient;
|
||||
use tokio::sync::mpsc;
|
||||
use anyhow::Result;
|
||||
use crate::components::common::text_editor::TextEditor;
|
||||
use crate::services::ui_service::UiService;
|
||||
use tui_textarea::CursorMove; // Ensure this import is present
|
||||
|
||||
pub type SaveLogicResultSender = mpsc::Sender<Result<String>>;
|
||||
|
||||
pub fn handle_add_logic_navigation(
|
||||
key_event: KeyEvent,
|
||||
config: &Config,
|
||||
app_state: &mut AppState,
|
||||
add_logic_state: &mut AddLogicState,
|
||||
is_edit_mode: &mut bool,
|
||||
buffer_state: &mut BufferState,
|
||||
grpc_client: GrpcClient,
|
||||
_save_logic_sender: SaveLogicResultSender, // Marked as unused
|
||||
command_message: &mut String,
|
||||
) -> bool {
|
||||
// === FULLSCREEN SCRIPT EDITING - COMPLETE ISOLATION ===
|
||||
if add_logic_state.current_focus == AddLogicFocus::InsideScriptContent {
|
||||
// === AUTOCOMPLETE HANDLING ===
|
||||
if add_logic_state.script_editor_autocomplete_active {
|
||||
match key_event.code {
|
||||
// ... (Char, Backspace, Tab, Down, Up cases remain the same) ...
|
||||
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => {
|
||||
add_logic_state.script_editor_filter_text.push(c);
|
||||
add_logic_state.update_script_editor_suggestions();
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
*command_message = format!("Filtering: @{}", add_logic_state.script_editor_filter_text);
|
||||
return true;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if !add_logic_state.script_editor_filter_text.is_empty() {
|
||||
add_logic_state.script_editor_filter_text.pop();
|
||||
add_logic_state.update_script_editor_suggestions();
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
*command_message = if add_logic_state.script_editor_filter_text.is_empty() {
|
||||
"Autocomplete: @".to_string()
|
||||
} else {
|
||||
format!("Filtering: @{}", add_logic_state.script_editor_filter_text)
|
||||
};
|
||||
} else {
|
||||
let should_deactivate = if let Some((trigger_line, trigger_col)) = add_logic_state.script_editor_trigger_position {
|
||||
let current_cursor = {
|
||||
let editor_borrow = add_logic_state.script_content_editor.borrow();
|
||||
editor_borrow.cursor()
|
||||
};
|
||||
current_cursor.0 == trigger_line && current_cursor.1 == trigger_col + 1
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if should_deactivate {
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
*command_message = "Autocomplete cancelled".to_string();
|
||||
}
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Down => {
|
||||
if !add_logic_state.script_editor_suggestions.is_empty() {
|
||||
let current = add_logic_state.script_editor_selected_suggestion_index.unwrap_or(0);
|
||||
let next = (current + 1) % add_logic_state.script_editor_suggestions.len();
|
||||
add_logic_state.script_editor_selected_suggestion_index = Some(next);
|
||||
*command_message = format!("Selected: {}", add_logic_state.script_editor_suggestions[next]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Up => {
|
||||
if !add_logic_state.script_editor_suggestions.is_empty() {
|
||||
let current = add_logic_state.script_editor_selected_suggestion_index.unwrap_or(0);
|
||||
let prev = if current == 0 {
|
||||
add_logic_state.script_editor_suggestions.len() - 1
|
||||
} else {
|
||||
current - 1
|
||||
};
|
||||
add_logic_state.script_editor_selected_suggestion_index = Some(prev);
|
||||
*command_message = format!("Selected: {}", add_logic_state.script_editor_suggestions[prev]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(selected_idx) = add_logic_state.script_editor_selected_suggestion_index {
|
||||
if let Some(suggestion) = add_logic_state.script_editor_suggestions.get(selected_idx).cloned() {
|
||||
let trigger_pos = add_logic_state.script_editor_trigger_position;
|
||||
let filter_len = add_logic_state.script_editor_filter_text.len();
|
||||
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
add_logic_state.has_unsaved_changes = true;
|
||||
|
||||
if let Some(pos) = trigger_pos {
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
|
||||
if suggestion == "sql" {
|
||||
replace_autocomplete_text(&mut editor_borrow, pos, filter_len, "sql");
|
||||
editor_borrow.insert_str("('')");
|
||||
// Move cursor back twice to be between the single quotes
|
||||
editor_borrow.move_cursor(CursorMove::Back); // Before ')'
|
||||
editor_borrow.move_cursor(CursorMove::Back); // Before ''' (inside '')
|
||||
*command_message = "Inserted: @sql('')".to_string();
|
||||
} else {
|
||||
let is_table_selection = add_logic_state.is_table_name_suggestion(&suggestion);
|
||||
replace_autocomplete_text(&mut editor_borrow, pos, filter_len, &suggestion);
|
||||
|
||||
if is_table_selection {
|
||||
editor_borrow.insert_str(".");
|
||||
let new_cursor = editor_borrow.cursor();
|
||||
drop(editor_borrow); // Release borrow before calling add_logic_state methods
|
||||
|
||||
add_logic_state.script_editor_trigger_position = Some(new_cursor);
|
||||
add_logic_state.script_editor_autocomplete_active = true;
|
||||
add_logic_state.script_editor_filter_text.clear();
|
||||
add_logic_state.trigger_column_autocomplete_for_table(suggestion.clone());
|
||||
|
||||
let profile_name = add_logic_state.profile_name.clone();
|
||||
let table_name_for_fetch = suggestion.clone();
|
||||
let mut client_clone = grpc_client.clone();
|
||||
tokio::spawn(async move {
|
||||
match UiService::fetch_columns_for_table(&mut client_clone, &profile_name, &table_name_for_fetch).await {
|
||||
Ok(_columns) => {
|
||||
// Result handled by main UI loop
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to fetch columns for {}.{}: {}", profile_name, table_name_for_fetch, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
*command_message = format!("Selected table '{}', fetching columns...", suggestion);
|
||||
} else {
|
||||
*command_message = format!("Inserted: {}", suggestion);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
*command_message = "Autocomplete cancelled".to_string();
|
||||
}
|
||||
_ => {
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
*command_message = "Autocomplete cancelled".to_string();
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if key_event.code == KeyCode::Char('@') && key_event.modifiers == KeyModifiers::NONE {
|
||||
let should_trigger = match add_logic_state.editor_keybinding_mode {
|
||||
EditorKeybindingMode::Vim => *is_edit_mode,
|
||||
_ => true,
|
||||
};
|
||||
if should_trigger {
|
||||
let cursor_before = {
|
||||
let editor_borrow = add_logic_state.script_content_editor.borrow();
|
||||
editor_borrow.cursor()
|
||||
};
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
add_logic_state.script_editor_trigger_position = Some(cursor_before);
|
||||
add_logic_state.script_editor_autocomplete_active = true;
|
||||
add_logic_state.script_editor_filter_text.clear();
|
||||
add_logic_state.update_script_editor_suggestions();
|
||||
add_logic_state.has_unsaved_changes = true;
|
||||
*command_message = "Autocomplete: @ (Tab/↑↓ to navigate, Enter to select, Esc to cancel)".to_string();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if key_event.code == KeyCode::Esc && key_event.modifiers == KeyModifiers::NONE {
|
||||
match add_logic_state.editor_keybinding_mode {
|
||||
EditorKeybindingMode::Vim => {
|
||||
if *is_edit_mode {
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
if TextEditor::is_vim_normal_mode(&add_logic_state.vim_state) {
|
||||
*is_edit_mode = false;
|
||||
*command_message = "VIM: Normal Mode. Esc again to exit script.".to_string();
|
||||
}
|
||||
} else {
|
||||
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
|
||||
app_state.ui.focus_outside_canvas = true;
|
||||
*is_edit_mode = false;
|
||||
*command_message = "Exited script editing.".to_string();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if *is_edit_mode {
|
||||
*is_edit_mode = false;
|
||||
*command_message = "Exited script edit. Esc again to exit script.".to_string();
|
||||
} else {
|
||||
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
|
||||
app_state.ui.focus_outside_canvas = true;
|
||||
*is_edit_mode = false;
|
||||
*command_message = "Exited script editing.".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let changed = {
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
)
|
||||
};
|
||||
if changed {
|
||||
add_logic_state.has_unsaved_changes = true;
|
||||
}
|
||||
if add_logic_state.editor_keybinding_mode == EditorKeybindingMode::Vim {
|
||||
*is_edit_mode = !TextEditor::is_vim_normal_mode(&add_logic_state.vim_state);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let action = config.get_general_action(key_event.code, key_event.modifiers);
|
||||
let current_focus = add_logic_state.current_focus;
|
||||
let mut handled = true;
|
||||
let mut new_focus = current_focus;
|
||||
|
||||
match action.as_deref() {
|
||||
Some("exit_table_scroll") => {
|
||||
handled = false;
|
||||
}
|
||||
Some("move_up") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName => {}
|
||||
AddLogicFocus::InputTargetColumn => new_focus = AddLogicFocus::InputLogicName,
|
||||
AddLogicFocus::InputDescription => new_focus = AddLogicFocus::InputTargetColumn,
|
||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::ScriptContentPreview,
|
||||
AddLogicFocus::CancelButton => new_focus = AddLogicFocus::SaveButton,
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("move_down") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName => new_focus = AddLogicFocus::InputTargetColumn,
|
||||
AddLogicFocus::InputTargetColumn => new_focus = AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::InputDescription => {
|
||||
add_logic_state.last_canvas_field = 2;
|
||||
new_focus = AddLogicFocus::ScriptContentPreview;
|
||||
},
|
||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::SaveButton,
|
||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
|
||||
AddLogicFocus::CancelButton => {}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("next_option") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription =>
|
||||
{ new_focus = AddLogicFocus::ScriptContentPreview; }
|
||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::SaveButton,
|
||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
|
||||
AddLogicFocus::CancelButton => { }
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("previous_option") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription =>
|
||||
{ }
|
||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::ScriptContentPreview,
|
||||
AddLogicFocus::CancelButton => new_focus = AddLogicFocus::SaveButton,
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("next_field") => {
|
||||
new_focus = match current_focus {
|
||||
AddLogicFocus::InputLogicName => AddLogicFocus::InputTargetColumn,
|
||||
AddLogicFocus::InputTargetColumn => AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::InputDescription => AddLogicFocus::ScriptContentPreview,
|
||||
AddLogicFocus::ScriptContentPreview => AddLogicFocus::SaveButton,
|
||||
AddLogicFocus::SaveButton => AddLogicFocus::CancelButton,
|
||||
AddLogicFocus::CancelButton => AddLogicFocus::InputLogicName,
|
||||
_ => current_focus,
|
||||
};
|
||||
}
|
||||
Some("prev_field") => {
|
||||
new_focus = match current_focus {
|
||||
AddLogicFocus::InputLogicName => AddLogicFocus::CancelButton,
|
||||
AddLogicFocus::InputTargetColumn => AddLogicFocus::InputLogicName,
|
||||
AddLogicFocus::InputDescription => AddLogicFocus::InputTargetColumn,
|
||||
AddLogicFocus::ScriptContentPreview => AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::SaveButton => AddLogicFocus::ScriptContentPreview,
|
||||
AddLogicFocus::CancelButton => AddLogicFocus::SaveButton,
|
||||
_ => current_focus,
|
||||
};
|
||||
}
|
||||
Some("select") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::ScriptContentPreview => {
|
||||
new_focus = AddLogicFocus::InsideScriptContent;
|
||||
*is_edit_mode = false;
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
let mode_hint = match add_logic_state.editor_keybinding_mode {
|
||||
EditorKeybindingMode::Vim => "VIM mode - 'i'/'a'/'o' to edit",
|
||||
_ => "Enter/Ctrl+E to edit",
|
||||
};
|
||||
*command_message = format!("Fullscreen script editing. {} or Esc to exit.", mode_hint);
|
||||
}
|
||||
AddLogicFocus::SaveButton => {
|
||||
*command_message = "Save logic action".to_string();
|
||||
}
|
||||
AddLogicFocus::CancelButton => {
|
||||
buffer_state.update_history(AppView::Admin);
|
||||
app_state.ui.show_add_logic = false;
|
||||
*command_message = "Cancelled Add Logic".to_string();
|
||||
*is_edit_mode = false;
|
||||
}
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription => {
|
||||
*is_edit_mode = !*is_edit_mode;
|
||||
*command_message = format!("Field edit mode: {}", if *is_edit_mode { "ON" } else { "OFF" });
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("toggle_edit_mode") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription => {
|
||||
*is_edit_mode = !*is_edit_mode;
|
||||
*command_message = format!("Canvas field edit mode: {}", if *is_edit_mode { "ON" } else { "OFF" });
|
||||
}
|
||||
_ => {
|
||||
*command_message = "Cannot toggle edit mode here.".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
|
||||
if handled && current_focus != new_focus {
|
||||
add_logic_state.current_focus = new_focus;
|
||||
let new_is_canvas_input_focus = matches!(new_focus,
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription
|
||||
);
|
||||
if new_is_canvas_input_focus {
|
||||
*is_edit_mode = false;
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
} else {
|
||||
app_state.ui.focus_outside_canvas = true;
|
||||
if matches!(new_focus, AddLogicFocus::ScriptContentPreview) {
|
||||
*is_edit_mode = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
handled
|
||||
}
|
||||
|
||||
fn replace_autocomplete_text(
|
||||
editor: &mut tui_textarea::TextArea,
|
||||
trigger_pos: (usize, usize),
|
||||
filter_len: usize,
|
||||
replacement: &str,
|
||||
) {
|
||||
// use tui_textarea::CursorMove; // Already imported at the top of the module
|
||||
let filter_start_pos = (trigger_pos.0, trigger_pos.1 + 1);
|
||||
editor.move_cursor(CursorMove::Jump(filter_start_pos.0 as u16, filter_start_pos.1 as u16));
|
||||
for _ in 0..filter_len {
|
||||
editor.delete_next_char();
|
||||
}
|
||||
editor.insert_str(replacement);
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
// src/functions/modes/navigation/add_table_nav.rs
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::state::{
|
||||
app::state::AppState,
|
||||
pages::add_table::{AddTableFocus, AddTableState},
|
||||
};
|
||||
use crossterm::event::{KeyEvent};
|
||||
use ratatui::widgets::TableState;
|
||||
use crate::tui::functions::common::add_table::{handle_add_column_action, handle_save_table_action};
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
use crate::services::GrpcClient;
|
||||
use tokio::sync::mpsc;
|
||||
use anyhow::Result;
|
||||
|
||||
pub type SaveTableResultSender = mpsc::Sender<Result<String>>;
|
||||
|
||||
fn navigate_table_up(table_state: &mut TableState, item_count: usize) -> bool {
|
||||
if item_count == 0 { return false; }
|
||||
let current_selection = table_state.selected();
|
||||
match current_selection {
|
||||
Some(index) => {
|
||||
if index > 0 { table_state.select(Some(index - 1)); true }
|
||||
else { false }
|
||||
}
|
||||
None => { table_state.select(Some(0)); true }
|
||||
}
|
||||
}
|
||||
|
||||
fn navigate_table_down(table_state: &mut TableState, item_count: usize) -> bool {
|
||||
if item_count == 0 { return false; }
|
||||
let current_selection = table_state.selected();
|
||||
match current_selection {
|
||||
Some(index) => {
|
||||
if index < item_count - 1 { table_state.select(Some(index + 1)); true }
|
||||
else { false }
|
||||
}
|
||||
None => { table_state.select(Some(0)); true }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_add_table_navigation(
|
||||
key: KeyEvent,
|
||||
config: &Config,
|
||||
app_state: &mut AppState,
|
||||
add_table_state: &mut AddTableState,
|
||||
grpc_client: GrpcClient,
|
||||
save_result_sender: SaveTableResultSender,
|
||||
command_message: &mut String,
|
||||
) -> bool {
|
||||
let action = config.get_general_action(key.code, key.modifiers);
|
||||
let current_focus = add_table_state.current_focus;
|
||||
let mut handled = true;
|
||||
let mut new_focus = current_focus;
|
||||
|
||||
if matches!(current_focus, AddTableFocus::InsideColumnsTable | AddTableFocus::InsideIndexesTable | AddTableFocus::InsideLinksTable) {
|
||||
if matches!(action.as_deref(), Some("next_option") | Some("previous_option")) {
|
||||
*command_message = "Press Esc to exit table item navigation first.".to_string();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
match action.as_deref() {
|
||||
Some("exit_table_scroll") => {
|
||||
match current_focus {
|
||||
AddTableFocus::InsideColumnsTable => {
|
||||
add_table_state.column_table_state.select(None);
|
||||
new_focus = AddTableFocus::ColumnsTable;
|
||||
// *command_message = "Exited Columns Table".to_string(); // Minimal change: remove message
|
||||
}
|
||||
AddTableFocus::InsideIndexesTable => {
|
||||
add_table_state.index_table_state.select(None);
|
||||
new_focus = AddTableFocus::IndexesTable;
|
||||
// *command_message = "Exited Indexes Table".to_string();
|
||||
}
|
||||
AddTableFocus::InsideLinksTable => {
|
||||
add_table_state.link_table_state.select(None);
|
||||
new_focus = AddTableFocus::LinksTable;
|
||||
// *command_message = "Exited Links Table".to_string();
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("move_up") => {
|
||||
match current_focus {
|
||||
AddTableFocus::InputTableName => {
|
||||
// MINIMAL CHANGE: Do nothing, new_focus remains current_focus
|
||||
// *command_message = "At top of form.".to_string(); // Remove message
|
||||
}
|
||||
AddTableFocus::InputColumnName => new_focus = AddTableFocus::InputTableName,
|
||||
AddTableFocus::InputColumnType => new_focus = AddTableFocus::InputColumnName,
|
||||
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::InputColumnType,
|
||||
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::AddColumnButton,
|
||||
AddTableFocus::IndexesTable => new_focus = AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::LinksTable => new_focus = AddTableFocus::IndexesTable,
|
||||
AddTableFocus::InsideColumnsTable => { navigate_table_up(&mut add_table_state.column_table_state, add_table_state.columns.len()); }
|
||||
AddTableFocus::InsideIndexesTable => { navigate_table_up(&mut add_table_state.index_table_state, add_table_state.indexes.len()); }
|
||||
AddTableFocus::InsideLinksTable => { navigate_table_up(&mut add_table_state.link_table_state, add_table_state.links.len()); }
|
||||
AddTableFocus::SaveButton => new_focus = AddTableFocus::LinksTable,
|
||||
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::SaveButton,
|
||||
AddTableFocus::CancelButton => new_focus = AddTableFocus::DeleteSelectedButton,
|
||||
}
|
||||
}
|
||||
Some("move_down") => {
|
||||
match current_focus {
|
||||
AddTableFocus::InputTableName => new_focus = AddTableFocus::InputColumnName,
|
||||
AddTableFocus::InputColumnName => new_focus = AddTableFocus::InputColumnType,
|
||||
AddTableFocus::InputColumnType => {
|
||||
add_table_state.last_canvas_field = 2;
|
||||
new_focus = AddTableFocus::AddColumnButton;
|
||||
},
|
||||
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::IndexesTable,
|
||||
AddTableFocus::IndexesTable => new_focus = AddTableFocus::LinksTable,
|
||||
AddTableFocus::LinksTable => new_focus = AddTableFocus::SaveButton,
|
||||
AddTableFocus::InsideColumnsTable => { navigate_table_down(&mut add_table_state.column_table_state, add_table_state.columns.len()); }
|
||||
AddTableFocus::InsideIndexesTable => { navigate_table_down(&mut add_table_state.index_table_state, add_table_state.indexes.len()); }
|
||||
AddTableFocus::InsideLinksTable => { navigate_table_down(&mut add_table_state.link_table_state, add_table_state.links.len()); }
|
||||
AddTableFocus::SaveButton => new_focus = AddTableFocus::DeleteSelectedButton,
|
||||
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::CancelButton,
|
||||
AddTableFocus::CancelButton => {
|
||||
// MINIMAL CHANGE: Do nothing, new_focus remains current_focus
|
||||
// *command_message = "At bottom of form.".to_string(); // Remove message
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("next_option") => { // This logic should already be non-wrapping
|
||||
match current_focus {
|
||||
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType =>
|
||||
{ new_focus = AddTableFocus::AddColumnButton; }
|
||||
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::IndexesTable,
|
||||
AddTableFocus::IndexesTable => new_focus = AddTableFocus::LinksTable,
|
||||
AddTableFocus::LinksTable => new_focus = AddTableFocus::SaveButton,
|
||||
AddTableFocus::SaveButton => new_focus = AddTableFocus::DeleteSelectedButton,
|
||||
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::CancelButton,
|
||||
AddTableFocus::CancelButton => { /* *command_message = "At last focusable area.".to_string(); */ } // No change in focus
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("previous_option") => { // This logic should already be non-wrapping
|
||||
match current_focus {
|
||||
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType =>
|
||||
{ /* *command_message = "At first focusable area.".to_string(); */ } // No change in focus
|
||||
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::InputColumnType,
|
||||
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::AddColumnButton,
|
||||
AddTableFocus::IndexesTable => new_focus = AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::LinksTable => new_focus = AddTableFocus::IndexesTable,
|
||||
AddTableFocus::SaveButton => new_focus = AddTableFocus::LinksTable,
|
||||
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::SaveButton,
|
||||
AddTableFocus::CancelButton => new_focus = AddTableFocus::DeleteSelectedButton,
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("next_field") => {
|
||||
new_focus = match current_focus {
|
||||
AddTableFocus::InputTableName => AddTableFocus::InputColumnName, AddTableFocus::InputColumnName => AddTableFocus::InputColumnType, AddTableFocus::InputColumnType => AddTableFocus::AddColumnButton, AddTableFocus::AddColumnButton => AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable => AddTableFocus::IndexesTable, AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable => AddTableFocus::LinksTable, AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable => AddTableFocus::SaveButton,
|
||||
AddTableFocus::SaveButton => AddTableFocus::DeleteSelectedButton, AddTableFocus::DeleteSelectedButton => AddTableFocus::CancelButton, AddTableFocus::CancelButton => AddTableFocus::InputTableName,
|
||||
};
|
||||
}
|
||||
Some("prev_field") => {
|
||||
new_focus = match current_focus {
|
||||
AddTableFocus::InputTableName => AddTableFocus::CancelButton, AddTableFocus::InputColumnName => AddTableFocus::InputTableName, AddTableFocus::InputColumnType => AddTableFocus::InputColumnName, AddTableFocus::AddColumnButton => AddTableFocus::InputColumnType,
|
||||
AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable => AddTableFocus::AddColumnButton, AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable => AddTableFocus::ColumnsTable, AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable => AddTableFocus::IndexesTable,
|
||||
AddTableFocus::SaveButton => AddTableFocus::LinksTable, AddTableFocus::DeleteSelectedButton => AddTableFocus::SaveButton, AddTableFocus::CancelButton => AddTableFocus::DeleteSelectedButton,
|
||||
};
|
||||
}
|
||||
Some("select") => {
|
||||
match current_focus {
|
||||
AddTableFocus::ColumnsTable => { new_focus = AddTableFocus::InsideColumnsTable; if add_table_state.column_table_state.selected().is_none() && !add_table_state.columns.is_empty() { add_table_state.column_table_state.select(Some(0)); } /* Message removed */ }
|
||||
AddTableFocus::IndexesTable => { new_focus = AddTableFocus::InsideIndexesTable; if add_table_state.index_table_state.selected().is_none() && !add_table_state.indexes.is_empty() { add_table_state.index_table_state.select(Some(0)); } /* Message removed */ }
|
||||
AddTableFocus::LinksTable => { new_focus = AddTableFocus::InsideLinksTable; if add_table_state.link_table_state.selected().is_none() && !add_table_state.links.is_empty() { add_table_state.link_table_state.select(Some(0)); } /* Message removed */ }
|
||||
AddTableFocus::InsideColumnsTable => { if let Some(index) = add_table_state.column_table_state.selected() { if let Some(col) = add_table_state.columns.get_mut(index) { col.selected = !col.selected; add_table_state.has_unsaved_changes = true; /* Message removed */ }} /* else { Message removed } */ }
|
||||
AddTableFocus::InsideIndexesTable => { if let Some(index) = add_table_state.index_table_state.selected() { if let Some(idx_def) = add_table_state.indexes.get_mut(index) { idx_def.selected = !idx_def.selected; add_table_state.has_unsaved_changes = true; /* Message removed */ }} /* else { Message removed } */ }
|
||||
AddTableFocus::InsideLinksTable => { if let Some(index) = add_table_state.link_table_state.selected() { if let Some(link) = add_table_state.links.get_mut(index) { link.selected = !link.selected; add_table_state.has_unsaved_changes = true; /* Message removed */ }} /* else { Message removed } */ }
|
||||
AddTableFocus::AddColumnButton => { if let Some(focus_after_add) = handle_add_column_action(add_table_state, command_message) { new_focus = focus_after_add; } else { /* Message already set by handle_add_column_action */ }}
|
||||
AddTableFocus::SaveButton => { if add_table_state.table_name.is_empty() { *command_message = "Cannot save: Table name is empty.".to_string(); } else if add_table_state.columns.is_empty() { *command_message = "Cannot save: No columns defined.".to_string(); } else { *command_message = "Saving table...".to_string(); app_state.show_loading_dialog("Saving", "Please wait..."); let mut client_clone = grpc_client.clone(); let state_clone = add_table_state.clone(); let sender_clone = save_result_sender.clone(); tokio::spawn(async move { let result = handle_save_table_action(&mut client_clone, &state_clone).await; let _ = sender_clone.send(result).await; }); }}
|
||||
AddTableFocus::DeleteSelectedButton => { let columns_to_delete: Vec<(usize, String, String)> = add_table_state.columns.iter().enumerate().filter(|(_, col)| col.selected).map(|(index, col)| (index, col.name.clone(), col.data_type.clone())).collect(); if columns_to_delete.is_empty() { *command_message = "No columns selected for deletion.".to_string(); } else { let column_details: String = columns_to_delete.iter().map(|(index, name, dtype)| format!("{}. {} ({})", index + 1, name, dtype)).collect::<Vec<String>>().join("\n"); let message = format!("Delete the following columns?\n\n{}", column_details); app_state.show_dialog("Confirm Deletion", &message, vec!["Confirm".to_string(), "Cancel".to_string()], DialogPurpose::ConfirmDeleteColumns); }}
|
||||
AddTableFocus::CancelButton => { *command_message = "Action: Cancel Add Table (Not Implemented)".to_string(); }
|
||||
_ => { handled = false; }
|
||||
}
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
|
||||
if handled && current_focus != new_focus {
|
||||
add_table_state.current_focus = new_focus;
|
||||
// Minimal change: Command message update logic can be simplified or removed if not desired
|
||||
// For now, let's keep it minimal and only update if it was truly a focus change,
|
||||
// and not a boundary message.
|
||||
if !command_message.starts_with("At ") && current_focus != new_focus { // Avoid overwriting boundary messages
|
||||
// *command_message = format!("Focus: {:?}", add_table_state.current_focus); // Optional: restore if needed
|
||||
}
|
||||
|
||||
|
||||
let new_is_canvas_input_focus = matches!(new_focus,
|
||||
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType
|
||||
);
|
||||
app_state.ui.focus_outside_canvas = !new_is_canvas_input_focus;
|
||||
}
|
||||
// If not handled, command_message remains as it was (e.g., from a deeper function call or previous event)
|
||||
// or can be cleared if that's the desired default. For minimal change, we leave it.
|
||||
|
||||
handled
|
||||
}
|
||||
41
client/src/input/action.rs
Normal file
41
client/src/input/action.rs
Normal 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
195
client/src/input/engine.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
74
client/src/input/leader.rs
Normal file
74
client/src/input/leader.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
// src/input/leader.rs
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::config::binds::key_sequences::parse_binding;
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
/// Collect leader (= space-prefixed) bindings from *all* binding maps
|
||||
fn leader_bindings<'a>(config: &'a Config) -> Vec<(&'a str, Vec<KeyCode>)> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
// Include all keybinding maps, not just global
|
||||
let all_modes: Vec<&std::collections::HashMap<String, Vec<String>>> = vec![
|
||||
&config.keybindings.general,
|
||||
&config.keybindings.read_only,
|
||||
&config.keybindings.edit,
|
||||
&config.keybindings.highlight,
|
||||
&config.keybindings.command,
|
||||
&config.keybindings.common,
|
||||
&config.keybindings.global,
|
||||
];
|
||||
|
||||
for mode in all_modes {
|
||||
for (action, bindings) in mode {
|
||||
for b in bindings {
|
||||
let parsed = parse_binding(b);
|
||||
if parsed.first().map(|pk| pk.code) == Some(KeyCode::Char(' ')) {
|
||||
let codes =
|
||||
parsed.into_iter().map(|pk| pk.code).collect::<Vec<_>>();
|
||||
out.push((action.as_str(), codes));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Is there any leader binding configured at all?
|
||||
pub fn leader_has_any_start(config: &Config) -> bool {
|
||||
leader_bindings(config)
|
||||
.iter()
|
||||
.any(|(_, seq)| seq.first() == Some(&KeyCode::Char(' ')))
|
||||
}
|
||||
|
||||
/// Is `sequence` a prefix of any configured leader sequence?
|
||||
pub fn leader_is_prefix(config: &Config, sequence: &[KeyCode]) -> bool {
|
||||
if sequence.is_empty() || sequence[0] != KeyCode::Char(' ') {
|
||||
return false;
|
||||
}
|
||||
for (_, full) in leader_bindings(config) {
|
||||
if full.len() > sequence.len()
|
||||
&& full.iter().zip(sequence.iter()).all(|(a, b)| a == b)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Is `sequence` an exact leader match? If yes, return the action string.
|
||||
pub fn leader_match_action<'a>(
|
||||
config: &'a Config,
|
||||
sequence: &[KeyCode],
|
||||
) -> Option<&'a str> {
|
||||
if sequence.is_empty() || sequence[0] != KeyCode::Char(' ') {
|
||||
return None;
|
||||
}
|
||||
for (action, full) in leader_bindings(config) {
|
||||
if full.len() == sequence.len()
|
||||
&& full.iter().zip(sequence.iter()).all(|(a, b)| a == b)
|
||||
{
|
||||
return Some(action);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
4
client/src/input/mod.rs
Normal file
4
client/src/input/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
// src/input/mod.rs
|
||||
pub mod action;
|
||||
pub mod engine;
|
||||
pub mod leader;
|
||||
@@ -5,9 +5,16 @@ pub mod config;
|
||||
pub mod state;
|
||||
pub mod components;
|
||||
pub mod modes;
|
||||
pub mod functions;
|
||||
pub mod services;
|
||||
pub mod utils;
|
||||
pub mod buffer;
|
||||
pub mod sidebar;
|
||||
pub mod dialog;
|
||||
pub mod search;
|
||||
pub mod bottom_panel;
|
||||
pub mod pages;
|
||||
pub mod movement;
|
||||
pub mod input;
|
||||
|
||||
pub use ui::run_ui;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,86 +1,95 @@
|
||||
// src/modes/canvas/common_mode.rs
|
||||
|
||||
use crate::tui::terminal::core::TerminalCore;
|
||||
use crate::state::pages::{form::FormState, auth::LoginState, auth::RegisterState, auth::AuthState};
|
||||
use crate::state::pages::auth::AuthState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::services::auth::AuthClient;
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use crate::tui::functions::common::form::SaveOutcome;
|
||||
crate::pages::forms::logic::SaveOutcome;
|
||||
use anyhow::{Context, Result};
|
||||
use crate::tui::functions::common::{
|
||||
form::{save as form_save, revert as form_revert},
|
||||
login::{save as login_save, revert as login_revert},
|
||||
register::{revert as register_revert},
|
||||
};
|
||||
use crate::pages::routing::{Router, Page};
|
||||
|
||||
pub async fn handle_core_action(
|
||||
action: &str,
|
||||
form_state: &mut FormState,
|
||||
auth_state: &mut AuthState,
|
||||
login_state: &mut LoginState,
|
||||
register_state: &mut RegisterState,
|
||||
grpc_client: &mut GrpcClient,
|
||||
auth_client: &mut AuthClient,
|
||||
terminal: &mut TerminalCore,
|
||||
app_state: &mut AppState,
|
||||
router: &mut Router,
|
||||
) -> Result<EventOutcome> {
|
||||
match action {
|
||||
"save" => {
|
||||
if app_state.ui.show_login {
|
||||
let message = login_save(auth_state, login_state, auth_client, app_state).await.context("Login save action failed")?;
|
||||
Ok(EventOutcome::Ok(message))
|
||||
} else {
|
||||
let save_outcome = form_save(
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
).await.context("Register save action failed")?;
|
||||
let message = match save_outcome {
|
||||
SaveOutcome::NoChange => "No changes to save.".to_string(),
|
||||
SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
|
||||
SaveOutcome::CreatedNew(_) => "New entry created.".to_string(),
|
||||
};
|
||||
Ok(EventOutcome::DataSaved(save_outcome, message))
|
||||
match &mut router.current {
|
||||
Page::Login(state) => {
|
||||
let message = login_save(auth_state, state, auth_client, app_state)
|
||||
.await
|
||||
.context("Login save action failed")?;
|
||||
Ok(EventOutcome::Ok(message))
|
||||
}
|
||||
Page::Form(form_state) => {
|
||||
let save_outcome = form_save(app_state, form_state, grpc_client)
|
||||
.await
|
||||
.context("Form save action failed")?;
|
||||
let message = match save_outcome {
|
||||
SaveOutcome::NoChange => "No changes to save.".to_string(),
|
||||
SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
|
||||
SaveOutcome::CreatedNew(_) => "New entry created.".to_string(),
|
||||
};
|
||||
Ok(EventOutcome::DataSaved(save_outcome, message))
|
||||
}
|
||||
_ => Ok(EventOutcome::Ok("Save not applicable".into())),
|
||||
}
|
||||
},
|
||||
}
|
||||
"force_quit" => {
|
||||
terminal.cleanup()?;
|
||||
Ok(EventOutcome::Exit("Force exiting without saving.".to_string()))
|
||||
},
|
||||
}
|
||||
"save_and_quit" => {
|
||||
let message = if app_state.ui.show_login {
|
||||
login_save(auth_state, login_state, auth_client, app_state).await.context("Login save n quit action failed")?
|
||||
} else {
|
||||
let save_outcome = form_save(
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
).await?;
|
||||
match save_outcome {
|
||||
SaveOutcome::NoChange => "No changes to save.".to_string(),
|
||||
SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
|
||||
SaveOutcome::CreatedNew(_) => "New entry created.".to_string(),
|
||||
let message = match &mut router.current {
|
||||
Page::Login(state) => {
|
||||
login_save(auth_state, state, auth_client, app_state)
|
||||
.await
|
||||
.context("Login save and quit action failed")?
|
||||
}
|
||||
Page::Form(form_state) => {
|
||||
let save_outcome = form_save(app_state, form_state, grpc_client).await?;
|
||||
match save_outcome {
|
||||
SaveOutcome::NoChange => "No changes to save.".to_string(),
|
||||
SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
|
||||
SaveOutcome::CreatedNew(_) => "New entry created.".to_string(),
|
||||
}
|
||||
}
|
||||
_ => "Save not applicable".to_string(),
|
||||
};
|
||||
terminal.cleanup()?;
|
||||
Ok(EventOutcome::Exit(format!("{}. Exiting application.", message)))
|
||||
},
|
||||
}
|
||||
"revert" => {
|
||||
if app_state.ui.show_login {
|
||||
let message = login_revert(login_state, app_state).await;
|
||||
Ok(EventOutcome::Ok(message))
|
||||
} else if app_state.ui.show_register {
|
||||
let message = register_revert(register_state, app_state).await;
|
||||
Ok(EventOutcome::Ok(message))
|
||||
} else {
|
||||
let message = form_revert(
|
||||
form_state,
|
||||
grpc_client,
|
||||
).await.context("Form revert x action failed")?;
|
||||
Ok(EventOutcome::Ok(message))
|
||||
match &mut router.current {
|
||||
Page::Login(state) => {
|
||||
let message = login_revert(state, app_state).await;
|
||||
Ok(EventOutcome::Ok(message))
|
||||
}
|
||||
Page::Register(state) => {
|
||||
let message = register_revert(state, app_state).await;
|
||||
Ok(EventOutcome::Ok(message))
|
||||
}
|
||||
Page::Form(form_state) => {
|
||||
let message = form_revert(form_state, grpc_client)
|
||||
.await
|
||||
.context("Form revert action failed")?;
|
||||
Ok(EventOutcome::Ok(message))
|
||||
}
|
||||
_ => Ok(EventOutcome::Ok("Revert not applicable".into())),
|
||||
}
|
||||
},
|
||||
}
|
||||
_ => Ok(EventOutcome::Ok(format!("Core action not handled: {}", action))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,22 +3,19 @@
|
||||
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::state::{app::state::AppState, pages::auth::LoginState, pages::auth::RegisterState};
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::modes::common::commands::CommandHandler;
|
||||
use crate::tui::terminal::core::TerminalCore;
|
||||
use crate::tui::functions::common::form::{save, revert};
|
||||
use crate::pages::forms::logic::{save, revert ,SaveOutcome};
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use crate::tui::functions::common::form::SaveOutcome;
|
||||
use crate::pages::routing::{Router, Page};
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn handle_command_event(
|
||||
key: KeyEvent,
|
||||
config: &Config,
|
||||
app_state: &mut AppState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
form_state: &mut FormState,
|
||||
router: &mut Router,
|
||||
command_input: &mut String,
|
||||
command_message: &mut String,
|
||||
grpc_client: &mut GrpcClient,
|
||||
@@ -27,21 +24,19 @@ pub async fn handle_command_event(
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<EventOutcome> {
|
||||
// Exit command mode (via configurable keybinding)
|
||||
// Exit command mode
|
||||
if config.is_exit_command_mode(key.code, key.modifiers) {
|
||||
command_input.clear();
|
||||
*command_message = "".to_string();
|
||||
return Ok(EventOutcome::Ok("Exited command mode".to_string()));
|
||||
}
|
||||
|
||||
// Execute command (via configurable keybinding, defaults to Enter)
|
||||
// Execute command
|
||||
if config.is_command_execute(key.code, key.modifiers) {
|
||||
return process_command(
|
||||
config,
|
||||
form_state,
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
router,
|
||||
command_input,
|
||||
command_message,
|
||||
grpc_client,
|
||||
@@ -49,34 +44,31 @@ pub async fn handle_command_event(
|
||||
terminal,
|
||||
current_position,
|
||||
total_count,
|
||||
).await;
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Backspace (via configurable keybinding, defaults to Backspace)
|
||||
// Backspace
|
||||
if config.is_command_backspace(key.code, key.modifiers) {
|
||||
command_input.pop();
|
||||
return Ok(EventOutcome::Ok("".to_string()));
|
||||
}
|
||||
|
||||
// Regular character input - accept any character in command mode
|
||||
// Regular character input
|
||||
if let KeyCode::Char(c) = key.code {
|
||||
// Accept regular or shifted characters (e.g., 'a' or 'A')
|
||||
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT {
|
||||
command_input.push(c);
|
||||
return Ok(EventOutcome::Ok("".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore all other keys
|
||||
Ok(EventOutcome::Ok("".to_string()))
|
||||
}
|
||||
|
||||
async fn process_command(
|
||||
config: &Config,
|
||||
form_state: &mut FormState,
|
||||
app_state: &mut AppState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
router: &mut Router,
|
||||
command_input: &mut String,
|
||||
command_message: &mut String,
|
||||
grpc_client: &mut GrpcClient,
|
||||
@@ -85,28 +77,18 @@ async fn process_command(
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<EventOutcome> {
|
||||
// Clone the trimmed command to avoid borrow issues
|
||||
let command = command_input.trim().to_string();
|
||||
if command.is_empty() {
|
||||
*command_message = "Empty command".to_string();
|
||||
return Ok(EventOutcome::Ok(command_message.clone()));
|
||||
}
|
||||
|
||||
// Get the action for the command (now checks global and common bindings too)
|
||||
let action = config.get_action_for_command(&command)
|
||||
.unwrap_or("unknown");
|
||||
let action = config.get_action_for_command(&command).unwrap_or("unknown");
|
||||
|
||||
match action {
|
||||
"force_quit" | "save_and_quit" | "quit" => {
|
||||
let (should_exit, message) = command_handler
|
||||
.handle_command(
|
||||
action,
|
||||
terminal,
|
||||
app_state,
|
||||
form_state,
|
||||
login_state,
|
||||
register_state,
|
||||
)
|
||||
.handle_command(action, terminal, app_state, router)
|
||||
.await?;
|
||||
command_input.clear();
|
||||
if should_exit {
|
||||
@@ -114,29 +96,30 @@ async fn process_command(
|
||||
} else {
|
||||
Ok(EventOutcome::Ok(message))
|
||||
}
|
||||
},
|
||||
}
|
||||
"save" => {
|
||||
let outcome = save(
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
).await?;
|
||||
let message = match outcome {
|
||||
SaveOutcome::CreatedNew(_) => "New entry created".to_string(),
|
||||
SaveOutcome::UpdatedExisting => "Entry updated".to_string(),
|
||||
SaveOutcome::NoChange => "No changes to save".to_string(),
|
||||
};
|
||||
command_input.clear();
|
||||
Ok(EventOutcome::DataSaved(outcome, message))
|
||||
},
|
||||
if let Page::Form(path) = &router.current {
|
||||
let outcome = save(app_state, path, grpc_client).await?;
|
||||
let message = match outcome {
|
||||
SaveOutcome::CreatedNew(_) => "New entry created".to_string(),
|
||||
SaveOutcome::UpdatedExisting => "Entry updated".to_string(),
|
||||
SaveOutcome::NoChange => "No changes to save".to_string(),
|
||||
};
|
||||
command_input.clear();
|
||||
Ok(EventOutcome::DataSaved(outcome, message))
|
||||
} else {
|
||||
Ok(EventOutcome::Ok("Not in a form page".to_string()))
|
||||
}
|
||||
}
|
||||
"revert" => {
|
||||
let message = revert(
|
||||
form_state,
|
||||
grpc_client,
|
||||
).await?;
|
||||
command_input.clear();
|
||||
Ok(EventOutcome::Ok(message))
|
||||
},
|
||||
if let Page::Form(path) = &router.current {
|
||||
let message = revert(app_state, path, grpc_client).await?;
|
||||
command_input.clear();
|
||||
Ok(EventOutcome::Ok(message))
|
||||
} else {
|
||||
Ok(EventOutcome::Ok("Not in a form page".to_string()))
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let message = format!("Unhandled action: {}", action);
|
||||
command_input.clear();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/modes/common/commands.rs
|
||||
use crate::tui::terminal::core::TerminalCore;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::{form::FormState, auth::LoginState, auth::RegisterState};
|
||||
use crate::pages::routing::{Router, Page};
|
||||
use anyhow::Result;
|
||||
|
||||
pub struct CommandHandler;
|
||||
@@ -15,13 +15,11 @@ impl CommandHandler {
|
||||
&mut self,
|
||||
action: &str,
|
||||
terminal: &mut TerminalCore,
|
||||
app_state: &AppState,
|
||||
form_state: &FormState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
app_state: &mut AppState,
|
||||
router: &Router,
|
||||
) -> Result<(bool, String)> {
|
||||
match action {
|
||||
"quit" => self.handle_quit(terminal, app_state, form_state, login_state, register_state).await,
|
||||
"quit" => self.handle_quit(terminal, app_state, router).await,
|
||||
"force_quit" => self.handle_force_quit(terminal).await,
|
||||
"save_and_quit" => self.handle_save_quit(terminal).await,
|
||||
_ => Ok((false, format!("Unknown command: {}", action))),
|
||||
@@ -31,18 +29,18 @@ impl CommandHandler {
|
||||
async fn handle_quit(
|
||||
&self,
|
||||
terminal: &mut TerminalCore,
|
||||
app_state: &AppState,
|
||||
form_state: &FormState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
app_state: &mut AppState,
|
||||
router: &Router,
|
||||
) -> Result<(bool, String)> {
|
||||
// Use actual unsaved changes state instead of is_saved flag
|
||||
let has_unsaved = if app_state.ui.show_login {
|
||||
login_state.has_unsaved_changes()
|
||||
} else if app_state.ui.show_register {
|
||||
register_state.has_unsaved_changes()
|
||||
} else {
|
||||
form_state.has_unsaved_changes
|
||||
// Use router to check unsaved changes
|
||||
let has_unsaved = match &router.current {
|
||||
Page::Login(page) => page.state.has_unsaved_changes(),
|
||||
Page::Register(state) => state.has_unsaved_changes(),
|
||||
Page::Form(path) => app_state
|
||||
.form_state_for_path_ref(path)
|
||||
.map(|fs| fs.has_unsaved_changes())
|
||||
.unwrap_or(false),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if !has_unsaved {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// src/client/modes/general.rs
|
||||
pub mod navigation;
|
||||
pub mod dialog;
|
||||
pub mod command_navigation;
|
||||
|
||||
@@ -82,8 +82,6 @@ impl TableDependencyGraph {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ... (NavigationState struct and its new(), activate_*, deactivate(), add_char(), remove_char(), move_*, autocomplete_selected(), get_display_input() methods are unchanged) ...
|
||||
pub struct NavigationState {
|
||||
pub active: bool,
|
||||
pub input: String,
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
// src/modes/general/dialog.rs
|
||||
|
||||
use crossterm::event::{Event, KeyCode};
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
use crate::state::app::{state::AppState, buffer::AppView};
|
||||
use crate::state::app::buffer::BufferState;
|
||||
use crate::state::pages::auth::{LoginState, RegisterState};
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use crate::tui::functions::common::{login, register};
|
||||
use crate::tui::functions::common::add_table::handle_delete_selected_columns;
|
||||
use anyhow::Result;
|
||||
|
||||
/// Handles key events specifically when a dialog is active.
|
||||
/// Returns Some(Result<EventOutcome, Error>) if the event was handled (consumed),
|
||||
/// otherwise returns None.
|
||||
pub async fn handle_dialog_event(
|
||||
event: &Event,
|
||||
config: &Config,
|
||||
app_state: &mut AppState,
|
||||
login_state: &mut LoginState,
|
||||
register_state: &mut RegisterState,
|
||||
buffer_state: &mut BufferState,
|
||||
admin_state: &mut AdminState,
|
||||
) -> Option<Result<EventOutcome>> {
|
||||
if let Event::Key(key) = event {
|
||||
// Always allow Esc to dismiss
|
||||
if key.code == KeyCode::Esc {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Dialog dismissed".to_string())));
|
||||
}
|
||||
|
||||
// Check general bindings for dialog actions
|
||||
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
|
||||
match action {
|
||||
"move_down" | "next_option" => {
|
||||
let current_index = app_state.ui.dialog.dialog_active_button_index;
|
||||
let num_buttons = app_state.ui.dialog.dialog_buttons.len();
|
||||
if num_buttons > 0 && current_index < num_buttons - 1 {
|
||||
app_state.ui.dialog.dialog_active_button_index += 1;
|
||||
}
|
||||
return Some(Ok(EventOutcome::Ok(String::new())));
|
||||
}
|
||||
"move_up" | "previous_option" => {
|
||||
let current_index = app_state.ui.dialog.dialog_active_button_index;
|
||||
if current_index > 0 {
|
||||
app_state.ui.dialog.dialog_active_button_index -= 1;
|
||||
}
|
||||
return Some(Ok(EventOutcome::Ok(String::new())));
|
||||
}
|
||||
"select" => {
|
||||
let selected_index = app_state.ui.dialog.dialog_active_button_index;
|
||||
let purpose = match app_state.ui.dialog.purpose {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Internal Error: Dialog context lost".to_string())));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Dialog Actions Directly Here
|
||||
match purpose {
|
||||
DialogPurpose::LoginSuccess => {
|
||||
match selected_index {
|
||||
0 => { // "Menu" button selected
|
||||
app_state.hide_dialog();
|
||||
let message = login::back_to_main(login_state, app_state, buffer_state).await;
|
||||
return Some(Ok(EventOutcome::Ok(message)));
|
||||
}
|
||||
1 => {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Exiting dialog".to_string())));
|
||||
}
|
||||
_ => {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Unknown dialog button selected".to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
DialogPurpose::LoginFailed => {
|
||||
match selected_index {
|
||||
0 => { // "OK" button selected
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Login failed dialog dismissed".to_string())));
|
||||
}
|
||||
_ => {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Unknown dialog button selected".to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
DialogPurpose::RegisterSuccess => { // Add this arm
|
||||
match selected_index {
|
||||
0 => { // "OK" button for RegisterSuccess
|
||||
app_state.hide_dialog();
|
||||
let message = register::back_to_login(register_state, app_state, buffer_state).await;
|
||||
return Some(Ok(EventOutcome::Ok(message)));
|
||||
}
|
||||
_ => { // Default for RegisterSuccess
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Unknown dialog button selected".to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
DialogPurpose::RegisterFailed => { // Add this arm
|
||||
match selected_index {
|
||||
0 => { // "OK" button for RegisterFailed
|
||||
app_state.hide_dialog(); // Just dismiss
|
||||
return Some(Ok(EventOutcome::Ok("Register failed dialog dismissed".to_string())));
|
||||
}
|
||||
_ => { // Default for RegisterFailed
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Unknown dialog button selected".to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
DialogPurpose::ConfirmDeleteColumns => {
|
||||
match selected_index {
|
||||
0 => { // "Confirm" button selected
|
||||
let outcome_message = handle_delete_selected_columns(&mut admin_state.add_table_state);
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok(outcome_message)));
|
||||
}
|
||||
1 => { // "Cancel" button selected
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Deletion cancelled.".to_string())));
|
||||
}
|
||||
_ => { /* Handle unexpected index */ }
|
||||
}
|
||||
}
|
||||
DialogPurpose::SaveTableSuccess => {
|
||||
match selected_index {
|
||||
0 => { // "OK" button selected
|
||||
app_state.hide_dialog();
|
||||
buffer_state.update_history(AppView::Admin); // Navigate back
|
||||
return Some(Ok(EventOutcome::Ok("Save success dialog dismissed.".to_string())));
|
||||
}
|
||||
_ => { /* Handle unexpected index */ }
|
||||
}
|
||||
}
|
||||
DialogPurpose::SaveLogicSuccess => {
|
||||
match selected_index {
|
||||
0 => { // "OK" button selected
|
||||
app_state.hide_dialog();
|
||||
buffer_state.update_history(AppView::Admin);
|
||||
return Some(Ok(EventOutcome::Ok("Save success dialog dismissed.".to_string())));
|
||||
}
|
||||
_ => { /* Handle unexpected index */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {} // Ignore other general actions when dialog is shown
|
||||
}
|
||||
}
|
||||
// If it was a key event but not handled above, consume it
|
||||
Some(Ok(EventOutcome::Ok(String::new())))
|
||||
} else {
|
||||
// If it wasn't a key event, consume it too while dialog is active
|
||||
Some(Ok(EventOutcome::Ok(String::new())))
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,8 @@
|
||||
use crossterm::event::KeyEvent;
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::state::pages::auth::LoginState;
|
||||
use crate::state::pages::auth::RegisterState;
|
||||
use crate::state::pages::intro::IntroState;
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use crate::pages::routing::{Router, Page};
|
||||
use crate::pages::forms::FormState;
|
||||
use crate::ui::handlers::context::UiContext;
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use crate::modes::general::command_navigation::{handle_command_navigation_event, NavigationState};
|
||||
@@ -17,12 +14,8 @@ use anyhow::Result;
|
||||
pub async fn handle_navigation_event(
|
||||
key: KeyEvent,
|
||||
config: &Config,
|
||||
form_state: &mut FormState,
|
||||
app_state: &mut AppState,
|
||||
login_state: &mut LoginState,
|
||||
register_state: &mut RegisterState,
|
||||
intro_state: &mut IntroState,
|
||||
admin_state: &mut AdminState,
|
||||
router: &mut Router,
|
||||
command_mode: &mut bool,
|
||||
command_input: &mut String,
|
||||
command_message: &mut String,
|
||||
@@ -35,28 +28,36 @@ pub async fn handle_navigation_event(
|
||||
|
||||
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
|
||||
match action {
|
||||
"move_up" => {
|
||||
move_up(app_state, login_state, register_state, intro_state, admin_state);
|
||||
"up" => {
|
||||
up(app_state, router);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
"move_down" => {
|
||||
move_down(app_state, intro_state, admin_state);
|
||||
"down" => {
|
||||
down(app_state, router);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
"next_option" => {
|
||||
next_option(app_state, intro_state);
|
||||
next_option(app_state, router);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
"previous_option" => {
|
||||
previous_option(app_state, intro_state);
|
||||
previous_option(app_state, router);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
"next_field" => {
|
||||
next_field(form_state);
|
||||
if let Page::Form(path) = &router.current {
|
||||
if let Some(fs) = app_state.form_state_for_path(path) {
|
||||
next_field(fs);
|
||||
}
|
||||
}
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
"prev_field" => {
|
||||
prev_field(form_state);
|
||||
if let Page::Form(path) = &router.current {
|
||||
if let Some(fs) = app_state.form_state_for_path(path) {
|
||||
prev_field(fs);
|
||||
}
|
||||
}
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
"enter_command_mode" => {
|
||||
@@ -64,18 +65,21 @@ pub async fn handle_navigation_event(
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
"select" => {
|
||||
let (context, index) = if app_state.ui.show_intro {
|
||||
(UiContext::Intro, intro_state.selected_option)
|
||||
} else if app_state.ui.show_login && app_state.ui.focus_outside_canvas {
|
||||
(UiContext::Login, app_state.focused_button_index)
|
||||
} else if app_state.ui.show_register && app_state.ui.focus_outside_canvas {
|
||||
(UiContext::Register, app_state.focused_button_index)
|
||||
} else if app_state.ui.show_admin {
|
||||
(UiContext::Admin, admin_state.get_selected_index().unwrap_or(0))
|
||||
} else if app_state.ui.dialog.dialog_show {
|
||||
(UiContext::Dialog, app_state.ui.dialog.dialog_active_button_index)
|
||||
} else {
|
||||
return Ok(EventOutcome::Ok("Select (No Action)".to_string()));
|
||||
let (context, index) = match &router.current {
|
||||
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(state) if state.focus_outside_canvas => {
|
||||
(UiContext::Register, state.focused_button_index)
|
||||
}
|
||||
Page::Admin(state) => {
|
||||
(UiContext::Admin, state.get_selected_index().unwrap_or(0))
|
||||
}
|
||||
_ if app_state.ui.dialog.dialog_show => {
|
||||
(UiContext::Dialog, app_state.ui.dialog.dialog_active_button_index)
|
||||
}
|
||||
_ => return Ok(EventOutcome::Ok("Select (No Action)".to_string())),
|
||||
};
|
||||
return Ok(EventOutcome::ButtonSelected { context, index });
|
||||
}
|
||||
@@ -85,60 +89,82 @@ pub async fn handle_navigation_event(
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
|
||||
pub fn move_up(app_state: &mut AppState, login_state: &mut LoginState, register_state: &mut RegisterState, intro_state: &mut IntroState, admin_state: &mut AdminState) {
|
||||
if app_state.ui.focus_outside_canvas && app_state.ui.show_login || app_state.ui.show_register{
|
||||
if app_state.focused_button_index == 0 {
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
if app_state.ui.show_login {
|
||||
let last_field_index = login_state.field_count().saturating_sub(1);
|
||||
login_state.set_current_field(last_field_index);
|
||||
pub fn up(app_state: &mut AppState, router: &mut Router) {
|
||||
match &mut router.current {
|
||||
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 {
|
||||
let last_field_index = register_state.field_count().saturating_sub(1);
|
||||
register_state.set_current_field(last_field_index);
|
||||
page.focused_button_index =
|
||||
page.focused_button_index.saturating_sub(1);
|
||||
}
|
||||
} else {
|
||||
app_state.focused_button_index = app_state.focused_button_index.saturating_sub(1);
|
||||
}
|
||||
} else if app_state.ui.show_intro {
|
||||
intro_state.previous_option();
|
||||
} else if app_state.ui.show_admin {
|
||||
admin_state.previous();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_down(app_state: &mut AppState, intro_state: &mut IntroState, admin_state: &mut AdminState) {
|
||||
if app_state.ui.focus_outside_canvas && app_state.ui.show_login || app_state.ui.show_register {
|
||||
let num_general_elements = 2;
|
||||
if app_state.focused_button_index < num_general_elements - 1 {
|
||||
app_state.focused_button_index += 1;
|
||||
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 {
|
||||
state.focused_button_index =
|
||||
state.focused_button_index.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
} else if app_state.ui.show_intro {
|
||||
intro_state.next_option();
|
||||
} else if app_state.ui.show_admin {
|
||||
admin_state.next();
|
||||
Page::Intro(state) => state.previous_option(),
|
||||
Page::Admin(state) => state.previous(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_option(app_state: &mut AppState, intro_state: &mut IntroState) {
|
||||
if app_state.ui.show_intro {
|
||||
intro_state.next_option();
|
||||
} else {
|
||||
// Get option count from state instead of parameter
|
||||
let option_count = app_state.profile_tree.profiles.len();
|
||||
app_state.focused_button_index = (app_state.focused_button_index + 1) % option_count;
|
||||
pub fn down(app_state: &mut AppState, router: &mut Router) {
|
||||
match &mut router.current {
|
||||
Page::Login(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::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(),
|
||||
Page::Admin(state) => state.next(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn previous_option(app_state: &mut AppState, intro_state: &mut IntroState) {
|
||||
if app_state.ui.show_intro {
|
||||
intro_state.previous_option();
|
||||
} else {
|
||||
let option_count = app_state.profile_tree.profiles.len();
|
||||
app_state.focused_button_index = if app_state.focused_button_index == 0 {
|
||||
option_count.saturating_sub(1)
|
||||
} else {
|
||||
app_state.focused_button_index - 1
|
||||
};
|
||||
pub fn next_option(app_state: &mut AppState, router: &mut Router) {
|
||||
match &mut router.current {
|
||||
Page::Intro(state) => state.next_option(),
|
||||
Page::Admin(state) => {
|
||||
let option_count = app_state.profile_tree.profiles.len();
|
||||
if option_count > 0 {
|
||||
state.focused_button_index =
|
||||
(state.focused_button_index + 1) % option_count;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn previous_option(app_state: &mut AppState, router: &mut Router) {
|
||||
match &mut router.current {
|
||||
Page::Intro(state) => state.previous_option(),
|
||||
Page::Admin(state) => {
|
||||
let option_count = app_state.profile_tree.profiles.len();
|
||||
if option_count > 0 {
|
||||
state.focused_button_index = if state.focused_button_index == 0 {
|
||||
option_count.saturating_sub(1)
|
||||
} else {
|
||||
state.focused_button_index - 1
|
||||
};
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +187,7 @@ pub fn prev_field(form_state: &mut FormState) {
|
||||
pub fn handle_enter_command_mode(
|
||||
command_mode: &mut bool,
|
||||
command_input: &mut String,
|
||||
command_message: &mut String
|
||||
command_message: &mut String,
|
||||
) {
|
||||
*command_mode = true;
|
||||
command_input.clear();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,100 +1,56 @@
|
||||
// src/modes/handlers/mode_manager.rs
|
||||
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::modes::handlers::event::EventHandler;
|
||||
use crate::state::pages::add_logic::AddLogicFocus;
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use crate::pages::routing::{Router, Page};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AppMode {
|
||||
General, // For intro and admin screens
|
||||
ReadOnly, // Canvas read-only mode
|
||||
Edit, // Canvas edit mode
|
||||
Highlight, // Cnavas highlight/visual mode
|
||||
Command, // Command mode overlay
|
||||
/// General mode = when focus is outside any canvas
|
||||
/// (Intro, Admin, Login/Register buttons, AddTable/AddLogic menus, dialogs, etc.)
|
||||
General,
|
||||
|
||||
/// Command overlay (":" or "ctrl+;"), available globally
|
||||
Command,
|
||||
}
|
||||
|
||||
pub struct ModeManager;
|
||||
|
||||
impl ModeManager {
|
||||
// Determine current mode based on app state
|
||||
/// Determine current mode:
|
||||
/// - If navigation palette is active → General
|
||||
/// - If command overlay is active → Command
|
||||
/// - If focus is inside a canvas (Form, Login, Register, AddTable, AddLogic) → let canvas handle its own mode
|
||||
/// - Otherwise → General
|
||||
pub fn derive_mode(
|
||||
app_state: &AppState,
|
||||
event_handler: &EventHandler,
|
||||
admin_state: &AdminState,
|
||||
router: &Router,
|
||||
) -> AppMode {
|
||||
// Navigation palette always forces General
|
||||
if event_handler.navigation_state.active {
|
||||
return AppMode::General;
|
||||
}
|
||||
|
||||
// Explicit command overlay flag
|
||||
if event_handler.command_mode {
|
||||
return AppMode::Command;
|
||||
}
|
||||
|
||||
if !matches!(event_handler.highlight_state, HighlightState::Off) {
|
||||
return AppMode::Highlight;
|
||||
}
|
||||
|
||||
let is_canvas_view = app_state.ui.show_login
|
||||
|| app_state.ui.show_register
|
||||
|| app_state.ui.show_form
|
||||
|| app_state.ui.show_add_table
|
||||
|| app_state.ui.show_add_logic;
|
||||
|
||||
if app_state.ui.show_add_logic {
|
||||
// Specific logic for AddLogic view
|
||||
match admin_state.add_logic_state.current_focus {
|
||||
AddLogicFocus::InputLogicName
|
||||
| AddLogicFocus::InputTargetColumn
|
||||
| AddLogicFocus::InputDescription => {
|
||||
// These are canvas inputs
|
||||
if event_handler.is_edit_mode {
|
||||
AppMode::Edit
|
||||
} else {
|
||||
AppMode::ReadOnly
|
||||
}
|
||||
}
|
||||
_ => AppMode::General,
|
||||
}
|
||||
} else if app_state.ui.show_add_table {
|
||||
if app_state.ui.focus_outside_canvas {
|
||||
AppMode::General
|
||||
} else {
|
||||
if event_handler.is_edit_mode {
|
||||
AppMode::Edit
|
||||
} else {
|
||||
AppMode::ReadOnly
|
||||
}
|
||||
}
|
||||
} else if is_canvas_view {
|
||||
if app_state.ui.focus_outside_canvas {
|
||||
AppMode::General
|
||||
} else {
|
||||
if event_handler.is_edit_mode {
|
||||
AppMode::Edit
|
||||
} else {
|
||||
AppMode::ReadOnly
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AppMode::General
|
||||
// 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(_) => 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,
|
||||
}
|
||||
}
|
||||
|
||||
// Mode transition rules
|
||||
pub fn can_enter_command_mode(current_mode: AppMode) -> bool {
|
||||
!matches!(current_mode, AppMode::Edit)
|
||||
}
|
||||
|
||||
pub fn can_enter_edit_mode(current_mode: AppMode) -> bool {
|
||||
matches!(current_mode, AppMode::ReadOnly)
|
||||
}
|
||||
|
||||
pub fn can_enter_read_only_mode(current_mode: AppMode) -> bool {
|
||||
matches!(current_mode, AppMode::Edit | AppMode::Command | AppMode::Highlight)
|
||||
}
|
||||
|
||||
pub fn can_enter_highlight_mode(current_mode: AppMode) -> bool {
|
||||
matches!(current_mode, AppMode::ReadOnly)
|
||||
/// Command overlay can be entered from anywhere (General or Canvas).
|
||||
pub fn can_enter_command_mode(_current_mode: AppMode) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,3 @@ pub mod canvas;
|
||||
pub use handlers::*;
|
||||
pub use general::*;
|
||||
pub use common::*;
|
||||
pub use canvas::*;
|
||||
|
||||
12
client/src/movement/actions.rs
Normal file
12
client/src/movement/actions.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
// src/movement/actions.rs
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MovementAction {
|
||||
Next,
|
||||
Previous,
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
Select,
|
||||
Esc,
|
||||
}
|
||||
32
client/src/movement/lib.rs
Normal file
32
client/src/movement/lib.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
// src/movement/lib.rs
|
||||
|
||||
use crate::movement::MovementAction;
|
||||
|
||||
#[inline]
|
||||
pub fn move_focus<T: Copy + Eq>(
|
||||
order: &[T],
|
||||
current: &mut T,
|
||||
action: MovementAction,
|
||||
) -> bool {
|
||||
if order.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if let Some(pos) = order.iter().position(|k| *k == *current) {
|
||||
match action {
|
||||
MovementAction::Previous | MovementAction::Up | MovementAction::Left => {
|
||||
if pos > 0 {
|
||||
*current = order[pos - 1];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
MovementAction::Next | MovementAction::Down | MovementAction::Right => {
|
||||
if pos + 1 < order.len() {
|
||||
*current = order[pos + 1];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
6
client/src/movement/mod.rs
Normal file
6
client/src/movement/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// src/movement/mod.rs
|
||||
pub mod actions;
|
||||
pub mod lib;
|
||||
|
||||
pub use actions::MovementAction;
|
||||
pub use lib::move_focus;
|
||||
65
client/src/pages/admin/admin/event.rs
Normal file
65
client/src/pages/admin/admin/event.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
// src/pages/admin/admin/event.rs
|
||||
use anyhow::Result;
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
use crate::buffer::state::BufferState;
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::pages::admin::main::logic::handle_admin_navigation;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::pages::routing::{Router, Page};
|
||||
|
||||
/// Handle all Admin page-specific key events (movement + actions).
|
||||
/// Returns true if the key was handled (so the caller should stop propagation).
|
||||
pub fn handle_admin_event(
|
||||
key_event: KeyEvent,
|
||||
config: &Config,
|
||||
app_state: &mut AppState,
|
||||
buffer_state: &mut BufferState,
|
||||
router: &mut Router,
|
||||
command_message: &mut String,
|
||||
) -> Result<bool> {
|
||||
if let Page::Admin(admin_state) = &mut router.current {
|
||||
// 1) Map general action to MovementAction (same mapping used in event.rs)
|
||||
let movement_action = if let Some(act) =
|
||||
config.get_general_action(key_event.code, key_event.modifiers)
|
||||
{
|
||||
use crate::movement::MovementAction;
|
||||
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
|
||||
};
|
||||
|
||||
if let Some(ma) = movement_action {
|
||||
if admin_state.handle_movement(app_state, ma) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Rich Admin navigation (buttons, selections, etc.)
|
||||
if handle_admin_navigation(
|
||||
key_event,
|
||||
config,
|
||||
app_state,
|
||||
buffer_state,
|
||||
router,
|
||||
command_message,
|
||||
) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// If we reached here, nothing was handled
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
54
client/src/pages/admin/admin/loader.rs
Normal file
54
client/src/pages/admin/admin/loader.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
// src/pages/admin/admin/loader.rs
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::pages::admin::{AdminFocus, AdminState};
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::app::state::AppState;
|
||||
|
||||
/// Refresh admin data and ensure focus and selections are valid.
|
||||
pub async fn refresh_admin_state(
|
||||
grpc_client: &mut GrpcClient,
|
||||
app_state: &mut AppState,
|
||||
admin_state: &mut AdminState,
|
||||
) -> Result<()> {
|
||||
// Fetch latest profile tree
|
||||
let refreshed_tree = grpc_client
|
||||
.get_profile_tree()
|
||||
.await
|
||||
.context("Failed to refresh profile tree for Admin panel")?;
|
||||
app_state.profile_tree = refreshed_tree;
|
||||
|
||||
// Populate profile names for AdminState's list
|
||||
let profile_names = app_state
|
||||
.profile_tree
|
||||
.profiles
|
||||
.iter()
|
||||
.map(|p| p.name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
admin_state.set_profiles(profile_names);
|
||||
|
||||
// Ensure a sane focus
|
||||
if admin_state.current_focus == AdminFocus::default()
|
||||
|| !matches!(
|
||||
admin_state.current_focus,
|
||||
AdminFocus::InsideProfilesList
|
||||
| AdminFocus::Tables
|
||||
| AdminFocus::InsideTablesList
|
||||
| AdminFocus::Button1
|
||||
| AdminFocus::Button2
|
||||
| AdminFocus::Button3
|
||||
| AdminFocus::ProfilesPane
|
||||
)
|
||||
{
|
||||
admin_state.current_focus = AdminFocus::ProfilesPane;
|
||||
}
|
||||
|
||||
// Ensure a selection exists when profiles are present
|
||||
if admin_state.profile_list_state.selected().is_none()
|
||||
&& !app_state.profile_tree.profiles.is_empty()
|
||||
{
|
||||
admin_state.profile_list_state.select(Some(0));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
9
client/src/pages/admin/admin/mod.rs
Normal file
9
client/src/pages/admin/admin/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
// src/pages/admin/admin/mod.rs
|
||||
|
||||
pub mod state;
|
||||
pub mod ui;
|
||||
pub mod tui;
|
||||
pub mod event;
|
||||
pub mod loader;
|
||||
|
||||
pub use state::{AdminState, AdminFocus};
|
||||
193
client/src/pages/admin/admin/state.rs
Normal file
193
client/src/pages/admin/admin/state.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
// src/pages/admin/admin/state.rs
|
||||
use ratatui::widgets::ListState;
|
||||
use crate::movement::{move_focus, MovementAction};
|
||||
use crate::state::app::state::AppState;
|
||||
|
||||
/// Focus states for the admin panel
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum AdminFocus {
|
||||
#[default]
|
||||
ProfilesPane,
|
||||
InsideProfilesList,
|
||||
Tables,
|
||||
InsideTablesList,
|
||||
Button1,
|
||||
Button2,
|
||||
Button3,
|
||||
}
|
||||
|
||||
/// Full admin panel state (for logged-in admins)
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct AdminState {
|
||||
pub profiles: Vec<String>,
|
||||
pub profile_list_state: ListState,
|
||||
pub table_list_state: ListState,
|
||||
pub selected_profile_index: Option<usize>,
|
||||
pub selected_table_index: Option<usize>,
|
||||
pub current_focus: AdminFocus,
|
||||
pub focus_outside_canvas: bool,
|
||||
pub focused_button_index: usize,
|
||||
}
|
||||
|
||||
impl AdminState {
|
||||
pub fn get_selected_index(&self) -> Option<usize> {
|
||||
self.profile_list_state.selected()
|
||||
}
|
||||
|
||||
pub fn get_selected_profile_name(&self) -> Option<&String> {
|
||||
self.profile_list_state.selected().and_then(|i| self.profiles.get(i))
|
||||
}
|
||||
|
||||
pub fn set_profiles(&mut self, new_profiles: Vec<String>) {
|
||||
let current_selection_index = self.profile_list_state.selected();
|
||||
self.profiles = new_profiles;
|
||||
|
||||
if self.profiles.is_empty() {
|
||||
self.profile_list_state.select(None);
|
||||
} else {
|
||||
let new_selection = match current_selection_index {
|
||||
Some(index) => Some(index.min(self.profiles.len() - 1)),
|
||||
None => Some(0),
|
||||
};
|
||||
self.profile_list_state.select(new_selection);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next(&mut self) {
|
||||
if self.profiles.is_empty() {
|
||||
self.profile_list_state.select(None);
|
||||
return;
|
||||
}
|
||||
let i = match self.profile_list_state.selected() {
|
||||
Some(i) => if i >= self.profiles.len() - 1 { 0 } else { i + 1 },
|
||||
None => 0,
|
||||
};
|
||||
self.profile_list_state.select(Some(i));
|
||||
}
|
||||
|
||||
pub fn previous(&mut self) {
|
||||
if self.profiles.is_empty() {
|
||||
self.profile_list_state.select(None);
|
||||
return;
|
||||
}
|
||||
let i = match self.profile_list_state.selected() {
|
||||
Some(i) => if i == 0 { self.profiles.len() - 1 } else { i - 1 },
|
||||
None => self.profiles.len() - 1,
|
||||
};
|
||||
self.profile_list_state.select(Some(i));
|
||||
}
|
||||
|
||||
pub fn handle_movement(
|
||||
&mut self,
|
||||
app: &AppState,
|
||||
action: MovementAction,
|
||||
) -> bool {
|
||||
use AdminFocus::*;
|
||||
|
||||
const ORDER: [AdminFocus; 5] = [
|
||||
ProfilesPane,
|
||||
Tables,
|
||||
Button1,
|
||||
Button2,
|
||||
Button3,
|
||||
];
|
||||
|
||||
match (self.current_focus, action) {
|
||||
(ProfilesPane, MovementAction::Select) => {
|
||||
if !app.profile_tree.profiles.is_empty()
|
||||
&& self.profile_list_state.selected().is_none()
|
||||
{
|
||||
self.profile_list_state.select(Some(0));
|
||||
}
|
||||
self.current_focus = InsideProfilesList;
|
||||
return true;
|
||||
}
|
||||
(Tables, MovementAction::Select) => {
|
||||
let p_idx = self
|
||||
.selected_profile_index
|
||||
.or_else(|| self.profile_list_state.selected());
|
||||
if let Some(pi) = p_idx {
|
||||
let len = app
|
||||
.profile_tree
|
||||
.profiles
|
||||
.get(pi)
|
||||
.map(|p| p.tables.len())
|
||||
.unwrap_or(0);
|
||||
if len > 0 && self.table_list_state.selected().is_none() {
|
||||
self.table_list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
self.current_focus = InsideTablesList;
|
||||
return true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match self.current_focus {
|
||||
InsideProfilesList => match action {
|
||||
MovementAction::Up => {
|
||||
if !app.profile_tree.profiles.is_empty() {
|
||||
let curr = self.profile_list_state.selected().unwrap_or(0);
|
||||
let next = curr.saturating_sub(1);
|
||||
self.profile_list_state.select(Some(next));
|
||||
}
|
||||
true
|
||||
}
|
||||
MovementAction::Down => {
|
||||
let len = app.profile_tree.profiles.len();
|
||||
if len > 0 {
|
||||
let curr = self.profile_list_state.selected().unwrap_or(0);
|
||||
let next = if curr + 1 < len { curr + 1 } else { curr };
|
||||
self.profile_list_state.select(Some(next));
|
||||
}
|
||||
true
|
||||
}
|
||||
MovementAction::Esc => {
|
||||
self.current_focus = ProfilesPane;
|
||||
true
|
||||
}
|
||||
MovementAction::Next | MovementAction::Previous => true,
|
||||
MovementAction::Select => false,
|
||||
_ => false,
|
||||
},
|
||||
InsideTablesList => {
|
||||
let tables_len = {
|
||||
let p_idx = self
|
||||
.selected_profile_index
|
||||
.or_else(|| self.profile_list_state.selected());
|
||||
p_idx.and_then(|pi| app.profile_tree.profiles.get(pi))
|
||||
.map(|p| p.tables.len())
|
||||
.unwrap_or(0)
|
||||
};
|
||||
match action {
|
||||
MovementAction::Up => {
|
||||
if tables_len > 0 {
|
||||
let curr = self.table_list_state.selected().unwrap_or(0);
|
||||
let next = curr.saturating_sub(1);
|
||||
self.table_list_state.select(Some(next));
|
||||
}
|
||||
true
|
||||
}
|
||||
MovementAction::Down => {
|
||||
if tables_len > 0 {
|
||||
let curr = self.table_list_state.selected().unwrap_or(0);
|
||||
let next = if curr + 1 < tables_len { curr + 1 } else { curr };
|
||||
self.table_list_state.select(Some(next));
|
||||
}
|
||||
true
|
||||
}
|
||||
MovementAction::Esc => {
|
||||
self.current_focus = Tables;
|
||||
true
|
||||
}
|
||||
MovementAction::Next | MovementAction::Previous => true,
|
||||
MovementAction::Select => false,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
move_focus(&ORDER, &mut self.current_focus, action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// src/pages/admin/admin/tui.rs
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use crate::pages::admin::AdminState;
|
||||
|
||||
pub fn handle_admin_selection(app_state: &mut AppState, admin_state: &AdminState) {
|
||||
let profiles = &app_state.profile_tree.profiles;
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/components/admin/admin_panel_admin.rs
|
||||
// src/pages/admin/admin/ui.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::pages::admin::{AdminFocus, AdminState};
|
||||
use crate::pages::admin::{AdminFocus, AdminState};
|
||||
use crate::state::app::state::AppState;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
@@ -1,11 +1,12 @@
|
||||
// src/functions/modes/navigation/admin_nav.rs
|
||||
use crate::state::pages::admin::{AdminFocus, AdminState};
|
||||
// src/pages/admin/main/logic.rs
|
||||
use crate::pages::admin::{AdminFocus, AdminState};
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::state::app::buffer::{BufferState, AppView};
|
||||
use crate::state::pages::add_table::{AddTableState, LinkDefinition};
|
||||
use crate::buffer::state::{BufferState, AppView};
|
||||
use ratatui::widgets::ListState;
|
||||
use crate::state::pages::add_logic::{AddLogicState, AddLogicFocus}; // Added AddLogicFocus import
|
||||
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};
|
||||
|
||||
// Helper functions list_select_next and list_select_previous remain the same
|
||||
fn list_select_next(list_state: &mut ListState, item_count: usize) {
|
||||
@@ -36,17 +37,35 @@ pub fn handle_admin_navigation(
|
||||
key: crossterm::event::KeyEvent,
|
||||
config: &Config,
|
||||
app_state: &mut AppState,
|
||||
admin_state: &mut AdminState,
|
||||
buffer_state: &mut BufferState,
|
||||
router: &mut Router,
|
||||
command_message: &mut String,
|
||||
) -> bool {
|
||||
let action = config.get_general_action(key.code, key.modifiers).map(String::from);
|
||||
let current_focus = admin_state.current_focus;
|
||||
|
||||
// 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 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;
|
||||
@@ -64,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;
|
||||
}
|
||||
@@ -73,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 {
|
||||
@@ -90,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);
|
||||
}
|
||||
@@ -118,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;
|
||||
@@ -147,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;
|
||||
}
|
||||
@@ -166,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
|
||||
@@ -205,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))
|
||||
@@ -225,29 +255,36 @@ 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) {
|
||||
// Both profile and table are selected, proceed
|
||||
admin_state.add_logic_state = AddLogicState {
|
||||
profile_name: profile.name.clone(),
|
||||
selected_table_name: Some(table.name.clone()),
|
||||
selected_table_id: Some(table.id), // If you have table IDs
|
||||
editor_keybinding_mode: config.editor.keybinding_mode.clone(),
|
||||
current_focus: AddLogicFocus::default(),
|
||||
..AddLogicState::default()
|
||||
};
|
||||
|
||||
// Create AddLogic page with selected profile & table
|
||||
let add_logic_form = AddLogicFormState::new_with_table(
|
||||
&config.editor,
|
||||
profile.name.clone(),
|
||||
Some(table.id),
|
||||
table.name.clone(),
|
||||
);
|
||||
|
||||
// Store table info for later fetching
|
||||
app_state.pending_table_structure_fetch = Some((
|
||||
profile.name.clone(),
|
||||
table.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
|
||||
@@ -267,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;
|
||||
@@ -283,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();
|
||||
@@ -313,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;
|
||||
@@ -329,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;
|
||||
}
|
||||
7
client/src/pages/admin/main/mod.rs
Normal file
7
client/src/pages/admin/main/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// src/pages/admin/main/mod.rs
|
||||
|
||||
pub mod state;
|
||||
pub mod ui;
|
||||
pub mod logic;
|
||||
|
||||
pub use state::NonAdminState;
|
||||
55
client/src/pages/admin/main/state.rs
Normal file
55
client/src/pages/admin/main/state.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
// src/pages/admin/main/state.rs
|
||||
use ratatui::widgets::ListState;
|
||||
|
||||
/// State for non-admin users (simple profile browser)
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct NonAdminState {
|
||||
pub profiles: Vec<String>, // profile names
|
||||
pub profile_list_state: ListState, // highlight state
|
||||
pub selected_profile_index: Option<usize>, // persistent selection
|
||||
}
|
||||
|
||||
impl NonAdminState {
|
||||
pub fn get_selected_index(&self) -> Option<usize> {
|
||||
self.profile_list_state.selected()
|
||||
}
|
||||
|
||||
pub fn set_profiles(&mut self, new_profiles: Vec<String>) {
|
||||
let current_selection_index = self.profile_list_state.selected();
|
||||
self.profiles = new_profiles;
|
||||
|
||||
if self.profiles.is_empty() {
|
||||
self.profile_list_state.select(None);
|
||||
} else {
|
||||
let new_selection = match current_selection_index {
|
||||
Some(index) => Some(index.min(self.profiles.len() - 1)),
|
||||
None => Some(0),
|
||||
};
|
||||
self.profile_list_state.select(new_selection);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next(&mut self) {
|
||||
if self.profiles.is_empty() {
|
||||
self.profile_list_state.select(None);
|
||||
return;
|
||||
}
|
||||
let i = match self.profile_list_state.selected() {
|
||||
Some(i) => if i >= self.profiles.len() - 1 { 0 } else { i + 1 },
|
||||
None => 0,
|
||||
};
|
||||
self.profile_list_state.select(Some(i));
|
||||
}
|
||||
|
||||
pub fn previous(&mut self) {
|
||||
if self.profiles.is_empty() {
|
||||
self.profile_list_state.select(None);
|
||||
return;
|
||||
}
|
||||
let i = match self.profile_list_state.selected() {
|
||||
Some(i) => if i == 0 { self.profiles.len() - 1 } else { i - 1 },
|
||||
None => self.profiles.len() - 1,
|
||||
};
|
||||
self.profile_list_state.select(Some(i));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
// src/components/admin/admin_panel.rs
|
||||
// src/pages/admin/main/ui.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::pages::auth::AuthState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use crate::pages::admin::AdminState;
|
||||
use common::proto::komp_ac::table_definition::ProfileTreeResponse;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
@@ -12,7 +12,8 @@ use ratatui::{
|
||||
widgets::{Block, BorderType, Borders, List, ListItem, Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
use super::admin_panel_admin::render_admin_panel_admin;
|
||||
use crate::state::pages::auth::UserRole;
|
||||
use crate::pages::admin::admin::ui::render_admin_panel_admin;
|
||||
|
||||
pub fn render_admin_panel(
|
||||
f: &mut Frame,
|
||||
@@ -44,30 +45,27 @@ pub fn render_admin_panel(
|
||||
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
|
||||
.split(chunks[1]);
|
||||
|
||||
if auth_state.role.as_deref() != Some("admin") {
|
||||
render_admin_panel_non_admin(
|
||||
f,
|
||||
admin_state,
|
||||
&content_chunks,
|
||||
theme,
|
||||
profile_tree,
|
||||
selected_profile,
|
||||
);
|
||||
} else {
|
||||
render_admin_panel_admin(
|
||||
f,
|
||||
chunks[1],
|
||||
app_state,
|
||||
admin_state,
|
||||
theme,
|
||||
);
|
||||
match auth_state.role {
|
||||
Some(UserRole::Admin) => {
|
||||
render_admin_panel_admin(f, chunks[1], app_state, admin_state, theme);
|
||||
}
|
||||
_ => {
|
||||
render_admin_panel_non_admin(
|
||||
f,
|
||||
admin_state,
|
||||
&content_chunks,
|
||||
theme,
|
||||
profile_tree,
|
||||
selected_profile,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the view for non-admin users (profile list and details).
|
||||
fn render_admin_panel_non_admin(
|
||||
f: &mut Frame,
|
||||
admin_state: &AdminState,
|
||||
admin_state: &mut AdminState,
|
||||
content_chunks: &[Rect],
|
||||
theme: &Theme,
|
||||
profile_tree: &ProfileTreeResponse,
|
||||
@@ -92,8 +90,7 @@ fn render_admin_panel_non_admin(
|
||||
.block(Block::default().title("Profiles"))
|
||||
.highlight_style(Style::default().bg(theme.highlight).fg(theme.bg));
|
||||
|
||||
let mut profile_list_state_clone = admin_state.profile_list_state.clone();
|
||||
f.render_stateful_widget(list, content_chunks[0], &mut profile_list_state_clone);
|
||||
f.render_stateful_widget(list, content_chunks[0], &mut admin_state.profile_list_state);
|
||||
|
||||
// Profile details - Use selection info from admin_state
|
||||
if let Some(profile) = admin_state
|
||||
7
client/src/pages/admin/mod.rs
Normal file
7
client/src/pages/admin/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// src/pages/admin/mod.rs
|
||||
|
||||
pub mod main; // non-admin
|
||||
pub mod admin; // full admin panel
|
||||
|
||||
pub use main::NonAdminState;
|
||||
pub use admin::{AdminState, AdminFocus};
|
||||
159
client/src/pages/admin_panel/add_logic/event.rs
Normal file
159
client/src/pages/admin_panel/add_logic/event.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
// src/pages/admin_panel/add_logic/event.rs
|
||||
|
||||
use anyhow::Result;
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::movement::{move_focus, MovementAction};
|
||||
use crate::pages::admin_panel::add_logic::nav::SaveLogicResultSender;
|
||||
use crate::pages::admin_panel::add_logic::state::{AddLogicFocus, AddLogicFormState};
|
||||
use crate::components::common::text_editor::TextEditor;
|
||||
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 non-canvas navigation
|
||||
const ADD_LOGIC_FOCUS_ORDER: [AddLogicFocus; 6] = [
|
||||
AddLogicFocus::InputLogicName,
|
||||
AddLogicFocus::InputTargetColumn,
|
||||
AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::ScriptContentPreview,
|
||||
AddLogicFocus::SaveButton,
|
||||
AddLogicFocus::CancelButton,
|
||||
];
|
||||
|
||||
/// Handles all AddLogic 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_logic_event(
|
||||
key_event: KeyEvent,
|
||||
movement: Option<MovementAction>,
|
||||
config: &Config,
|
||||
app_state: &mut AppState,
|
||||
add_logic_page: &mut AddLogicFormState,
|
||||
grpc_client: GrpcClient,
|
||||
save_logic_sender: SaveLogicResultSender,
|
||||
) -> Result<EventOutcome> {
|
||||
// 1) Script editor fullscreen mode
|
||||
if add_logic_page.state.current_focus == AddLogicFocus::InsideScriptContent {
|
||||
match key_event.code {
|
||||
crossterm::event::KeyCode::Esc => {
|
||||
add_logic_page.state.current_focus = AddLogicFocus::ScriptContentPreview;
|
||||
add_logic_page.focus_outside_canvas = true;
|
||||
return Ok(EventOutcome::Ok("Exited script editing.".to_string()));
|
||||
}
|
||||
_ => {
|
||||
let changed = {
|
||||
let mut editor_borrow =
|
||||
add_logic_page.state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_page.state.editor_keybinding_mode,
|
||||
&mut add_logic_page.state.vim_state,
|
||||
)
|
||||
};
|
||||
if changed {
|
||||
add_logic_page.state.has_unsaved_changes = true;
|
||||
return Ok(EventOutcome::Ok("Script updated".to_string()));
|
||||
}
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Inside canvas: forward to FormEditor
|
||||
let inside_canvas_inputs = matches!(
|
||||
add_logic_page.state.current_focus,
|
||||
AddLogicFocus::InputLogicName
|
||||
| AddLogicFocus::InputTargetColumn
|
||||
| AddLogicFocus::InputDescription
|
||||
);
|
||||
|
||||
if inside_canvas_inputs {
|
||||
// Only allow leaving the canvas with Down/Next when the form editor
|
||||
// is in ReadOnly mode. In Edit mode, keep focus inside the canvas.
|
||||
let in_edit_mode = add_logic_page.editor.mode() == CanvasMode::Edit;
|
||||
if !in_edit_mode {
|
||||
if let Some(ma) = movement {
|
||||
let last_idx = add_logic_page
|
||||
.editor
|
||||
.data_provider()
|
||||
.field_count()
|
||||
.saturating_sub(1);
|
||||
let at_last = add_logic_page.editor.current_field() >= last_idx;
|
||||
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;
|
||||
add_logic_page.focus_outside_canvas = true;
|
||||
return Ok(EventOutcome::Ok("Moved to Script Preview".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match add_logic_page.handle_key_event(key_event) {
|
||||
canvas::keymap::KeyEventOutcome::Consumed(Some(msg)) => {
|
||||
add_logic_page.sync_from_editor();
|
||||
return Ok(EventOutcome::Ok(msg));
|
||||
}
|
||||
canvas::keymap::KeyEventOutcome::Consumed(None) => {
|
||||
add_logic_page.sync_from_editor();
|
||||
return Ok(EventOutcome::Ok("Input updated".into()));
|
||||
}
|
||||
canvas::keymap::KeyEventOutcome::Pending => {
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
canvas::keymap::KeyEventOutcome::NotMatched => {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Outside canvas
|
||||
if let Some(ma) = movement {
|
||||
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;
|
||||
add_logic_page.focus_outside_canvas = !matches!(
|
||||
add_logic_page.state.current_focus,
|
||||
AddLogicFocus::InputLogicName
|
||||
| AddLogicFocus::InputTargetColumn
|
||||
| AddLogicFocus::InputDescription
|
||||
);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
|
||||
match ma {
|
||||
MovementAction::Select => match add_logic_page.state.current_focus {
|
||||
AddLogicFocus::ScriptContentPreview => {
|
||||
add_logic_page.state.current_focus = AddLogicFocus::InsideScriptContent;
|
||||
add_logic_page.focus_outside_canvas = false;
|
||||
return Ok(EventOutcome::Ok(
|
||||
"Fullscreen script editing. Esc to exit.".to_string(),
|
||||
));
|
||||
}
|
||||
AddLogicFocus::SaveButton => {
|
||||
if let Some(msg) = add_logic_page.state.save_logic() {
|
||||
return Ok(EventOutcome::Ok(msg));
|
||||
} else {
|
||||
return Ok(EventOutcome::Ok("Saved (no changes)".to_string()));
|
||||
}
|
||||
}
|
||||
AddLogicFocus::CancelButton => {
|
||||
return Ok(EventOutcome::Ok("Cancelled Add Logic".to_string()));
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
MovementAction::Esc => {
|
||||
if add_logic_page.state.current_focus == AddLogicFocus::ScriptContentPreview {
|
||||
add_logic_page.state.current_focus = AddLogicFocus::InputDescription;
|
||||
add_logic_page.focus_outside_canvas = false;
|
||||
return Ok(EventOutcome::Ok("Back to Description".to_string()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
115
client/src/pages/admin_panel/add_logic/loader.rs
Normal file
115
client/src/pages/admin_panel/add_logic/loader.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
// src/pages/admin_panel/add_logic/loader.rs
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::pages::admin_panel::add_logic::state::AddLogicFormState;
|
||||
use crate::pages::routing::{Page, Router};
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::services::ui_service::UiService;
|
||||
use crate::state::app::state::AppState;
|
||||
|
||||
/// Process pending table structure fetch for AddLogic page.
|
||||
/// Returns true if UI needs a redraw.
|
||||
pub async fn process_pending_table_structure_fetch(
|
||||
app_state: &mut AppState,
|
||||
router: &mut Router,
|
||||
grpc_client: &mut GrpcClient,
|
||||
command_message: &mut String,
|
||||
) -> Result<bool> {
|
||||
let mut needs_redraw = false;
|
||||
|
||||
if let Some((profile_name, table_name)) = app_state.pending_table_structure_fetch.take() {
|
||||
if let Page::AddLogic(page) = &mut router.current {
|
||||
if page.profile_name() == profile_name
|
||||
&& page.selected_table_name().map(|s| s.as_str()) == Some(table_name.as_str())
|
||||
{
|
||||
info!(
|
||||
"Fetching table structure for {}.{}",
|
||||
profile_name, table_name
|
||||
);
|
||||
|
||||
let fetch_message = UiService::initialize_add_logic_table_data(
|
||||
grpc_client,
|
||||
&mut page.state, // keep state here, UiService expects AddLogicState
|
||||
&app_state.profile_tree,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
error!(
|
||||
"Error initializing add_logic_table_data for {}.{}: {}",
|
||||
profile_name, table_name, e
|
||||
);
|
||||
format!("Error fetching table structure: {}", e)
|
||||
});
|
||||
|
||||
if !fetch_message.contains("Error") && !fetch_message.contains("Warning") {
|
||||
info!("{}", fetch_message);
|
||||
} else {
|
||||
*command_message = fetch_message;
|
||||
}
|
||||
|
||||
// 🔑 Rebuild FormEditor with updated state (so suggestions work)
|
||||
page.editor = canvas::FormEditor::new(page.state.clone());
|
||||
|
||||
needs_redraw = true;
|
||||
} else {
|
||||
error!(
|
||||
"Mismatch in pending_table_structure_fetch: app_state wants {}.{}, \
|
||||
but AddLogic state is for {}.{:?}",
|
||||
profile_name,
|
||||
table_name,
|
||||
page.profile_name(),
|
||||
page.selected_table_name()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warn!(
|
||||
"Pending table structure fetch for {}.{} but AddLogic view is not active. Ignored.",
|
||||
profile_name, table_name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(needs_redraw)
|
||||
}
|
||||
|
||||
/// If the AddLogic page is awaiting columns for a selected table in the script editor,
|
||||
/// fetch them and update the state. Returns true if UI needs a redraw.
|
||||
pub async fn maybe_fetch_columns_for_awaiting_table(
|
||||
grpc_client: &mut GrpcClient,
|
||||
page: &mut AddLogicFormState,
|
||||
command_message: &mut String,
|
||||
) -> Result<bool> {
|
||||
if let Some(table_name) = page
|
||||
.state
|
||||
.script_editor_awaiting_column_autocomplete
|
||||
.clone()
|
||||
{
|
||||
let profile_name = page.state.profile_name.clone();
|
||||
|
||||
info!(
|
||||
"Fetching columns for table selection: {}.{}",
|
||||
profile_name, table_name
|
||||
);
|
||||
match UiService::fetch_columns_for_table(grpc_client, &profile_name, &table_name).await {
|
||||
Ok(columns) => {
|
||||
page.state.set_columns_for_table_autocomplete(columns.clone());
|
||||
info!("Loaded {} columns for table '{}'", columns.len(), table_name);
|
||||
*command_message =
|
||||
format!("Columns for '{}' loaded. Select a column.", table_name);
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to fetch columns for {}.{}: {}",
|
||||
profile_name, table_name, e
|
||||
);
|
||||
page.state.script_editor_awaiting_column_autocomplete = None;
|
||||
page.state.deactivate_script_editor_autocomplete();
|
||||
*command_message = format!("Error loading columns for '{}': {}", table_name, e);
|
||||
}
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
7
client/src/pages/admin_panel/add_logic/mod.rs
Normal file
7
client/src/pages/admin_panel/add_logic/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// src/pages/admin_panel/add_logic/mod.rs
|
||||
|
||||
pub mod ui;
|
||||
pub mod nav;
|
||||
pub mod state;
|
||||
pub mod loader;
|
||||
pub mod event;
|
||||
6
client/src/pages/admin_panel/add_logic/nav.rs
Normal file
6
client/src/pages/admin_panel/add_logic/nav.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// src/pages/admin_panel/add_logic/nav.rs
|
||||
|
||||
use anyhow::Result;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub type SaveLogicResultSender = mpsc::Sender<Result<String>>;
|
||||
@@ -1,7 +1,8 @@
|
||||
// src/state/pages/add_logic.rs
|
||||
// src/pages/admin_panel/add_logic/state.rs
|
||||
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
|
||||
use crate::components::common::text_editor::{TextEditor, VimState};
|
||||
use canvas::{DataProvider, AppMode};
|
||||
use canvas::{DataProvider, AppMode, FormEditor, SuggestionItem};
|
||||
use crossterm::event::KeyCode;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use tui_textarea::TextArea;
|
||||
@@ -54,7 +55,7 @@ pub struct AddLogicState {
|
||||
// New fields for same-profile table names and column autocomplete
|
||||
pub same_profile_table_names: Vec<String>, // Tables from same profile only
|
||||
pub script_editor_awaiting_column_autocomplete: Option<String>, // Table name waiting for column fetch
|
||||
pub app_mode: AppMode,
|
||||
pub app_mode: canvas::AppMode,
|
||||
}
|
||||
|
||||
impl AddLogicState {
|
||||
@@ -92,12 +93,25 @@ impl AddLogicState {
|
||||
|
||||
same_profile_table_names: Vec::new(),
|
||||
script_editor_awaiting_column_autocomplete: None,
|
||||
app_mode: AppMode::Edit,
|
||||
app_mode: canvas::AppMode::Edit,
|
||||
}
|
||||
}
|
||||
|
||||
pub const INPUT_FIELD_COUNT: usize = 3;
|
||||
|
||||
/// Build canvas SuggestionItem list for target column
|
||||
pub fn column_suggestions_sync(&self, query: &str) -> Vec<SuggestionItem> {
|
||||
let q = query.to_lowercase();
|
||||
self.table_columns_for_suggestions
|
||||
.iter()
|
||||
.filter(|c| q.is_empty() || c.to_lowercase().contains(&q))
|
||||
.map(|c| SuggestionItem {
|
||||
display_text: c.clone(),
|
||||
value_to_store: c.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Updates the target_column_suggestions based on current input.
|
||||
pub fn update_target_column_suggestions(&mut self) {
|
||||
let current_input = self.target_column_input.to_lowercase();
|
||||
@@ -272,7 +286,7 @@ impl AddLogicState {
|
||||
impl Default for AddLogicState {
|
||||
fn default() -> Self {
|
||||
let mut state = Self::new(&EditorConfig::default());
|
||||
state.app_mode = AppMode::Edit;
|
||||
state.app_mode = canvas::AppMode::Edit;
|
||||
state
|
||||
}
|
||||
}
|
||||
@@ -315,3 +329,242 @@ impl DataProvider for AddLogicState {
|
||||
field_index == 1
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper that owns both the raw state and its FormEditor (like LoginFormState)
|
||||
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
|
||||
impl std::fmt::Debug for AddLogicFormState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
impl AddLogicFormState {
|
||||
pub fn new(editor_config: &EditorConfig) -> Self {
|
||||
let state = AddLogicState::new(editor_config);
|
||||
let editor = FormEditor::new(state.clone());
|
||||
Self {
|
||||
state,
|
||||
editor,
|
||||
focus_outside_canvas: false,
|
||||
focused_button_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_table(
|
||||
editor_config: &EditorConfig,
|
||||
profile_name: String,
|
||||
table_id: Option<i64>,
|
||||
table_name: String,
|
||||
) -> Self {
|
||||
let mut state = AddLogicState::new(editor_config);
|
||||
state.profile_name = profile_name;
|
||||
state.selected_table_id = table_id;
|
||||
state.selected_table_name = Some(table_name);
|
||||
let editor = FormEditor::new(state.clone());
|
||||
Self {
|
||||
state,
|
||||
editor,
|
||||
focus_outside_canvas: false,
|
||||
focused_button_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_state(state: AddLogicState) -> Self {
|
||||
let editor = FormEditor::new(state.clone());
|
||||
Self {
|
||||
state,
|
||||
editor,
|
||||
focus_outside_canvas: false,
|
||||
focused_button_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync state from editor's data provider snapshot
|
||||
pub fn sync_from_editor(&mut self) {
|
||||
self.state = self.editor.data_provider().clone();
|
||||
}
|
||||
|
||||
// === Delegates to AddLogicState fields ===
|
||||
|
||||
pub fn current_focus(&self) -> AddLogicFocus {
|
||||
self.state.current_focus
|
||||
}
|
||||
|
||||
pub fn set_current_focus(&mut self, focus: AddLogicFocus) {
|
||||
self.state.current_focus = focus;
|
||||
}
|
||||
|
||||
pub fn has_unsaved_changes(&self) -> bool {
|
||||
self.state.has_unsaved_changes
|
||||
}
|
||||
|
||||
pub fn set_has_unsaved_changes(&mut self, changed: bool) {
|
||||
self.state.has_unsaved_changes = changed;
|
||||
}
|
||||
|
||||
pub fn profile_name(&self) -> &str {
|
||||
&self.state.profile_name
|
||||
}
|
||||
|
||||
pub fn selected_table_name(&self) -> Option<&String> {
|
||||
self.state.selected_table_name.as_ref()
|
||||
}
|
||||
|
||||
pub fn selected_table_id(&self) -> Option<i64> {
|
||||
self.state.selected_table_id
|
||||
}
|
||||
|
||||
pub fn script_content_editor(&self) -> &Rc<RefCell<TextArea<'static>>> {
|
||||
&self.state.script_content_editor
|
||||
}
|
||||
|
||||
pub fn script_content_editor_mut(&mut self) -> &mut Rc<RefCell<TextArea<'static>>> {
|
||||
&mut self.state.script_content_editor
|
||||
}
|
||||
|
||||
pub fn vim_state(&self) -> &VimState {
|
||||
&self.state.vim_state
|
||||
}
|
||||
|
||||
pub fn vim_state_mut(&mut self) -> &mut VimState {
|
||||
&mut self.state.vim_state
|
||||
}
|
||||
|
||||
pub fn editor_keybinding_mode(&self) -> &EditorKeybindingMode {
|
||||
&self.state.editor_keybinding_mode
|
||||
}
|
||||
|
||||
pub fn script_editor_autocomplete_active(&self) -> bool {
|
||||
self.state.script_editor_autocomplete_active
|
||||
}
|
||||
|
||||
pub fn script_editor_suggestions(&self) -> &Vec<String> {
|
||||
&self.state.script_editor_suggestions
|
||||
}
|
||||
|
||||
pub fn script_editor_selected_suggestion_index(&self) -> Option<usize> {
|
||||
self.state.script_editor_selected_suggestion_index
|
||||
}
|
||||
|
||||
pub fn target_column_suggestions(&self) -> &Vec<String> {
|
||||
&self.state.target_column_suggestions
|
||||
}
|
||||
|
||||
pub fn selected_target_column_suggestion_index(&self) -> Option<usize> {
|
||||
self.state.selected_target_column_suggestion_index
|
||||
}
|
||||
|
||||
pub fn in_target_column_suggestion_mode(&self) -> bool {
|
||||
self.state.in_target_column_suggestion_mode
|
||||
}
|
||||
|
||||
pub fn show_target_column_suggestions(&self) -> bool {
|
||||
self.state.show_target_column_suggestions
|
||||
}
|
||||
|
||||
// === Delegates to FormEditor ===
|
||||
|
||||
pub fn mode(&self) -> AppMode {
|
||||
self.editor.mode()
|
||||
}
|
||||
|
||||
pub fn cursor_position(&self) -> usize {
|
||||
self.editor.cursor_position()
|
||||
}
|
||||
|
||||
pub fn handle_key_event(
|
||||
&mut self,
|
||||
key_event: crossterm::event::KeyEvent,
|
||||
) -> canvas::keymap::KeyEventOutcome {
|
||||
// Customize behavior for Target Column (field index 1) in Edit mode,
|
||||
// mirroring how Register page does suggestions for Role.
|
||||
let in_target_col_field = self.editor.current_field() == 1;
|
||||
let in_edit_mode = self.editor.mode() == canvas::AppMode::Edit;
|
||||
|
||||
if in_target_col_field && in_edit_mode {
|
||||
match key_event.code {
|
||||
// Tab: open suggestions if inactive; otherwise cycle next
|
||||
KeyCode::Tab => {
|
||||
if !self.editor.is_suggestions_active() {
|
||||
if let Some(query) = self.editor.start_suggestions(1) {
|
||||
let items = self.state.column_suggestions_sync(&query);
|
||||
let applied =
|
||||
self.editor.apply_suggestions_result(1, &query, items);
|
||||
if applied {
|
||||
self.editor.update_inline_completion();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.editor.suggestions_next();
|
||||
}
|
||||
return canvas::keymap::KeyEventOutcome::Consumed(None);
|
||||
}
|
||||
// Shift+Tab: cycle suggestions too (fallback to next)
|
||||
KeyCode::BackTab => {
|
||||
if self.editor.is_suggestions_active() {
|
||||
self.editor.suggestions_next();
|
||||
return canvas::keymap::KeyEventOutcome::Consumed(None);
|
||||
}
|
||||
}
|
||||
// Enter: apply selected suggestion (if active)
|
||||
KeyCode::Enter => {
|
||||
if self.editor.is_suggestions_active() {
|
||||
let _ = self.editor.apply_suggestion();
|
||||
return canvas::keymap::KeyEventOutcome::Consumed(None);
|
||||
}
|
||||
}
|
||||
// Esc: close suggestions if active
|
||||
KeyCode::Esc => {
|
||||
if self.editor.is_suggestions_active() {
|
||||
self.editor.close_suggestions();
|
||||
return canvas::keymap::KeyEventOutcome::Consumed(None);
|
||||
}
|
||||
}
|
||||
// Character input: mutate then refresh suggestions if active
|
||||
KeyCode::Char(_) => {
|
||||
let outcome = self.editor.handle_key_event(key_event);
|
||||
if self.editor.is_suggestions_active() {
|
||||
if let Some(query) = self.editor.start_suggestions(1) {
|
||||
let items = self.state.column_suggestions_sync(&query);
|
||||
let applied =
|
||||
self.editor.apply_suggestions_result(1, &query, items);
|
||||
if applied {
|
||||
self.editor.update_inline_completion();
|
||||
}
|
||||
}
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
// Backspace/Delete: mutate then refresh suggestions if active
|
||||
KeyCode::Backspace | KeyCode::Delete => {
|
||||
let outcome = self.editor.handle_key_event(key_event);
|
||||
if self.editor.is_suggestions_active() {
|
||||
if let Some(query) = self.editor.start_suggestions(1) {
|
||||
let items = self.state.column_suggestions_sync(&query);
|
||||
let applied =
|
||||
self.editor.apply_suggestions_result(1, &query, items);
|
||||
if applied {
|
||||
self.editor.update_inline_completion();
|
||||
}
|
||||
}
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
_ => { /* fall through */ }
|
||||
}
|
||||
}
|
||||
// Default: let canvas handle it
|
||||
self.editor.handle_key_event(key_event)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
// src/components/admin/add_logic.rs
|
||||
// src/pages/admin_panel/add_logic/ui.rs
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::add_logic::{AddLogicFocus, AddLogicState};
|
||||
use canvas::{render_canvas, FormEditor};
|
||||
use crate::pages::admin_panel::add_logic::state::{AddLogicFocus, AddLogicState, AddLogicFormState};
|
||||
use canvas::{render_canvas, render_suggestions_dropdown, DefaultCanvasTheme, FormEditor};
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
@@ -11,7 +10,8 @@ use ratatui::{
|
||||
widgets::{Block, BorderType, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use crate::components::common::{dialog, autocomplete}; // Added autocomplete
|
||||
use crate::components::common::autocomplete;
|
||||
use crate::dialog;
|
||||
use crate::config::binds::config::EditorKeybindingMode;
|
||||
|
||||
pub fn render_add_logic(
|
||||
@@ -19,8 +19,7 @@ pub fn render_add_logic(
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
app_state: &AppState,
|
||||
add_logic_state: &mut AddLogicState,
|
||||
is_edit_mode: bool,
|
||||
add_logic_state: &mut AddLogicFormState,
|
||||
) {
|
||||
let main_block = Block::default()
|
||||
.title(" Add New Logic Script ")
|
||||
@@ -33,21 +32,29 @@ pub fn render_add_logic(
|
||||
f.render_widget(main_block, area);
|
||||
|
||||
// Handle full-screen script editing
|
||||
if add_logic_state.current_focus == AddLogicFocus::InsideScriptContent {
|
||||
let mut editor_ref = add_logic_state.script_content_editor.borrow_mut();
|
||||
let border_style_color = if is_edit_mode { theme.highlight } else { theme.secondary };
|
||||
if add_logic_state.current_focus() == AddLogicFocus::InsideScriptContent {
|
||||
let mut editor_ref = add_logic_state
|
||||
.state
|
||||
.script_content_editor
|
||||
.borrow_mut();
|
||||
|
||||
let border_style_color = if crate::components::common::text_editor::TextEditor::is_vim_insert_mode(add_logic_state.vim_state()) {
|
||||
theme.highlight
|
||||
} else {
|
||||
theme.secondary
|
||||
};
|
||||
let border_style = Style::default().fg(border_style_color);
|
||||
|
||||
editor_ref.set_cursor_line_style(Style::default());
|
||||
editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
|
||||
let script_title_hint = match add_logic_state.editor_keybinding_mode {
|
||||
let script_title_hint = match add_logic_state.editor_keybinding_mode() {
|
||||
EditorKeybindingMode::Vim => {
|
||||
let vim_mode_status = crate::components::common::text_editor::TextEditor::get_vim_mode_status(&add_logic_state.vim_state);
|
||||
let vim_mode_status = crate::components::common::text_editor::TextEditor::get_vim_mode_status(add_logic_state.vim_state());
|
||||
format!("Script {}", vim_mode_status)
|
||||
}
|
||||
EditorKeybindingMode::Emacs | EditorKeybindingMode::Default => {
|
||||
if is_edit_mode {
|
||||
if crate::components::common::text_editor::TextEditor::is_vim_insert_mode(add_logic_state.vim_state()) {
|
||||
"Script (Editing)".to_string()
|
||||
} else {
|
||||
"Script".to_string()
|
||||
@@ -69,10 +76,10 @@ pub fn render_add_logic(
|
||||
drop(editor_ref);
|
||||
|
||||
// === SCRIPT EDITOR AUTOCOMPLETE RENDERING ===
|
||||
if add_logic_state.script_editor_autocomplete_active && !add_logic_state.script_editor_suggestions.is_empty() {
|
||||
if add_logic_state.script_editor_autocomplete_active() && !add_logic_state.script_editor_suggestions().is_empty() {
|
||||
// Get the current cursor position from textarea
|
||||
let current_cursor = {
|
||||
let editor_borrow = add_logic_state.script_content_editor.borrow();
|
||||
let editor_borrow = add_logic_state.script_content_editor().borrow();
|
||||
editor_borrow.cursor() // Returns (row, col) as (usize, usize)
|
||||
};
|
||||
|
||||
@@ -100,8 +107,8 @@ pub fn render_add_logic(
|
||||
input_rect,
|
||||
f.area(), // Full frame area for clamping
|
||||
theme,
|
||||
&add_logic_state.script_editor_suggestions,
|
||||
add_logic_state.script_editor_selected_suggestion_index,
|
||||
add_logic_state.script_editor_suggestions(),
|
||||
add_logic_state.script_editor_selected_suggestion_index(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,67 +132,61 @@ pub fn render_add_logic(
|
||||
let buttons_area = main_chunks[3];
|
||||
|
||||
// Top info
|
||||
let table_label = if let Some(name) = add_logic_state.selected_table_name() {
|
||||
name.clone()
|
||||
} else if let Some(id) = add_logic_state.selected_table_id() {
|
||||
format!("ID {}", id)
|
||||
} else {
|
||||
"Global (Not Selected)".to_string()
|
||||
};
|
||||
|
||||
let profile_text = Paragraph::new(vec![
|
||||
Line::from(Span::styled(
|
||||
format!("Profile: {}", add_logic_state.profile_name),
|
||||
Style::default().fg(theme.fg),
|
||||
format!("Profile: {}", add_logic_state.profile_name()),
|
||||
Style::default().fg(theme.fg),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(
|
||||
"Table: {}",
|
||||
add_logic_state
|
||||
.selected_table_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| add_logic_state.selected_table_id
|
||||
.map(|id| format!("ID {}", id))
|
||||
.unwrap_or_else(|| "Global (Not Selected)".to_string()))
|
||||
),
|
||||
Style::default().fg(theme.fg),
|
||||
format!("Table: {}", table_label),
|
||||
Style::default().fg(theme.fg),
|
||||
)),
|
||||
])
|
||||
.block(
|
||||
Block::default()
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::BOTTOM)
|
||||
.border_style(Style::default().fg(theme.secondary)),
|
||||
);
|
||||
);
|
||||
f.render_widget(profile_text, top_info_area);
|
||||
|
||||
// Canvas - USING CANVAS LIBRARY
|
||||
let focus_on_canvas_inputs = matches!(
|
||||
add_logic_state.current_focus,
|
||||
add_logic_state.current_focus(),
|
||||
AddLogicFocus::InputLogicName
|
||||
| AddLogicFocus::InputTargetColumn
|
||||
| AddLogicFocus::InputDescription
|
||||
);
|
||||
|
||||
let editor = FormEditor::new(add_logic_state.clone());
|
||||
let active_field_rect = render_canvas(f, canvas_area, &editor, theme);
|
||||
let editor = &add_logic_state.editor;
|
||||
let active_field_rect = render_canvas(f, canvas_area, editor, theme);
|
||||
|
||||
// --- Render Autocomplete for Target Column ---
|
||||
// `is_edit_mode` here refers to the general edit mode of the EventHandler
|
||||
if is_edit_mode && editor.current_field() == 1 { // Target Column field
|
||||
if add_logic_state.in_target_column_suggestion_mode && add_logic_state.show_target_column_suggestions {
|
||||
if !add_logic_state.target_column_suggestions.is_empty() {
|
||||
if let Some(input_rect) = active_field_rect {
|
||||
autocomplete::render_autocomplete_dropdown(
|
||||
f,
|
||||
input_rect,
|
||||
f.area(), // Full frame area for clamping
|
||||
theme,
|
||||
&add_logic_state.target_column_suggestions,
|
||||
add_logic_state.selected_target_column_suggestion_index,
|
||||
);
|
||||
}
|
||||
}
|
||||
// --- Canvas suggestions dropdown (Target Column, etc.) ---
|
||||
if editor.mode() == canvas::AppMode::Edit {
|
||||
if let Some(input_rect) = active_field_rect {
|
||||
render_suggestions_dropdown(
|
||||
f,
|
||||
f.area(),
|
||||
input_rect,
|
||||
&DefaultCanvasTheme,
|
||||
editor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Script content preview
|
||||
{
|
||||
let mut editor_ref = add_logic_state.script_content_editor.borrow_mut();
|
||||
let mut editor_ref = add_logic_state.script_content_editor().borrow_mut();
|
||||
editor_ref.set_cursor_line_style(Style::default());
|
||||
|
||||
let is_script_preview_focused = add_logic_state.current_focus == AddLogicFocus::ScriptContentPreview;
|
||||
let is_script_preview_focused = add_logic_state.current_focus() == AddLogicFocus::ScriptContentPreview;
|
||||
|
||||
if is_script_preview_focused {
|
||||
editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
@@ -254,7 +255,7 @@ pub fn render_add_logic(
|
||||
let save_button = Paragraph::new(" Save Logic ")
|
||||
.style(get_button_style(
|
||||
AddLogicFocus::SaveButton,
|
||||
add_logic_state.current_focus,
|
||||
add_logic_state.current_focus(),
|
||||
))
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
@@ -262,7 +263,7 @@ pub fn render_add_logic(
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(get_button_border_style(
|
||||
add_logic_state.current_focus == AddLogicFocus::SaveButton,
|
||||
add_logic_state.current_focus() == AddLogicFocus::SaveButton,
|
||||
theme,
|
||||
)),
|
||||
);
|
||||
@@ -271,7 +272,7 @@ pub fn render_add_logic(
|
||||
let cancel_button = Paragraph::new(" Cancel ")
|
||||
.style(get_button_style(
|
||||
AddLogicFocus::CancelButton,
|
||||
add_logic_state.current_focus,
|
||||
add_logic_state.current_focus(),
|
||||
))
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
@@ -279,7 +280,7 @@ pub fn render_add_logic(
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(get_button_border_style(
|
||||
add_logic_state.current_focus == AddLogicFocus::CancelButton,
|
||||
add_logic_state.current_focus() == AddLogicFocus::CancelButton,
|
||||
theme,
|
||||
)),
|
||||
);
|
||||
287
client/src/pages/admin_panel/add_table/event.rs
Normal file
287
client/src/pages/admin_panel/add_table/event.rs
Normal file
@@ -0,0 +1,287 @@
|
||||
// src/pages/admin_panel/add_table/event.rs
|
||||
|
||||
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,
|
||||
};
|
||||
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::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; 10] = [
|
||||
AddTableFocus::InputTableName,
|
||||
AddTableFocus::InputColumnName,
|
||||
AddTableFocus::InputColumnType,
|
||||
AddTableFocus::AddColumnButton,
|
||||
AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::IndexesTable,
|
||||
AddTableFocus::LinksTable,
|
||||
AddTableFocus::SaveButton,
|
||||
AddTableFocus::DeleteSelectedButton,
|
||||
AddTableFocus::CancelButton,
|
||||
];
|
||||
|
||||
/// 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,
|
||||
app_state: &mut AppState,
|
||||
page: &mut AddTableFormState,
|
||||
mut grpc_client: GrpcClient,
|
||||
save_result_sender: SaveTableResultSender,
|
||||
) -> Result<EventOutcome> {
|
||||
// 1) Inside canvas (FormEditor)
|
||||
let inside_canvas_inputs = matches!(
|
||||
page.current_focus(),
|
||||
AddTableFocus::InputTableName
|
||||
| AddTableFocus::InputColumnName
|
||||
| AddTableFocus::InputColumnType
|
||||
);
|
||||
|
||||
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();
|
||||
return Ok(EventOutcome::Ok(msg));
|
||||
}
|
||||
canvas::keymap::KeyEventOutcome::Consumed(None) => {
|
||||
page.sync_from_editor();
|
||||
return Ok(EventOutcome::Ok("Input updated".into()));
|
||||
}
|
||||
canvas::keymap::KeyEventOutcome::Pending => {
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
canvas::keymap::KeyEventOutcome::NotMatched => {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
page.focus_outside_canvas = !matches!(
|
||||
page.current_focus(),
|
||||
AddTableFocus::InputTableName
|
||||
| AddTableFocus::InputColumnName
|
||||
| AddTableFocus::InputColumnType
|
||||
);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
|
||||
// 3) Rich actions
|
||||
match ma {
|
||||
MovementAction::Select => match page.current_focus() {
|
||||
AddTableFocus::AddColumnButton => {
|
||||
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 => {
|
||||
if page.state.table_name.is_empty() {
|
||||
return Ok(EventOutcome::Ok("Cannot save: Table name is empty".into()));
|
||||
}
|
||||
if page.state.columns.is_empty() {
|
||||
return Ok(EventOutcome::Ok("Cannot save: No columns defined".into()));
|
||||
}
|
||||
app_state.show_loading_dialog("Saving", "Please wait...");
|
||||
let state_clone = page.state.clone();
|
||||
let sender_clone = save_result_sender.clone();
|
||||
tokio::spawn(async move {
|
||||
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 = 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".to_string()));
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
78
client/src/pages/admin_panel/add_table/loader.rs
Normal file
78
client/src/pages/admin_panel/add_table/loader.rs
Normal 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)),
|
||||
}
|
||||
}
|
||||
24
client/src/pages/admin_panel/add_table/logic.rs
Normal file
24
client/src/pages/admin_panel/add_table/logic.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
// src/pages/admin_panel/add_table/logic.rs
|
||||
|
||||
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> {
|
||||
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
|
||||
}
|
||||
|
||||
/// 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())
|
||||
}
|
||||
8
client/src/pages/admin_panel/add_table/mod.rs
Normal file
8
client/src/pages/admin_panel/add_table/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
// src/pages/admin_panel/add_table/mod.rs
|
||||
|
||||
pub mod ui;
|
||||
pub mod nav;
|
||||
pub mod state;
|
||||
pub mod logic;
|
||||
pub mod event;
|
||||
pub mod loader;
|
||||
6
client/src/pages/admin_panel/add_table/nav.rs
Normal file
6
client/src/pages/admin_panel/add_table/nav.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// src/pages/admin_panel/add_table/nav.rs
|
||||
|
||||
use anyhow::Result;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub type SaveTableResultSender = mpsc::Sender<Result<String>>;
|
||||
@@ -1,6 +1,7 @@
|
||||
// src/state/pages/add_table.rs
|
||||
// src/pages/admin_panel/add_table/state.rs
|
||||
|
||||
use canvas::{DataProvider, CanvasAction, AppMode};
|
||||
use canvas::{DataProvider, AppMode};
|
||||
use canvas::FormEditor;
|
||||
use ratatui::widgets::TableState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -64,7 +65,7 @@ pub struct AddTableState {
|
||||
pub column_name_cursor_pos: usize,
|
||||
pub column_type_cursor_pos: usize,
|
||||
pub has_unsaved_changes: bool,
|
||||
pub app_mode: AppMode,
|
||||
pub app_mode: canvas::AppMode,
|
||||
}
|
||||
|
||||
impl Default for AddTableState {
|
||||
@@ -87,7 +88,7 @@ impl Default for AddTableState {
|
||||
column_name_cursor_pos: 0,
|
||||
column_type_cursor_pos: 0,
|
||||
has_unsaved_changes: false,
|
||||
app_mode: AppMode::Edit,
|
||||
app_mode: canvas::AppMode::Edit,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,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;
|
||||
@@ -122,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();
|
||||
@@ -166,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(", ")))
|
||||
}
|
||||
}
|
||||
@@ -208,3 +246,87 @@ impl DataProvider for AddTableState {
|
||||
false // AddTableState doesn’t use suggestions
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
impl AddTableFormState {
|
||||
pub fn new(profile_name: String) -> Self {
|
||||
let mut state = AddTableState::default();
|
||||
state.profile_name = profile_name;
|
||||
let editor = FormEditor::new(state.clone());
|
||||
Self {
|
||||
state,
|
||||
editor,
|
||||
focus_outside_canvas: false,
|
||||
focused_button_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_state(state: AddTableState) -> Self {
|
||||
let editor = FormEditor::new(state.clone());
|
||||
Self {
|
||||
state,
|
||||
editor,
|
||||
focus_outside_canvas: false,
|
||||
focused_button_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync state from editor’s snapshot
|
||||
pub fn sync_from_editor(&mut self) {
|
||||
self.state = self.editor.data_provider().clone();
|
||||
}
|
||||
|
||||
// === Delegates to AddTableState fields ===
|
||||
pub fn current_focus(&self) -> AddTableFocus {
|
||||
self.state.current_focus
|
||||
}
|
||||
pub fn set_current_focus(&mut self, focus: AddTableFocus) {
|
||||
self.state.current_focus = focus;
|
||||
}
|
||||
pub fn profile_name(&self) -> &str {
|
||||
&self.state.profile_name
|
||||
}
|
||||
pub fn table_name(&self) -> &str {
|
||||
&self.state.table_name
|
||||
}
|
||||
pub fn columns(&self) -> &Vec<ColumnDefinition> {
|
||||
&self.state.columns
|
||||
}
|
||||
pub fn indexes(&self) -> &Vec<IndexDefinition> {
|
||||
&self.state.indexes
|
||||
}
|
||||
pub fn links(&self) -> &Vec<LinkDefinition> {
|
||||
&self.state.links
|
||||
}
|
||||
pub fn column_table_state(&mut self) -> &mut TableState {
|
||||
&mut self.state.column_table_state
|
||||
}
|
||||
pub fn index_table_state(&mut self) -> &mut TableState {
|
||||
&mut self.state.index_table_state
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
// src/components/admin/add_table.rs
|
||||
// src/pages/admin_panel/add_table/ui.rs
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::add_table::{AddTableFocus, AddTableState};
|
||||
use canvas::{render_canvas_default, render_canvas, FormEditor};
|
||||
use crate::pages::admin_panel::add_table::state::{AddTableFocus, AddTableFormState};
|
||||
use canvas::render_canvas;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
@@ -11,7 +10,7 @@ use ratatui::{
|
||||
widgets::{Block, BorderType, Borders, Cell, Paragraph, Row, Table},
|
||||
Frame,
|
||||
};
|
||||
use crate::components::common::dialog;
|
||||
use crate::dialog;
|
||||
|
||||
/// Renders the Add New Table page layout, structuring the display of table information,
|
||||
/// input fields, and action buttons. Adapts layout based on terminal width.
|
||||
@@ -20,8 +19,7 @@ pub fn render_add_table(
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
app_state: &AppState,
|
||||
add_table_state: &mut AddTableState,
|
||||
is_edit_mode: bool, // Determines if canvas inputs are in edit mode
|
||||
add_table_state: &mut AddTableFormState,
|
||||
) {
|
||||
// --- Configuration ---
|
||||
// Threshold width to switch between wide and narrow layouts
|
||||
@@ -29,7 +27,7 @@ pub fn render_add_table(
|
||||
|
||||
// --- State Checks ---
|
||||
let focus_on_canvas_inputs = matches!(
|
||||
add_table_state.current_focus,
|
||||
add_table_state.current_focus(),
|
||||
AddTableFocus::InputTableName
|
||||
| AddTableFocus::InputColumnName
|
||||
| AddTableFocus::InputColumnType
|
||||
@@ -47,11 +45,11 @@ pub fn render_add_table(
|
||||
f.render_widget(main_block, area);
|
||||
|
||||
// --- Fullscreen Columns Table Check (Narrow Screens Only) ---
|
||||
if area.width < NARROW_LAYOUT_THRESHOLD && add_table_state.current_focus == AddTableFocus::InsideColumnsTable {
|
||||
if area.width < NARROW_LAYOUT_THRESHOLD && add_table_state.current_focus() == AddTableFocus::InsideColumnsTable {
|
||||
// Render ONLY the columns table taking the full inner area
|
||||
let columns_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen
|
||||
let column_rows: Vec<Row<'_>> = add_table_state
|
||||
.columns
|
||||
.columns()
|
||||
.iter()
|
||||
.map(|col_def| {
|
||||
Row::new(vec![
|
||||
@@ -82,16 +80,16 @@ pub fn render_add_table(
|
||||
.fg(theme.highlight),
|
||||
)
|
||||
.highlight_symbol(" > "); // Use the inside symbol
|
||||
f.render_stateful_widget(columns_table, inner_area, &mut add_table_state.column_table_state);
|
||||
f.render_stateful_widget(columns_table, inner_area, add_table_state.column_table_state());
|
||||
return; // IMPORTANT: Stop rendering here for fullscreen mode
|
||||
}
|
||||
|
||||
// --- Fullscreen Indexes Table Check ---
|
||||
if add_table_state.current_focus == AddTableFocus::InsideIndexesTable { // Remove width check
|
||||
if add_table_state.current_focus() == AddTableFocus::InsideIndexesTable { // Remove width check
|
||||
// Render ONLY the indexes table taking the full inner area
|
||||
let indexes_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen
|
||||
let index_rows: Vec<Row<'_>> = add_table_state
|
||||
.indexes
|
||||
.indexes()
|
||||
.iter()
|
||||
.map(|index_def| {
|
||||
Row::new(vec![
|
||||
@@ -117,16 +115,16 @@ pub fn render_add_table(
|
||||
)
|
||||
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED).fg(theme.highlight))
|
||||
.highlight_symbol(" > "); // Use the inside symbol
|
||||
f.render_stateful_widget(indexes_table, inner_area, &mut add_table_state.index_table_state);
|
||||
f.render_stateful_widget(indexes_table, inner_area, &mut add_table_state.index_table_state());
|
||||
return; // IMPORTANT: Stop rendering here for fullscreen mode
|
||||
}
|
||||
|
||||
// --- Fullscreen Links Table Check ---
|
||||
if add_table_state.current_focus == AddTableFocus::InsideLinksTable {
|
||||
if add_table_state.current_focus() == AddTableFocus::InsideLinksTable {
|
||||
// Render ONLY the links table taking the full inner area
|
||||
let links_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen
|
||||
let link_rows: Vec<Row<'_>> = add_table_state
|
||||
.links
|
||||
.links()
|
||||
.iter()
|
||||
.map(|link_def| {
|
||||
Row::new(vec![
|
||||
@@ -153,7 +151,7 @@ pub fn render_add_table(
|
||||
)
|
||||
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED).fg(theme.highlight))
|
||||
.highlight_symbol(" > "); // Use the inside symbol
|
||||
f.render_stateful_widget(links_table, inner_area, &mut add_table_state.link_table_state);
|
||||
f.render_stateful_widget(links_table, inner_area, &mut add_table_state.link_table_state());
|
||||
return; // IMPORTANT: Stop rendering here for fullscreen mode
|
||||
}
|
||||
|
||||
@@ -222,11 +220,11 @@ pub fn render_add_table(
|
||||
// --- Top Info Rendering (Wide - 2 lines) ---
|
||||
let profile_text = Paragraph::new(vec![
|
||||
Line::from(Span::styled(
|
||||
format!("Profile: {}", add_table_state.profile_name),
|
||||
format!("Profile: {}", add_table_state.profile_name()),
|
||||
theme.fg,
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!("Table name: {}", add_table_state.table_name),
|
||||
format!("Table name: {}", add_table_state.table_name()),
|
||||
theme.fg,
|
||||
)),
|
||||
])
|
||||
@@ -278,14 +276,14 @@ pub fn render_add_table(
|
||||
.split(top_info_area);
|
||||
|
||||
let profile_text = Paragraph::new(Span::styled(
|
||||
format!("Profile: {}", add_table_state.profile_name),
|
||||
format!("Profile: {}", add_table_state.profile_name()),
|
||||
theme.fg,
|
||||
))
|
||||
.alignment(Alignment::Left);
|
||||
f.render_widget(profile_text, top_info_chunks[0]);
|
||||
|
||||
let table_name_text = Paragraph::new(Span::styled(
|
||||
format!("Table: {}", add_table_state.table_name),
|
||||
format!("Table: {}", add_table_state.table_name()),
|
||||
theme.fg,
|
||||
))
|
||||
.alignment(Alignment::Left);
|
||||
@@ -295,14 +293,14 @@ pub fn render_add_table(
|
||||
// --- Common Widget Rendering (Uses calculated areas) ---
|
||||
|
||||
// --- Columns Table Rendering ---
|
||||
let columns_focused = matches!(add_table_state.current_focus, AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable);
|
||||
let columns_focused = matches!(add_table_state.current_focus(), AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable);
|
||||
let columns_border_style = if columns_focused {
|
||||
Style::default().fg(theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(theme.secondary)
|
||||
};
|
||||
let column_rows: Vec<Row<'_>> = add_table_state
|
||||
.columns
|
||||
.columns()
|
||||
.iter()
|
||||
.map(|col_def| {
|
||||
Row::new(vec![
|
||||
@@ -343,12 +341,11 @@ pub fn render_add_table(
|
||||
f.render_stateful_widget(
|
||||
columns_table,
|
||||
columns_area,
|
||||
&mut add_table_state.column_table_state,
|
||||
&mut add_table_state.column_table_state(),
|
||||
);
|
||||
|
||||
// --- Canvas Rendering (Column Definition Input) - USING CANVAS LIBRARY ---
|
||||
let editor = FormEditor::new(add_table_state.clone());
|
||||
let _active_field_rect = render_canvas(f, canvas_area, &editor, theme);
|
||||
let _active_field_rect = render_canvas(f, canvas_area, &add_table_state.editor, theme);
|
||||
|
||||
// --- Button Style Helpers ---
|
||||
let get_button_style = |button_focus: AddTableFocus, current_focus| {
|
||||
@@ -376,11 +373,11 @@ pub fn render_add_table(
|
||||
|
||||
// --- Add Button Rendering ---
|
||||
// Determine if the add button is focused
|
||||
let is_add_button_focused = add_table_state.current_focus == AddTableFocus::AddColumnButton;
|
||||
let is_add_button_focused = add_table_state.current_focus() == AddTableFocus::AddColumnButton;
|
||||
|
||||
// Create the Add button Paragraph widget
|
||||
let add_button = Paragraph::new(" Add ")
|
||||
.style(get_button_style(AddTableFocus::AddColumnButton, add_table_state.current_focus)) // Use existing closure
|
||||
.style(get_button_style(AddTableFocus::AddColumnButton, add_table_state.current_focus())) // Use existing closure
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
@@ -393,14 +390,14 @@ pub fn render_add_table(
|
||||
f.render_widget(add_button, add_button_area);
|
||||
|
||||
// --- Indexes Table Rendering ---
|
||||
let indexes_focused = matches!(add_table_state.current_focus, AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable);
|
||||
let indexes_focused = matches!(add_table_state.current_focus(), AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable);
|
||||
let indexes_border_style = if indexes_focused {
|
||||
Style::default().fg(theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(theme.secondary)
|
||||
};
|
||||
let index_rows: Vec<Row<'_>> = add_table_state
|
||||
.indexes
|
||||
.indexes()
|
||||
.iter()
|
||||
.map(|index_def| { // Use index_def now
|
||||
Row::new(vec![
|
||||
@@ -434,18 +431,18 @@ pub fn render_add_table(
|
||||
f.render_stateful_widget(
|
||||
indexes_table,
|
||||
indexes_area,
|
||||
&mut add_table_state.index_table_state,
|
||||
&mut add_table_state.index_table_state(),
|
||||
);
|
||||
|
||||
// --- Links Table Rendering ---
|
||||
let links_focused = matches!(add_table_state.current_focus, AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable);
|
||||
let links_focused = matches!(add_table_state.current_focus(), AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable);
|
||||
let links_border_style = if links_focused {
|
||||
Style::default().fg(theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(theme.secondary)
|
||||
};
|
||||
let link_rows: Vec<Row<'_>> = add_table_state
|
||||
.links
|
||||
.links()
|
||||
.iter()
|
||||
.map(|link_def| {
|
||||
Row::new(vec![
|
||||
@@ -479,7 +476,7 @@ pub fn render_add_table(
|
||||
f.render_stateful_widget(
|
||||
links_table,
|
||||
links_area,
|
||||
&mut add_table_state.link_table_state,
|
||||
&mut add_table_state.link_table_state(),
|
||||
);
|
||||
|
||||
// --- Save/Cancel Buttons Rendering ---
|
||||
@@ -493,51 +490,54 @@ 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()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(get_button_border_style(
|
||||
add_table_state.current_focus == AddTableFocus::SaveButton, // Pass bool
|
||||
add_table_state.current_focus() == AddTableFocus::SaveButton, // Pass bool
|
||||
theme,
|
||||
)),
|
||||
);
|
||||
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,
|
||||
)),
|
||||
);
|
||||
4
client/src/pages/admin_panel/mod.rs
Normal file
4
client/src/pages/admin_panel/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
// src/pages/admin_panel/mod.rs
|
||||
|
||||
pub mod add_table;
|
||||
pub mod add_logic;
|
||||
62
client/src/pages/forms/event.rs
Normal file
62
client/src/pages/forms/event.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
// src/pages/forms/event.rs
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::event::Event;
|
||||
use canvas::keymap::KeyEventOutcome;
|
||||
use crate::{
|
||||
state::app::state::AppState,
|
||||
pages::forms::{FormState, logic},
|
||||
modes::handlers::event::EventOutcome,
|
||||
};
|
||||
|
||||
pub fn handle_form_event(
|
||||
event: Event,
|
||||
app_state: &mut AppState,
|
||||
path: &str,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<EventOutcome> {
|
||||
if let Event::Key(key_event) = event {
|
||||
if let Some(editor) = app_state.editor_for_path(path) {
|
||||
match editor.handle_key_event(key_event) {
|
||||
KeyEventOutcome::Consumed(Some(msg)) => {
|
||||
return Ok(EventOutcome::Ok(msg));
|
||||
}
|
||||
KeyEventOutcome::Consumed(None) => {
|
||||
return Ok(EventOutcome::Ok("Form input updated".into()));
|
||||
}
|
||||
KeyEventOutcome::Pending => {
|
||||
return Ok(EventOutcome::Ok("Waiting for next key...".into()));
|
||||
}
|
||||
KeyEventOutcome::NotMatched => {
|
||||
// fall through to navigation / save / revert
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
|
||||
// Save wrapper
|
||||
pub async fn save_form(
|
||||
app_state: &mut AppState,
|
||||
path: &str,
|
||||
grpc_client: &mut crate::services::grpc_client::GrpcClient,
|
||||
) -> Result<EventOutcome> {
|
||||
let outcome = logic::save(app_state, path, grpc_client).await?;
|
||||
let message = match outcome {
|
||||
logic::SaveOutcome::NoChange => "No changes to save.".to_string(),
|
||||
logic::SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
|
||||
logic::SaveOutcome::CreatedNew(_) => "New entry created.".to_string(),
|
||||
};
|
||||
Ok(EventOutcome::DataSaved(outcome, message))
|
||||
}
|
||||
|
||||
pub async fn revert_form(
|
||||
app_state: &mut AppState,
|
||||
path: &str,
|
||||
grpc_client: &mut crate::services::grpc_client::GrpcClient,
|
||||
) -> Result<EventOutcome> {
|
||||
let message = logic::revert(app_state, path, grpc_client).await?;
|
||||
Ok(EventOutcome::Ok(message))
|
||||
}
|
||||
39
client/src/pages/forms/loader.rs
Normal file
39
client/src/pages/forms/loader.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
// src/pages/forms/loader.rs
|
||||
use anyhow::{Context, Result};
|
||||
use crate::{
|
||||
state::app::state::AppState,
|
||||
services::grpc_client::GrpcClient,
|
||||
services::ui_service::UiService, // ✅ import UiService
|
||||
config::binds::Config,
|
||||
pages::forms::FormState,
|
||||
};
|
||||
|
||||
pub async fn ensure_form_loaded_and_count(
|
||||
grpc_client: &mut GrpcClient,
|
||||
app_state: &mut AppState,
|
||||
config: &Config,
|
||||
profile: &str,
|
||||
table: &str,
|
||||
) -> Result<()> {
|
||||
let path = format!("{}/{}", profile, table);
|
||||
|
||||
app_state.ensure_form_editor(&path, config, || {
|
||||
FormState::new(profile.to_string(), table.to_string(), vec![])
|
||||
});
|
||||
|
||||
if let Some(form_state) = app_state.form_state_for_path(&path) {
|
||||
UiService::fetch_and_set_table_count(grpc_client, form_state)
|
||||
.await
|
||||
.context("Failed to fetch table count")?;
|
||||
|
||||
if form_state.total_count > 0 {
|
||||
UiService::load_table_data_by_position(grpc_client, form_state)
|
||||
.await
|
||||
.context("Failed to load table data")?;
|
||||
} else {
|
||||
form_state.reset_to_empty();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
184
client/src/pages/forms/logic.rs
Normal file
184
client/src/pages/forms/logic.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
// src/pages/forms/logic.rs
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::pages::forms::FormState;
|
||||
use crate::utils::data_converter;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SaveOutcome {
|
||||
NoChange,
|
||||
UpdatedExisting,
|
||||
CreatedNew(i64),
|
||||
}
|
||||
|
||||
pub async fn save(
|
||||
app_state: &mut AppState,
|
||||
path: &str,
|
||||
grpc_client: &mut GrpcClient,
|
||||
) -> Result<SaveOutcome> {
|
||||
if let Some(fs) = app_state.form_state_for_path(path) {
|
||||
if !fs.has_unsaved_changes {
|
||||
return Ok(SaveOutcome::NoChange);
|
||||
}
|
||||
|
||||
let profile_name = fs.profile_name.clone();
|
||||
let table_name = fs.table_name.clone();
|
||||
let fields = fs.fields.clone();
|
||||
let values = fs.values.clone();
|
||||
let id = fs.id;
|
||||
let total_count = fs.total_count;
|
||||
let current_position = fs.current_position;
|
||||
|
||||
let cache_key = format!("{}.{}", profile_name, table_name);
|
||||
let schema = app_state
|
||||
.schema_cache
|
||||
.get(&cache_key)
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"Schema for table '{}' not found in cache. Cannot save.",
|
||||
table_name
|
||||
)
|
||||
})?;
|
||||
|
||||
let data_map: HashMap<String, String> = fields
|
||||
.iter()
|
||||
.zip(values.iter())
|
||||
.map(|(field_def, value)| (field_def.data_key.clone(), value.clone()))
|
||||
.collect();
|
||||
|
||||
let converted_data =
|
||||
data_converter::convert_and_validate_data(&data_map, schema)
|
||||
.map_err(|user_error| anyhow!(user_error))?;
|
||||
|
||||
let is_new_entry = id == 0
|
||||
|| (total_count > 0 && current_position > total_count)
|
||||
|| (total_count == 0 && current_position == 1);
|
||||
|
||||
let outcome = if is_new_entry {
|
||||
let response = grpc_client
|
||||
.post_table_data(profile_name.clone(), table_name.clone(), converted_data)
|
||||
.await
|
||||
.context("Failed to post new table data")?;
|
||||
|
||||
if response.success {
|
||||
if let Some(fs) = app_state.form_state_for_path(path) {
|
||||
fs.id = response.inserted_id;
|
||||
fs.total_count += 1;
|
||||
fs.current_position = fs.total_count;
|
||||
fs.has_unsaved_changes = false;
|
||||
}
|
||||
SaveOutcome::CreatedNew(response.inserted_id)
|
||||
} else {
|
||||
return Err(anyhow!("Server failed to insert data: {}", response.message));
|
||||
}
|
||||
} else {
|
||||
if id == 0 {
|
||||
return Err(anyhow!(
|
||||
"Cannot update record: ID is 0, but not classified as new entry."
|
||||
));
|
||||
}
|
||||
let response = grpc_client
|
||||
.put_table_data(profile_name.clone(), table_name.clone(), id, converted_data)
|
||||
.await
|
||||
.context("Failed to put (update) table data")?;
|
||||
|
||||
if response.success {
|
||||
if let Some(fs) = app_state.form_state_for_path(path) {
|
||||
fs.has_unsaved_changes = false;
|
||||
}
|
||||
SaveOutcome::UpdatedExisting
|
||||
} else {
|
||||
return Err(anyhow!("Server failed to update data: {}", response.message));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(outcome)
|
||||
} else {
|
||||
Ok(SaveOutcome::NoChange)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn revert(
|
||||
app_state: &mut AppState,
|
||||
path: &str,
|
||||
grpc_client: &mut GrpcClient,
|
||||
) -> Result<String> {
|
||||
if let Some(fs) = app_state.form_state_for_path(path) {
|
||||
if fs.id == 0
|
||||
|| (fs.total_count > 0 && fs.current_position > fs.total_count)
|
||||
|| (fs.total_count == 0 && fs.current_position == 1)
|
||||
{
|
||||
let old_total_count = fs.total_count;
|
||||
fs.reset_to_empty();
|
||||
fs.total_count = old_total_count;
|
||||
if fs.total_count > 0 {
|
||||
fs.current_position = fs.total_count + 1;
|
||||
} else {
|
||||
fs.current_position = 1;
|
||||
}
|
||||
return Ok("New entry cleared".to_string());
|
||||
}
|
||||
|
||||
if fs.current_position == 0 || fs.current_position > fs.total_count {
|
||||
if fs.total_count > 0 {
|
||||
fs.current_position = 1;
|
||||
} else {
|
||||
fs.reset_to_empty();
|
||||
return Ok("No saved data to revert to; form cleared.".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let response = grpc_client
|
||||
.get_table_data_by_position(
|
||||
fs.profile_name.clone(),
|
||||
fs.table_name.clone(),
|
||||
fs.current_position as i32,
|
||||
)
|
||||
.await
|
||||
.context(format!(
|
||||
"Failed to get table data by position {} for table {}.{}",
|
||||
fs.current_position, fs.profile_name, fs.table_name
|
||||
))?;
|
||||
|
||||
fs.update_from_response(&response.data, fs.current_position);
|
||||
Ok("Changes discarded, reloaded last saved version".to_string())
|
||||
} else {
|
||||
Ok("Nothing to revert".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_action(
|
||||
action: &str,
|
||||
form_state: &mut FormState,
|
||||
_grpc_client: &mut GrpcClient,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<String> {
|
||||
if form_state.has_unsaved_changes() {
|
||||
return Ok(
|
||||
"Unsaved changes. Save (Ctrl+S) or Revert (Ctrl+R) before navigating."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let total_count = form_state.total_count;
|
||||
|
||||
match action {
|
||||
"previous_entry" => {
|
||||
if form_state.current_position > 1 {
|
||||
form_state.current_position -= 1;
|
||||
*ideal_cursor_column = 0;
|
||||
}
|
||||
}
|
||||
"next_entry" => {
|
||||
if form_state.current_position <= total_count {
|
||||
form_state.current_position += 1;
|
||||
*ideal_cursor_column = 0;
|
||||
}
|
||||
}
|
||||
_ => return Err(anyhow!("Unknown form action: {}", action)),
|
||||
}
|
||||
|
||||
Ok(String::new())
|
||||
}
|
||||
13
client/src/pages/forms/mod.rs
Normal file
13
client/src/pages/forms/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
// src/pages/forms/mod.rs
|
||||
|
||||
pub mod ui;
|
||||
pub mod state;
|
||||
pub mod logic;
|
||||
pub mod event;
|
||||
pub mod loader;
|
||||
|
||||
pub use ui::*;
|
||||
pub use state::*;
|
||||
pub use logic::*;
|
||||
pub use event::*;
|
||||
pub use loader::*;
|
||||
@@ -1,11 +1,11 @@
|
||||
// src/state/pages/form.rs
|
||||
// src/pages/forms/state.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use canvas::{DataProvider, AppMode, EditorState, FormEditor};
|
||||
use canvas::canvas::HighlightState;
|
||||
use canvas::{DataProvider, AppMode};
|
||||
#[cfg(feature = "validation")]
|
||||
use canvas::{CharacterLimits, ValidationConfig, ValidationConfigBuilder};
|
||||
#[cfg(feature = "validation")]
|
||||
use canvas::validation::limits::CountMode;
|
||||
use common::proto::komp_ac::search::search_response::Hit;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::Frame;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn json_value_to_string(value: &serde_json::Value) -> String {
|
||||
@@ -25,7 +25,7 @@ pub struct FieldDefinition {
|
||||
pub link_target_table: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FormState {
|
||||
pub id: i64,
|
||||
pub profile_name: String,
|
||||
@@ -43,6 +43,18 @@ pub struct FormState {
|
||||
pub autocomplete_loading: bool,
|
||||
pub link_display_map: HashMap<usize, String>,
|
||||
pub app_mode: AppMode,
|
||||
// Validation 1 (character limits) per field. None = no validation for that field.
|
||||
// Leave room for future rules (patterns, masks, etc.).
|
||||
pub char_limits: Vec<Option<CharLimitsRule>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CharLimitsRule {
|
||||
pub min: Option<usize>,
|
||||
pub max: Option<usize>,
|
||||
pub warn_at: Option<usize>,
|
||||
pub count_mode: CountMode,
|
||||
}
|
||||
|
||||
impl FormState {
|
||||
@@ -60,6 +72,7 @@ impl FormState {
|
||||
fields: Vec<FieldDefinition>,
|
||||
) -> Self {
|
||||
let values = vec![String::new(); fields.len()];
|
||||
let len = values.len();
|
||||
FormState {
|
||||
id: 0,
|
||||
profile_name,
|
||||
@@ -76,7 +89,8 @@ impl FormState {
|
||||
selected_suggestion_index: None,
|
||||
autocomplete_loading: false,
|
||||
link_display_map: HashMap::new(),
|
||||
app_mode: AppMode::Edit,
|
||||
app_mode: canvas::AppMode::Edit,
|
||||
char_limits: vec![None; len],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,27 +131,6 @@ impl FormState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
&self,
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
) {
|
||||
// Wrap in FormEditor for new API
|
||||
let mut editor = FormEditor::new(self.clone());
|
||||
|
||||
// Use new canvas rendering
|
||||
canvas::render_canvas_default(f, area, &editor);
|
||||
|
||||
// If autocomplete is active, render suggestions
|
||||
if self.autocomplete_active && !self.autocomplete_suggestions.is_empty() {
|
||||
// Note: This will need to be updated when suggestions are integrated
|
||||
// canvas::render_suggestions_dropdown(f, area, input_rect, &canvas::DefaultCanvasTheme, &editor);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_to_empty(&mut self) {
|
||||
self.id = 0;
|
||||
self.values.iter_mut().for_each(|v| v.clear());
|
||||
@@ -228,15 +221,6 @@ impl FormState {
|
||||
self.autocomplete_loading = false;
|
||||
}
|
||||
|
||||
// NEW: Add these methods to change modes
|
||||
pub fn set_edit_mode(&mut self) {
|
||||
self.app_mode = AppMode::Edit;
|
||||
}
|
||||
|
||||
pub fn set_readonly_mode(&mut self) {
|
||||
self.app_mode = AppMode::ReadOnly;
|
||||
}
|
||||
|
||||
// Legacy method compatibility
|
||||
pub fn fields(&self) -> Vec<&str> {
|
||||
self.fields
|
||||
@@ -290,6 +274,24 @@ impl FormState {
|
||||
pub fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||
self.current_cursor_pos = pos;
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn set_character_limits_rules(
|
||||
&mut self,
|
||||
rules: Vec<Option<CharLimitsRule>>,
|
||||
) {
|
||||
if rules.len() == self.fields.len() {
|
||||
self.char_limits = rules;
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"Character limits count {} != field count {} for {}.{}",
|
||||
rules.len(),
|
||||
self.fields.len(),
|
||||
self.profile_name,
|
||||
self.table_name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Implement DataProvider for FormState
|
||||
@@ -316,4 +318,26 @@ impl DataProvider for FormState {
|
||||
fn supports_suggestions(&self, field_index: usize) -> bool {
|
||||
self.fields.get(field_index).map(|f| f.is_link).unwrap_or(false)
|
||||
}
|
||||
|
||||
// Validation 1: Provide character-limit-based validation to canvas
|
||||
// Only compiled when the "validation" feature is enabled on canvas.
|
||||
#[cfg(feature = "validation")]
|
||||
fn validation_config(&self, index: usize) -> Option<ValidationConfig> {
|
||||
let rule = self.char_limits.get(index)?.as_ref()?;
|
||||
let mut limits = match (rule.min, rule.max) {
|
||||
(Some(min), Some(max)) => CharacterLimits::new_range(min, max),
|
||||
(None, Some(max)) => CharacterLimits::new(max),
|
||||
(Some(min), None) => CharacterLimits::new_range(min, usize::MAX),
|
||||
(None, None) => CharacterLimits::new(usize::MAX),
|
||||
};
|
||||
limits = limits.with_count_mode(rule.count_mode);
|
||||
if let Some(warn) = rule.warn_at {
|
||||
limits = limits.with_warning_threshold(warn);
|
||||
}
|
||||
Some(
|
||||
ValidationConfigBuilder::new()
|
||||
.with_character_limits(limits)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,22 @@
|
||||
// src/components/form/form.rs
|
||||
// src/pages/forms/ui.rs
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::pages::form::FormState;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
|
||||
style::Style,
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use canvas::canvas::HighlightState;
|
||||
use canvas::{FormEditor, render_canvas_default, render_canvas, render_suggestions_dropdown, DefaultCanvasTheme};
|
||||
use canvas::{
|
||||
render_canvas, render_suggestions_dropdown, DefaultCanvasTheme, FormEditor,
|
||||
};
|
||||
use crate::pages::forms::FormState;
|
||||
|
||||
pub fn render_form(
|
||||
pub fn render_form_page(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
form_state: &FormState,
|
||||
fields: &[&str], // no longer needed, FormEditor handles this
|
||||
current_field_idx: &usize, // no longer needed
|
||||
inputs: &[&String], // no longer needed
|
||||
editor: &FormEditor<FormState>,
|
||||
table_name: &str,
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool, // FormEditor tracks mode internally
|
||||
highlight_state: &HighlightState,
|
||||
total_count: u64,
|
||||
current_position: u64,
|
||||
) {
|
||||
@@ -62,24 +58,15 @@ pub fn render_form(
|
||||
.alignment(Alignment::Left);
|
||||
f.render_widget(count_para, main_layout[0]);
|
||||
|
||||
// --- FORM RENDERING (Using new canvas API) ---
|
||||
let editor = FormEditor::new(form_state.clone());
|
||||
|
||||
let active_field_rect = render_canvas(
|
||||
f,
|
||||
main_layout[1],
|
||||
&editor,
|
||||
theme,
|
||||
);
|
||||
|
||||
// --- SUGGESTIONS DROPDOWN ---
|
||||
// --- FORM RENDERING (Using persistent FormEditor) ---
|
||||
let active_field_rect = render_canvas(f, main_layout[1], editor, theme);
|
||||
if let Some(active_rect) = active_field_rect {
|
||||
render_suggestions_dropdown(
|
||||
f,
|
||||
main_layout[1],
|
||||
active_rect,
|
||||
&DefaultCanvasTheme,
|
||||
&editor,
|
||||
editor,
|
||||
);
|
||||
}
|
||||
}
|
||||
73
client/src/pages/intro/logic.rs
Normal file
73
client/src/pages/intro/logic.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
// src/pages/intro/logic.rs
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::buffer::state::{AppView, BufferState};
|
||||
|
||||
/// Handles intro screen selection by updating view history and managing focus state.
|
||||
/// 0: Continue (restores last form or default)
|
||||
/// 1: Admin view
|
||||
/// 2: Login view
|
||||
/// 3: Register view (with focus reset)
|
||||
pub fn handle_intro_selection(
|
||||
app_state: &mut AppState,
|
||||
buffer_state: &mut BufferState,
|
||||
index: usize,
|
||||
) {
|
||||
match index {
|
||||
// Continue: go to the most recent existing Form tab, or open a sensible default
|
||||
0 => {
|
||||
// 1) Try to switch to an already open Form buffer (most recent)
|
||||
if let Some(existing_path) = buffer_state
|
||||
.history
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|view| {
|
||||
if let AppView::Form(p) = view {
|
||||
Some(p.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
{
|
||||
buffer_state.update_history(AppView::Form(existing_path));
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Otherwise pick a fallback path
|
||||
let fallback_path = if let (Some(profile), Some(table)) = (
|
||||
app_state.current_view_profile_name.clone(),
|
||||
app_state.current_view_table_name.clone(),
|
||||
) {
|
||||
Some(format!("{}/{}", profile, table))
|
||||
} else if let Some(any_key) = app_state.form_editor.keys().next().cloned() {
|
||||
// Use any existing editor key if available
|
||||
Some(any_key)
|
||||
} else {
|
||||
// Otherwise pick the first available table from the profile tree
|
||||
let mut found: Option<String> = None;
|
||||
for prof in &app_state.profile_tree.profiles {
|
||||
if let Some(tbl) = prof.tables.first() {
|
||||
found = Some(format!("{}/{}", prof.name, tbl.name));
|
||||
break;
|
||||
}
|
||||
}
|
||||
found
|
||||
};
|
||||
|
||||
if let Some(path) = fallback_path {
|
||||
buffer_state.update_history(AppView::Form(path));
|
||||
} else {
|
||||
// No sensible default; stay on Intro
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
buffer_state.update_history(AppView::Admin);
|
||||
}
|
||||
2 => {
|
||||
buffer_state.update_history(AppView::Login);
|
||||
}
|
||||
3 => {
|
||||
buffer_state.update_history(AppView::Register);
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
9
client/src/pages/intro/mod.rs
Normal file
9
client/src/pages/intro/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
// src/pages/intro/mod.rs
|
||||
|
||||
pub mod state;
|
||||
pub mod ui;
|
||||
pub mod logic;
|
||||
|
||||
pub use state::*;
|
||||
pub use ui::render_intro;
|
||||
pub use logic::*;
|
||||
52
client/src/pages/intro/state.rs
Normal file
52
client/src/pages/intro/state.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
// src/state/pages/intro.rs
|
||||
use crate::movement::MovementAction;
|
||||
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct IntroState {
|
||||
pub focus_outside_canvas: bool,
|
||||
pub focused_button_index: usize,
|
||||
}
|
||||
|
||||
impl IntroState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
focus_outside_canvas: true,
|
||||
focused_button_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_option(&mut self) {
|
||||
if self.focused_button_index < 3 {
|
||||
self.focused_button_index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn previous_option(&mut self) {
|
||||
if self.focused_button_index > 0 {
|
||||
self.focused_button_index -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntroState {
|
||||
pub fn handle_movement(&mut self, action: MovementAction) -> bool {
|
||||
match action {
|
||||
MovementAction::Next | MovementAction::Right | MovementAction::Down => {
|
||||
self.next_option();
|
||||
true
|
||||
}
|
||||
MovementAction::Previous | MovementAction::Left | MovementAction::Up => {
|
||||
self.previous_option();
|
||||
true
|
||||
}
|
||||
MovementAction::Select => {
|
||||
// Actual selection handled in event loop (UiContext::Intro)
|
||||
false
|
||||
}
|
||||
MovementAction::Esc => {
|
||||
// Nothing special for Intro, but could be used to quit
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// src/components/intro/intro.rs
|
||||
// src/pages/intro/ui.rs
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::Style,
|
||||
@@ -8,7 +8,7 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::pages::intro::IntroState;
|
||||
use crate::pages::intro::IntroState;
|
||||
|
||||
pub fn render_intro(f: &mut Frame, intro_state: &IntroState, area: Rect, theme: &Theme) {
|
||||
let block = Block::default()
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
71
client/src/pages/login/event.rs
Normal file
71
client/src/pages/login/event.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
// src/pages/login/event.rs
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{Event, KeyCode, KeyModifiers};
|
||||
use canvas::{keymap::KeyEventOutcome, AppMode as CanvasMode};
|
||||
use crate::{
|
||||
state::app::state::AppState,
|
||||
pages::login::LoginFormState,
|
||||
modes::handlers::event::EventOutcome,
|
||||
};
|
||||
use canvas::DataProvider;
|
||||
|
||||
/// Handles all Login page-specific events
|
||||
pub fn handle_login_event(
|
||||
event: Event,
|
||||
app_state: &mut AppState,
|
||||
login_page: &mut LoginFormState,
|
||||
) -> Result<EventOutcome> {
|
||||
if let Event::Key(key_event) = event {
|
||||
let key_code = key_event.code;
|
||||
let modifiers = key_event.modifiers;
|
||||
|
||||
// From buttons (outside) back into the canvas (ReadOnly) with Up/k from the left-most button
|
||||
if login_page.focus_outside_canvas
|
||||
&& login_page.focused_button_index == 0
|
||||
&& matches!(key_code, KeyCode::Up | KeyCode::Char('k'))
|
||||
&& modifiers.is_empty()
|
||||
{
|
||||
login_page.focus_outside_canvas = false;
|
||||
login_page.editor.set_mode(CanvasMode::ReadOnly);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
|
||||
// Focus handoff: inside canvas → buttons
|
||||
if !login_page.focus_outside_canvas {
|
||||
let last_idx = login_page.editor.data_provider().field_count().saturating_sub(1);
|
||||
let at_last = login_page.editor.current_field() >= last_idx;
|
||||
if login_page.editor.mode() == CanvasMode::ReadOnly
|
||||
&& at_last
|
||||
&& matches!(
|
||||
(key_code, modifiers),
|
||||
(KeyCode::Char('j'), KeyModifiers::NONE) | (KeyCode::Down, _)
|
||||
)
|
||||
{
|
||||
login_page.focus_outside_canvas = true;
|
||||
login_page.focused_button_index = 0;
|
||||
login_page.editor.set_mode(CanvasMode::ReadOnly);
|
||||
return Ok(EventOutcome::Ok("Focus moved to buttons".into()));
|
||||
}
|
||||
}
|
||||
|
||||
// Forward to canvas if focus is inside
|
||||
if !login_page.focus_outside_canvas {
|
||||
match login_page.handle_key_event(key_event) {
|
||||
KeyEventOutcome::Consumed(Some(msg)) => {
|
||||
return Ok(EventOutcome::Ok(msg));
|
||||
}
|
||||
KeyEventOutcome::Consumed(None) => {
|
||||
return Ok(EventOutcome::Ok("Login input updated".into()));
|
||||
}
|
||||
KeyEventOutcome::Pending => {
|
||||
return Ok(EventOutcome::Ok("Waiting for next key...".into()));
|
||||
}
|
||||
KeyEventOutcome::NotMatched => {
|
||||
// fall through to button handling
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user