Compare commits

...

84 Commits

Author SHA1 Message Date
Priec
ceb560c658 serialization of the gRPC JSONB now fully works for the validation 2025-09-14 11:24:27 +02:00
Priec
d88c239bf6 serde of jsonb in grpc 2025-09-14 10:56:38 +02:00
filipriec
01c4ff2e14 validation backend 2025-09-13 21:15:44 +02:00
Priec
c2890e1f3d tests via script and make test works now 2025-09-13 08:53:26 +02:00
Priec
e1ea44c68c inputing data for the client for the validation 2025-09-12 23:06:42 +02:00
Priec
cec2361b00 validation1 for the form 2025-09-12 21:25:49 +02:00
Priec
9672b9949c finally a working space 2025-09-12 19:14:21 +02:00
Priec
e4e9594a9d minor changes 2025-09-12 19:05:17 +02:00
Priec
6daa5202b1 debug is now running properly in the background without any issues 2025-09-12 18:17:52 +02:00
Priec
cae47da5f2 reused grpc connections, not a constant refreshes anymore, all fixed now, keep on fixing other bugs 2025-09-12 18:15:46 +02:00
filipriec
85c7c89c28 space2 is now debugging better 2025-09-12 15:46:14 +02:00
Priec
0d80266e9b space commands here we go again 2025-09-11 22:36:40 +02:00
Priec
a604d62d44 inputs from keyboard are now decoupled 2025-09-10 22:12:22 +02:00
Priec
2cbbfd21aa revert works on login, now do the same for other pages as well 2025-09-08 22:11:53 +02:00
Priec
1c17d07497 space and revert working properly well, also shift 2025-09-08 20:05:39 +02:00
Priec
ad15becd7a doing key sequencing via space 2025-09-08 12:56:03 +02:00
Priec
c2a6272413 buttons in add_logic and add_table works properly well now 2025-09-04 18:56:21 +02:00
Priec
c51af13fb1 intro buttons fixed 2025-09-04 17:46:32 +02:00
Priec
d9d8562539 moving the state from general to each page owning its own state of button or canvas focus 2025-09-04 17:36:13 +02:00
Priec
6891631b8d validation of exact strings 2025-09-02 13:52:36 +02:00
Priec
738d58b5f1 moving add_table to add_logic modern architecture3 2025-09-02 11:46:35 +02:00
Priec
3081125716 moving add_table to add_logic modern architecture2 2025-09-02 00:36:49 +02:00
Priec
6073c7ab43 moving add_table to add_logic modern architecture 2025-09-02 00:23:50 +02:00
filipriec
8157dc7a60 add_table working properly well 2025-09-01 16:37:43 +02:00
filipriec
3b130e9208 add_table fixing 2025-09-01 16:30:57 +02:00
Priec
ab81434c4e add table3 2025-09-01 07:41:13 +02:00
Priec
62c54dc1eb moving add table to the same way as add logic 2025-08-31 23:07:57 +02:00
Priec
347802b2a4 working suggestions in add_logic 2025-08-31 21:48:54 +02:00
Priec
a5a8d98984 add Logic fully decoupled 2025-08-31 21:37:18 +02:00
filipriec
5b42da8290 separate page 2025-08-31 21:25:43 +02:00
filipriec
4e041f36ce move out of canvas properly fixed, now working everyhing properly well 2025-08-31 10:02:48 +02:00
filipriec
22926b7266 add logic now using general movement 2025-08-30 22:38:48 +02:00
filipriec
0a7f032028 add_logic cursor when refocus is proper again 2025-08-30 21:26:20 +02:00
filipriec
4edec5e72d add logic is now using canvas library now 2025-08-30 21:10:10 +02:00
filipriec
c7d524c76a add table and add logic removal from ui.rs and event.rs 2025-08-30 19:47:26 +02:00
filipriec
9ed558562b moved admin now 2025-08-30 19:26:12 +02:00
filipriec
43f5c1a764 login and register are now havving own handlers and loaders, moving logic out of event.rs and ui.rs 2025-08-30 19:13:12 +02:00
filipriec
46149c09db event.rs and ui.rs refactor for the forms page(moved logic to the forms page dir and just calling it now) 2025-08-30 16:42:04 +02:00
filipriec
a0757efe8b moved add_table and add_logic, needs more things done tho 2025-08-30 14:46:34 +02:00
filipriec
10f4b9d8e2 moved add_table to be feature based 2025-08-30 14:25:33 +02:00
filipriec
42db496ad7 admin page being rendered properly well now 2025-08-30 13:32:45 +02:00
filipriec
d6fd672409 roles are now better 2025-08-30 13:19:45 +02:00
filipriec
60eb1c9f51 register has dropdown now 2025-08-29 22:07:04 +02:00
filipriec
a09c804595 intro empty buffer fixed 2025-08-29 20:13:25 +02:00
filipriec
a17f73fd54 buffer bug fixed, now proper names are being displayed 2025-08-29 19:53:31 +02:00
filipriec
2373ae4b8c form pages robust finish chnages 2025-08-29 19:49:27 +02:00
filipriec
16dd460469 we compiled but buffer doesnt work 2025-08-29 18:11:27 +02:00
filipriec
58f109ca91 adding to have multiple forms pages 2025-08-29 16:18:42 +02:00
filipriec
75da9c0f4b login and register are sending data to the backend successfuly 2025-08-29 14:46:43 +02:00
filipriec
833b918c5b cursor style is handled properly now 2025-08-29 12:32:33 +02:00
filipriec
72c2691a17 registration now has working form 2025-08-29 12:22:25 +02:00
filipriec
cf79bc7bd5 bottom_panel decoupled 2025-08-29 08:25:24 +02:00
filipriec
f5f2f2cdef login page using canvas library 2025-08-28 21:26:21 +02:00
filipriec
19a9bab8c2 login page using canvas for forms 2025-08-28 21:07:23 +02:00
filipriec
6e221ef8c1 HARDEST COMMIT IN THE RECENT TIMES we fixed movement in the admin page 2025-08-28 13:43:17 +02:00
Priec
e142f56706 admin page 2025-08-28 09:15:14 +02:00
Priec
a794f22366 admin page is now featured 2025-08-27 16:32:20 +02:00
Priec
cfe4903c79 admin page is now featured 2025-08-27 16:32:09 +02:00
Priec
a0a473f96c admin page 2025-08-27 12:14:09 +02:00
Priec
9e4dd3b4c7 intro movement fully fixed 2025-08-27 01:38:51 +02:00
Priec
e5db0334c0 fixed intro movement, select not working yet 2025-08-27 01:34:56 +02:00
Priec
d641ad1bbb centralized general movement 2025-08-27 01:06:54 +02:00
filipriec
18393ff661 is edit mode is gone from the codebase 2025-08-24 16:54:18 +02:00
filipriec
b2a82fba30 fixing is_edit_mode flag removal 2025-08-24 16:37:30 +02:00
filipriec
f6c2fd627f fixing is_edit_mode 2025-08-24 16:00:58 +02:00
filipriec
15d9b31cb6 removing edit mode from the codebase 2025-08-24 15:32:24 +02:00
filipriec
06cc1663b3 working general mode only with canvas, removing highlight, readonly or edit 2025-08-23 23:34:14 +02:00
filipriec
88a4b2d69c intro is now separated 2025-08-23 21:58:29 +02:00
filipriec
e6072d25c5 register is now separated also 2025-08-23 21:47:18 +02:00
filipriec
fc2b65601e login function moved 2025-08-23 21:05:02 +02:00
filipriec
597bdde7e1 login moved to the pages 2025-08-23 20:58:12 +02:00
filipriec
f56092e86c login page now in a separate dir 2025-08-23 19:48:23 +02:00
filipriec
d5cfe59f47 dialog refactor comment, dialog crate finished for now 2025-08-23 13:36:46 +02:00
filipriec
f281eaa662 dialog is a feature 2025-08-23 13:29:28 +02:00
Priec
cbb3ed7c48 small cleanup 2025-08-23 00:22:07 +02:00
Priec
41a0b85376 forms page moved more2 2025-08-23 00:16:07 +02:00
Priec
b5a31ee81c forms page 2025-08-22 23:54:22 +02:00
Priec
dceb031822 removed docs book from git history 2025-08-22 23:31:08 +02:00
Priec
78bc9fc432 router4 compiled 2025-08-22 23:27:32 +02:00
Priec
b9072e4d7c router2, needs bug fixes3 2025-08-22 22:57:28 +02:00
Priec
5d97e63f93 router2, needs bug fixes 2025-08-22 22:52:20 +02:00
Priec
957f5bf9f0 router implementation 2025-08-22 22:19:59 +02:00
Priec
6833ac5fad find palette in the bottom panel 2025-08-22 17:11:52 +02:00
Priec
3dff2ced6c bottom panel moved 2025-08-22 16:48:25 +02:00
198 changed files with 6700 additions and 14630 deletions

125
Cargo.lock generated
View File

@@ -595,8 +595,8 @@ dependencies = [
"dotenvy",
"futures",
"lazy_static",
"prost",
"prost-types",
"prost 0.13.5",
"prost-types 0.13.5",
"ratatui",
"rstest",
"serde",
@@ -637,9 +637,11 @@ dependencies = [
name = "common"
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"
@@ -3026,7 +3104,7 @@ version = "0.5.0"
dependencies = [
"anyhow",
"common",
"prost",
"prost 0.13.5",
"serde",
"serde_json",
"sqlx",
@@ -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",
]

View File

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

1
client/.gitignore vendored
View File

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

View File

@@ -8,7 +8,7 @@ license.workspace = true
anyhow = { workspace = true }
async-trait = "0.1.88"
common = { path = "../common" }
canvas = { path = "../canvas", features = ["gui", "suggestions", "cursor-style", "keymap"] }
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"

View File

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

View File

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

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

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

View File

@@ -4,10 +4,12 @@ use crate::state::app::state::AppState;
use ratatui::{
layout::Rect,
style::Style,
text::{Line, Span},
widgets::Paragraph,
text::{Line, Span, Text},
widgets::{Paragraph, Wrap},
Frame,
};
use crate::pages::routing::Page;
use crate::pages::routing::Router;
use std::path::Path;
use unicode_width::UnicodeWidthStr;
@@ -16,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")]
{
@@ -48,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())

View File

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

View File

@@ -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(),
}

View File

@@ -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::*;

View File

@@ -1,6 +0,0 @@
// src/components/form.rs
pub mod login;
pub mod register;
pub use login::*;
pub use register::*;

View File

@@ -1,16 +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 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 find_file_palette::*;

View File

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

View File

@@ -1,4 +0,0 @@
// src/components/form.rs
pub mod form;
pub use form::*;

View File

@@ -1,4 +0,0 @@
// src/components/intro.rs
pub mod intro;
pub use intro::*;

View File

@@ -1,14 +1,7 @@
// src/components/mod.rs
pub mod intro;
pub mod admin;
pub mod common;
pub mod form;
pub mod auth;
pub mod utils;
pub use intro::*;
pub use admin::*;
pub use common::*;
pub use form::*;
pub use auth::*;
pub use utils::*;

View File

@@ -148,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))
}
}
@@ -252,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,
@@ -373,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,
@@ -791,12 +825,43 @@ impl Config {
None
}
// Normalize bindings for canvas consumption:
// - "shift+<char>" -> also add "<CHAR>"
// - "shift+tab" -> also add "backtab"
// This keeps your config human-friendly while making the canvas happy.
fn normalize_for_canvas(
map: &HashMap<String, Vec<String>>,
) -> HashMap<String, Vec<String>> {
let mut out: HashMap<String, Vec<String>> = HashMap::new();
for (action, bindings) in map {
let mut new_list: Vec<String> = Vec::new();
for b in bindings {
new_list.push(b.clone());
let blc = b.to_lowercase();
if blc.starts_with("shift+") {
let parts: Vec<&str> = b.split('+').collect();
if parts.len() == 2 && parts[1].chars().count() == 1 {
let ch = parts[1].chars().next().unwrap();
new_list.push(ch.to_ascii_uppercase().to_string());
}
if blc == "shift+tab" {
new_list.push("backtab".to_string());
}
}
if blc == "shift+tab" {
new_list.push("backtab".to_string());
}
}
out.insert(action.clone(), new_list);
}
out
}
pub fn build_canvas_keymap(&self) -> CanvasKeyMap {
CanvasKeyMap::from_mode_maps(
&self.keybindings.read_only,
&self.keybindings.edit,
&self.keybindings.highlight,
)
let ro = Self::normalize_for_canvas(&self.keybindings.read_only);
let ed = Self::normalize_for_canvas(&self.keybindings.edit);
let hl = Self::normalize_for_canvas(&self.keybindings.highlight);
CanvasKeyMap::from_mode_maps(&ro, &ed, &hl)
}
}

View File

@@ -1,6 +1,7 @@
// client/src/config/key_sequences.rs
use crossterm::event::{KeyCode, KeyModifiers};
use std::time::{Duration, Instant};
use tracing::info;
#[derive(Debug, Clone, PartialEq)]
pub struct ParsedKey {
@@ -25,19 +26,21 @@ impl KeySequenceTracker {
}
pub fn reset(&mut self) {
info!("KeySequenceTracker.reset() from {:?}", self.current_sequence);
self.current_sequence.clear();
self.last_key_time = Instant::now();
}
pub fn add_key(&mut self, key: KeyCode) -> bool {
// Check if timeout has expired
let now = Instant::now();
if now.duration_since(self.last_key_time) > self.timeout {
info!("KeySequenceTracker timeout — reset before adding {:?}", key);
self.reset();
}
self.current_sequence.push(key);
self.last_key_time = now;
info!("KeySequenceTracker state after add: {:?}", self.current_sequence);
true
}
@@ -67,6 +70,7 @@ impl KeySequenceTracker {
// Helper function to convert any KeyCode to a string representation
pub fn key_to_string(key: &KeyCode) -> String {
match key {
KeyCode::Char(' ') => "space".to_string(),
KeyCode::Char(c) => c.to_string(),
KeyCode::Left => "left".to_string(),
KeyCode::Right => "right".to_string(),
@@ -90,6 +94,7 @@ pub fn key_to_string(key: &KeyCode) -> String {
// Helper function to convert a string to a KeyCode
pub fn string_to_keycode(s: &str) -> Option<KeyCode> {
match s.to_lowercase().as_str() {
"space" => Some(KeyCode::Char(' ')),
"left" => Some(KeyCode::Left),
"right" => Some(KeyCode::Right),
"up" => Some(KeyCode::Up),
@@ -113,26 +118,21 @@ pub fn string_to_keycode(s: &str) -> Option<KeyCode> {
pub fn parse_binding(binding: &str) -> Vec<ParsedKey> {
let mut sequence = Vec::new();
// Handle different binding formats
let parts: Vec<String> = if binding.contains('+') {
// Format with explicit '+' separators like "g+left"
binding.split('+').map(|s| s.to_string()).collect()
} else if binding.contains(' ') {
// Format with spaces like "g left"
binding.split(' ').map(|s| s.to_string()).collect()
} else if is_compound_key(binding) {
// A single compound key like "left" or "enter"
vec![binding.to_string()]
// Split into multi-key sequence:
// - If contains space → sequence split by space
// - Else split by '+'
let parts: Vec<&str> = if binding.contains(' ') {
binding.split(' ').collect()
} else {
// Simple character sequence like "gg"
binding.chars().map(|c| c.to_string()).collect()
binding.split('+').collect()
};
for part in &parts {
if let Some(key) = parse_key_part(part) {
sequence.push(key);
for part in parts {
if let Some(parsed) = parse_key_part(part) {
sequence.push(parsed);
}
}
sequence
}
@@ -140,7 +140,7 @@ fn is_compound_key(part: &str) -> bool {
matches!(part.to_lowercase().as_str(),
"esc" | "up" | "down" | "left" | "right" | "enter" |
"backspace" | "delete" | "tab" | "backtab" | "home" |
"end" | "pageup" | "pagedown" | "insert"
"end" | "pageup" | "pagedown" | "insert" | "space"
)
}

View File

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

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

View File

@@ -1,3 +1,5 @@
// src/dialog/ui.rs
use crate::config::colors::themes::Theme;
use ratatui::{
layout::{Constraint, Direction, Layout, Margin, Rect},

View File

@@ -1,5 +0,0 @@
// src/functions/mod.rs
pub mod modes;
pub use modes::*;

View File

@@ -1,5 +0,0 @@
// src/functions/modes.rs
pub mod navigation;
pub use navigation::*;

View File

@@ -1,5 +0,0 @@
// src/functions/modes/navigation.rs
pub mod admin_nav;
pub mod add_table_nav;
pub mod add_logic_nav;

View File

@@ -1,439 +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},
};
use crate::buffer::{AppView, 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);
}

View File

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

View File

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

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

@@ -0,0 +1,195 @@
// src/input/engine.rs
use crate::config::binds::config::Config;
use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::input::action::{AppAction, BufferAction, CoreAction};
use crate::movement::MovementAction;
use crate::modes::handlers::mode_manager::AppMode;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::input::leader::{leader_has_any_start, leader_is_prefix, leader_match_action};
use tracing::info;
#[derive(Debug, Clone, Copy)]
pub struct InputContext {
pub app_mode: AppMode,
pub overlay_active: bool,
pub allow_navigation_capture: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InputOutcome {
Action(AppAction),
Pending, // sequence in progress
PassThrough, // let page/canvas handle it
}
pub struct InputEngine {
seq: KeySequenceTracker,
leader_seq: KeySequenceTracker,
}
impl InputEngine {
pub fn new(normal_timeout_ms: u64, leader_timeout_ms: u64) -> Self {
Self {
seq: KeySequenceTracker::new(normal_timeout_ms),
leader_seq: KeySequenceTracker::new(leader_timeout_ms),
}
}
pub fn reset_sequence(&mut self) {
info!("InputEngine.reset_sequence() leader_seq_before={:?}", self.leader_seq.current_sequence);
self.seq.reset();
self.leader_seq.reset();
}
pub fn has_active_sequence(&self) -> bool {
!self.seq.current_sequence.is_empty()
|| !self.leader_seq.current_sequence.is_empty()
}
pub fn process_key(
&mut self,
key_event: KeyEvent,
ctx: &InputContext,
config: &Config,
) -> InputOutcome {
// Command mode keys are special (exit/execute/backspace) and typed chars
if ctx.app_mode == AppMode::Command {
if config.is_exit_command_mode(key_event.code, key_event.modifiers) {
self.seq.reset();
return InputOutcome::Action(AppAction::ExitCommandMode);
}
if config.is_command_execute(key_event.code, key_event.modifiers) {
self.seq.reset();
return InputOutcome::Action(AppAction::CommandExecute);
}
if config.is_command_backspace(key_event.code, key_event.modifiers) {
self.seq.reset();
return InputOutcome::Action(AppAction::CommandBackspace);
}
// Let command-line collect characters and other keys pass through
self.seq.reset();
return InputOutcome::PassThrough;
}
// If overlays are active, do not intercept (palette, navigation, etc.)
if ctx.overlay_active {
self.seq.reset();
// Also reset leader sequence to avoid leaving a stale "space" active
info!("Overlay active → reset leader_seq (was {:?})", self.leader_seq.current_sequence);
self.leader_seq.reset();
return InputOutcome::PassThrough;
}
// Space-led multi-key sequences (leader = space)
let space = KeyCode::Char(' ');
let leader_active = !self.leader_seq.current_sequence.is_empty()
&& self.leader_seq.current_sequence[0] == space;
// Keep collecting leader sequence even if allow_navigation_capture is false.
if leader_active {
self.leader_seq.add_key(key_event.code);
let sequence = self.leader_seq.get_sequence();
info!(
"Leader active updated: {:?} (added {:?})",
sequence, key_event.code
);
if let Some(action_str) = leader_match_action(config, &sequence) {
info!("Leader matched '{}' with sequence {:?}", action_str, sequence);
if let Some(app_action) = map_action_string(action_str, ctx) {
self.leader_seq.reset();
return InputOutcome::Action(app_action);
}
self.leader_seq.reset();
return InputOutcome::PassThrough;
}
if leader_is_prefix(config, &sequence) {
info!("Leader prefix continuing...");
return InputOutcome::Pending;
}
info!("Leader sequence reset (no match/prefix).");
self.leader_seq.reset();
// fall through to regular handling of this key
} else if ctx.allow_navigation_capture
&& key_event.code == space
&& leader_has_any_start(config)
{
// Start a leader sequence only if capturing is allowed
self.leader_seq.reset();
self.leader_seq.add_key(space);
info!("Leader started: {:?}", self.leader_seq.get_sequence());
return InputOutcome::Pending;
}
// Single-key mapping: try general binds first (arrows, open_search, enter_command_mode)
if let Some(action_str) =
config.get_general_action(key_event.code, key_event.modifiers)
{
if let Some(app_action) = map_action_string(action_str, ctx) {
return InputOutcome::Action(app_action);
}
// Unknown to app layer (likely canvas movement etc.) → pass
return InputOutcome::PassThrough;
}
// Then app-level common/read-only/edit/highlight for UI toggles or core actions
if let Some(action_str) = config.get_app_action(key_event.code, key_event.modifiers) {
if let Some(app_action) = map_action_string(action_str, ctx) {
return InputOutcome::Action(app_action);
}
}
InputOutcome::PassThrough
}
}
fn str_to_movement(s: &str) -> Option<MovementAction> {
match s {
"up" => Some(MovementAction::Up),
"down" => Some(MovementAction::Down),
"left" => Some(MovementAction::Left),
"right" => Some(MovementAction::Right),
"next" => Some(MovementAction::Next),
"previous" => Some(MovementAction::Previous),
"select" => Some(MovementAction::Select),
"esc" => Some(MovementAction::Esc),
_ => None,
}
}
fn map_action_string(action: &str, ctx: &InputContext) -> Option<AppAction> {
match action {
// Global/UI
"toggle_sidebar" => Some(AppAction::ToggleSidebar),
"toggle_buffer_list" => Some(AppAction::ToggleBufferList),
"open_search" => Some(AppAction::OpenSearch),
"find_file_palette_toggle" => Some(AppAction::FindFilePaletteToggle),
// Buffers
"next_buffer" => Some(AppAction::Buffer(BufferAction::Next)),
"previous_buffer" => Some(AppAction::Buffer(BufferAction::Previous)),
"close_buffer" => Some(AppAction::Buffer(BufferAction::Close)),
// Command mode
"enter_command_mode" => Some(AppAction::EnterCommandMode),
"exit_command_mode" => Some(AppAction::ExitCommandMode),
"command_execute" => Some(AppAction::CommandExecute),
"command_backspace" => Some(AppAction::CommandBackspace),
// Navigation across UI (only if allowed)
s if str_to_movement(s).is_some() && ctx.allow_navigation_capture => {
Some(AppAction::Navigate(str_to_movement(s).unwrap()))
}
// Core actions
"save" => Some(AppAction::Core(CoreAction::Save)),
"force_quit" => Some(AppAction::Core(CoreAction::ForceQuit)),
"save_and_quit" => Some(AppAction::Core(CoreAction::SaveAndQuit)),
"revert" => Some(AppAction::Core(CoreAction::Revert)),
// Unknown to app layer: ignore (canvas-specific actions, etc.)
_ => None,
}
}

View File

@@ -0,0 +1,74 @@
// src/input/leader.rs
use crate::config::binds::config::Config;
use crate::config::binds::key_sequences::parse_binding;
use crossterm::event::KeyCode;
/// Collect leader (= space-prefixed) bindings from *all* binding maps
fn leader_bindings<'a>(config: &'a Config) -> Vec<(&'a str, Vec<KeyCode>)> {
let mut out = Vec::new();
// Include all keybinding maps, not just global
let all_modes: Vec<&std::collections::HashMap<String, Vec<String>>> = vec![
&config.keybindings.general,
&config.keybindings.read_only,
&config.keybindings.edit,
&config.keybindings.highlight,
&config.keybindings.command,
&config.keybindings.common,
&config.keybindings.global,
];
for mode in all_modes {
for (action, bindings) in mode {
for b in bindings {
let parsed = parse_binding(b);
if parsed.first().map(|pk| pk.code) == Some(KeyCode::Char(' ')) {
let codes =
parsed.into_iter().map(|pk| pk.code).collect::<Vec<_>>();
out.push((action.as_str(), codes));
}
}
}
}
out
}
/// Is there any leader binding configured at all?
pub fn leader_has_any_start(config: &Config) -> bool {
leader_bindings(config)
.iter()
.any(|(_, seq)| seq.first() == Some(&KeyCode::Char(' ')))
}
/// Is `sequence` a prefix of any configured leader sequence?
pub fn leader_is_prefix(config: &Config, sequence: &[KeyCode]) -> bool {
if sequence.is_empty() || sequence[0] != KeyCode::Char(' ') {
return false;
}
for (_, full) in leader_bindings(config) {
if full.len() > sequence.len()
&& full.iter().zip(sequence.iter()).all(|(a, b)| a == b)
{
return true;
}
}
false
}
/// Is `sequence` an exact leader match? If yes, return the action string.
pub fn leader_match_action<'a>(
config: &'a Config,
sequence: &[KeyCode],
) -> Option<&'a str> {
if sequence.is_empty() || sequence[0] != KeyCode::Char(' ') {
return None;
}
for (action, full) in leader_bindings(config) {
if full.len() == sequence.len()
&& full.iter().zip(sequence.iter()).all(|(a, b)| a == b)
{
return Some(action);
}
}
None
}

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

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

View File

@@ -5,12 +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;

View File

@@ -4,26 +4,35 @@ use client::run_ui;
use client::utils::debug_logger::UiDebugWriter;
use dotenvy::dotenv;
use anyhow::Result;
use tracing_subscriber;
use tracing_subscriber::EnvFilter;
use std::env;
#[tokio::main]
async fn main() -> Result<()> {
#[cfg(feature = "ui-debug")]
{
// If ui-debug is on, set up our custom writer.
let writer = UiDebugWriter::new();
tracing_subscriber::fmt()
.with_level(false) // Don't show INFO, ERROR, etc.
.with_target(false) // Don't show the module path.
.without_time() // This is the correct and simpler method.
.with_writer(move || writer.clone())
.init();
use std::sync::Once;
static INIT_LOGGER: Once = Once::new();
INIT_LOGGER.call_once(|| {
let writer = UiDebugWriter::new();
let _ = tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.with_target(false)
.without_time()
.with_writer(move || writer.clone())
// Filter out noisy grpc/h2 internals
.with_env_filter("client=debug,tonic=info,h2=info,tower=info")
.try_init();
client::utils::debug_logger::spawn_file_logger();
});
}
#[cfg(not(feature = "ui-debug"))]
{
if env::var("ENABLE_TRACING").is_ok() {
tracing_subscriber::fmt::init();
let _ = tracing_subscriber::fmt::try_init();
}
}

View File

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

View File

@@ -3,20 +3,19 @@
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
use crate::config::binds::config::Config;
use crate::services::grpc_client::GrpcClient;
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,
router: &mut Router,
command_input: &mut String,
command_message: &mut String,
grpc_client: &mut GrpcClient,
@@ -25,20 +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,
app_state,
login_state,
register_state,
router,
command_input,
command_message,
grpc_client,
@@ -46,33 +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,
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,
@@ -81,27 +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,
login_state,
register_state,
)
.handle_command(action, terminal, app_state, router)
.await?;
command_input.clear();
if should_exit {
@@ -109,28 +96,30 @@ async fn process_command(
} else {
Ok(EventOutcome::Ok(message))
}
},
}
"save" => {
let outcome = save(
app_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(
app_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();

View File

@@ -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::{auth::LoginState, auth::RegisterState};
use crate::pages::routing::{Router, Page};
use anyhow::Result;
pub struct CommandHandler;
@@ -16,11 +16,10 @@ impl CommandHandler {
action: &str,
terminal: &mut TerminalCore,
app_state: &mut AppState,
login_state: &LoginState,
register_state: &RegisterState,
router: &Router,
) -> Result<(bool, String)> {
match action {
"quit" => self.handle_quit(terminal, app_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 +30,17 @@ impl CommandHandler {
&self,
terminal: &mut TerminalCore,
app_state: &mut AppState,
login_state: &LoginState,
register_state: &RegisterState,
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 if let Some(fs) = app_state.form_state_mut() {
fs.has_unsaved_changes
} else {
false
// 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 {

View File

@@ -1,4 +1,3 @@
// src/client/modes/general.rs
pub mod navigation;
pub mod dialog;
pub mod command_navigation;

View File

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

View File

@@ -1,164 +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;
use crate::buffer::AppView;
use crate::buffer::state::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())))
}
}

View File

@@ -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};
@@ -18,10 +15,7 @@ pub async fn handle_navigation_event(
key: KeyEvent,
config: &Config,
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,
@@ -34,31 +28,35 @@ 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" => {
if let Some(fs) = app_state.form_state_mut() {
next_field(fs);
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" => {
if let Some(fs) = app_state.form_state_mut() {
prev_field(fs);
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()));
}
@@ -67,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 });
}
@@ -88,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
};
}
}
_ => {}
}
}
@@ -164,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

View File

@@ -1,109 +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::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, // Canvas 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,
impl From<canvas::AppMode> for AppMode {
fn from(mode: canvas::AppMode) -> Self {
match mode {
canvas::AppMode::General => AppMode::General,
canvas::AppMode::ReadOnly => AppMode::ReadOnly,
canvas::AppMode::Edit => AppMode::Edit,
canvas::AppMode::Highlight => AppMode::Highlight,
canvas::AppMode::Command => AppMode::Command,
}
}
/// 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 mode flag
// Explicit command overlay flag
if event_handler.command_mode {
return AppMode::Command;
}
// Always trust the FormEditor when a form is active
if app_state.ui.show_form && !app_state.ui.focus_outside_canvas {
if let Some(editor) = &app_state.form_editor {
return AppMode::from(editor.mode());
}
}
// --- Non-form views (add_logic, add_table, etc.) ---
if app_state.ui.show_add_logic {
match admin_state.add_logic_state.current_focus {
AddLogicFocus::InputLogicName
| AddLogicFocus::InputTargetColumn
| AddLogicFocus::InputDescription => {
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 app_state.ui.show_login
|| app_state.ui.show_register
{
// login/register still use the old flag
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
}
}

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

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

View File

@@ -0,0 +1,6 @@
// src/movement/mod.rs
pub mod actions;
pub mod lib;
pub use actions::MovementAction;
pub use lib::move_focus;

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

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

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

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

View File

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

View File

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

View File

@@ -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::buffer::state::{BufferState, AppView};
use crate::state::pages::add_table::{AddTableState, LinkDefinition};
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;
}

View File

@@ -0,0 +1,7 @@
// src/pages/admin/main/mod.rs
pub mod state;
pub mod ui;
pub mod logic;
pub use state::NonAdminState;

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

View File

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

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

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

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

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

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

View File

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

View File

@@ -1,8 +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::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},
@@ -10,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(
@@ -18,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 ")
@@ -32,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()
@@ -68,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)
};
@@ -99,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(),
);
}
@@ -124,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));
@@ -253,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(
@@ -261,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,
)),
);
@@ -270,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(
@@ -278,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,
)),
);

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

View File

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

View File

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

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

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

View File

@@ -1,6 +1,7 @@
// src/state/pages/add_table.rs
// src/pages/admin_panel/add_table/state.rs
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 doesnt 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 editors 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
}
}

View File

@@ -1,8 +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::state::AppState;
use crate::state::pages::add_table::{AddTableFocus, AddTableState};
use canvas::{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},
@@ -10,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.
@@ -19,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
@@ -28,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
@@ -46,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![
@@ -81,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![
@@ -116,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![
@@ -152,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
}
@@ -221,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,
)),
])
@@ -277,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);
@@ -294,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![
@@ -342,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| {
@@ -375,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()
@@ -392,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![
@@ -433,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![
@@ -478,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 ---
@@ -492,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,
)),
);

View File

@@ -0,0 +1,4 @@
// src/pages/admin_panel/mod.rs
pub mod add_table;
pub mod add_logic;

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

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

View File

@@ -1,6 +1,7 @@
// src/tui/functions/common/form.rs
// 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;
@@ -14,14 +15,14 @@ pub enum SaveOutcome {
pub async fn save(
app_state: &mut AppState,
path: &str,
grpc_client: &mut GrpcClient,
) -> Result<SaveOutcome> {
if let Some(fs) = app_state.form_state_mut() {
if let Some(fs) = app_state.form_state_for_path(path) {
if !fs.has_unsaved_changes {
return Ok(SaveOutcome::NoChange);
}
// Copy out what we need before dropping the mutable borrow
let profile_name = fs.profile_name.clone();
let table_name = fs.table_name.clone();
let fields = fs.fields.clone();
@@ -62,7 +63,7 @@ pub async fn save(
.context("Failed to post new table data")?;
if response.success {
if let Some(fs) = app_state.form_state_mut() {
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;
@@ -75,7 +76,7 @@ pub async fn save(
} else {
if id == 0 {
return Err(anyhow!(
"Cannot update record: ID is 0, but not classified as new entry."
"Cannot update record: ID is 0, but not classified as new entry."
));
}
let response = grpc_client
@@ -84,7 +85,7 @@ pub async fn save(
.context("Failed to put (update) table data")?;
if response.success {
if let Some(fs) = app_state.form_state_mut() {
if let Some(fs) = app_state.form_state_for_path(path) {
fs.has_unsaved_changes = false;
}
SaveOutcome::UpdatedExisting
@@ -101,12 +102,13 @@ pub async fn save(
pub async fn revert(
app_state: &mut AppState,
path: &str,
grpc_client: &mut GrpcClient,
) -> Result<String> {
if let Some(fs) = app_state.form_state_mut() {
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)
|| (fs.total_count == 0 && fs.current_position == 1)
{
let old_total_count = fs.total_count;
fs.reset_to_empty();
@@ -136,8 +138,8 @@ pub async fn revert(
)
.await
.context(format!(
"Failed to get table data by position {} for table {}.{}",
fs.current_position, fs.profile_name, fs.table_name
"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);
@@ -146,3 +148,37 @@ pub async fn revert(
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())
}

View 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::*;

View File

@@ -1,6 +1,10 @@
// src/state/pages/form.rs
// src/pages/forms/state.rs
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 std::collections::HashMap;
@@ -21,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,
@@ -39,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 {
@@ -56,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,
@@ -72,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],
}
}
@@ -256,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
@@ -282,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(),
)
}
}

View File

@@ -1,7 +1,5 @@
// src/components/form/form.rs
// src/pages/forms/ui.rs
use crate::config::colors::themes::Theme;
use crate::state::app::state::AppState;
use crate::state::pages::form::FormState;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
style::Style,
@@ -9,14 +7,14 @@ use ratatui::{
Frame,
};
use canvas::{
render_canvas, render_suggestions_dropdown, DefaultCanvasTheme,
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,
app_state: &AppState,
form_state: &FormState, // not needed directly anymore, editor holds it
editor: &FormEditor<FormState>,
table_name: &str,
theme: &Theme,
total_count: u64,
@@ -61,18 +59,14 @@ pub fn render_form(
f.render_widget(count_para, main_layout[0]);
// --- FORM RENDERING (Using persistent FormEditor) ---
if let Some(editor) = &app_state.form_editor {
let active_field_rect = render_canvas(f, main_layout[1], editor, theme);
// --- SUGGESTIONS DROPDOWN ---
if let Some(active_rect) = active_field_rect {
render_suggestions_dropdown(
f,
main_layout[1],
active_rect,
&DefaultCanvasTheme,
editor,
);
}
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,
);
}
}

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

View 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::*;

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

View File

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

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

View File

@@ -1,14 +1,16 @@
// src/tui/functions/common/login.rs
// src/pages/login/logic.rs
use crate::services::auth::AuthClient;
use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::LoginState;
use crate::state::app::state::AppState;
use crate::buffer::state::{AppView, BufferState};
use crate::config::storage::storage::{StoredAuthData, save_auth_data};
use crate::ui::handlers::context::DialogPurpose;
use common::proto::komp_ac::auth::LoginResponse;
use anyhow::{Context, Result};
use crate::pages::login::LoginFormState;
use crate::state::pages::auth::UserRole;
use canvas::DataProvider;
use anyhow::{Context, Result, anyhow};
use tokio::spawn;
use tokio::sync::mpsc;
use tracing::{info, error};
@@ -24,15 +26,14 @@ pub enum LoginResult {
/// Updates AuthState and AppState on success or failure.
pub async fn save(
auth_state: &mut AuthState,
login_state: &mut LoginState,
login_state: &mut LoginFormState,
auth_client: &mut AuthClient,
app_state: &mut AppState,
) -> Result<String> {
let identifier = login_state.username.clone();
let password = login_state.password.clone();
let identifier = login_state.username().to_string();
let password = login_state.password().to_string();
// --- Client-side validation ---
// Prevent login attempt if the identifier field is empty or whitespace.
if identifier.trim().is_empty() {
let error_message = "Username/Email cannot be empty.".to_string();
app_state.show_dialog(
@@ -41,33 +42,33 @@ pub async fn save(
vec!["OK".to_string()],
DialogPurpose::LoginFailed,
);
login_state.error_message = Some(error_message.clone());
return Err(anyhow::anyhow!(error_message));
login_state.set_error_message(Some(error_message.clone()));
return Err(anyhow!(error_message));
}
// Clear previous error/dialog state before attempting
login_state.error_message = None;
app_state.hide_dialog(); // Hide any previous dialog
login_state.set_error_message(None);
app_state.hide_dialog();
// Call the gRPC login method
match auth_client.login(identifier.clone(), password).await
.with_context(|| format!("gRPC login attempt failed for identifier: {}", identifier))
{
Ok(response) => {
// Store authentication details using correct field names
// Store authentication details
auth_state.auth_token = Some(response.access_token.clone());
auth_state.user_id = Some(response.user_id.clone());
auth_state.role = Some(response.role.clone());
auth_state.role = Some(UserRole::from_str(&response.role));
auth_state.decoded_username = Some(response.username.clone());
login_state.set_has_unsaved_changes(false);
login_state.error_message = None;
// Format the success message using response data
login_state.set_has_unsaved_changes(false);
login_state.set_error_message(None);
let success_message = format!(
"Login Successful!\n\n\
Username: {}\n\
User ID: {}\n\
Role: {}",
Username: {}\n\
User ID: {}\n\
Role: {}",
response.username,
response.user_id,
response.role
@@ -79,9 +80,11 @@ pub async fn save(
vec!["Menu".to_string(), "Exit".to_string()],
DialogPurpose::LoginSuccess,
);
login_state.password.clear();
login_state.username.clear();
login_state.current_cursor_pos = 0;
login_state.username_mut().clear();
login_state.password_mut().clear();
login_state.set_current_cursor_pos(0);
Ok("Login successful, details shown in dialog.".to_string())
}
Err(e) => {
@@ -92,10 +95,10 @@ pub async fn save(
vec!["OK".to_string()],
DialogPurpose::LoginFailed,
);
login_state.error_message = Some(error_message.clone());
login_state.set_error_message(Some(error_message.clone()));
login_state.set_has_unsaved_changes(true);
login_state.username.clear();
login_state.password.clear();
login_state.username_mut().clear();
login_state.password_mut().clear();
Err(e)
}
}
@@ -103,56 +106,52 @@ pub async fn save(
/// Reverts the login form fields to empty and returns to the previous screen (Intro).
pub async fn revert(
login_state: &mut LoginState,
_app_state: &mut AppState, // Keep signature consistent if needed elsewhere
login_state: &mut LoginFormState,
app_state: &mut AppState,
) -> String {
// Clear the input fields
login_state.username.clear();
login_state.password.clear();
login_state.error_message = None;
login_state.set_has_unsaved_changes(false);
login_state.login_request_pending = false; // Ensure flag is reset on revert
// Clear the underlying state
login_state.clear();
// Also clear values inside the editors data provider
{
let dp = login_state.editor.data_provider_mut();
dp.set_field_value(0, "".to_string());
dp.set_field_value(1, "".to_string());
dp.set_current_field(0);
dp.set_current_cursor_pos(0);
dp.set_has_unsaved_changes(false);
}
app_state.hide_dialog();
"Login reverted".to_string()
}
/// Clears login form and navigates back to main menu.
pub async fn back_to_main(
login_state: &mut LoginState,
login_state: &mut LoginFormState,
app_state: &mut AppState,
buffer_state: &mut BufferState,
) -> String {
// Clear the input fields
login_state.username.clear();
login_state.password.clear();
login_state.error_message = None;
login_state.set_has_unsaved_changes(false);
login_state.login_request_pending = false; // Ensure flag is reset
// Ensure dialog is hidden if revert is called
login_state.clear();
app_state.hide_dialog();
// Navigation logic
buffer_state.close_active_buffer();
buffer_state.update_history(AppView::Intro);
// Reset focus state
app_state.ui.focus_outside_canvas = false;
app_state.focused_button_index= 0;
"Returned to main menu".to_string()
}
/// Validates input, shows loading, and spawns the login task.
pub fn initiate_login(
login_state: &LoginState,
login_state: &mut LoginFormState,
app_state: &mut AppState,
mut auth_client: AuthClient,
sender: mpsc::Sender<LoginResult>,
) -> String {
let username = login_state.username.clone();
let password = login_state.password.clone();
login_state.sync_from_editor();
let username = login_state.username().to_string();
let password = login_state.password().to_string();
// 1. Client-side validation
if username.trim().is_empty() {
app_state.show_dialog(
"Login Failed",
@@ -162,25 +161,20 @@ pub fn initiate_login(
);
"Username cannot be empty.".to_string()
} else {
// 2. Show Loading Dialog
app_state.show_loading_dialog("Logging In", "Please wait...");
// 3. Spawn the login task
spawn(async move {
// Use the passed-in (and moved) auth_client directly
let login_outcome = match auth_client.login(username.clone(), password).await
.with_context(|| format!("Spawned login task failed for identifier: {}", username))
{
Ok(response) => LoginResult::Success(response),
Err(e) => LoginResult::Failure(format!("{}", e)),
};
// Send result back to the main UI thread
{
Ok(response) => LoginResult::Success(response),
Err(e) => LoginResult::Failure(format!("{}", e)),
};
if let Err(e) = sender.send(login_outcome).await {
error!("Failed to send login result: {}", e);
}
});
// 4. Return immediately
"Login initiated.".to_string()
}
}
@@ -191,28 +185,24 @@ pub fn handle_login_result(
result: LoginResult,
app_state: &mut AppState,
auth_state: &mut AuthState,
login_state: &mut LoginState,
login_state: &mut LoginFormState,
) -> bool {
match result {
LoginResult::Success(response) => {
auth_state.auth_token = Some(response.access_token.clone());
auth_state.user_id = Some(response.user_id.clone());
auth_state.role = Some(response.role.clone());
auth_state.role = Some(UserRole::from_str(&response.role));
auth_state.decoded_username = Some(response.username.clone());
// --- NEW: Save auth data to file ---
let data_to_store = StoredAuthData {
access_token: response.access_token.clone(),
user_id: response.user_id.clone(),
role: response.role.clone(),
username: response.username.clone(),
};
if let Err(e) = save_auth_data(&data_to_store) {
error!("Failed to save auth data to file: {}", e);
// Continue anyway - user is still logged in for this session
}
// --- END NEW ---
let success_message = format!(
"Login Successful!\n\nUsername: {}\nUser ID: {}\nRole: {}",
@@ -226,14 +216,28 @@ pub fn handle_login_result(
info!(message = %success_message, "Login successful");
}
LoginResult::Failure(err_msg) | LoginResult::ConnectionError(err_msg) => {
app_state.update_dialog_content(&err_msg, vec!["OK".to_string()], DialogPurpose::LoginFailed);
login_state.error_message = Some(err_msg.clone());
app_state.update_dialog_content(
&err_msg,
vec!["OK".to_string()],
DialogPurpose::LoginFailed,
);
login_state.set_error_message(Some(err_msg.clone()));
error!(error = %err_msg, "Login failed/connection error");
}
}
login_state.username.clear();
login_state.password.clear();
login_state.username_mut().clear();
login_state.password_mut().clear();
login_state.set_has_unsaved_changes(false);
login_state.current_cursor_pos = 0;
true // Request redraw as dialog content changed
login_state.set_current_cursor_pos(0);
true
}
pub async fn handle_action(action: &str) -> Result<String> {
match action {
"previous_entry" => Ok("Previous entry not implemented".into()),
"next_entry" => Ok("Next entry not implemented".into()),
_ => Err(anyhow!("Unknown login action: {}", action)),
}
}

View File

@@ -0,0 +1,11 @@
// src/pages/login/mod.rs
pub mod state;
pub mod ui;
pub mod logic;
pub mod event;
pub use state::*;
pub use ui::render_login;
pub use logic::*;
pub use event::*;

View File

@@ -0,0 +1,248 @@
// src/pages/login/state.rs
use canvas::{AppMode, DataProvider};
use canvas::FormEditor;
use std::fmt;
#[derive(Debug, Clone)]
pub struct LoginState {
pub username: String,
pub password: String,
pub error_message: Option<String>,
pub current_field: usize,
pub current_cursor_pos: usize,
pub has_unsaved_changes: bool,
pub login_request_pending: bool,
pub app_mode: AppMode,
}
impl Default for LoginState {
fn default() -> Self {
Self {
username: String::new(),
password: String::new(),
error_message: None,
current_field: 0,
current_cursor_pos: 0,
has_unsaved_changes: false,
login_request_pending: false,
app_mode: canvas::AppMode::Edit,
}
}
}
impl LoginState {
pub fn new() -> Self {
Self {
app_mode: canvas::AppMode::Edit,
..Default::default()
}
}
pub fn current_field(&self) -> usize {
self.current_field
}
pub fn current_cursor_pos(&self) -> usize {
self.current_cursor_pos
}
pub fn set_current_field(&mut self, index: usize) {
if index < 2 {
self.current_field = index;
}
}
pub fn set_current_cursor_pos(&mut self, pos: usize) {
self.current_cursor_pos = pos;
}
pub fn get_current_input(&self) -> &str {
match self.current_field {
0 => &self.username,
1 => &self.password,
_ => "",
}
}
pub fn get_current_input_mut(&mut self) -> &mut String {
match self.current_field {
0 => &mut self.username,
1 => &mut self.password,
_ => panic!("Invalid current_field index in LoginState"),
}
}
pub fn current_mode(&self) -> AppMode {
self.app_mode
}
pub fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
pub fn set_has_unsaved_changes(&mut self, changed: bool) {
self.has_unsaved_changes = changed;
}
}
// Implement DataProvider for LoginState
impl DataProvider for LoginState {
fn field_count(&self) -> usize {
2
}
fn field_name(&self, index: usize) -> &str {
match index {
0 => "Username/Email",
1 => "Password",
_ => "",
}
}
fn field_value(&self, index: usize) -> &str {
match index {
0 => &self.username,
1 => &self.password,
_ => "",
}
}
fn set_field_value(&mut self, index: usize, value: String) {
match index {
0 => self.username = value,
1 => self.password = value,
_ => {}
}
self.has_unsaved_changes = true;
}
fn supports_suggestions(&self, _field_index: usize) -> bool {
false // Login form doesn't support suggestions
}
}
/// Wrapper that owns both the raw login state and its editor
pub struct LoginFormState {
pub state: LoginState,
pub editor: FormEditor<LoginState>,
pub focus_outside_canvas: bool,
pub focused_button_index: usize,
}
// manual debug because FormEditor doesnt implement debug
impl fmt::Debug for LoginFormState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LoginFormState")
.field("state", &self.state) // ✅ only print the data
.finish()
}
}
impl LoginFormState {
/// Sync the editor's data provider back into our state
pub fn sync_from_editor(&mut self) {
// FormEditor holds the authoritative data
let dp = self.editor.data_provider();
self.state = dp.clone(); // LoginState implements Clone
}
/// Create a new LoginFormState with default LoginState and FormEditor
pub fn new() -> Self {
let state = LoginState::default();
let editor = FormEditor::new(state.clone());
Self {
state,
editor,
focus_outside_canvas: false,
focused_button_index: 0,
}
}
// === Delegates to LoginState fields ===
pub fn username(&self) -> &str {
&self.state.username
}
pub fn username_mut(&mut self) -> &mut String {
&mut self.state.username
}
pub fn password(&self) -> &str {
&self.state.password
}
pub fn password_mut(&mut self) -> &mut String {
&mut self.state.password
}
pub fn error_message(&self) -> Option<&String> {
self.state.error_message.as_ref()
}
pub fn set_error_message(&mut self, msg: Option<String>) {
self.state.error_message = msg;
}
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 clear(&mut self) {
self.state.username.clear();
self.state.password.clear();
self.state.error_message = None;
self.state.has_unsaved_changes = false;
self.state.login_request_pending = false;
self.state.current_cursor_pos = 0;
}
// === Delegates to LoginState cursor/input ===
pub fn current_field(&self) -> usize {
self.state.current_field()
}
pub fn set_current_field(&mut self, index: usize) {
self.state.set_current_field(index);
}
pub fn current_cursor_pos(&self) -> usize {
self.state.current_cursor_pos()
}
pub fn set_current_cursor_pos(&mut self, pos: usize) {
self.state.set_current_cursor_pos(pos);
}
pub fn get_current_input(&self) -> &str {
self.state.get_current_input()
}
pub fn get_current_input_mut(&mut self) -> &mut String {
self.state.get_current_input_mut()
}
// === 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 {
self.editor.handle_key_event(key_event)
}
}

View File

@@ -1,9 +1,7 @@
// src/components/auth/login.rs
// src/pages/login/ui.rs
use crate::{
config::colors::themes::Theme,
state::pages::auth::LoginState,
components::common::dialog,
state::app::state::AppState,
};
use ratatui::{
@@ -19,15 +17,19 @@ use canvas::{
DefaultCanvasTheme,
};
use crate::pages::login::LoginFormState;
use crate::dialog;
pub fn render_login(
f: &mut Frame,
area: Rect,
theme: &Theme,
// FIX: take &LoginState (reference), not owned
login_state: &LoginState,
login_page: &LoginFormState,
app_state: &AppState,
is_edit_mode: bool,
) {
let login_state = &login_page.state;
let editor = &login_page.editor;
// Main container
let block = Block::default()
.borders(Borders::ALL)
@@ -53,14 +55,10 @@ pub fn render_login(
])
.split(inner_area);
// Wrap LoginState in FormEditor (no clone needed)
let editor = FormEditor::new(login_state.clone());
// Use DefaultCanvasTheme instead of app Theme
let input_rect = render_canvas(
f,
chunks[0],
&editor,
editor,
&DefaultCanvasTheme,
);
@@ -82,11 +80,8 @@ pub fn render_login(
// Login Button
let login_button_index = 0;
let login_active = if app_state.ui.focus_outside_canvas {
app_state.focused_button_index == login_button_index
} else {
false
};
let login_active = login_page.focus_outside_canvas
&& login_page.focused_button_index == login_button_index;
let mut login_style = Style::default().fg(theme.fg);
let mut login_border = Style::default().fg(theme.border);
if login_active {
@@ -109,11 +104,8 @@ pub fn render_login(
// Return Button
let return_button_index = 1;
let return_active = if app_state.ui.focus_outside_canvas {
app_state.focused_button_index == return_button_index
} else {
false
};
let return_active = login_page.focus_outside_canvas
&& login_page.focused_button_index == return_button_index;
let mut return_style = Style::default().fg(theme.fg);
let mut return_border = Style::default().fg(theme.border);
if return_active {
@@ -135,14 +127,14 @@ pub fn render_login(
);
// --- SUGGESTIONS DROPDOWN (if active) ---
if app_state.current_mode == crate::modes::handlers::mode_manager::AppMode::Edit {
if editor.mode() == canvas::AppMode::Edit {
if let Some(input_rect) = input_rect {
render_suggestions_dropdown(
f,
f.area(),
chunks[0],
input_rect,
&DefaultCanvasTheme,
&editor, // FIX: pass &editor
editor,
);
}
}

9
client/src/pages/mod.rs Normal file
View File

@@ -0,0 +1,9 @@
// src/pages/mod.rs
pub mod routing;
pub mod intro;
pub mod login;
pub mod register;
pub mod forms;
pub mod admin;
pub mod admin_panel;

View File

@@ -0,0 +1,72 @@
// src/pages/register/event.rs
use anyhow::Result;
use crossterm::event::{Event, KeyCode, KeyModifiers};
use canvas::{keymap::KeyEventOutcome, AppMode as CanvasMode};
use canvas::DataProvider;
use crate::{
state::app::state::AppState,
pages::register::RegisterFormState,
modes::handlers::event::EventOutcome,
};
/// Handles all Register 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_register_event(
event: Event,
app_state: &mut AppState,
register_page: &mut RegisterFormState,
)-> 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 register_page.focus_outside_canvas
&& register_page.focused_button_index == 0
&& matches!(key_code, KeyCode::Up | KeyCode::Char('k'))
&& modifiers.is_empty()
{
register_page.focus_outside_canvas = false;
register_page.editor.set_mode(CanvasMode::ReadOnly);
return Ok(EventOutcome::Ok(String::new()));
}
// Focus handoff: inside canvas → buttons
if !register_page.focus_outside_canvas {
let last_idx = register_page.editor.data_provider().field_count().saturating_sub(1);
let at_last = register_page.editor.current_field() >= last_idx;
if register_page.editor.mode() == CanvasMode::ReadOnly
&& at_last
&& matches!(
(key_code, modifiers),
(KeyCode::Char('j'), KeyModifiers::NONE) | (KeyCode::Down, _)
)
{
register_page.focus_outside_canvas = true;
register_page.focused_button_index = 0; // focus "Register" button
register_page.editor.set_mode(CanvasMode::ReadOnly);
return Ok(EventOutcome::Ok("Focus moved to buttons".into()));
}
}
// Forward to canvas if focus is inside
if !register_page.focus_outside_canvas {
match register_page.handle_key_event(key_event) {
KeyEventOutcome::Consumed(Some(msg)) => {
return Ok(EventOutcome::Ok(msg));
}
KeyEventOutcome::Consumed(None) => {
return Ok(EventOutcome::Ok("Register input updated".into()));
}
KeyEventOutcome::Pending => {
return Ok(EventOutcome::Ok("Waiting for next key...".into()));
}
KeyEventOutcome::NotMatched => {
// fall through
}
}
}
}
Ok(EventOutcome::Ok(String::new()))
}

View File

@@ -1,13 +1,11 @@
// src/tui/functions/common/register.rs
// src/pages/register/logic.rs
use crate::services::auth::AuthClient;
use crate::state::{
pages::auth::RegisterState,
app::state::AppState,
};
use crate::state::app::state::AppState;
use crate::ui::handlers::context::DialogPurpose;
use crate::buffer::state::{AppView, BufferState};
use common::proto::komp_ac::auth::AuthResponse;
use crate::pages::register::RegisterFormState;
use anyhow::Context;
use tokio::spawn;
use tokio::sync::mpsc;
@@ -22,24 +20,26 @@ pub enum RegisterResult {
/// Clears the registration form fields.
pub async fn revert(
register_state: &mut RegisterState,
_app_state: &mut AppState, // Keep signature consistent if needed elsewhere
register_state: &mut RegisterFormState,
app_state: &mut AppState,
) -> String {
register_state.username.clear();
register_state.email.clear();
register_state.password.clear();
register_state.password_confirmation.clear();
register_state.role.clear();
register_state.error_message = None;
register_state.username_mut().clear();
register_state.email_mut().clear();
register_state.password_mut().clear();
register_state.password_confirmation_mut().clear();
register_state.role_mut().clear();
register_state.set_error_message(None);
register_state.set_has_unsaved_changes(false);
register_state.current_field = 0; // Reset focus to first field
register_state.current_cursor_pos = 0;
register_state.set_current_field(0); // Reset focus to first field
register_state.set_current_cursor_pos(0);
app_state.hide_dialog();
"Registration form cleared".to_string()
}
/// Clears the form and returns to the intro screen.
pub async fn back_to_login(
register_state: &mut RegisterState,
register_state: &mut RegisterFormState,
app_state: &mut AppState,
buffer_state: &mut BufferState,
) -> String {
@@ -54,33 +54,43 @@ pub async fn back_to_login(
buffer_state.update_history(AppView::Login);
// Reset focus state
app_state.ui.focus_outside_canvas = false;
app_state.focused_button_index = 0;
register_state.focus_outside_canvas = false;
register_state.focused_button_index = 0;
"Returned to main menu".to_string()
}
/// Validates input, shows loading, and spawns the registration task.
pub fn initiate_registration(
register_state: &RegisterState,
register_state: &mut RegisterFormState,
app_state: &mut AppState,
mut auth_client: AuthClient,
sender: mpsc::Sender<RegisterResult>,
) -> String {
// Clone necessary data
let username = register_state.username.clone();
let email = register_state.email.clone();
let password = register_state.password.clone();
let password_confirmation = register_state.password_confirmation.clone();
let role = register_state.role.clone();
register_state.sync_from_editor();
let username = register_state.username().to_string();
let email = register_state.email().to_string();
let password = register_state.password().to_string();
let password_confirmation = register_state.password_confirmation().to_string();
let role = register_state.role().to_string();
// 1. Client-side validation
if username.trim().is_empty() {
app_state.show_dialog("Registration Failed", "Username cannot be empty.", vec!["OK".to_string()], DialogPurpose::RegisterFailed);
app_state.show_dialog(
"Registration Failed",
"Username cannot be empty.",
vec!["OK".to_string()],
DialogPurpose::RegisterFailed,
);
"Username cannot be empty.".to_string()
} else if !password.is_empty() && password != password_confirmation {
app_state.show_dialog("Registration Failed", "Passwords do not match.", vec!["OK".to_string()], DialogPurpose::RegisterFailed);
"Passwords do not match.".to_string()
app_state.show_dialog(
"Registration Failed",
"Passwords do not match.",
vec!["OK".to_string()],
DialogPurpose::RegisterFailed,
);
"Passwords do not match.".to_string()
} else {
// 2. Show Loading Dialog
app_state.show_loading_dialog("Registering", "Please wait...");
@@ -88,14 +98,19 @@ pub fn initiate_registration(
// 3. Spawn the registration task
spawn(async move {
let password_opt = if password.is_empty() { None } else { Some(password) };
let password_conf_opt = if password_confirmation.is_empty() { None } else { Some(password_confirmation) };
let password_conf_opt =
if password_confirmation.is_empty() { None } else { Some(password_confirmation) };
let role_opt = if role.is_empty() { None } else { Some(role) };
let register_outcome = match auth_client.register(username.clone(), email, password_opt, password_conf_opt, role_opt).await
let register_outcome = match auth_client
.register(username.clone(), email, password_opt, password_conf_opt, role_opt)
.await
.with_context(|| format!("Spawned register task failed for username: {}", username))
{
Ok(response) => RegisterResult::Success(response),
Err(e) => RegisterResult::Failure(format!("{}", e)),
};
// Send result back to the main UI thread
if let Err(e) = sender.send(register_outcome).await {
error!("Failed to send registration result: {}", e);
@@ -112,7 +127,7 @@ pub fn initiate_registration(
pub fn handle_registration_result(
result: RegisterResult,
app_state: &mut AppState,
register_state: &mut RegisterState,
register_state: &mut RegisterFormState,
) -> bool {
match result {
RegisterResult::Success(response) => {
@@ -133,7 +148,7 @@ pub fn handle_registration_result(
vec!["OK".to_string()],
DialogPurpose::RegisterFailed,
);
register_state.error_message = Some(err_msg.clone());
register_state.set_error_message(Some(err_msg.clone()));
error!(error = %err_msg, "Registration failed/connection error");
}
}

View File

@@ -0,0 +1,14 @@
// src/pages/register/mod.rs
// pub mod state;
pub mod ui;
pub mod state;
pub mod logic;
pub mod suggestions;
pub mod event;
// pub use state::*;
pub use ui::render_register;
pub use logic::*;
pub use state::*;
pub use event::*;

View File

@@ -0,0 +1,370 @@
// src/pages/register/state.rs
use canvas::{DataProvider, AppMode, FormEditor};
use std::fmt;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use canvas::keymap::KeyEventOutcome;
use crate::pages::register::suggestions::role_suggestions_sync;
/// Represents the state of the Registration form UI
#[derive(Debug, Clone)]
pub struct RegisterState {
pub username: String,
pub email: String,
pub password: String,
pub password_confirmation: String,
pub role: String,
pub error_message: Option<String>,
pub current_field: usize,
pub current_cursor_pos: usize,
pub has_unsaved_changes: bool,
pub app_mode: canvas::AppMode,
}
impl Default for RegisterState {
fn default() -> Self {
Self {
username: String::new(),
email: String::new(),
password: String::new(),
password_confirmation: String::new(),
role: String::new(),
error_message: None,
current_field: 0,
current_cursor_pos: 0,
has_unsaved_changes: false,
app_mode: canvas::AppMode::Edit,
}
}
}
impl RegisterState {
pub fn new() -> Self {
Self {
app_mode: canvas::AppMode::Edit,
..Default::default()
}
}
pub fn current_field(&self) -> usize {
self.current_field
}
pub fn current_cursor_pos(&self) -> usize {
self.current_cursor_pos
}
pub fn set_current_field(&mut self, index: usize) {
if index < 5 {
self.current_field = index;
}
}
pub fn set_current_cursor_pos(&mut self, pos: usize) {
self.current_cursor_pos = pos;
}
pub fn get_current_input(&self) -> &str {
match self.current_field {
0 => &self.username,
1 => &self.email,
2 => &self.password,
3 => &self.password_confirmation,
4 => &self.role,
_ => "",
}
}
pub fn get_current_input_mut(&mut self, index: usize) -> &mut String {
match index {
0 => &mut self.username,
1 => &mut self.email,
2 => &mut self.password,
3 => &mut self.password_confirmation,
4 => &mut self.role,
_ => panic!("Invalid current_field index in RegisterState"),
}
}
pub fn current_mode(&self) -> AppMode {
self.app_mode
}
pub fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
pub fn set_has_unsaved_changes(&mut self, changed: bool) {
self.has_unsaved_changes = changed;
}
}
impl DataProvider for RegisterState {
fn field_count(&self) -> usize { 5 }
fn field_name(&self, index: usize) -> &str {
match index {
0 => "Username",
1 => "Email (Optional)",
2 => "Password (Optional)",
3 => "Confirm Password",
4 => "Role (Optional)",
_ => "",
}
}
fn field_value(&self, index: usize) -> &str {
match index {
0 => &self.username,
1 => &self.email,
2 => &self.password,
3 => &self.password_confirmation,
4 => &self.role,
_ => "",
}
}
fn set_field_value(&mut self, index: usize, value: String) {
match index {
0 => self.username = value,
1 => self.email = value,
2 => self.password = value,
3 => self.password_confirmation = value,
4 => self.role = value,
_ => {}
}
self.has_unsaved_changes = true;
}
fn supports_suggestions(&self, field_index: usize) -> bool {
field_index == 4 // only Role field supports suggestions
}
}
/// Wrapper that owns both the raw register state and its editor
pub struct RegisterFormState {
pub state: RegisterState,
pub editor: FormEditor<RegisterState>,
pub focus_outside_canvas: bool,
pub focused_button_index: usize,
}
impl Default for RegisterFormState {
fn default() -> Self {
Self::new()
}
}
// manual Debug because FormEditor doesnt implement Debug
impl fmt::Debug for RegisterFormState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RegisterFormState")
.field("state", &self.state)
.finish()
}
}
impl RegisterFormState {
/// Sync the editor's data provider back into our state
pub fn sync_from_editor(&mut self) {
// The FormEditor holds the authoritative data
let dp = self.editor.data_provider();
self.state = dp.clone(); // because RegisterState: Clone
}
pub fn new() -> Self {
let state = RegisterState::default();
let editor = FormEditor::new(state.clone());
Self {
state,
editor,
focus_outside_canvas: false,
focused_button_index: 0,
}
}
// === Delegates to RegisterState ===
pub fn username(&self) -> &str {
&self.state.username
}
pub fn username_mut(&mut self) -> &mut String {
&mut self.state.username
}
pub fn email(&self) -> &str {
&self.state.email
}
pub fn email_mut(&mut self) -> &mut String {
&mut self.state.email
}
pub fn password(&self) -> &str {
&self.state.password
}
pub fn password_mut(&mut self) -> &mut String {
&mut self.state.password
}
pub fn password_confirmation(&self) -> &str {
&self.state.password_confirmation
}
pub fn password_confirmation_mut(&mut self) -> &mut String {
&mut self.state.password_confirmation
}
pub fn role(&self) -> &str {
&self.state.role
}
pub fn role_mut(&mut self) -> &mut String {
&mut self.state.role
}
pub fn error_message(&self) -> Option<&String> {
self.state.error_message.as_ref()
}
pub fn set_error_message(&mut self, msg: Option<String>) {
self.state.error_message = msg;
}
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 clear(&mut self) {
self.state.username.clear();
self.state.email.clear();
self.state.password.clear();
self.state.password_confirmation.clear();
self.state.role.clear();
self.state.error_message = None;
self.state.has_unsaved_changes = false;
self.state.current_field = 0;
self.state.current_cursor_pos = 0;
}
// === Delegates to cursor/input ===
pub fn current_field(&self) -> usize {
self.state.current_field()
}
pub fn set_current_field(&mut self, index: usize) {
self.state.set_current_field(index);
}
pub fn current_cursor_pos(&self) -> usize {
self.state.current_cursor_pos()
}
pub fn set_current_cursor_pos(&mut self, pos: usize) {
self.state.set_current_cursor_pos(pos);
}
pub fn get_current_input(&self) -> &str {
self.state.get_current_input()
}
pub fn get_current_input_mut(&mut self) -> &mut String {
self.state.get_current_input_mut(self.state.current_field)
}
// === Delegates to FormEditor ===
pub fn mode(&self) -> canvas::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 {
// Only customize behavior for the Role field (index 4) in Edit mode
let in_role_field = self.editor.current_field() == 4;
let in_edit_mode = self.editor.mode() == canvas::AppMode::Edit;
if in_role_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(4) {
let items = role_suggestions_sync(&query);
let applied =
self.editor.apply_suggestions_result(4, &query, items);
if applied {
self.editor.update_inline_completion();
}
}
} else {
// Cycle to next suggestion
self.editor.suggestions_next();
}
return KeyEventOutcome::Consumed(None);
}
// Shift+Tab (BackTab): cycle suggestions too (fallback to next)
KeyCode::BackTab => {
if self.editor.is_suggestions_active() {
// If your canvas exposes suggestions_prev(), use it here.
// Fallback: cycle next.
self.editor.suggestions_next();
return KeyEventOutcome::Consumed(None);
}
}
// Enter: if suggestions active — apply selected suggestion
KeyCode::Enter => {
if self.editor.is_suggestions_active() {
let _ = self.editor.apply_suggestion();
return KeyEventOutcome::Consumed(None);
}
}
// Esc: close suggestions if active
KeyCode::Esc => {
if self.editor.is_suggestions_active() {
self.editor.close_suggestions();
return KeyEventOutcome::Consumed(None);
}
}
// Character input: first let editor mutate text, then refilter 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(4) {
let items = role_suggestions_sync(&query);
let applied =
self.editor.apply_suggestions_result(4, &query, items);
if applied {
self.editor.update_inline_completion();
}
}
}
return outcome;
}
// Backspace/Delete: mutate then refilter 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(4) {
let items = role_suggestions_sync(&query);
let applied =
self.editor.apply_suggestions_result(4, &query, items);
if applied {
self.editor.update_inline_completion();
}
}
}
return outcome;
}
_ => { /* fall through to default */ }
}
}
// Default: let canvas handle it
self.editor.handle_key_event(key_event)
}
}

View File

@@ -0,0 +1,36 @@
// src/pages/register/suggestions.rs
use anyhow::Result;
use async_trait::async_trait;
use canvas::{SuggestionItem, SuggestionsProvider};
// Keep the async provider if you want, but add this sync helper and shared data.
const ROLES: &[&str] = &["admin", "moderator", "accountant", "viewer"];
pub fn role_suggestions_sync(query: &str) -> Vec<SuggestionItem> {
let q = query.to_lowercase();
ROLES
.iter()
.filter(|r| q.is_empty() || r.to_lowercase().contains(&q))
.map(|r| SuggestionItem {
display_text: (*r).to_string(),
value_to_store: (*r).to_string(),
})
.collect()
}
pub struct RoleSuggestionsProvider;
#[async_trait]
impl SuggestionsProvider for RoleSuggestionsProvider {
async fn fetch_suggestions(
&mut self,
field_index: usize,
query: &str,
) -> Result<Vec<SuggestionItem>> {
if field_index != 4 {
return Ok(Vec::new());
}
Ok(role_suggestions_sync(query))
}
}

View File

@@ -1,11 +1,8 @@
// src/components/auth/register.rs
// src/pages/register/ui.rs
use crate::{
config::colors::themes::Theme,
state::pages::auth::RegisterState,
components::common::dialog,
state::app::state::AppState,
modes::handlers::mode_manager::AppMode,
};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect, Margin},
@@ -13,16 +10,24 @@ use ratatui::{
widgets::{Block, BorderType, Borders, Paragraph},
Frame,
};
use canvas::{FormEditor, render_canvas, render_suggestions_dropdown, DefaultCanvasTheme};
use crate::dialog;
use crate::pages::register::RegisterFormState;
use crate::pages::register::suggestions::RoleSuggestionsProvider;
use tokio::runtime::Handle;
use canvas::{render_canvas, render_suggestions_dropdown, DefaultCanvasTheme};
use canvas::SuggestionsProvider;
pub fn render_register(
f: &mut Frame,
area: Rect,
theme: &Theme,
state: &RegisterState,
register_page: &RegisterFormState,
app_state: &AppState,
is_edit_mode: bool,
) {
let state = &register_page.state;
let editor = &register_page.editor;
// Outer block
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
@@ -47,15 +52,9 @@ pub fn render_register(
])
.split(inner_area);
// Wrap RegisterState in FormEditor
let editor = FormEditor::new(state.clone());
// Render the form canvas
let input_rect = render_canvas(f, chunks[0], editor, theme);
let input_rect = render_canvas(
f,
chunks[0],
&editor,
theme,
);
// --- HELP TEXT ---
let help_text = Paragraph::new("* are optional fields")
@@ -81,11 +80,9 @@ pub fn render_register(
// Register Button
let register_button_index = 0;
let register_active = if app_state.ui.focus_outside_canvas {
app_state.focused_button_index == register_button_index
} else {
false
};
let register_active =
register_page.focus_outside_canvas
&& register_page.focused_button_index == register_button_index;
let mut register_style = Style::default().fg(theme.fg);
let mut register_border = Style::default().fg(theme.border);
if register_active {
@@ -108,11 +105,9 @@ pub fn render_register(
// Return Button
let return_button_index = 1;
let return_active = if app_state.ui.focus_outside_canvas {
app_state.focused_button_index == return_button_index
} else {
false
};
let return_active =
register_page.focus_outside_canvas
&& register_page.focused_button_index == return_button_index;
let mut return_style = Style::default().fg(theme.fg);
let mut return_border = Style::default().fg(theme.border);
if return_active {
@@ -133,19 +128,6 @@ pub fn render_register(
button_chunks[1],
);
// --- AUTOCOMPLETE DROPDOWN (Using new canvas suggestions) ---
if app_state.current_mode == AppMode::Edit {
if let Some(input_rect) = input_rect {
render_suggestions_dropdown(
f,
f.area(), // Frame area
input_rect, // Current input field rect
&DefaultCanvasTheme,
&editor,
);
}
}
// --- DIALOG ---
if app_state.ui.dialog.dialog_show {
dialog::render_dialog(
@@ -159,4 +141,17 @@ pub fn render_register(
app_state.ui.dialog.is_loading,
);
}
// Render suggestions dropdown if active (library GUI)
if editor.mode() == canvas::AppMode::Edit {
if let Some(input_rect) = input_rect {
render_suggestions_dropdown(
f,
f.area(),
input_rect,
&DefaultCanvasTheme,
editor,
);
}
}
}

View File

@@ -0,0 +1,5 @@
// src/pages/routing/mod.rs
pub mod router;
pub use router::{Page, Router};

View File

@@ -0,0 +1,36 @@
// src/pages/routing/router.rs
use crate::state::pages::auth::AuthState;
use crate::pages::admin_panel::add_logic::state::AddLogicFormState;
use crate::pages::admin_panel::add_table::state::AddTableFormState;
use crate::pages::admin::AdminState;
use crate::pages::forms::FormState;
use crate::pages::login::LoginFormState;
use crate::pages::register::RegisterFormState;
use crate::pages::intro::IntroState;
#[derive(Debug)]
pub enum Page {
Intro(IntroState),
Login(LoginFormState),
Register(RegisterFormState),
Admin(AdminState),
AddLogic(AddLogicFormState),
AddTable(AddTableFormState),
Form(String),
}
pub struct Router {
pub current: Page,
}
impl Router {
pub fn new() -> Self {
Self {
current: Page::Intro(IntroState::default()),
}
}
pub fn navigate(&mut self, page: Page) {
self.current = page;
}
}

View File

@@ -34,9 +34,16 @@ pub async fn handle_search_palette_event(
// Step 2: Process outside the borrow
if let Some((id, content_json)) = maybe_data {
if let Ok(data) = serde_json::from_str::<HashMap<String, String>>(&content_json) {
if let Some(fs) = app_state.form_state_mut() {
let detached_pos = fs.total_count + 2;
fs.update_from_response(&data, detached_pos);
// Use current view path to access the active form
if let (Some(profile), Some(table)) = (
app_state.current_view_profile_name.clone(),
app_state.current_view_table_name.clone(),
) {
let path = format!("{}/{}", profile, table);
if let Some(fs) = app_state.form_state_for_path(&path) {
let detached_pos = fs.total_count + 2;
fs.update_from_response(&data, detached_pos);
}
}
should_close = true;
outcome_message = Some(format!("Loaded record ID {}", id));
@@ -99,7 +106,6 @@ pub async fn handle_search_palette_event(
if should_close {
app_state.search_state = None;
app_state.ui.show_search_palette = false;
app_state.ui.focus_outside_canvas = false;
}
Ok(outcome_message)

View File

@@ -14,12 +14,20 @@ pub struct AuthClient {
impl AuthClient {
pub async fn new() -> Result<Self> {
// Kept for backward compatibility; opens a new connection.
let client = AuthServiceClient::connect("http://[::1]:50051")
.await
.context("Failed to connect to auth service")?;
Ok(Self { client })
}
/// Preferred: reuse an existing Channel (from GrpcClient).
pub async fn with_channel(channel: Channel) -> Result<Self> {
Ok(Self {
client: AuthServiceClient::new(channel),
})
}
/// Login user via gRPC.
pub async fn login(&mut self, identifier: String, password: String) -> Result<LoginResponse> {
let request = tonic::Request::new(LoginRequest { identifier, password });

Some files were not shown because too many files have changed in this diff Show More