Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dff320d534 | ||
|
|
96cde3ca0d | ||
|
|
6ba0124779 | ||
|
|
34c68858a3 | ||
|
|
4c8cfd4f80 | ||
|
|
85c5d7ccf9 | ||
|
|
46a0d2b9db | ||
|
|
c9b4841f67 | ||
|
|
d62cc2add6 | ||
|
|
9c36e76eaa | ||
|
|
abd8cba7a5 | ||
|
|
e6c4cb7e75 | ||
|
|
3d4435bac5 | ||
|
|
4146d0820b | ||
|
|
dbaa32f589 | ||
|
|
2b8eae67b9 | ||
|
|
225bdc2bb6 | ||
|
|
8605ed1547 | ||
|
|
91cecabaca | ||
|
|
d4922233ae | ||
|
|
c00a214a0f | ||
|
|
0baf152c3e | ||
|
|
c92c617314 | ||
|
|
8c8ba53668 | ||
|
|
2b08e64db8 | ||
|
|
643db8e586 | ||
|
|
5c39386a3a | ||
|
|
8f99aa79ec | ||
|
|
c594c35b37 | ||
|
|
828a63c30c | ||
|
|
36690e674a | ||
|
|
8788323c62 | ||
|
|
5b64996462 | ||
|
|
3f4380ff48 | ||
|
|
59a29aa54b | ||
|
|
5d084bf822 | ||
|
|
ebe4adaa5d | ||
|
|
c3441647e0 | ||
|
|
574803988d | ||
|
|
9ff3c59961 | ||
|
|
c5f22d7da1 | ||
|
|
3c62877757 | ||
|
|
cc19c61f37 | ||
|
|
ad82bd4302 | ||
|
|
d584a25fdb | ||
|
|
baa4295059 | ||
|
|
6cbfac9d6e | ||
|
|
13d28f19ea | ||
|
|
8fa86965b8 | ||
|
|
72c38f613f | ||
|
|
e4982f871f | ||
|
|
4e0338276f | ||
|
|
fe193f4f91 | ||
|
|
0011ba0c04 | ||
|
|
a4e94878e7 | ||
|
|
c7353ac81e | ||
|
|
1fbc720620 | ||
|
|
263ccc3260 | ||
|
|
00c0a399cd |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@
|
||||
server/tantivy_indexes
|
||||
steel_decimal/tests/property_tests.proptest-regressions
|
||||
.direnv/
|
||||
canvas/*.toml
|
||||
|
||||
175
Cargo.lock
generated
175
Cargo.lock
generated
@@ -384,9 +384,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bon"
|
||||
version = "3.6.4"
|
||||
version = "3.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f61138465baf186c63e8d9b6b613b508cd832cba4ce93cf37ce5f096f91ac1a6"
|
||||
checksum = "33d9ef19ae5263a138da9a86871eca537478ab0332a7770bac7e3f08b801f89f"
|
||||
dependencies = [
|
||||
"bon-macros",
|
||||
"rustversion",
|
||||
@@ -394,11 +394,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bon-macros"
|
||||
version = "3.6.4"
|
||||
version = "3.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40d1dad34aa19bf02295382f08d9bc40651585bd497266831d40ee6296fb49ca"
|
||||
checksum = "577ae008f2ca11ca7641bd44601002ee5ab49ef0af64846ce1ab6057218a5cc1"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.21.0",
|
||||
"ident_case",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
@@ -475,13 +475,18 @@ name = "canvas"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"common",
|
||||
"crossterm",
|
||||
"ratatui",
|
||||
"regex",
|
||||
"serde",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"unicode-width 0.2.0",
|
||||
]
|
||||
|
||||
@@ -493,18 +498,18 @@ checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.3"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5"
|
||||
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.29"
|
||||
version = "1.2.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c1599538de2394445747c8cf7935946e3cc27e9625f889d979bfb2aaf569362"
|
||||
checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7"
|
||||
dependencies = [
|
||||
"jobserver",
|
||||
"libc",
|
||||
@@ -649,9 +654,9 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const_panic"
|
||||
version = "0.2.12"
|
||||
version = "0.2.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2459fc9262a1aa204eb4b5764ad4f189caec88aea9634389c0a25f8be7f6265e"
|
||||
checksum = "b98d1483e98c9d67f341ab4b3915cfdc54740bd6f5cccc9226ee0535d86aa8fb"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
@@ -710,9 +715,9 @@ checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.4.2"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
@@ -807,8 +812,18 @@ version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
"darling_core 0.20.11",
|
||||
"darling_macro 0.20.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a79c4acb1fd5fa3d9304be4c76e031c54d2e92d172a393e24b19a14fe8532fe9"
|
||||
dependencies = [
|
||||
"darling_core 0.21.0",
|
||||
"darling_macro 0.21.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -825,13 +840,38 @@ dependencies = [
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74875de90daf30eb59609910b84d4d368103aaec4c924824c6799b28f77d6a1d"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_core 0.20.11",
|
||||
"quote",
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e79f8e61677d5df9167cd85265f8e5f64b215cdea3fb55eebc3e622e44c7a146"
|
||||
dependencies = [
|
||||
"darling_core 0.21.0",
|
||||
"quote",
|
||||
"syn 2.0.104",
|
||||
]
|
||||
@@ -955,7 +995,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1426,9 +1466,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df"
|
||||
checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
@@ -1439,7 +1479,7 @@ dependencies = [
|
||||
"hyper",
|
||||
"libc",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"socket2 0.6.0",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -1608,9 +1648,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "im-lists"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88485149c4fcec01ebce4e4b8284a3c75b3d8a4749169f5481144e6433e9bcd2"
|
||||
checksum = "8b971d2652e5700514cc92ca020dba64c790352af0ff2b9acb7514868a32d6aa"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
@@ -1667,11 +1707,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.7"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d"
|
||||
checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.20.11",
|
||||
"indoc",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -1680,9 +1720,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "io-uring"
|
||||
version = "0.7.8"
|
||||
version = "0.7.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013"
|
||||
checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
@@ -1799,9 +1839,9 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.4"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638"
|
||||
checksum = "4488594b9328dee448adb906d8b126d9b7deb7cf5c22161ee591610bb1be83c0"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"libc",
|
||||
@@ -1899,9 +1939,9 @@ checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
version = "0.9.5"
|
||||
version = "0.9.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f"
|
||||
checksum = "483758ad303d734cec05e5c12b41d7e93e6a6390c5e9dae6bdeb7c1259012d28"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
@@ -2289,17 +2329,16 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.8.0"
|
||||
version = "3.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b53a684391ad002dd6a596ceb6c74fd004fdce75f4be2e3f615068abbea5fd50"
|
||||
checksum = "8ee9b2fa7a4517d2c91ff5bc6c297a427a96749d15f98fcdbb22c05571a4d4b7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"pin-project-lite",
|
||||
"rustix 1.0.7",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
"rustix 1.0.8",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2339,9 +2378,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.35"
|
||||
version = "0.2.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "061c1221631e079b26479d25bbf2275bfe5917ae8419cd7e34f13bfc2aa7539a"
|
||||
checksum = "ff24dfcda44452b9816fff4cd4227e1bb73ff5a2f1bc1105aa92fb8565ce44d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn 2.0.104",
|
||||
@@ -2503,9 +2542,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.1"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.3",
|
||||
@@ -2611,9 +2650,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.13"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6"
|
||||
checksum = "7e8af0dde094006011e6a740d4879319439489813bd0bcdc7d821beaeeff48ec"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
@@ -2842,20 +2881,20 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.0.7"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266"
|
||||
checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.9.4",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2959,9 +2998,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.140"
|
||||
version = "1.0.141"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
|
||||
checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -3005,7 +3044,7 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"prost",
|
||||
"prost-types",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.2",
|
||||
"regex",
|
||||
"rstest",
|
||||
"rust-stemmers",
|
||||
@@ -3169,6 +3208,16 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
@@ -3443,7 +3492,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"polling",
|
||||
"quickscope",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"smallvec",
|
||||
@@ -3458,9 +3507,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "steel-decimal"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c43950a3eed43f3e9765a51f5dc1b0de5e1687ba824b8589990747d9ba241187"
|
||||
checksum = "4cd8a6d1a41d2146705b29292cac75c78a3e32d7b6cabb72d808209546615f37"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"rust_decimal",
|
||||
@@ -3595,9 +3644,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tantivy"
|
||||
version = "0.24.1"
|
||||
version = "0.24.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca2374a21157427c5faff2d90930f035b6c22a5d7b0e5b0b7f522e988ef33c06"
|
||||
checksum = "64a966cb0e76e311f09cf18507c9af192f15d34886ee43d7ba7c7e3803660c43"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"arc-swap",
|
||||
@@ -3754,8 +3803,8 @@ dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.3",
|
||||
"once_cell",
|
||||
"rustix 1.0.7",
|
||||
"windows-sys 0.59.0",
|
||||
"rustix 1.0.8",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3869,7 +3918,7 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"socket2",
|
||||
"socket2 0.5.10",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
@@ -3983,7 +4032,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"prost",
|
||||
"socket2",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower",
|
||||
@@ -4272,7 +4321,7 @@ version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.20.11",
|
||||
"once_cell",
|
||||
"proc-macro-error2",
|
||||
"proc-macro2",
|
||||
@@ -4400,7 +4449,7 @@ checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762"
|
||||
dependencies = [
|
||||
"either",
|
||||
"env_home",
|
||||
"rustix 1.0.7",
|
||||
"rustix 1.0.8",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
@@ -4436,7 +4485,7 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4727,9 +4776,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.11"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd"
|
||||
checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
1
canvas/.gitignore
vendored
Normal file
1
canvas/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
docs_prompts/
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
This guide covers the migration from the legacy canvas library structure to the new clean, modular architecture. The new design separates core canvas functionality from autocomplete features, providing better type safety and maintainability.
|
||||
This guide covers the migration from the legacy canvas library structure to the new clean, modular architecture. The new design separates core canvas functionality from suggestions features, providing better type safety and maintainability.
|
||||
|
||||
## Key Changes
|
||||
|
||||
@@ -10,7 +10,7 @@ This guide covers the migration from the legacy canvas library structure to the
|
||||
```
|
||||
# Old Structure (LEGACY)
|
||||
src/
|
||||
├── state.rs # Mixed canvas + autocomplete
|
||||
├── state.rs # Mixed canvas + suggestions
|
||||
├── actions/edit.rs # Mixed concerns
|
||||
├── gui/render.rs # Everything together
|
||||
└── suggestions.rs # Legacy file
|
||||
@@ -21,9 +21,9 @@ src/
|
||||
│ ├── state.rs # CanvasState trait only
|
||||
│ ├── actions/edit.rs # Canvas actions only
|
||||
│ └── gui.rs # Canvas rendering
|
||||
├── autocomplete/ # Rich autocomplete features
|
||||
│ ├── state.rs # AutocompleteCanvasState trait
|
||||
│ ├── types.rs # SuggestionItem, AutocompleteState
|
||||
├── suggestions/ # Suggestions dropdown features (not inline autocomplete)
|
||||
│ ├── state.rs # Suggestion provider types
|
||||
│ ├── gui.rs # Suggestions dropdown rendering
|
||||
│ ├── actions.rs # Autocomplete actions
|
||||
│ └── gui.rs # Autocomplete dropdown rendering
|
||||
└── dispatcher.rs # Action routing
|
||||
@@ -31,7 +31,7 @@ src/
|
||||
|
||||
### 2. **Trait Separation**
|
||||
- **CanvasState**: Core form functionality (navigation, input, validation)
|
||||
- **AutocompleteCanvasState**: Optional rich autocomplete features
|
||||
- Suggestions module: Optional dropdown suggestions support
|
||||
|
||||
### 3. **Rich Suggestions**
|
||||
Replaced simple string suggestions with typed, rich suggestion objects.
|
||||
@@ -93,34 +93,29 @@ impl CanvasState for YourFormState {
|
||||
|
||||
### Step 3: Implement Rich Autocomplete (Optional)
|
||||
|
||||
**If you want rich autocomplete features:**
|
||||
**If you want suggestions dropdown features:**
|
||||
|
||||
```rust
|
||||
use canvas::autocomplete::{AutocompleteCanvasState, SuggestionItem, AutocompleteState};
|
||||
use canvas::{SuggestionItem};
|
||||
|
||||
impl AutocompleteCanvasState for YourFormState {
|
||||
type SuggestionData = YourDataType; // e.g., Hit, CustomRecord, etc.
|
||||
|
||||
fn supports_autocomplete(&self, field_index: usize) -> bool {
|
||||
// Define which fields support autocomplete
|
||||
impl YourFormState {
|
||||
fn supports_suggestions(&self, field_index: usize) -> bool {
|
||||
// Define which fields support suggestions
|
||||
matches!(field_index, 2 | 3 | 5) // Example: only certain fields
|
||||
}
|
||||
|
||||
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> {
|
||||
Some(&self.autocomplete)
|
||||
}
|
||||
// Manage your own suggestion state or rely on FormEditor APIs
|
||||
|
||||
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> {
|
||||
Some(&mut self.autocomplete)
|
||||
}
|
||||
// Manage your own suggestion state or rely on FormEditor APIs
|
||||
}
|
||||
```
|
||||
|
||||
**Add autocomplete field to your state:**
|
||||
**Add suggestions storage to your state (optional, if you need to persist outside the editor):**
|
||||
```rust
|
||||
pub struct YourFormState {
|
||||
// ... existing fields
|
||||
pub autocomplete: AutocompleteState<YourDataType>,
|
||||
// Optional: your own suggestions cache if needed
|
||||
// pub suggestion_cache: Vec<SuggestionItem>,
|
||||
}
|
||||
```
|
||||
|
||||
@@ -149,9 +144,9 @@ form_state.set_autocomplete_suggestions(suggestions);
|
||||
|
||||
**Old rendering:**
|
||||
```rust
|
||||
// Manual autocomplete rendering
|
||||
if form_state.autocomplete_active {
|
||||
render_autocomplete_dropdown(/* ... */);
|
||||
// Manual suggestions rendering
|
||||
if editor.is_suggestions_active() {
|
||||
suggestions::gui::render_suggestions_dropdown(/* ... */);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -162,13 +157,12 @@ use canvas::canvas::render_canvas;
|
||||
|
||||
let active_field_rect = render_canvas(f, area, form_state, theme, edit_mode, highlight_state);
|
||||
|
||||
// Optional: Rich autocomplete (if implementing AutocompleteCanvasState)
|
||||
if form_state.is_autocomplete_active() {
|
||||
if let Some(autocomplete_state) = form_state.autocomplete_state() {
|
||||
canvas::autocomplete::render_autocomplete_dropdown(
|
||||
f, f.area(), active_field_rect.unwrap(), theme, autocomplete_state
|
||||
);
|
||||
}
|
||||
// Suggestions dropdown (if active)
|
||||
if editor.is_suggestions_active() {
|
||||
canvas::suggestions::render_suggestions_dropdown(
|
||||
f, f.area(), active_field_rect.unwrap(), theme, &editor
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -181,16 +175,16 @@ form_state.deactivate_suggestions();
|
||||
|
||||
# NEW - Option A: Add your own method
|
||||
impl YourFormState {
|
||||
pub fn deactivate_autocomplete(&mut self) {
|
||||
pub fn deactivate_suggestions(&mut self) {
|
||||
self.autocomplete_active = false;
|
||||
self.autocomplete_suggestions.clear();
|
||||
self.selected_suggestion_index = None;
|
||||
}
|
||||
}
|
||||
form_state.deactivate_autocomplete();
|
||||
editor.ui_state_mut().deactivate_suggestions();
|
||||
|
||||
# NEW - Option B: Use rich autocomplete trait
|
||||
form_state.deactivate_autocomplete(); // If implementing AutocompleteCanvasState
|
||||
# NEW - Option B: Suggestions via editor APIs
|
||||
editor.ui_state_mut().deactivate_suggestions();
|
||||
```
|
||||
|
||||
## Benefits of New Architecture
|
||||
@@ -217,8 +211,8 @@ let suggestions: Vec<SuggestionItem<UserRecord>> = vec![
|
||||
- **Display Overrides**: Show friendly text while storing normalized data
|
||||
|
||||
### 4. **Future-Proof**
|
||||
- Easy to add new autocomplete features
|
||||
- Canvas features don't interfere with autocomplete
|
||||
- Easy to add new suggestion features
|
||||
- Canvas features don't interfere with suggestions
|
||||
- Modular: Use only what you need
|
||||
|
||||
## Advanced Features
|
||||
@@ -262,7 +256,7 @@ SuggestionItem::new(user, "John Doe (Manager)".to_string(), "123".to_string());
|
||||
## Breaking Changes Summary
|
||||
|
||||
1. **Import paths changed**: Add `canvas::` or `dispatcher::` prefixes
|
||||
2. **Legacy suggestion methods removed**: Replace with rich autocomplete or custom methods
|
||||
2. **Legacy suggestion methods removed**: Replace with SuggestionItem-based dropdown or custom methods
|
||||
3. **No more simple suggestions**: Use `SuggestionItem` for typed suggestions
|
||||
4. **Trait split**: `AutocompleteCanvasState` is now separate and optional
|
||||
|
||||
@@ -283,11 +277,11 @@ SuggestionItem::new(user, "John Doe (Manager)".to_string(), "123".to_string());
|
||||
|
||||
- [ ] Updated all import paths
|
||||
- [ ] Removed legacy methods from CanvasState implementation
|
||||
- [ ] Added custom autocomplete methods if needed
|
||||
- [ ] Updated suggestion usage to SuggestionItem
|
||||
- [ ] Added custom suggestion methods if needed
|
||||
- [ ] Updated usage to SuggestionItem
|
||||
- [ ] Updated rendering calls
|
||||
- [ ] Tested form functionality
|
||||
- [ ] Tested autocomplete functionality (if using)
|
||||
- [ ] Tested suggestions functionality (if using)
|
||||
|
||||
## Example: Complete Migration
|
||||
|
||||
@@ -305,29 +299,25 @@ impl CanvasState for FormState {
|
||||
**After:**
|
||||
```rust
|
||||
use canvas::canvas::{CanvasState, CanvasAction};
|
||||
use canvas::autocomplete::{AutocompleteCanvasState, SuggestionItem};
|
||||
use canvas::SuggestionItem;
|
||||
|
||||
impl CanvasState for FormState {
|
||||
// Only core canvas methods, no suggestion methods
|
||||
// Only core canvas methods
|
||||
fn current_field(&self) -> usize { /* ... */ }
|
||||
fn get_current_input(&self) -> &str { /* ... */ }
|
||||
// ... other core methods only
|
||||
}
|
||||
|
||||
impl AutocompleteCanvasState for FormState {
|
||||
// Use FormEditor + SuggestionsProvider for suggestions dropdown
|
||||
type SuggestionData = Hit;
|
||||
|
||||
fn supports_autocomplete(&self, field_index: usize) -> bool {
|
||||
fn supports_suggestions(&self, field_index: usize) -> bool {
|
||||
self.fields[field_index].is_link
|
||||
}
|
||||
|
||||
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> {
|
||||
Some(&self.autocomplete)
|
||||
}
|
||||
// Maintain suggestion state through FormEditor and DataProvider
|
||||
|
||||
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> {
|
||||
Some(&mut self.autocomplete)
|
||||
}
|
||||
// Maintain suggestion state through FormEditor and DataProvider
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -12,16 +12,55 @@ categories.workspace = true
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
ratatui = { workspace = true, optional = true }
|
||||
crossterm = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
crossterm = { workspace = true, optional = true }
|
||||
anyhow.workspace = true
|
||||
tokio = { workspace = true, optional = true }
|
||||
toml = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde.workspace = true
|
||||
unicode-width.workspace = true
|
||||
thiserror = { workspace = true }
|
||||
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.19"
|
||||
async-trait.workspace = true
|
||||
regex = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4.4"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
gui = ["ratatui"]
|
||||
gui = ["ratatui", "crossterm"]
|
||||
suggestions = ["tokio"]
|
||||
cursor-style = ["crossterm"]
|
||||
validation = ["regex"]
|
||||
|
||||
[[example]]
|
||||
name = "suggestions"
|
||||
required-features = ["suggestions", "gui", "cursor-style"]
|
||||
path = "examples/suggestions.rs"
|
||||
|
||||
[[example]]
|
||||
name = "canvas_gui_demo"
|
||||
required-features = ["gui"]
|
||||
path = "examples/canvas_gui_demo.rs"
|
||||
|
||||
[[example]]
|
||||
name = "validation_1"
|
||||
required-features = ["gui", "validation"]
|
||||
|
||||
[[example]]
|
||||
name = "validation_2"
|
||||
required-features = ["gui", "validation"]
|
||||
|
||||
[[example]]
|
||||
name = "validation_3"
|
||||
required-features = ["gui", "validation"]
|
||||
|
||||
[[example]]
|
||||
name = "validation_4"
|
||||
required-features = ["gui", "validation"]
|
||||
|
||||
[[example]]
|
||||
name = "validation_5"
|
||||
required-features = ["gui", "validation"]
|
||||
|
||||
@@ -7,7 +7,7 @@ A reusable, type-safe canvas system for building form-based TUI applications wit
|
||||
- **Type-Safe Actions**: No more string-based action names - everything is compile-time checked
|
||||
- **Generic Design**: Implement `CanvasState` once, get navigation, editing, and suggestions for free
|
||||
- **Vim-Like Experience**: Modal editing with familiar keybindings
|
||||
- **Suggestion System**: Built-in autocomplete and suggestions support
|
||||
- **Suggestion System**: Built-in suggestions dropdown support
|
||||
- **Framework Agnostic**: Works with any TUI framework or raw terminal handling
|
||||
- **Async Ready**: Full async/await support for modern Rust applications
|
||||
- **Batch Operations**: Execute multiple actions atomically
|
||||
@@ -144,7 +144,7 @@ pub enum CanvasAction {
|
||||
|
||||
## 🔧 Advanced Features
|
||||
|
||||
### Suggestions and Autocomplete
|
||||
### Suggestions Dropdown (not inline autocomplete)
|
||||
|
||||
```rust
|
||||
impl CanvasState for MyForm {
|
||||
@@ -170,7 +170,7 @@ impl CanvasState for MyForm {
|
||||
CanvasAction::SelectSuggestion => {
|
||||
if let Some(suggestion) = self.suggestions.get_selected() {
|
||||
*self.get_current_input_mut() = suggestion.clone();
|
||||
self.deactivate_autocomplete();
|
||||
self.deactivate_suggestions();
|
||||
Some("Applied suggestion".to_string())
|
||||
}
|
||||
None
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
# canvas_config.toml - Complete Canvas Configuration
|
||||
|
||||
[behavior]
|
||||
wrap_around_fields = true
|
||||
auto_save_on_field_change = false
|
||||
word_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"
|
||||
max_suggestions = 6
|
||||
|
||||
[appearance]
|
||||
cursor_style = "block" # "block", "bar", "underline"
|
||||
show_field_numbers = false
|
||||
highlight_current_field = true
|
||||
|
||||
# Read-only mode keybindings (vim-style)
|
||||
[keybindings.read_only]
|
||||
move_left = ["h"]
|
||||
move_right = ["l"]
|
||||
move_up = ["k"]
|
||||
move_down = ["j"]
|
||||
move_word_next = ["w"]
|
||||
move_word_end = ["e"]
|
||||
move_word_prev = ["b"]
|
||||
move_word_end_prev = ["ge"]
|
||||
move_line_start = ["0"]
|
||||
move_line_end = ["$"]
|
||||
move_first_line = ["gg"]
|
||||
move_last_line = ["shift+g"]
|
||||
next_field = ["Tab"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
|
||||
# Edit mode keybindings
|
||||
[keybindings.edit]
|
||||
delete_char_backward = ["Backspace"]
|
||||
delete_char_forward = ["Delete"]
|
||||
move_left = ["Left"]
|
||||
move_right = ["Right"]
|
||||
move_up = ["Up"]
|
||||
move_down = ["Down"]
|
||||
move_line_start = ["Home"]
|
||||
move_line_end = ["End"]
|
||||
move_word_next = ["Ctrl+Right"]
|
||||
move_word_prev = ["Ctrl+Left"]
|
||||
next_field = ["Tab"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
|
||||
# Suggestion/autocomplete keybindings
|
||||
[keybindings.suggestions]
|
||||
suggestion_up = ["Up", "Ctrl+p"]
|
||||
suggestion_down = ["Down", "Ctrl+n"]
|
||||
select_suggestion = ["Enter", "Tab"]
|
||||
exit_suggestions = ["Esc"]
|
||||
|
||||
# Global keybindings (work in both modes)
|
||||
[keybindings.global]
|
||||
move_up = ["Up"]
|
||||
move_down = ["Down"]
|
||||
792
canvas/examples/canvas_cursor_auto.rs
Normal file
792
canvas/examples/canvas_cursor_auto.rs
Normal file
@@ -0,0 +1,792 @@
|
||||
// examples/canvas-cursor-auto.rs
|
||||
//! Demonstrates automatic cursor management with the canvas library
|
||||
//!
|
||||
//! This example REQUIRES the `cursor-style` feature to compile.
|
||||
//!
|
||||
//! Run with:
|
||||
//! cargo run --example canvas_cursor_auto --features "gui,cursor-style"
|
||||
//!
|
||||
//! This will fail without cursor-style:
|
||||
//! cargo run --example canvas-cursor-auto --features "gui"
|
||||
|
||||
// REQUIRE cursor-style feature - example won't compile without it
|
||||
#[cfg(not(feature = "cursor-style"))]
|
||||
compile_error!(
|
||||
"This example requires the 'cursor-style' feature. \
|
||||
Run with: cargo run --example canvas-cursor-auto --features \"gui,cursor-style\""
|
||||
);
|
||||
|
||||
use std::io;
|
||||
use crossterm::{
|
||||
event::{
|
||||
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
|
||||
},
|
||||
execute,
|
||||
terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
},
|
||||
};
|
||||
use ratatui::{
|
||||
backend::{Backend, CrosstermBackend},
|
||||
layout::{Constraint, Direction, Layout},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use canvas::{
|
||||
canvas::{
|
||||
gui::render_canvas_default,
|
||||
modes::{AppMode, ModeManager, HighlightState},
|
||||
CursorManager, // This import only exists when cursor-style feature is enabled
|
||||
},
|
||||
DataProvider, FormEditor,
|
||||
};
|
||||
|
||||
// Enhanced FormEditor that demonstrates automatic cursor management
|
||||
struct AutoCursorFormEditor<D: DataProvider> {
|
||||
editor: FormEditor<D>,
|
||||
has_unsaved_changes: bool,
|
||||
debug_message: String,
|
||||
command_buffer: String, // For multi-key vim commands like "gg"
|
||||
}
|
||||
|
||||
impl<D: DataProvider> AutoCursorFormEditor<D> {
|
||||
fn new(data_provider: D) -> Self {
|
||||
Self {
|
||||
editor: FormEditor::new(data_provider),
|
||||
has_unsaved_changes: false,
|
||||
debug_message: "🎯 Automatic Cursor Demo - cursor-style feature enabled!".to_string(),
|
||||
command_buffer: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// === COMMAND BUFFER HANDLING ===
|
||||
|
||||
fn clear_command_buffer(&mut self) {
|
||||
self.command_buffer.clear();
|
||||
}
|
||||
|
||||
fn add_to_command_buffer(&mut self, ch: char) {
|
||||
self.command_buffer.push(ch);
|
||||
}
|
||||
|
||||
fn get_command_buffer(&self) -> &str {
|
||||
&self.command_buffer
|
||||
}
|
||||
|
||||
fn has_pending_command(&self) -> bool {
|
||||
!self.command_buffer.is_empty()
|
||||
}
|
||||
|
||||
// === VISUAL/HIGHLIGHT MODE SUPPORT ===
|
||||
|
||||
|
||||
fn enter_visual_mode(&mut self) {
|
||||
// Use the library method instead of manual state setting
|
||||
self.editor.enter_highlight_mode();
|
||||
self.debug_message = "🔥 VISUAL MODE - Cursor: Blinking Block █".to_string();
|
||||
}
|
||||
|
||||
fn enter_visual_line_mode(&mut self) {
|
||||
// Use the library method instead of manual state setting
|
||||
self.editor.enter_highlight_line_mode();
|
||||
self.debug_message = "🔥 VISUAL LINE MODE - Cursor: Blinking Block █".to_string();
|
||||
}
|
||||
|
||||
fn exit_visual_mode(&mut self) {
|
||||
// Use the library method
|
||||
self.editor.exit_highlight_mode();
|
||||
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █".to_string();
|
||||
}
|
||||
|
||||
fn update_visual_selection(&mut self) {
|
||||
if self.editor.is_highlight_mode() {
|
||||
use canvas::canvas::state::SelectionState;
|
||||
match self.editor.selection_state() {
|
||||
SelectionState::Characterwise { anchor } => {
|
||||
self.debug_message = format!(
|
||||
"🎯 Visual selection: anchor=({},{}) current=({},{}) - Cursor: Blinking Block █",
|
||||
anchor.0, anchor.1,
|
||||
self.editor.current_field(),
|
||||
self.editor.cursor_position()
|
||||
);
|
||||
}
|
||||
SelectionState::Linewise { anchor_field } => {
|
||||
self.debug_message = format!(
|
||||
"🎯 Visual LINE selection: anchor={} current={} - Cursor: Blinking Block █",
|
||||
anchor_field,
|
||||
self.editor.current_field()
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === ENHANCED MOVEMENT WITH VISUAL UPDATES ===
|
||||
|
||||
fn move_left(&mut self) {
|
||||
self.editor.move_left();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_right(&mut self) {
|
||||
self.editor.move_right();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
self.editor.move_up();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
self.editor.move_down();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_word_next(&mut self) {
|
||||
self.editor.move_word_next();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_word_prev(&mut self) {
|
||||
self.editor.move_word_prev();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_word_end(&mut self) {
|
||||
self.editor.move_word_end();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_word_end_prev(&mut self) {
|
||||
self.editor.move_word_end_prev();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_line_start(&mut self) {
|
||||
self.editor.move_line_start();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_line_end(&mut self) {
|
||||
self.editor.move_line_end();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_first_line(&mut self) {
|
||||
self.editor.move_first_line();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_last_line(&mut self) {
|
||||
self.editor.move_last_line();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn prev_field(&mut self) {
|
||||
self.editor.prev_field();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn next_field(&mut self) {
|
||||
self.editor.next_field();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
// === DELETE OPERATIONS ===
|
||||
|
||||
fn delete_backward(&mut self) -> anyhow::Result<()> {
|
||||
let result = self.editor.delete_backward();
|
||||
if result.is_ok() {
|
||||
self.has_unsaved_changes = true;
|
||||
self.debug_message = "⌫ Deleted character backward".to_string();
|
||||
}
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
fn delete_forward(&mut self) -> anyhow::Result<()> {
|
||||
let result = self.editor.delete_forward();
|
||||
if result.is_ok() {
|
||||
self.has_unsaved_changes = true;
|
||||
self.debug_message = "⌦ Deleted character forward".to_string();
|
||||
}
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
// === MODE TRANSITIONS WITH AUTOMATIC CURSOR MANAGEMENT ===
|
||||
|
||||
fn enter_edit_mode(&mut self) {
|
||||
self.editor.enter_edit_mode(); // 🎯 Library automatically sets cursor to bar |
|
||||
self.debug_message = "✏️ INSERT MODE - Cursor: Steady Bar |".to_string();
|
||||
}
|
||||
|
||||
fn enter_append_mode(&mut self) {
|
||||
self.editor.enter_append_mode(); // 🎯 Library automatically positions cursor and sets mode
|
||||
self.debug_message = "✏️ INSERT (append) - Cursor: Steady Bar |".to_string();
|
||||
}
|
||||
|
||||
fn exit_edit_mode(&mut self) {
|
||||
self.editor.exit_edit_mode(); // 🎯 Library automatically sets cursor to block █
|
||||
self.exit_visual_mode();
|
||||
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █".to_string();
|
||||
}
|
||||
|
||||
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
|
||||
let result = self.editor.insert_char(ch);
|
||||
if result.is_ok() {
|
||||
self.has_unsaved_changes = true;
|
||||
}
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
// === MANUAL CURSOR OVERRIDE DEMONSTRATION ===
|
||||
|
||||
/// Demonstrate manual cursor control (for advanced users)
|
||||
fn demo_manual_cursor_control(&mut self) -> std::io::Result<()> {
|
||||
// Users can still manually control cursor if needed
|
||||
CursorManager::update_for_mode(AppMode::Command)?;
|
||||
self.debug_message = "🔧 Manual override: Command cursor _".to_string();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_automatic_cursor(&mut self) -> std::io::Result<()> {
|
||||
// Restore automatic cursor based on current mode
|
||||
CursorManager::update_for_mode(self.editor.mode())?;
|
||||
self.debug_message = "🎯 Restored automatic cursor management".to_string();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// === DELEGATE TO ORIGINAL EDITOR ===
|
||||
|
||||
fn current_field(&self) -> usize {
|
||||
self.editor.current_field()
|
||||
}
|
||||
|
||||
fn cursor_position(&self) -> usize {
|
||||
self.editor.cursor_position()
|
||||
}
|
||||
|
||||
fn mode(&self) -> AppMode {
|
||||
self.editor.mode()
|
||||
}
|
||||
|
||||
fn current_text(&self) -> &str {
|
||||
self.editor.current_text()
|
||||
}
|
||||
|
||||
fn data_provider(&self) -> &D {
|
||||
self.editor.data_provider()
|
||||
}
|
||||
|
||||
fn ui_state(&self) -> &canvas::EditorState {
|
||||
self.editor.ui_state()
|
||||
}
|
||||
|
||||
fn set_mode(&mut self, mode: AppMode) {
|
||||
self.editor.set_mode(mode); // 🎯 Library automatically updates cursor
|
||||
if mode != AppMode::Highlight {
|
||||
self.exit_visual_mode();
|
||||
}
|
||||
}
|
||||
|
||||
// === STATUS AND DEBUG ===
|
||||
|
||||
fn set_debug_message(&mut self, msg: String) {
|
||||
self.debug_message = msg;
|
||||
}
|
||||
|
||||
fn debug_message(&self) -> &str {
|
||||
&self.debug_message
|
||||
}
|
||||
|
||||
fn has_unsaved_changes(&self) -> bool {
|
||||
self.has_unsaved_changes
|
||||
}
|
||||
}
|
||||
|
||||
// Demo form data with interesting text for cursor demonstration
|
||||
struct CursorDemoData {
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl CursorDemoData {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
fields: vec![
|
||||
("👤 Name".to_string(), "John-Paul McDonald".to_string()),
|
||||
("📧 Email".to_string(), "user@example-domain.com".to_string()),
|
||||
("📱 Phone".to_string(), "+1 (555) 123-4567".to_string()),
|
||||
("🏠 Address".to_string(), "123 Main St, Apt 4B".to_string()),
|
||||
("🏷️ Tags".to_string(), "urgent,important,follow-up".to_string()),
|
||||
("📝 Notes".to_string(), "Watch the cursor change! Normal=█ Insert=| Visual=blinking█".to_string()),
|
||||
("🎯 Cursor Demo".to_string(), "Press 'i' for insert, 'v' for visual, 'Esc' for normal".to_string()),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DataProvider for CursorDemoData {
|
||||
fn field_count(&self) -> usize {
|
||||
self.fields.len()
|
||||
}
|
||||
|
||||
fn field_name(&self, index: usize) -> &str {
|
||||
&self.fields[index].0
|
||||
}
|
||||
|
||||
fn field_value(&self, index: usize) -> &str {
|
||||
&self.fields[index].1
|
||||
}
|
||||
|
||||
fn set_field_value(&mut self, index: usize, value: String) {
|
||||
self.fields[index].1 = value;
|
||||
}
|
||||
|
||||
fn supports_suggestions(&self, _field_index: usize) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn display_value(&self, _index: usize) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Automatic cursor management demonstration
|
||||
/// Features the CursorManager directly to show it's working
|
||||
fn handle_key_press(
|
||||
key: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
editor: &mut AutoCursorFormEditor<CursorDemoData>,
|
||||
) -> anyhow::Result<bool> {
|
||||
let mode = editor.mode();
|
||||
|
||||
// Quit handling
|
||||
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL))
|
||||
|| (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL))
|
||||
|| key == KeyCode::F(10)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
match (mode, key, modifiers) {
|
||||
// === MODE TRANSITIONS WITH AUTOMATIC CURSOR MANAGEMENT ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('i'), _) => {
|
||||
editor.enter_edit_mode(); // 🎯 Automatic: cursor becomes bar |
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
|
||||
editor.enter_append_mode();
|
||||
editor.set_debug_message("✏️ INSERT (append) - Cursor: Steady Bar |".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('A'), _) => {
|
||||
editor.move_line_end();
|
||||
editor.enter_edit_mode(); // 🎯 Automatic: cursor becomes bar |
|
||||
editor.set_debug_message("✏️ INSERT (end of line) - Cursor: Steady Bar |".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('o'), _) => {
|
||||
editor.move_line_end();
|
||||
editor.enter_edit_mode(); // 🎯 Automatic: cursor becomes bar |
|
||||
editor.set_debug_message("✏️ INSERT (open line) - Cursor: Steady Bar |".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// From Normal Mode: Enter visual modes
|
||||
(AppMode::ReadOnly, KeyCode::Char('v'), _) => {
|
||||
editor.enter_visual_mode();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('V'), _) => {
|
||||
editor.enter_visual_line_mode();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// From Visual Mode: Switch between visual modes or exit
|
||||
(AppMode::Highlight, KeyCode::Char('v'), _) => {
|
||||
use canvas::canvas::state::SelectionState;
|
||||
match editor.editor.selection_state() {
|
||||
SelectionState::Characterwise { .. } => {
|
||||
// Already in characterwise mode, exit visual mode (vim behavior)
|
||||
editor.exit_visual_mode();
|
||||
editor.set_debug_message("🔒 Exited visual mode".to_string());
|
||||
}
|
||||
_ => {
|
||||
// Switch from linewise to characterwise mode
|
||||
editor.editor.enter_highlight_mode();
|
||||
editor.update_visual_selection();
|
||||
editor.set_debug_message("🔥 Switched to VISUAL mode".to_string());
|
||||
}
|
||||
}
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
(AppMode::Highlight, KeyCode::Char('V'), _) => {
|
||||
use canvas::canvas::state::SelectionState;
|
||||
match editor.editor.selection_state() {
|
||||
SelectionState::Linewise { .. } => {
|
||||
// Already in linewise mode, exit visual mode (vim behavior)
|
||||
editor.exit_visual_mode();
|
||||
editor.set_debug_message("🔒 Exited visual mode".to_string());
|
||||
}
|
||||
_ => {
|
||||
// Switch from characterwise to linewise mode
|
||||
editor.editor.enter_highlight_line_mode();
|
||||
editor.update_visual_selection();
|
||||
editor.set_debug_message("🔥 Switched to VISUAL LINE mode".to_string());
|
||||
}
|
||||
}
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// Escape: Exit any mode back to normal
|
||||
(_, KeyCode::Esc, _) => {
|
||||
match mode {
|
||||
AppMode::Edit => {
|
||||
editor.exit_edit_mode(); // Exit insert mode
|
||||
}
|
||||
AppMode::Highlight => {
|
||||
editor.exit_visual_mode(); // Exit visual mode
|
||||
}
|
||||
_ => {
|
||||
// Already in normal mode, just clear command buffer
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === CURSOR MANAGEMENT DEMONSTRATION ===
|
||||
(AppMode::ReadOnly, KeyCode::F(1), _) => {
|
||||
editor.demo_manual_cursor_control()?;
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::F(2), _) => {
|
||||
editor.restore_automatic_cursor()?;
|
||||
}
|
||||
|
||||
// === MOVEMENT: VIM-STYLE NAVIGATION ===
|
||||
|
||||
// Basic movement (hjkl and arrows)
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('h'), _)
|
||||
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Left, _) => {
|
||||
editor.move_left();
|
||||
editor.set_debug_message("← left".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('l'), _)
|
||||
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Right, _) => {
|
||||
editor.move_right();
|
||||
editor.set_debug_message("→ right".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('j'), _)
|
||||
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Down, _) => {
|
||||
editor.move_down();
|
||||
editor.set_debug_message("↓ next field".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('k'), _)
|
||||
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Up, _) => {
|
||||
editor.move_up();
|
||||
editor.set_debug_message("↑ previous field".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// Word movement
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('w'), _) => {
|
||||
editor.move_word_next();
|
||||
editor.set_debug_message("w: next word start".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('b'), _) => {
|
||||
editor.move_word_prev();
|
||||
editor.set_debug_message("b: previous word start".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('e'), _) => {
|
||||
editor.move_word_end();
|
||||
editor.set_debug_message("e: word end".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// Line movement
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('0'), _)
|
||||
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Home, _) => {
|
||||
editor.move_line_start();
|
||||
editor.set_debug_message("0: line start".to_string());
|
||||
}
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('$'), _)
|
||||
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::End, _) => {
|
||||
editor.move_line_end();
|
||||
editor.set_debug_message("$: line end".to_string());
|
||||
}
|
||||
|
||||
// Field/document movement
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('g'), _) => {
|
||||
if editor.get_command_buffer() == "g" {
|
||||
editor.move_first_line();
|
||||
editor.set_debug_message("gg: first field".to_string());
|
||||
editor.clear_command_buffer();
|
||||
} else {
|
||||
editor.clear_command_buffer();
|
||||
editor.add_to_command_buffer('g');
|
||||
editor.set_debug_message("g".to_string());
|
||||
}
|
||||
}
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('G'), _) => {
|
||||
editor.move_last_line();
|
||||
editor.set_debug_message("G: last field".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// === EDIT MODE MOVEMENT ===
|
||||
(AppMode::Edit, KeyCode::Left, m) if m.contains(KeyModifiers::CONTROL) => {
|
||||
editor.move_word_prev();
|
||||
editor.set_debug_message("Ctrl+← word back".to_string());
|
||||
}
|
||||
(AppMode::Edit, KeyCode::Right, m) if m.contains(KeyModifiers::CONTROL) => {
|
||||
editor.move_word_next();
|
||||
editor.set_debug_message("Ctrl+→ word forward".to_string());
|
||||
}
|
||||
(AppMode::Edit, KeyCode::Left, _) => {
|
||||
editor.move_left();
|
||||
}
|
||||
(AppMode::Edit, KeyCode::Right, _) => {
|
||||
editor.move_right();
|
||||
}
|
||||
(AppMode::Edit, KeyCode::Up, _) => {
|
||||
editor.move_up();
|
||||
}
|
||||
(AppMode::Edit, KeyCode::Down, _) => {
|
||||
editor.move_down();
|
||||
}
|
||||
(AppMode::Edit, KeyCode::Home, _) => {
|
||||
editor.move_line_start();
|
||||
}
|
||||
(AppMode::Edit, KeyCode::End, _) => {
|
||||
editor.move_line_end();
|
||||
}
|
||||
|
||||
// === DELETE OPERATIONS ===
|
||||
(AppMode::Edit, KeyCode::Backspace, _) => {
|
||||
editor.delete_backward()?;
|
||||
}
|
||||
(AppMode::Edit, KeyCode::Delete, _) => {
|
||||
editor.delete_forward()?;
|
||||
}
|
||||
|
||||
// Delete operations in normal mode (vim x)
|
||||
(AppMode::ReadOnly, KeyCode::Char('x'), _) => {
|
||||
editor.delete_forward()?;
|
||||
editor.set_debug_message("x: deleted character".to_string());
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('X'), _) => {
|
||||
editor.delete_backward()?;
|
||||
editor.set_debug_message("X: deleted character backward".to_string());
|
||||
}
|
||||
|
||||
// === TAB NAVIGATION ===
|
||||
(_, KeyCode::Tab, _) => {
|
||||
editor.next_field();
|
||||
editor.set_debug_message("Tab: next field".to_string());
|
||||
}
|
||||
(_, KeyCode::BackTab, _) => {
|
||||
editor.prev_field();
|
||||
editor.set_debug_message("Shift+Tab: previous field".to_string());
|
||||
}
|
||||
|
||||
// === CHARACTER INPUT ===
|
||||
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
|
||||
editor.insert_char(c)?;
|
||||
}
|
||||
|
||||
// === DEBUG/INFO COMMANDS ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
|
||||
editor.set_debug_message(format!(
|
||||
"Field {}/{}, Pos {}, Mode: {:?} - Cursor managed automatically!",
|
||||
editor.current_field() + 1,
|
||||
editor.data_provider().field_count(),
|
||||
editor.cursor_position(),
|
||||
editor.mode()
|
||||
));
|
||||
}
|
||||
|
||||
_ => {
|
||||
if editor.has_pending_command() {
|
||||
editor.clear_command_buffer();
|
||||
editor.set_debug_message("Invalid command sequence".to_string());
|
||||
} else {
|
||||
editor.set_debug_message(format!(
|
||||
"Unhandled: {:?} + {:?} in {:?} mode",
|
||||
key, modifiers, mode
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn run_app<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
mut editor: AutoCursorFormEditor<CursorDemoData>,
|
||||
) -> io::Result<()> {
|
||||
loop {
|
||||
terminal.draw(|f| ui(f, &editor))?;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
match handle_key_press(key.code, key.modifiers, &mut editor) {
|
||||
Ok(should_continue) => {
|
||||
if !should_continue {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
editor.set_debug_message(format!("Error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ui(f: &mut Frame, editor: &AutoCursorFormEditor<CursorDemoData>) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(8), Constraint::Length(10)])
|
||||
.split(f.area());
|
||||
|
||||
render_enhanced_canvas(f, chunks[0], editor);
|
||||
render_status_and_help(f, chunks[1], editor);
|
||||
}
|
||||
|
||||
fn render_enhanced_canvas(
|
||||
f: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
editor: &AutoCursorFormEditor<CursorDemoData>,
|
||||
) {
|
||||
render_canvas_default(f, area, &editor.editor);
|
||||
}
|
||||
|
||||
fn render_status_and_help(
|
||||
f: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
editor: &AutoCursorFormEditor<CursorDemoData>,
|
||||
) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Length(7)])
|
||||
.split(area);
|
||||
|
||||
// Status bar with cursor information - FIXED VERSION
|
||||
let mode_text = match editor.mode() {
|
||||
AppMode::Edit => "INSERT | (bar cursor)",
|
||||
AppMode::ReadOnly => "NORMAL █ (block cursor)",
|
||||
AppMode::Highlight => {
|
||||
// Use library selection state instead of editor.highlight_state()
|
||||
use canvas::canvas::state::SelectionState;
|
||||
match editor.editor.selection_state() {
|
||||
SelectionState::Characterwise { .. } => "VISUAL █ (blinking block)",
|
||||
SelectionState::Linewise { .. } => "VISUAL LINE █ (blinking block)",
|
||||
_ => "VISUAL █ (blinking block)",
|
||||
}
|
||||
},
|
||||
_ => "NORMAL █ (block cursor)",
|
||||
};
|
||||
|
||||
let status_text = if editor.has_pending_command() {
|
||||
format!("-- {} -- {} [{}]", mode_text, editor.debug_message(), editor.get_command_buffer())
|
||||
} else if editor.has_unsaved_changes() {
|
||||
format!("-- {} -- [Modified] {}", mode_text, editor.debug_message())
|
||||
} else {
|
||||
format!("-- {} -- {}", mode_text, editor.debug_message())
|
||||
};
|
||||
|
||||
let status = Paragraph::new(Line::from(Span::raw(status_text)))
|
||||
.block(Block::default().borders(Borders::ALL).title("🎯 Automatic Cursor Status"));
|
||||
|
||||
f.render_widget(status, chunks[0]);
|
||||
|
||||
// Enhanced help text (no changes needed here)
|
||||
let help_text = match editor.mode() {
|
||||
AppMode::ReadOnly => {
|
||||
if editor.has_pending_command() {
|
||||
match editor.get_command_buffer() {
|
||||
"g" => "Press 'g' again for first field, or any other key to cancel",
|
||||
_ => "Pending command... (Esc to cancel)"
|
||||
}
|
||||
} else {
|
||||
"🎯 CURSOR-STYLE DEMO: Normal █ | Insert | | Visual blinking█\n\
|
||||
Normal: hjkl/arrows=move, w/b/e=words, 0/$=line, gg/G=first/last\n\
|
||||
i/a/A=insert, v/b=visual, x/X=delete, ?=info\n\
|
||||
F1=demo manual cursor, F2=restore automatic"
|
||||
}
|
||||
}
|
||||
AppMode::Edit => {
|
||||
"🎯 INSERT MODE - Cursor: | (bar)\n\
|
||||
arrows=move, Ctrl+arrows=words, Backspace/Del=delete\n\
|
||||
Esc=normal, Tab/Shift+Tab=fields"
|
||||
}
|
||||
AppMode::Highlight => {
|
||||
"🎯 VISUAL MODE - Cursor: █ (blinking block)\n\
|
||||
hjkl/arrows=extend selection, w/b/e=word selection\n\
|
||||
Esc=normal"
|
||||
}
|
||||
_ => "🎯 Watch the cursor change automatically!"
|
||||
};
|
||||
|
||||
let help = Paragraph::new(help_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("🚀 Automatic Cursor Management"))
|
||||
.style(Style::default().fg(Color::Gray));
|
||||
|
||||
f.render_widget(help, chunks[1]);
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Print feature status
|
||||
println!("🎯 Canvas Cursor Auto Demo");
|
||||
println!("✅ cursor-style feature: ENABLED");
|
||||
println!("🚀 Automatic cursor management: ACTIVE");
|
||||
println!("📖 Watch your terminal cursor change based on mode!");
|
||||
println!();
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let data = CursorDemoData::new();
|
||||
let mut editor = AutoCursorFormEditor::new(data);
|
||||
|
||||
// Initialize with normal mode - library automatically sets block cursor
|
||||
editor.set_mode(AppMode::ReadOnly);
|
||||
|
||||
// Demonstrate that CursorManager is available and working
|
||||
CursorManager::update_for_mode(AppMode::ReadOnly)?;
|
||||
|
||||
let res = run_app(&mut terminal, editor);
|
||||
|
||||
// Library automatically resets cursor on FormEditor::drop()
|
||||
// But we can also manually reset if needed
|
||||
CursorManager::reset()?;
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(err) = res {
|
||||
println!("{:?}", err);
|
||||
}
|
||||
|
||||
println!("🎯 Cursor automatically reset to default!");
|
||||
Ok(())
|
||||
}
|
||||
724
canvas/examples/full_canvas_demo.rs
Normal file
724
canvas/examples/full_canvas_demo.rs
Normal file
@@ -0,0 +1,724 @@
|
||||
// examples/full_canvas_demo.rs
|
||||
//! Demonstrates the FULL potential of the canvas library using the native API
|
||||
|
||||
use std::io;
|
||||
use crossterm::{
|
||||
cursor::SetCursorStyle,
|
||||
event::{
|
||||
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
|
||||
},
|
||||
execute,
|
||||
terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
},
|
||||
};
|
||||
use ratatui::{
|
||||
backend::{Backend, CrosstermBackend},
|
||||
layout::{Constraint, Direction, Layout},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use canvas::{
|
||||
canvas::{
|
||||
gui::render_canvas_default,
|
||||
modes::{AppMode, ModeManager, HighlightState},
|
||||
},
|
||||
DataProvider, FormEditor,
|
||||
};
|
||||
|
||||
/// Update cursor style based on current AppMode
|
||||
fn update_cursor_for_mode(mode: AppMode) -> io::Result<()> {
|
||||
let style = match mode {
|
||||
AppMode::Edit => SetCursorStyle::SteadyBar, // Thin line for insert mode
|
||||
AppMode::ReadOnly => SetCursorStyle::SteadyBlock, // Block for normal mode
|
||||
AppMode::Highlight => SetCursorStyle::BlinkingBlock, // Blinking block for visual mode
|
||||
AppMode::General => SetCursorStyle::SteadyBlock, // Block for general mode
|
||||
AppMode::Command => SetCursorStyle::SteadyUnderScore, // Underscore for command mode
|
||||
};
|
||||
|
||||
execute!(io::stdout(), style)
|
||||
}
|
||||
|
||||
// Enhanced FormEditor that adds visual mode and status tracking
|
||||
struct EnhancedFormEditor<D: DataProvider> {
|
||||
editor: FormEditor<D>,
|
||||
highlight_state: HighlightState,
|
||||
has_unsaved_changes: bool,
|
||||
debug_message: String,
|
||||
command_buffer: String, // For multi-key vim commands like "gg"
|
||||
}
|
||||
|
||||
impl<D: DataProvider> EnhancedFormEditor<D> {
|
||||
fn new(data_provider: D) -> Self {
|
||||
Self {
|
||||
editor: FormEditor::new(data_provider),
|
||||
highlight_state: HighlightState::Off,
|
||||
has_unsaved_changes: false,
|
||||
debug_message: "Full Canvas Demo - All features enabled".to_string(),
|
||||
command_buffer: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// === COMMAND BUFFER HANDLING ===
|
||||
|
||||
fn clear_command_buffer(&mut self) {
|
||||
self.command_buffer.clear();
|
||||
}
|
||||
|
||||
fn add_to_command_buffer(&mut self, ch: char) {
|
||||
self.command_buffer.push(ch);
|
||||
}
|
||||
|
||||
fn get_command_buffer(&self) -> &str {
|
||||
&self.command_buffer
|
||||
}
|
||||
|
||||
fn has_pending_command(&self) -> bool {
|
||||
!self.command_buffer.is_empty()
|
||||
}
|
||||
|
||||
// === VISUAL/HIGHLIGHT MODE SUPPORT ===
|
||||
|
||||
fn enter_visual_mode(&mut self) {
|
||||
if ModeManager::can_enter_highlight_mode(self.editor.mode()) {
|
||||
self.editor.set_mode(AppMode::Highlight);
|
||||
self.highlight_state = HighlightState::Characterwise {
|
||||
anchor: (
|
||||
self.editor.current_field(),
|
||||
self.editor.cursor_position(),
|
||||
),
|
||||
};
|
||||
self.debug_message = "-- VISUAL --".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn enter_visual_line_mode(&mut self) {
|
||||
if ModeManager::can_enter_highlight_mode(self.editor.mode()) {
|
||||
self.editor.set_mode(AppMode::Highlight);
|
||||
self.highlight_state =
|
||||
HighlightState::Linewise { anchor_line: self.editor.current_field() };
|
||||
self.debug_message = "-- VISUAL LINE --".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn exit_visual_mode(&mut self) {
|
||||
self.highlight_state = HighlightState::Off;
|
||||
if self.editor.mode() == AppMode::Highlight {
|
||||
self.editor.set_mode(AppMode::ReadOnly);
|
||||
self.debug_message = "Visual mode exited".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn update_visual_selection(&mut self) {
|
||||
if self.editor.mode() == AppMode::Highlight {
|
||||
match &self.highlight_state {
|
||||
HighlightState::Characterwise { anchor: _ } => {
|
||||
self.debug_message = format!(
|
||||
"Visual selection: char {} in field {}",
|
||||
self.editor.cursor_position(),
|
||||
self.editor.current_field()
|
||||
);
|
||||
}
|
||||
HighlightState::Linewise { anchor_line: _ } => {
|
||||
self.debug_message = format!(
|
||||
"Visual line selection: field {}",
|
||||
self.editor.current_field()
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === ENHANCED MOVEMENT WITH VISUAL UPDATES ===
|
||||
|
||||
fn move_left(&mut self) {
|
||||
self.editor.move_left();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_right(&mut self) {
|
||||
self.editor.move_right();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
self.editor.move_up();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
self.editor.move_down();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_word_next(&mut self) {
|
||||
self.editor.move_word_next();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_word_prev(&mut self) {
|
||||
self.editor.move_word_prev();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_word_end(&mut self) {
|
||||
self.editor.move_word_end();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_word_end_prev(&mut self) {
|
||||
self.editor.move_word_end_prev();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_line_start(&mut self) {
|
||||
self.editor.move_line_start();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_line_end(&mut self) {
|
||||
self.editor.move_line_end();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_first_line(&mut self) {
|
||||
self.editor.move_first_line();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn move_last_line(&mut self) {
|
||||
self.editor.move_last_line();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn prev_field(&mut self) {
|
||||
self.editor.prev_field();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
fn next_field(&mut self) {
|
||||
self.editor.next_field();
|
||||
self.update_visual_selection();
|
||||
}
|
||||
|
||||
// === DELETE OPERATIONS ===
|
||||
|
||||
fn delete_backward(&mut self) -> anyhow::Result<()> {
|
||||
let result = self.editor.delete_backward();
|
||||
if result.is_ok() {
|
||||
self.has_unsaved_changes = true;
|
||||
self.debug_message = "Deleted character backward".to_string();
|
||||
}
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
fn delete_forward(&mut self) -> anyhow::Result<()> {
|
||||
let result = self.editor.delete_forward();
|
||||
if result.is_ok() {
|
||||
self.has_unsaved_changes = true;
|
||||
self.debug_message = "Deleted character forward".to_string();
|
||||
}
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
// === MODE TRANSITIONS ===
|
||||
|
||||
fn enter_edit_mode(&mut self) {
|
||||
self.editor.enter_edit_mode();
|
||||
self.debug_message = "-- INSERT --".to_string();
|
||||
}
|
||||
|
||||
fn exit_edit_mode(&mut self) {
|
||||
self.editor.exit_edit_mode();
|
||||
self.exit_visual_mode();
|
||||
self.debug_message = "".to_string();
|
||||
}
|
||||
|
||||
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
|
||||
let result = self.editor.insert_char(ch);
|
||||
if result.is_ok() {
|
||||
self.has_unsaved_changes = true;
|
||||
}
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
// === DELEGATE TO ORIGINAL EDITOR ===
|
||||
|
||||
fn current_field(&self) -> usize {
|
||||
self.editor.current_field()
|
||||
}
|
||||
|
||||
fn cursor_position(&self) -> usize {
|
||||
self.editor.cursor_position()
|
||||
}
|
||||
|
||||
fn mode(&self) -> AppMode {
|
||||
self.editor.mode()
|
||||
}
|
||||
|
||||
fn current_text(&self) -> &str {
|
||||
self.editor.current_text()
|
||||
}
|
||||
|
||||
fn data_provider(&self) -> &D {
|
||||
self.editor.data_provider()
|
||||
}
|
||||
|
||||
fn ui_state(&self) -> &canvas::EditorState {
|
||||
self.editor.ui_state()
|
||||
}
|
||||
|
||||
fn set_mode(&mut self, mode: AppMode) {
|
||||
self.editor.set_mode(mode);
|
||||
if mode != AppMode::Highlight {
|
||||
self.exit_visual_mode();
|
||||
}
|
||||
}
|
||||
|
||||
// === STATUS AND DEBUG ===
|
||||
|
||||
fn set_debug_message(&mut self, msg: String) {
|
||||
self.debug_message = msg;
|
||||
}
|
||||
|
||||
fn debug_message(&self) -> &str {
|
||||
&self.debug_message
|
||||
}
|
||||
|
||||
fn highlight_state(&self) -> &HighlightState {
|
||||
&self.highlight_state
|
||||
}
|
||||
|
||||
fn has_unsaved_changes(&self) -> bool {
|
||||
self.has_unsaved_changes
|
||||
}
|
||||
}
|
||||
|
||||
// Demo form data with interesting text for word movement
|
||||
struct FullDemoData {
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl FullDemoData {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
fields: vec![
|
||||
("Name".to_string(), "John-Paul McDonald".to_string()),
|
||||
(
|
||||
"Email".to_string(),
|
||||
"user@example-domain.com".to_string(),
|
||||
),
|
||||
("Phone".to_string(), "+1 (555) 123-4567".to_string()),
|
||||
("Address".to_string(), "123 Main St, Apt 4B".to_string()),
|
||||
(
|
||||
"Tags".to_string(),
|
||||
"urgent,important,follow-up".to_string(),
|
||||
),
|
||||
(
|
||||
"Notes".to_string(),
|
||||
"This is a sample note with multiple words, punctuation! And symbols @#$"
|
||||
.to_string(),
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DataProvider for FullDemoData {
|
||||
fn field_count(&self) -> usize {
|
||||
self.fields.len()
|
||||
}
|
||||
|
||||
fn field_name(&self, index: usize) -> &str {
|
||||
&self.fields[index].0
|
||||
}
|
||||
|
||||
fn field_value(&self, index: usize) -> &str {
|
||||
&self.fields[index].1
|
||||
}
|
||||
|
||||
fn set_field_value(&mut self, index: usize, value: String) {
|
||||
self.fields[index].1 = value;
|
||||
}
|
||||
|
||||
fn supports_suggestions(&self, _field_index: usize) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn display_value(&self, _index: usize) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Full vim-like key handling using the native FormEditor API
|
||||
fn handle_key_press(
|
||||
key: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
editor: &mut EnhancedFormEditor<FullDemoData>,
|
||||
) -> anyhow::Result<bool> {
|
||||
let old_mode = editor.mode(); // Store mode before processing
|
||||
|
||||
// Quit handling
|
||||
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL))
|
||||
|| (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL))
|
||||
|| key == KeyCode::F(10)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
match (old_mode, key, modifiers) {
|
||||
// === MODE TRANSITIONS ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('i'), _) => {
|
||||
editor.enter_edit_mode();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
|
||||
editor.move_right(); // Move after current character
|
||||
editor.enter_edit_mode();
|
||||
editor.set_debug_message("-- INSERT -- (append)".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('A'), _) => {
|
||||
editor.move_line_end();
|
||||
editor.enter_edit_mode();
|
||||
editor.set_debug_message("-- INSERT -- (end of line)".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('o'), _) => {
|
||||
editor.move_line_end();
|
||||
editor.enter_edit_mode();
|
||||
editor.set_debug_message("-- INSERT -- (open line)".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('v'), _) => {
|
||||
editor.enter_visual_mode();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('V'), _) => {
|
||||
editor.enter_visual_line_mode();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(_, KeyCode::Esc, _) => {
|
||||
editor.exit_edit_mode();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// === MOVEMENT: VIM-STYLE NAVIGATION ===
|
||||
|
||||
// Basic movement (hjkl and arrows)
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('h'), _)
|
||||
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Left, _) => {
|
||||
editor.move_left();
|
||||
editor.set_debug_message("← left".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('l'), _)
|
||||
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Right, _) => {
|
||||
editor.move_right();
|
||||
editor.set_debug_message("→ right".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('j'), _)
|
||||
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Down, _) => {
|
||||
editor.move_down();
|
||||
editor.set_debug_message("↓ next field".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('k'), _)
|
||||
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Up, _) => {
|
||||
editor.move_up();
|
||||
editor.set_debug_message("↑ previous field".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// Word movement - Full vim word navigation
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('w'), _) => {
|
||||
editor.move_word_next();
|
||||
editor.set_debug_message("w: next word start".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('b'), _) => {
|
||||
editor.move_word_prev();
|
||||
editor.set_debug_message("b: previous word start".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('e'), _) => {
|
||||
editor.move_word_end();
|
||||
editor.set_debug_message("e: word end".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('W'), _) => {
|
||||
editor.move_word_end_prev();
|
||||
editor.set_debug_message("W: previous word end".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// Line movement
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('0'), _)
|
||||
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Home, _) => {
|
||||
editor.move_line_start();
|
||||
editor.set_debug_message("0: line start".to_string());
|
||||
}
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('$'), _)
|
||||
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::End, _) => {
|
||||
editor.move_line_end();
|
||||
editor.set_debug_message("$: line end".to_string());
|
||||
}
|
||||
|
||||
// Field/document movement
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('g'), _) => {
|
||||
if editor.get_command_buffer() == "g" {
|
||||
// Second 'g' - execute "gg" command
|
||||
editor.move_first_line();
|
||||
editor.set_debug_message("gg: first field".to_string());
|
||||
editor.clear_command_buffer();
|
||||
} else {
|
||||
// First 'g' - start command buffer
|
||||
editor.clear_command_buffer();
|
||||
editor.add_to_command_buffer('g');
|
||||
editor.set_debug_message("g".to_string());
|
||||
}
|
||||
}
|
||||
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('G'), _) => {
|
||||
editor.move_last_line();
|
||||
editor.set_debug_message("G: last field".to_string());
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// === EDIT MODE MOVEMENT ===
|
||||
(AppMode::Edit, KeyCode::Left, m) if m.contains(KeyModifiers::CONTROL) => {
|
||||
editor.move_word_prev();
|
||||
editor.set_debug_message("Ctrl+← word back".to_string());
|
||||
}
|
||||
(AppMode::Edit, KeyCode::Right, m) if m.contains(KeyModifiers::CONTROL) => {
|
||||
editor.move_word_next();
|
||||
editor.set_debug_message("Ctrl+→ word forward".to_string());
|
||||
}
|
||||
(AppMode::Edit, KeyCode::Left, _) => {
|
||||
editor.move_left();
|
||||
}
|
||||
(AppMode::Edit, KeyCode::Right, _) => {
|
||||
editor.move_right();
|
||||
}
|
||||
(AppMode::Edit, KeyCode::Up, _) => {
|
||||
editor.move_up();
|
||||
}
|
||||
(AppMode::Edit, KeyCode::Down, _) => {
|
||||
editor.move_down();
|
||||
}
|
||||
(AppMode::Edit, KeyCode::Home, _) => {
|
||||
editor.move_line_start();
|
||||
}
|
||||
(AppMode::Edit, KeyCode::End, _) => {
|
||||
editor.move_line_end();
|
||||
}
|
||||
|
||||
// === DELETE OPERATIONS ===
|
||||
(AppMode::Edit, KeyCode::Backspace, _) => {
|
||||
editor.delete_backward()?;
|
||||
}
|
||||
(AppMode::Edit, KeyCode::Delete, _) => {
|
||||
editor.delete_forward()?;
|
||||
}
|
||||
|
||||
// Delete operations in normal mode (vim x)
|
||||
(AppMode::ReadOnly, KeyCode::Char('x'), _) => {
|
||||
editor.delete_forward()?;
|
||||
editor.set_debug_message("x: deleted character".to_string());
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('X'), _) => {
|
||||
editor.delete_backward()?;
|
||||
editor.set_debug_message("X: deleted character backward".to_string());
|
||||
}
|
||||
|
||||
// === TAB NAVIGATION ===
|
||||
(_, KeyCode::Tab, _) => {
|
||||
editor.next_field();
|
||||
editor.set_debug_message("Tab: next field".to_string());
|
||||
}
|
||||
(_, KeyCode::BackTab, _) => {
|
||||
editor.prev_field();
|
||||
editor.set_debug_message("Shift+Tab: previous field".to_string());
|
||||
}
|
||||
|
||||
// === CHARACTER INPUT ===
|
||||
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
|
||||
editor.insert_char(c)?;
|
||||
}
|
||||
|
||||
// === DEBUG/INFO COMMANDS ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
|
||||
editor.set_debug_message(format!(
|
||||
"Field {}/{}, Pos {}, Mode: {:?}",
|
||||
editor.current_field() + 1,
|
||||
editor.data_provider().field_count(),
|
||||
editor.cursor_position(),
|
||||
editor.mode()
|
||||
));
|
||||
}
|
||||
|
||||
_ => {
|
||||
// If we have a pending command and this key doesn't complete it, clear the buffer
|
||||
if editor.has_pending_command() {
|
||||
editor.clear_command_buffer();
|
||||
editor.set_debug_message("Invalid command sequence".to_string());
|
||||
} else {
|
||||
editor.set_debug_message(format!(
|
||||
"Unhandled: {:?} + {:?} in {:?} mode",
|
||||
key, modifiers, old_mode
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update cursor if mode changed
|
||||
let new_mode = editor.mode();
|
||||
if old_mode != new_mode {
|
||||
update_cursor_for_mode(new_mode)?;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn run_app<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
mut editor: EnhancedFormEditor<FullDemoData>,
|
||||
) -> io::Result<()> {
|
||||
loop {
|
||||
terminal.draw(|f| ui(f, &editor))?;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
match handle_key_press(key.code, key.modifiers, &mut editor) {
|
||||
Ok(should_continue) => {
|
||||
if !should_continue {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
editor.set_debug_message(format!("Error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ui(f: &mut Frame, editor: &EnhancedFormEditor<FullDemoData>) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(8), Constraint::Length(8)])
|
||||
.split(f.area());
|
||||
|
||||
render_enhanced_canvas(f, chunks[0], editor);
|
||||
render_status_and_help(f, chunks[1], editor);
|
||||
}
|
||||
|
||||
fn render_enhanced_canvas(
|
||||
f: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
editor: &EnhancedFormEditor<FullDemoData>,
|
||||
) {
|
||||
render_canvas_default(f, area, &editor.editor);
|
||||
}
|
||||
|
||||
fn render_status_and_help(
|
||||
f: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
editor: &EnhancedFormEditor<FullDemoData>,
|
||||
) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Length(5)])
|
||||
.split(area);
|
||||
|
||||
// Status bar
|
||||
let mode_text = match editor.mode() {
|
||||
AppMode::Edit => "INSERT",
|
||||
AppMode::ReadOnly => "NORMAL",
|
||||
AppMode::Highlight => match editor.highlight_state() {
|
||||
HighlightState::Characterwise { .. } => "VISUAL",
|
||||
HighlightState::Linewise { .. } => "VISUAL LINE",
|
||||
_ => "VISUAL",
|
||||
},
|
||||
_ => "NORMAL",
|
||||
};
|
||||
|
||||
let status_text = if editor.has_pending_command() {
|
||||
format!("-- {} -- {} [{}]", mode_text, editor.debug_message(), editor.get_command_buffer())
|
||||
} else if editor.has_unsaved_changes() {
|
||||
format!("-- {} -- [Modified] {}", mode_text, editor.debug_message())
|
||||
} else {
|
||||
format!("-- {} -- {}", mode_text, editor.debug_message())
|
||||
};
|
||||
|
||||
let status = Paragraph::new(Line::from(Span::raw(status_text)))
|
||||
.block(Block::default().borders(Borders::ALL).title("Status"));
|
||||
|
||||
f.render_widget(status, chunks[0]);
|
||||
|
||||
// Help text
|
||||
let help_text = match editor.mode() {
|
||||
AppMode::ReadOnly => {
|
||||
if editor.has_pending_command() {
|
||||
match editor.get_command_buffer() {
|
||||
"g" => "Press 'g' again for first field, or any other key to cancel",
|
||||
_ => "Pending command... (Esc to cancel)"
|
||||
}
|
||||
} else {
|
||||
"Normal: hjkl/arrows=move, w/b/e=words, 0/$=line, gg/G=first/last, i/a/A=insert, v/V=visual, x/X=delete, ?=info"
|
||||
}
|
||||
}
|
||||
AppMode::Edit => {
|
||||
"Insert: arrows=move, Ctrl+arrows=words, Backspace/Del=delete, Esc=normal, Tab/Shift+Tab=fields"
|
||||
}
|
||||
AppMode::Highlight => {
|
||||
"Visual: hjkl/arrows=extend selection, w/b/e=word selection, Esc=normal"
|
||||
}
|
||||
_ => "Press ? for help"
|
||||
};
|
||||
|
||||
let help = Paragraph::new(Line::from(Span::raw(help_text)))
|
||||
.block(Block::default().borders(Borders::ALL).title("Commands"))
|
||||
.style(Style::default().fg(Color::Gray));
|
||||
|
||||
f.render_widget(help, chunks[1]);
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let data = FullDemoData::new();
|
||||
let mut editor = EnhancedFormEditor::new(data);
|
||||
editor.set_mode(AppMode::ReadOnly); // Start in normal mode
|
||||
|
||||
// Set initial cursor style
|
||||
update_cursor_for_mode(editor.mode())?;
|
||||
|
||||
let res = run_app(&mut terminal, editor);
|
||||
|
||||
// Reset cursor style on exit
|
||||
execute!(io::stdout(), SetCursorStyle::DefaultUserShape)?;
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(err) = res {
|
||||
println!("{:?}", err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
386
canvas/examples/suggestions.rs
Normal file
386
canvas/examples/suggestions.rs
Normal file
@@ -0,0 +1,386 @@
|
||||
// examples/suggestions.rs
|
||||
// Run with: cargo run --example suggestions --features "suggestions,gui"
|
||||
|
||||
use std::io;
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{
|
||||
backend::{Backend, CrosstermBackend},
|
||||
layout::{Constraint, Direction, Layout},
|
||||
style::Color,
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use canvas::{
|
||||
canvas::{
|
||||
gui::render_canvas,
|
||||
modes::AppMode,
|
||||
theme::CanvasTheme,
|
||||
},
|
||||
suggestions::gui::render_suggestions_dropdown,
|
||||
FormEditor, DataProvider, SuggestionsProvider, SuggestionItem,
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use anyhow::Result;
|
||||
|
||||
// Simple theme implementation
|
||||
#[derive(Clone)]
|
||||
struct DemoTheme;
|
||||
|
||||
impl CanvasTheme for DemoTheme {
|
||||
fn bg(&self) -> Color { Color::Reset }
|
||||
fn fg(&self) -> Color { Color::White }
|
||||
fn accent(&self) -> Color { Color::Cyan }
|
||||
fn secondary(&self) -> Color { Color::Gray }
|
||||
fn highlight(&self) -> Color { Color::Yellow }
|
||||
fn highlight_bg(&self) -> Color { Color::DarkGray }
|
||||
fn warning(&self) -> Color { Color::Red }
|
||||
fn border(&self) -> Color { Color::Gray }
|
||||
}
|
||||
|
||||
// Custom suggestion data type
|
||||
#[derive(Clone, Debug)]
|
||||
struct EmailSuggestion {
|
||||
email: String,
|
||||
provider: String,
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// SIMPLE DATA PROVIDER - Only business data, no UI concerns!
|
||||
// ===================================================================
|
||||
|
||||
struct ContactForm {
|
||||
// Only business data - no UI state!
|
||||
name: String,
|
||||
email: String,
|
||||
phone: String,
|
||||
city: String,
|
||||
}
|
||||
|
||||
impl ContactForm {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
name: "John Doe".to_string(),
|
||||
email: "john@".to_string(), // Partial email for demo
|
||||
phone: "+1 234 567 8900".to_string(),
|
||||
city: "San Francisco".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simple trait implementation - only 4 methods!
|
||||
impl DataProvider for ContactForm {
|
||||
fn field_count(&self) -> usize { 4 }
|
||||
|
||||
fn field_name(&self, index: usize) -> &str {
|
||||
match index {
|
||||
0 => "Name",
|
||||
1 => "Email",
|
||||
2 => "Phone",
|
||||
3 => "City",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn field_value(&self, index: usize) -> &str {
|
||||
match index {
|
||||
0 => &self.name,
|
||||
1 => &self.email,
|
||||
2 => &self.phone,
|
||||
3 => &self.city,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn set_field_value(&mut self, index: usize, value: String) {
|
||||
match index {
|
||||
0 => self.name = value,
|
||||
1 => self.email = value,
|
||||
2 => self.phone = value,
|
||||
3 => self.city = value,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_suggestions(&self, field_index: usize) -> bool {
|
||||
field_index == 1 // Only email field
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// SIMPLE AUTOCOMPLETE PROVIDER - Only data fetching!
|
||||
// ===================================================================
|
||||
|
||||
struct EmailAutocomplete;
|
||||
|
||||
#[async_trait]
|
||||
impl SuggestionsProvider for EmailAutocomplete {
|
||||
async fn fetch_suggestions(&mut self, _field_index: usize, query: &str)
|
||||
-> Result<Vec<SuggestionItem>>
|
||||
{
|
||||
// Extract domain part from email
|
||||
let (email_prefix, domain_part) = if let Some(at_pos) = query.find('@') {
|
||||
(query[..at_pos].to_string(), query[at_pos + 1..].to_string())
|
||||
} else {
|
||||
return Ok(Vec::new()); // No @ symbol
|
||||
};
|
||||
|
||||
// Simulate async API call
|
||||
let suggestions = tokio::task::spawn_blocking(move || {
|
||||
// Simulate network delay
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
|
||||
// Mock email suggestions
|
||||
let popular_domains = vec![
|
||||
("gmail.com", "Gmail"),
|
||||
("yahoo.com", "Yahoo Mail"),
|
||||
("outlook.com", "Outlook"),
|
||||
("hotmail.com", "Hotmail"),
|
||||
("company.com", "Company Email"),
|
||||
("university.edu", "University"),
|
||||
];
|
||||
|
||||
let mut results = Vec::new();
|
||||
for (domain, provider) in popular_domains {
|
||||
if domain.starts_with(&domain_part) || domain_part.is_empty() {
|
||||
let full_email = format!("{}@{}", email_prefix, domain);
|
||||
results.push(SuggestionItem {
|
||||
display_text: format!("{} ({})", full_email, provider),
|
||||
value_to_store: full_email,
|
||||
});
|
||||
}
|
||||
}
|
||||
results
|
||||
}).await.unwrap_or_default();
|
||||
|
||||
Ok(suggestions)
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// APPLICATION STATE - Much simpler!
|
||||
// ===================================================================
|
||||
|
||||
struct AppState {
|
||||
editor: FormEditor<ContactForm>,
|
||||
suggestions_provider: EmailAutocomplete,
|
||||
debug_message: String,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn new() -> Self {
|
||||
let contact_form = ContactForm::new();
|
||||
let mut editor = FormEditor::new(contact_form);
|
||||
|
||||
// Start on email field (index 1) at end of existing text
|
||||
editor.set_mode(AppMode::Edit);
|
||||
// TODO: Add method to set initial field/cursor position
|
||||
|
||||
Self {
|
||||
editor,
|
||||
suggestions_provider: EmailAutocomplete,
|
||||
debug_message: "Type in email field, Tab to trigger suggestions, Enter to select, Esc to cancel".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// INPUT HANDLING - Much cleaner!
|
||||
// ===================================================================
|
||||
|
||||
async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut AppState) -> bool {
|
||||
if key == KeyCode::F(10) || (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) {
|
||||
return false; // Quit
|
||||
}
|
||||
|
||||
// Handle input based on key
|
||||
let result = match key {
|
||||
// === SUGGESTIONS KEYS ===
|
||||
KeyCode::Tab => {
|
||||
if state.editor.is_suggestions_active() {
|
||||
state.editor.suggestions_next();
|
||||
Ok("Navigated to next suggestion".to_string())
|
||||
} else if state.editor.data_provider().supports_suggestions(state.editor.current_field()) {
|
||||
state.editor.trigger_suggestions(&mut state.suggestions_provider).await
|
||||
.map(|_| "Triggered suggestions".to_string())
|
||||
} else {
|
||||
state.editor.move_to_next_field();
|
||||
Ok("Moved to next field".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
KeyCode::Enter => {
|
||||
if state.editor.is_suggestions_active() {
|
||||
if let Some(applied) = state.editor.apply_suggestion() {
|
||||
Ok(format!("Applied: {}", applied))
|
||||
} else {
|
||||
Ok("No suggestion to apply".to_string())
|
||||
}
|
||||
} else {
|
||||
state.editor.move_to_next_field();
|
||||
Ok("Moved to next field".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
KeyCode::Esc => {
|
||||
if state.editor.is_suggestions_active() {
|
||||
// Suggestions will be cleared automatically by mode change
|
||||
Ok("Cancelled suggestions".to_string())
|
||||
} else {
|
||||
// Toggle between edit and readonly mode
|
||||
let new_mode = match state.editor.mode() {
|
||||
AppMode::Edit => AppMode::ReadOnly,
|
||||
_ => AppMode::Edit,
|
||||
};
|
||||
state.editor.set_mode(new_mode);
|
||||
Ok(format!("Switched to {:?} mode", new_mode))
|
||||
}
|
||||
}
|
||||
|
||||
// === MOVEMENT KEYS ===
|
||||
KeyCode::Left => {
|
||||
state.editor.move_left();
|
||||
Ok("Moved left".to_string())
|
||||
}
|
||||
KeyCode::Right => {
|
||||
state.editor.move_right();
|
||||
Ok("Moved right".to_string())
|
||||
}
|
||||
KeyCode::Up => {
|
||||
state.editor.move_to_next_field(); // TODO: Add move_up method
|
||||
Ok("Moved up".to_string())
|
||||
}
|
||||
KeyCode::Down => {
|
||||
state.editor.move_to_next_field(); // TODO: Add move_down method
|
||||
Ok("Moved down".to_string())
|
||||
}
|
||||
|
||||
// === TEXT INPUT ===
|
||||
KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
state.editor.insert_char(c)
|
||||
.map(|_| format!("Inserted '{}'", c))
|
||||
}
|
||||
|
||||
KeyCode::Backspace => {
|
||||
// TODO: Add delete_backward method to FormEditor
|
||||
Ok("Backspace (not implemented yet)".to_string())
|
||||
}
|
||||
|
||||
_ => Ok(format!("Unhandled key: {:?}", key)),
|
||||
};
|
||||
|
||||
// Update debug message
|
||||
match result {
|
||||
Ok(msg) => state.debug_message = msg,
|
||||
Err(e) => state.debug_message = format!("Error: {}", e),
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut state: AppState) -> io::Result<()> {
|
||||
let theme = DemoTheme;
|
||||
|
||||
loop {
|
||||
terminal.draw(|f| ui(f, &state, &theme))?;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
let should_continue = handle_key_press(key.code, key.modifiers, &mut state).await;
|
||||
if !should_continue {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ui(f: &mut Frame, state: &AppState, theme: &DemoTheme) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(8),
|
||||
Constraint::Length(5),
|
||||
])
|
||||
.split(f.area());
|
||||
|
||||
// Render the canvas form - much simpler!
|
||||
let active_field_rect = render_canvas(
|
||||
f,
|
||||
chunks[0],
|
||||
&state.editor,
|
||||
theme,
|
||||
);
|
||||
|
||||
// Render suggestions dropdown if active
|
||||
if let Some(input_rect) = active_field_rect {
|
||||
render_suggestions_dropdown(
|
||||
f,
|
||||
chunks[0],
|
||||
input_rect,
|
||||
theme,
|
||||
&state.editor,
|
||||
);
|
||||
}
|
||||
|
||||
// Status info
|
||||
let autocomplete_status = if state.editor.is_suggestions_active() {
|
||||
if state.editor.ui_state().is_suggestions_loading() {
|
||||
"Loading suggestions..."
|
||||
} else if !state.editor.suggestions().is_empty() {
|
||||
"Use Tab to navigate, Enter to select, Esc to cancel"
|
||||
} else {
|
||||
"No suggestions found"
|
||||
}
|
||||
} else {
|
||||
"Tab to trigger suggestions"
|
||||
};
|
||||
|
||||
let status_lines = vec![
|
||||
Line::from(Span::raw(format!("Mode: {:?} | Field: {}/{} | Cursor: {}",
|
||||
state.editor.mode(),
|
||||
state.editor.current_field() + 1,
|
||||
state.editor.data_provider().field_count(),
|
||||
state.editor.cursor_position()))),
|
||||
Line::from(Span::raw(format!("Suggestions: {}", autocomplete_status))),
|
||||
Line::from(Span::raw(state.debug_message.clone())),
|
||||
Line::from(Span::raw("F10: Quit | Tab: Trigger/Navigate suggestions | Enter: Select | Esc: Cancel/Toggle mode")),
|
||||
];
|
||||
|
||||
let status = Paragraph::new(status_lines)
|
||||
.block(Block::default().borders(Borders::ALL).title("Status & Help"));
|
||||
|
||||
f.render_widget(status, chunks[1]);
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let state = AppState::new();
|
||||
let res = run_app(&mut terminal, state).await;
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(err) = res {
|
||||
println!("{:?}", err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
831
canvas/examples/validation_1.rs
Normal file
831
canvas/examples/validation_1.rs
Normal file
@@ -0,0 +1,831 @@
|
||||
// examples/validation_1.rs
|
||||
//! Demonstrates field validation with the canvas library
|
||||
//!
|
||||
//! This example REQUIRES the `validation` feature to compile.
|
||||
//!
|
||||
//! Run with:
|
||||
//! cargo run --example validation_1 --features "gui,validation"
|
||||
//!
|
||||
//! This will fail without validation:
|
||||
//! cargo run --example validation_1 --features "gui"
|
||||
|
||||
// REQUIRE validation feature - example won't compile without it
|
||||
#[cfg(not(feature = "validation"))]
|
||||
compile_error!(
|
||||
"This example requires the 'validation' feature. \
|
||||
Run with: cargo run --example validation_1 --features \"gui,validation\""
|
||||
);
|
||||
|
||||
use std::io;
|
||||
use crossterm::{
|
||||
event::{
|
||||
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
|
||||
},
|
||||
execute,
|
||||
terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
},
|
||||
};
|
||||
use ratatui::{
|
||||
backend::{Backend, CrosstermBackend},
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use canvas::{
|
||||
canvas::{
|
||||
gui::render_canvas_default,
|
||||
modes::AppMode,
|
||||
},
|
||||
DataProvider, FormEditor,
|
||||
ValidationConfig, ValidationConfigBuilder, CharacterLimits, ValidationResult,
|
||||
};
|
||||
|
||||
// Import CountMode from the validation module directly
|
||||
use canvas::validation::limits::CountMode;
|
||||
|
||||
// Enhanced FormEditor that demonstrates validation functionality
|
||||
struct ValidationFormEditor<D: DataProvider> {
|
||||
editor: FormEditor<D>,
|
||||
has_unsaved_changes: bool,
|
||||
debug_message: String,
|
||||
command_buffer: String,
|
||||
validation_enabled: bool,
|
||||
field_switch_blocked: bool,
|
||||
block_reason: Option<String>,
|
||||
}
|
||||
|
||||
impl<D: DataProvider> ValidationFormEditor<D> {
|
||||
fn new(data_provider: D) -> Self {
|
||||
let mut editor = FormEditor::new(data_provider);
|
||||
|
||||
// Enable validation by default
|
||||
editor.set_validation_enabled(true);
|
||||
|
||||
Self {
|
||||
editor,
|
||||
has_unsaved_changes: false,
|
||||
debug_message: "🔍 Validation Demo - Try typing in different fields!".to_string(),
|
||||
command_buffer: String::new(),
|
||||
validation_enabled: true,
|
||||
field_switch_blocked: false,
|
||||
block_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
// === COMMAND BUFFER HANDLING ===
|
||||
fn clear_command_buffer(&mut self) {
|
||||
self.command_buffer.clear();
|
||||
}
|
||||
|
||||
fn add_to_command_buffer(&mut self, ch: char) {
|
||||
self.command_buffer.push(ch);
|
||||
}
|
||||
|
||||
fn get_command_buffer(&self) -> &str {
|
||||
&self.command_buffer
|
||||
}
|
||||
|
||||
fn has_pending_command(&self) -> bool {
|
||||
!self.command_buffer.is_empty()
|
||||
}
|
||||
|
||||
// === VALIDATION CONTROL ===
|
||||
fn toggle_validation(&mut self) {
|
||||
self.validation_enabled = !self.validation_enabled;
|
||||
self.editor.set_validation_enabled(self.validation_enabled);
|
||||
|
||||
if self.validation_enabled {
|
||||
self.debug_message = "✅ Validation ENABLED - Try exceeding limits!".to_string();
|
||||
} else {
|
||||
self.debug_message = "❌ Validation DISABLED - No limits enforced".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn check_field_switch_allowed(&self) -> (bool, Option<String>) {
|
||||
if !self.validation_enabled {
|
||||
return (true, None);
|
||||
}
|
||||
|
||||
let can_switch = self.editor.can_switch_fields();
|
||||
let reason = if !can_switch {
|
||||
self.editor.field_switch_block_reason()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
(can_switch, reason)
|
||||
}
|
||||
|
||||
fn get_validation_status(&self) -> String {
|
||||
if !self.validation_enabled {
|
||||
return "❌ DISABLED".to_string();
|
||||
}
|
||||
|
||||
if self.field_switch_blocked {
|
||||
return "🚫 SWITCH BLOCKED".to_string();
|
||||
}
|
||||
|
||||
let summary = self.editor.validation_summary();
|
||||
if summary.has_errors() {
|
||||
format!("❌ {} ERRORS", summary.error_fields)
|
||||
} else if summary.has_warnings() {
|
||||
format!("⚠️ {} WARNINGS", summary.warning_fields)
|
||||
} else if summary.validated_fields > 0 {
|
||||
format!("✅ {} VALID", summary.valid_fields)
|
||||
} else {
|
||||
"🔍 READY".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_current_field(&mut self) {
|
||||
let result = self.editor.validate_current_field();
|
||||
match result {
|
||||
ValidationResult::Valid => {
|
||||
self.debug_message = "✅ Current field is valid!".to_string();
|
||||
}
|
||||
ValidationResult::Warning { message } => {
|
||||
self.debug_message = format!("⚠️ Warning: {}", message);
|
||||
}
|
||||
ValidationResult::Error { message } => {
|
||||
self.debug_message = format!("❌ Error: {}", message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_all_fields(&mut self) {
|
||||
let field_count = self.editor.data_provider().field_count();
|
||||
for i in 0..field_count {
|
||||
self.editor.validate_field(i);
|
||||
}
|
||||
|
||||
let summary = self.editor.validation_summary();
|
||||
self.debug_message = format!(
|
||||
"🔍 Validated all fields: {} valid, {} warnings, {} errors",
|
||||
summary.valid_fields, summary.warning_fields, summary.error_fields
|
||||
);
|
||||
}
|
||||
|
||||
fn clear_validation_results(&mut self) {
|
||||
self.editor.clear_validation_results();
|
||||
self.debug_message = "🧹 Cleared all validation results".to_string();
|
||||
}
|
||||
|
||||
// === ENHANCED MOVEMENT WITH VALIDATION ===
|
||||
fn move_left(&mut self) {
|
||||
self.editor.move_left();
|
||||
self.field_switch_blocked = false;
|
||||
self.block_reason = None;
|
||||
}
|
||||
|
||||
fn move_right(&mut self) {
|
||||
self.editor.move_right();
|
||||
self.field_switch_blocked = false;
|
||||
self.block_reason = None;
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
match self.editor.move_up() {
|
||||
Ok(()) => {
|
||||
self.update_field_validation_status();
|
||||
self.field_switch_blocked = false;
|
||||
self.block_reason = None;
|
||||
}
|
||||
Err(e) => {
|
||||
self.field_switch_blocked = true;
|
||||
self.block_reason = Some(e.to_string());
|
||||
self.debug_message = format!("🚫 Field switch blocked: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
match self.editor.move_down() {
|
||||
Ok(()) => {
|
||||
self.update_field_validation_status();
|
||||
self.field_switch_blocked = false;
|
||||
self.block_reason = None;
|
||||
}
|
||||
Err(e) => {
|
||||
self.field_switch_blocked = true;
|
||||
self.block_reason = Some(e.to_string());
|
||||
self.debug_message = format!("🚫 Field switch blocked: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn move_line_start(&mut self) {
|
||||
self.editor.move_line_start();
|
||||
}
|
||||
|
||||
fn move_line_end(&mut self) {
|
||||
self.editor.move_line_end();
|
||||
}
|
||||
|
||||
fn move_word_next(&mut self) {
|
||||
self.editor.move_word_next();
|
||||
}
|
||||
|
||||
fn move_word_prev(&mut self) {
|
||||
self.editor.move_word_prev();
|
||||
}
|
||||
|
||||
fn move_word_end(&mut self) {
|
||||
self.editor.move_word_end();
|
||||
}
|
||||
|
||||
fn move_first_line(&mut self) {
|
||||
self.editor.move_first_line();
|
||||
}
|
||||
|
||||
fn move_last_line(&mut self) {
|
||||
self.editor.move_last_line();
|
||||
}
|
||||
|
||||
fn update_field_validation_status(&mut self) {
|
||||
if !self.validation_enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(result) = self.editor.current_field_validation() {
|
||||
match result {
|
||||
ValidationResult::Valid => {
|
||||
self.debug_message = format!("Field {}: ✅ Valid", self.editor.current_field() + 1);
|
||||
}
|
||||
ValidationResult::Warning { message } => {
|
||||
self.debug_message = format!("Field {}: ⚠️ {}", self.editor.current_field() + 1, message);
|
||||
}
|
||||
ValidationResult::Error { message } => {
|
||||
self.debug_message = format!("Field {}: ❌ {}", self.editor.current_field() + 1, message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.debug_message = format!("Field {}: 🔍 Not validated yet", self.editor.current_field() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// === MODE TRANSITIONS ===
|
||||
fn enter_edit_mode(&mut self) {
|
||||
self.editor.enter_edit_mode();
|
||||
self.debug_message = "✏️ INSERT MODE - Type to test validation".to_string();
|
||||
}
|
||||
|
||||
fn enter_append_mode(&mut self) {
|
||||
self.editor.enter_append_mode();
|
||||
self.debug_message = "✏️ INSERT (append) - Validation active".to_string();
|
||||
}
|
||||
|
||||
fn exit_edit_mode(&mut self) {
|
||||
self.editor.exit_edit_mode();
|
||||
self.debug_message = "🔒 NORMAL MODE - Press 'v' to validate current field".to_string();
|
||||
self.update_field_validation_status();
|
||||
}
|
||||
|
||||
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
|
||||
let result = self.editor.insert_char(ch);
|
||||
if result.is_ok() {
|
||||
self.has_unsaved_changes = true;
|
||||
// Show real-time validation feedback
|
||||
if let Some(validation_result) = self.editor.current_field_validation() {
|
||||
match validation_result {
|
||||
ValidationResult::Valid => {
|
||||
// Don't spam with valid messages, just show character count if applicable
|
||||
if let Some(limits) = self.get_current_field_limits() {
|
||||
if let Some(status) = limits.status_text(self.editor.current_text()) {
|
||||
self.debug_message = format!("✏️ {}", status);
|
||||
}
|
||||
}
|
||||
}
|
||||
ValidationResult::Warning { message } => {
|
||||
self.debug_message = format!("⚠️ {}", message);
|
||||
}
|
||||
ValidationResult::Error { message } => {
|
||||
self.debug_message = format!("❌ {}", message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
fn get_current_field_limits(&self) -> Option<&CharacterLimits> {
|
||||
let validation_state = self.editor.validation_state();
|
||||
let config = validation_state.get_field_config(self.editor.current_field())?;
|
||||
config.character_limits.as_ref()
|
||||
}
|
||||
|
||||
// === DELETE OPERATIONS ===
|
||||
fn delete_backward(&mut self) -> anyhow::Result<()> {
|
||||
let result = self.editor.delete_backward();
|
||||
if result.is_ok() {
|
||||
self.has_unsaved_changes = true;
|
||||
self.debug_message = "⌫ Deleted character".to_string();
|
||||
}
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
fn delete_forward(&mut self) -> anyhow::Result<()> {
|
||||
let result = self.editor.delete_forward();
|
||||
if result.is_ok() {
|
||||
self.has_unsaved_changes = true;
|
||||
self.debug_message = "⌦ Deleted character".to_string();
|
||||
}
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
// === DELEGATE TO ORIGINAL EDITOR ===
|
||||
fn current_field(&self) -> usize {
|
||||
self.editor.current_field()
|
||||
}
|
||||
|
||||
fn cursor_position(&self) -> usize {
|
||||
self.editor.cursor_position()
|
||||
}
|
||||
|
||||
fn mode(&self) -> AppMode {
|
||||
self.editor.mode()
|
||||
}
|
||||
|
||||
fn current_text(&self) -> &str {
|
||||
self.editor.current_text()
|
||||
}
|
||||
|
||||
fn data_provider(&self) -> &D {
|
||||
self.editor.data_provider()
|
||||
}
|
||||
|
||||
fn ui_state(&self) -> &canvas::EditorState {
|
||||
self.editor.ui_state()
|
||||
}
|
||||
|
||||
fn set_mode(&mut self, mode: AppMode) {
|
||||
self.editor.set_mode(mode);
|
||||
}
|
||||
|
||||
fn next_field(&mut self) {
|
||||
match self.editor.next_field() {
|
||||
Ok(()) => {
|
||||
self.update_field_validation_status();
|
||||
self.field_switch_blocked = false;
|
||||
self.block_reason = None;
|
||||
}
|
||||
Err(e) => {
|
||||
self.field_switch_blocked = true;
|
||||
self.block_reason = Some(e.to_string());
|
||||
self.debug_message = format!("🚫 Cannot move to next field: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prev_field(&mut self) {
|
||||
match self.editor.prev_field() {
|
||||
Ok(()) => {
|
||||
self.update_field_validation_status();
|
||||
self.field_switch_blocked = false;
|
||||
self.block_reason = None;
|
||||
}
|
||||
Err(e) => {
|
||||
self.field_switch_blocked = true;
|
||||
self.block_reason = Some(e.to_string());
|
||||
self.debug_message = format!("🚫 Cannot move to previous field: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === STATUS AND DEBUG ===
|
||||
fn set_debug_message(&mut self, msg: String) {
|
||||
self.debug_message = msg;
|
||||
}
|
||||
|
||||
fn debug_message(&self) -> &str {
|
||||
&self.debug_message
|
||||
}
|
||||
|
||||
fn has_unsaved_changes(&self) -> bool {
|
||||
self.has_unsaved_changes
|
||||
}
|
||||
}
|
||||
|
||||
// Demo form data with different validation rules
|
||||
struct ValidationDemoData {
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl ValidationDemoData {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
fields: vec![
|
||||
("👤 Name (max 20)".to_string(), "".to_string()),
|
||||
("📧 Email (max 50, warn@40)".to_string(), "".to_string()),
|
||||
("🔑 Password (5-20 chars)".to_string(), "".to_string()),
|
||||
("🔢 ID (min 3, max 10)".to_string(), "".to_string()),
|
||||
("📝 Comment (min 10, max 100)".to_string(), "".to_string()),
|
||||
("🏷️ Tag (max 30, bytes)".to_string(), "".to_string()),
|
||||
("🌍 Unicode (width, min 2)".to_string(), "".to_string()),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DataProvider for ValidationDemoData {
|
||||
fn field_count(&self) -> usize {
|
||||
self.fields.len()
|
||||
}
|
||||
|
||||
fn field_name(&self, index: usize) -> &str {
|
||||
&self.fields[index].0
|
||||
}
|
||||
|
||||
fn field_value(&self, index: usize) -> &str {
|
||||
&self.fields[index].1
|
||||
}
|
||||
|
||||
fn set_field_value(&mut self, index: usize, value: String) {
|
||||
self.fields[index].1 = value;
|
||||
}
|
||||
|
||||
fn supports_suggestions(&self, _field_index: usize) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn display_value(&self, _index: usize) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
// 🎯 NEW: Validation configuration per field
|
||||
fn validation_config(&self, field_index: usize) -> Option<ValidationConfig> {
|
||||
match field_index {
|
||||
0 => Some(ValidationConfig::with_max_length(20)), // Name: simple 20 char limit
|
||||
1 => Some(
|
||||
ValidationConfigBuilder::new()
|
||||
.with_character_limits(
|
||||
CharacterLimits::new(50).with_warning_threshold(40)
|
||||
)
|
||||
.build()
|
||||
), // Email: 50 chars with warning at 40
|
||||
2 => Some(
|
||||
ValidationConfigBuilder::new()
|
||||
.with_character_limits(CharacterLimits::new_range(5, 20))
|
||||
.build()
|
||||
), // Password: must be 5-20 characters (blocks field switching if 1-4 chars)
|
||||
3 => Some(
|
||||
ValidationConfigBuilder::new()
|
||||
.with_character_limits(CharacterLimits::new_range(3, 10))
|
||||
.build()
|
||||
), // ID: must be 3-10 characters (blocks field switching if 1-2 chars)
|
||||
4 => Some(
|
||||
ValidationConfigBuilder::new()
|
||||
.with_character_limits(CharacterLimits::new_range(10, 100))
|
||||
.build()
|
||||
), // Comment: must be 10-100 characters (blocks field switching if 1-9 chars)
|
||||
5 => Some(
|
||||
ValidationConfigBuilder::new()
|
||||
.with_character_limits(
|
||||
CharacterLimits::new(30).with_count_mode(CountMode::Bytes)
|
||||
)
|
||||
.build()
|
||||
), // Tag: 30 bytes (useful for UTF-8)
|
||||
6 => Some(
|
||||
ValidationConfigBuilder::new()
|
||||
.with_character_limits(
|
||||
CharacterLimits::new_range(2, 20).with_count_mode(CountMode::DisplayWidth)
|
||||
)
|
||||
.build()
|
||||
), // Unicode: 2-20 display width (useful for CJK characters, blocks if 1 char)
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle key presses with validation-focused commands
|
||||
fn handle_key_press(
|
||||
key: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
editor: &mut ValidationFormEditor<ValidationDemoData>,
|
||||
) -> anyhow::Result<bool> {
|
||||
let mode = editor.mode();
|
||||
|
||||
// Quit handling
|
||||
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL))
|
||||
|| (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL))
|
||||
|| key == KeyCode::F(10)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
match (mode, key, modifiers) {
|
||||
// === MODE TRANSITIONS ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('i'), _) => {
|
||||
editor.enter_edit_mode();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
|
||||
editor.enter_append_mode();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('A'), _) => {
|
||||
editor.move_line_end();
|
||||
editor.enter_edit_mode();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// Escape: Exit edit mode
|
||||
(_, KeyCode::Esc, _) => {
|
||||
if mode == AppMode::Edit {
|
||||
editor.exit_edit_mode();
|
||||
} else {
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
}
|
||||
|
||||
// === VALIDATION COMMANDS ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('v'), _) => {
|
||||
editor.validate_current_field();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('V'), _) => {
|
||||
editor.validate_all_fields();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('c'), _) => {
|
||||
editor.clear_validation_results();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::F(1), _) => {
|
||||
editor.toggle_validation();
|
||||
}
|
||||
|
||||
// === MOVEMENT ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('h'), _) | (AppMode::ReadOnly, KeyCode::Left, _) => {
|
||||
editor.move_left();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('l'), _) | (AppMode::ReadOnly, KeyCode::Right, _) => {
|
||||
editor.move_right();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('j'), _) | (AppMode::ReadOnly, KeyCode::Down, _) => {
|
||||
editor.move_down();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('k'), _) | (AppMode::ReadOnly, KeyCode::Up, _) => {
|
||||
editor.move_up();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// === EDIT MODE MOVEMENT ===
|
||||
(AppMode::Edit, KeyCode::Left, _) => {
|
||||
editor.move_left();
|
||||
}
|
||||
(AppMode::Edit, KeyCode::Right, _) => {
|
||||
editor.move_right();
|
||||
}
|
||||
(AppMode::Edit, KeyCode::Up, _) => {
|
||||
editor.move_up();
|
||||
}
|
||||
(AppMode::Edit, KeyCode::Down, _) => {
|
||||
editor.move_down();
|
||||
}
|
||||
|
||||
// === DELETE OPERATIONS ===
|
||||
(AppMode::Edit, KeyCode::Backspace, _) => {
|
||||
editor.delete_backward()?;
|
||||
}
|
||||
(AppMode::Edit, KeyCode::Delete, _) => {
|
||||
editor.delete_forward()?;
|
||||
}
|
||||
|
||||
// === TAB NAVIGATION ===
|
||||
(_, KeyCode::Tab, _) => {
|
||||
editor.next_field();
|
||||
}
|
||||
(_, KeyCode::BackTab, _) => {
|
||||
editor.prev_field();
|
||||
}
|
||||
|
||||
// === CHARACTER INPUT ===
|
||||
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
|
||||
editor.insert_char(c)?;
|
||||
}
|
||||
|
||||
// === DEBUG/INFO COMMANDS ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
|
||||
let summary = editor.editor.validation_summary();
|
||||
editor.set_debug_message(format!(
|
||||
"Field {}/{}, Pos {}, Mode: {:?}, Validation: {} fields configured, {} validated",
|
||||
editor.current_field() + 1,
|
||||
editor.data_provider().field_count(),
|
||||
editor.cursor_position(),
|
||||
editor.mode(),
|
||||
summary.total_fields,
|
||||
summary.validated_fields
|
||||
));
|
||||
}
|
||||
|
||||
_ => {
|
||||
if editor.has_pending_command() {
|
||||
editor.clear_command_buffer();
|
||||
editor.set_debug_message("Invalid command sequence".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn run_app<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
mut editor: ValidationFormEditor<ValidationDemoData>,
|
||||
) -> io::Result<()> {
|
||||
loop {
|
||||
terminal.draw(|f| ui(f, &editor))?;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
match handle_key_press(key.code, key.modifiers, &mut editor) {
|
||||
Ok(should_continue) => {
|
||||
if !should_continue {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
editor.set_debug_message(format!("Error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ui(f: &mut Frame, editor: &ValidationFormEditor<ValidationDemoData>) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(8), Constraint::Length(12)])
|
||||
.split(f.area());
|
||||
|
||||
render_enhanced_canvas(f, chunks[0], editor);
|
||||
render_validation_status(f, chunks[1], editor);
|
||||
}
|
||||
|
||||
fn render_enhanced_canvas(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
editor: &ValidationFormEditor<ValidationDemoData>,
|
||||
) {
|
||||
render_canvas_default(f, area, &editor.editor);
|
||||
}
|
||||
|
||||
fn render_validation_status(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
editor: &ValidationFormEditor<ValidationDemoData>,
|
||||
) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Status bar
|
||||
Constraint::Length(4), // Validation summary
|
||||
Constraint::Length(5), // Help
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Status bar with validation information
|
||||
let mode_text = match editor.mode() {
|
||||
AppMode::Edit => "INSERT",
|
||||
AppMode::ReadOnly => "NORMAL",
|
||||
_ => "OTHER",
|
||||
};
|
||||
|
||||
let validation_status = editor.get_validation_status();
|
||||
let status_text = if editor.has_pending_command() {
|
||||
format!("-- {} -- {} [{}] | Validation: {}",
|
||||
mode_text, editor.debug_message(), editor.get_command_buffer(), validation_status)
|
||||
} else if editor.has_unsaved_changes() {
|
||||
format!("-- {} -- [Modified] {} | Validation: {}",
|
||||
mode_text, editor.debug_message(), validation_status)
|
||||
} else {
|
||||
format!("-- {} -- {} | Validation: {}",
|
||||
mode_text, editor.debug_message(), validation_status)
|
||||
};
|
||||
|
||||
let status = Paragraph::new(Line::from(Span::raw(status_text)))
|
||||
.block(Block::default().borders(Borders::ALL).title("🔍 Validation Status"));
|
||||
|
||||
f.render_widget(status, chunks[0]);
|
||||
|
||||
// Validation summary with field switching info
|
||||
let summary = editor.editor.validation_summary();
|
||||
let summary_text = if editor.validation_enabled {
|
||||
let switch_info = if editor.field_switch_blocked {
|
||||
format!("\n🚫 Field switching blocked: {}",
|
||||
editor.block_reason.as_deref().unwrap_or("Unknown reason"))
|
||||
} else {
|
||||
let (can_switch, reason) = editor.check_field_switch_allowed();
|
||||
if !can_switch {
|
||||
format!("\n⚠️ Field switching will be blocked: {}",
|
||||
reason.as_deref().unwrap_or("Unknown reason"))
|
||||
} else {
|
||||
"\n✅ Field switching allowed".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
format!(
|
||||
"📊 Validation Summary: {} fields configured, {} validated{}\n\
|
||||
✅ Valid: {} ⚠️ Warnings: {} ❌ Errors: {} 📈 Progress: {:.0}%",
|
||||
summary.total_fields,
|
||||
summary.validated_fields,
|
||||
switch_info,
|
||||
summary.valid_fields,
|
||||
summary.warning_fields,
|
||||
summary.error_fields,
|
||||
summary.completion_percentage() * 100.0
|
||||
)
|
||||
} else {
|
||||
"❌ Validation is currently DISABLED\nPress F1 to enable validation".to_string()
|
||||
};
|
||||
|
||||
let summary_style = if summary.has_errors() {
|
||||
Style::default().fg(Color::Red)
|
||||
} else if summary.has_warnings() {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default().fg(Color::Green)
|
||||
};
|
||||
|
||||
let validation_summary = Paragraph::new(summary_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("📈 Validation Overview"))
|
||||
.style(summary_style)
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
f.render_widget(validation_summary, chunks[1]);
|
||||
|
||||
// Enhanced help text
|
||||
let help_text = match editor.mode() {
|
||||
AppMode::ReadOnly => {
|
||||
"🔍 VALIDATION DEMO: Different fields have different limits!\n\
|
||||
Fields with MINIMUM requirements will block field switching if too short!\n\
|
||||
Movement: hjkl/arrows=move, Tab/Shift+Tab=fields\n\
|
||||
Edit: i/a/A=insert modes, Esc=normal\n\
|
||||
Validation: v=validate current, V=validate all, c=clear results, F1=toggle\n\
|
||||
?=info, Ctrl+C/Ctrl+Q=quit"
|
||||
}
|
||||
AppMode::Edit => {
|
||||
"✏️ INSERT MODE - Type to test validation limits!\n\
|
||||
Some fields have MINIMUM character requirements!\n\
|
||||
Try typing 1-2 chars in Password/ID/Comment fields, then try to switch!\n\
|
||||
arrows=move, Backspace/Del=delete, Esc=normal, Tab=next field\n\
|
||||
Field switching may be BLOCKED if minimum requirements not met!"
|
||||
}
|
||||
_ => "🔍 Validation Demo Active!"
|
||||
};
|
||||
|
||||
let help = Paragraph::new(help_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("🚀 Validation Commands"))
|
||||
.style(Style::default().fg(Color::Gray))
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
f.render_widget(help, chunks[2]);
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Print feature status
|
||||
println!("🔍 Canvas Validation Demo");
|
||||
println!("✅ validation feature: ENABLED");
|
||||
println!("🚀 Field validation: ACTIVE");
|
||||
println!("🚫 Field switching validation: ACTIVE");
|
||||
println!("📊 Try typing in fields with minimum requirements!");
|
||||
println!(" - Password (min 5): Type 1-4 chars, then try to switch fields");
|
||||
println!(" - ID (min 3): Type 1-2 chars, then try to switch fields");
|
||||
println!(" - Comment (min 10): Type 1-9 chars, then try to switch fields");
|
||||
println!(" - Unicode (min 2): Type 1 char, then try to switch fields");
|
||||
println!();
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let data = ValidationDemoData::new();
|
||||
let editor = ValidationFormEditor::new(data);
|
||||
|
||||
let res = run_app(&mut terminal, editor);
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(err) = res {
|
||||
println!("{:?}", err);
|
||||
}
|
||||
|
||||
println!("🔍 Validation demo completed!");
|
||||
Ok(())
|
||||
}
|
||||
647
canvas/examples/validation_2.rs
Normal file
647
canvas/examples/validation_2.rs
Normal file
@@ -0,0 +1,647 @@
|
||||
// examples/validation_2.rs
|
||||
//! Advanced TUI Example demonstrating complex pattern filtering edge cases
|
||||
//!
|
||||
//! This example showcases the full potential of the pattern validation system
|
||||
//! with creative real-world scenarios and edge cases.
|
||||
//!
|
||||
//! Run with: cargo run --example validation_advanced_patterns --features "validation,gui"
|
||||
|
||||
// REQUIRE validation and gui features
|
||||
#[cfg(not(all(feature = "validation", feature = "gui")))]
|
||||
compile_error!(
|
||||
"This example requires the 'validation' and 'gui' features. \
|
||||
Run with: cargo run --example validation_advanced_patterns --features \"validation,gui\""
|
||||
);
|
||||
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
use canvas::ValidationResult;
|
||||
use crossterm::{
|
||||
event::{
|
||||
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
|
||||
},
|
||||
execute,
|
||||
terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
},
|
||||
};
|
||||
use ratatui::{
|
||||
backend::{Backend, CrosstermBackend},
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use canvas::{
|
||||
canvas::{
|
||||
gui::render_canvas_default,
|
||||
modes::AppMode,
|
||||
},
|
||||
DataProvider, FormEditor,
|
||||
ValidationConfig, ValidationConfigBuilder, PatternFilters, PositionFilter, PositionRange, CharacterFilter,
|
||||
};
|
||||
|
||||
// Enhanced FormEditor wrapper (keeping the same structure as before)
|
||||
struct AdvancedPatternFormEditor<D: DataProvider> {
|
||||
editor: FormEditor<D>,
|
||||
debug_message: String,
|
||||
command_buffer: String,
|
||||
validation_enabled: bool,
|
||||
field_switch_blocked: bool,
|
||||
block_reason: Option<String>,
|
||||
}
|
||||
|
||||
impl<D: DataProvider> AdvancedPatternFormEditor<D> {
|
||||
fn new(data_provider: D) -> Self {
|
||||
let mut editor = FormEditor::new(data_provider);
|
||||
editor.set_validation_enabled(true);
|
||||
|
||||
Self {
|
||||
editor,
|
||||
debug_message: "🚀 Advanced Pattern Validation - Showcasing edge cases and complex patterns!".to_string(),
|
||||
command_buffer: String::new(),
|
||||
validation_enabled: true,
|
||||
field_switch_blocked: false,
|
||||
block_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ... (keeping all the same methods as before for brevity)
|
||||
// [All the previous methods: clear_command_buffer, add_to_command_buffer, etc.]
|
||||
|
||||
fn clear_command_buffer(&mut self) { self.command_buffer.clear(); }
|
||||
fn add_to_command_buffer(&mut self, ch: char) { self.command_buffer.push(ch); }
|
||||
fn get_command_buffer(&self) -> &str { &self.command_buffer }
|
||||
fn has_pending_command(&self) -> bool { !self.command_buffer.is_empty() }
|
||||
|
||||
fn toggle_validation(&mut self) {
|
||||
self.validation_enabled = !self.validation_enabled;
|
||||
self.editor.set_validation_enabled(self.validation_enabled);
|
||||
if self.validation_enabled {
|
||||
self.debug_message = "✅ Advanced Pattern Validation ENABLED".to_string();
|
||||
} else {
|
||||
self.debug_message = "❌ Advanced Pattern Validation DISABLED".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn move_left(&mut self) { self.editor.move_left(); self.field_switch_blocked = false; self.block_reason = None; }
|
||||
fn move_right(&mut self) { self.editor.move_right(); self.field_switch_blocked = false; self.block_reason = None; }
|
||||
|
||||
fn move_up(&mut self) {
|
||||
match self.editor.move_up() {
|
||||
Ok(()) => { self.update_field_validation_status(); self.field_switch_blocked = false; self.block_reason = None; }
|
||||
Err(e) => { self.field_switch_blocked = true; self.block_reason = Some(e.to_string()); self.debug_message = format!("🚫 Field switch blocked: {}", e); }
|
||||
}
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
match self.editor.move_down() {
|
||||
Ok(()) => { self.update_field_validation_status(); self.field_switch_blocked = false; self.block_reason = None; }
|
||||
Err(e) => { self.field_switch_blocked = true; self.block_reason = Some(e.to_string()); self.debug_message = format!("🚫 Field switch blocked: {}", e); }
|
||||
}
|
||||
}
|
||||
|
||||
fn move_line_start(&mut self) { self.editor.move_line_start(); }
|
||||
fn move_line_end(&mut self) { self.editor.move_line_end(); }
|
||||
|
||||
fn enter_edit_mode(&mut self) {
|
||||
self.editor.enter_edit_mode();
|
||||
self.debug_message = "✏️ INSERT MODE - Testing advanced pattern validation".to_string();
|
||||
}
|
||||
|
||||
fn enter_append_mode(&mut self) {
|
||||
self.editor.enter_append_mode();
|
||||
self.debug_message = "✏️ INSERT (append) - Advanced patterns active".to_string();
|
||||
}
|
||||
|
||||
fn exit_edit_mode(&mut self) {
|
||||
self.editor.exit_edit_mode();
|
||||
self.debug_message = "🔒 NORMAL MODE".to_string();
|
||||
self.update_field_validation_status();
|
||||
}
|
||||
|
||||
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
|
||||
let result = self.editor.insert_char(ch);
|
||||
if result.is_ok() {
|
||||
if let Some(validation_result) = self.editor.current_field_validation() {
|
||||
match validation_result {
|
||||
ValidationResult::Valid => { self.debug_message = "✅ Character accepted".to_string(); }
|
||||
ValidationResult::Warning { message } => { self.debug_message = format!("⚠️ Warning: {}", message); }
|
||||
ValidationResult::Error { message } => { self.debug_message = format!("❌ Pattern violation: {}", message); }
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
fn delete_backward(&mut self) -> anyhow::Result<()> {
|
||||
let result = self.editor.delete_backward();
|
||||
if result.is_ok() { self.debug_message = "⌫ Character deleted".to_string(); }
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
fn delete_forward(&mut self) -> anyhow::Result<()> {
|
||||
let result = self.editor.delete_forward();
|
||||
if result.is_ok() { self.debug_message = "⌦ Character deleted".to_string(); }
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
// Delegate methods
|
||||
fn current_field(&self) -> usize { self.editor.current_field() }
|
||||
fn cursor_position(&self) -> usize { self.editor.cursor_position() }
|
||||
fn mode(&self) -> AppMode { self.editor.mode() }
|
||||
fn current_text(&self) -> &str { self.editor.current_text() }
|
||||
fn data_provider(&self) -> &D { self.editor.data_provider() }
|
||||
fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() }
|
||||
fn set_mode(&mut self, mode: AppMode) { self.editor.set_mode(mode); }
|
||||
|
||||
fn next_field(&mut self) {
|
||||
match self.editor.next_field() {
|
||||
Ok(()) => { self.update_field_validation_status(); self.field_switch_blocked = false; self.block_reason = None; }
|
||||
Err(e) => { self.field_switch_blocked = true; self.block_reason = Some(e.to_string()); self.debug_message = format!("🚫 Cannot move to next field: {}", e); }
|
||||
}
|
||||
}
|
||||
|
||||
fn prev_field(&mut self) {
|
||||
match self.editor.prev_field() {
|
||||
Ok(()) => { self.update_field_validation_status(); self.field_switch_blocked = false; self.block_reason = None; }
|
||||
Err(e) => { self.field_switch_blocked = true; self.block_reason = Some(e.to_string()); self.debug_message = format!("🚫 Cannot move to previous field: {}", e); }
|
||||
}
|
||||
}
|
||||
|
||||
fn set_debug_message(&mut self, msg: String) { self.debug_message = msg; }
|
||||
fn debug_message(&self) -> &str { &self.debug_message }
|
||||
|
||||
fn update_field_validation_status(&mut self) {
|
||||
if !self.validation_enabled { return; }
|
||||
if let Some(result) = self.editor.current_field_validation() {
|
||||
match result {
|
||||
ValidationResult::Valid => { self.debug_message = format!("Field {}: ✅ Pattern valid", self.editor.current_field() + 1); }
|
||||
ValidationResult::Warning { message } => { self.debug_message = format!("Field {}: ⚠️ {}", self.editor.current_field() + 1, message); }
|
||||
ValidationResult::Error { message } => { self.debug_message = format!("Field {}: ❌ {}", self.editor.current_field() + 1, message); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_validation_status(&self) -> String {
|
||||
if !self.validation_enabled { return "❌ DISABLED".to_string(); }
|
||||
if self.field_switch_blocked { return "🚫 SWITCH BLOCKED".to_string(); }
|
||||
let summary = self.editor.validation_summary();
|
||||
if summary.has_errors() { format!("❌ {} ERRORS", summary.error_fields) }
|
||||
else if summary.has_warnings() { format!("⚠️ {} WARNINGS", summary.warning_fields) }
|
||||
else if summary.validated_fields > 0 { format!("✅ {} VALID", summary.valid_fields) }
|
||||
else { "🔍 READY".to_string() }
|
||||
}
|
||||
}
|
||||
|
||||
// Advanced demo form with creative and edge-case-heavy validation patterns
|
||||
struct AdvancedPatternData {
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl AdvancedPatternData {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
fields: vec![
|
||||
("🕐 Time (HH:MM) - 24hr format".to_string(), "".to_string()),
|
||||
("🎨 Hex Color (#RRGGBB) - Web colors".to_string(), "".to_string()),
|
||||
("🌐 IPv4 (XXX.XXX.XXX.XXX) - Network address".to_string(), "".to_string()),
|
||||
("🏷️ Product Code (ABC-123-XYZ) - Mixed format".to_string(), "".to_string()),
|
||||
("📅 Date Code (2024W15) - Year + Week".to_string(), "".to_string()),
|
||||
("🔢 Binary (101010) - Only 0s and 1s".to_string(), "".to_string()),
|
||||
("🎯 Complex ID (A1-B2C-3D4E) - Multi-rule".to_string(), "".to_string()),
|
||||
("🚀 Custom Pattern - Advanced logic".to_string(), "".to_string()),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DataProvider for AdvancedPatternData {
|
||||
fn field_count(&self) -> usize { self.fields.len() }
|
||||
fn field_name(&self, index: usize) -> &str { &self.fields[index].0 }
|
||||
fn field_value(&self, index: usize) -> &str { &self.fields[index].1 }
|
||||
fn set_field_value(&mut self, index: usize, value: String) { self.fields[index].1 = value; }
|
||||
|
||||
fn validation_config(&self, field_index: usize) -> Option<ValidationConfig> {
|
||||
match field_index {
|
||||
0 => {
|
||||
// 🕐 Time (HH:MM) - Hours 00-23, Minutes 00-59
|
||||
// This showcases: Multiple position ranges, exact character matching, custom validation
|
||||
let time_pattern = PatternFilters::new()
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Multiple(vec![0, 1, 3, 4]), // Hours and minutes positions
|
||||
CharacterFilter::Numeric,
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Single(2), // Colon separator
|
||||
CharacterFilter::Exact(':'),
|
||||
));
|
||||
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_pattern_filters(time_pattern)
|
||||
.with_max_length(5) // HH:MM = 5 characters
|
||||
.build())
|
||||
}
|
||||
1 => {
|
||||
// 🎨 Hex Color (#RRGGBB) - Web color format
|
||||
// This showcases: OneOf filter with hex digits, exact character at start
|
||||
let hex_digits = vec!['0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F','a','b','c','d','e','f'];
|
||||
let hex_color_pattern = PatternFilters::new()
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Single(0), // Hash symbol
|
||||
CharacterFilter::Exact('#'),
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Range(1, 6), // 6 hex digits for RGB
|
||||
CharacterFilter::OneOf(hex_digits),
|
||||
));
|
||||
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_pattern_filters(hex_color_pattern)
|
||||
.with_max_length(7) // #RRGGBB = 7 characters
|
||||
.build())
|
||||
}
|
||||
2 => {
|
||||
// 🌐 IPv4 Address (XXX.XXX.XXX.XXX) - Network address
|
||||
// This showcases: Complex pattern with dots at specific positions
|
||||
let ipv4_pattern = PatternFilters::new()
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Multiple(vec![3, 7, 11]), // Dots at specific positions
|
||||
CharacterFilter::Exact('.'),
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Multiple(vec![0,1,2,4,5,6,8,9,10,12,13,14]), // Number positions
|
||||
CharacterFilter::Numeric,
|
||||
));
|
||||
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_pattern_filters(ipv4_pattern)
|
||||
.with_max_length(15) // XXX.XXX.XXX.XXX = up to 15 chars
|
||||
.build())
|
||||
}
|
||||
3 => {
|
||||
// 🏷️ Product Code (ABC-123-XYZ) - Mixed format sections
|
||||
// This showcases: Different rules for different sections
|
||||
let product_code_pattern = PatternFilters::new()
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Range(0, 2), // First 3 positions: letters
|
||||
CharacterFilter::Alphabetic,
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Multiple(vec![3, 7]), // Dashes
|
||||
CharacterFilter::Exact('-'),
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Range(4, 6), // Middle 3 positions: numbers
|
||||
CharacterFilter::Numeric,
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Range(8, 10), // Last 3 positions: letters
|
||||
CharacterFilter::Alphabetic,
|
||||
));
|
||||
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_pattern_filters(product_code_pattern)
|
||||
.with_max_length(11) // ABC-123-XYZ = 11 characters
|
||||
.build())
|
||||
}
|
||||
4 => {
|
||||
// 📅 Date Code (2024W15) - Year + Week format
|
||||
// This showcases: From position filtering and mixed patterns
|
||||
let date_code_pattern = PatternFilters::new()
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Range(0, 3), // Year: 4 digits
|
||||
CharacterFilter::Numeric,
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Single(4), // Week indicator
|
||||
CharacterFilter::Exact('W'),
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::From(5), // Week number: rest are digits
|
||||
CharacterFilter::Numeric,
|
||||
));
|
||||
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_pattern_filters(date_code_pattern)
|
||||
.with_max_length(7) // 2024W15 = 7 characters
|
||||
.build())
|
||||
}
|
||||
5 => {
|
||||
// 🔢 Binary (101010) - Only 0s and 1s
|
||||
// This showcases: OneOf filter with limited character set
|
||||
let binary_pattern = PatternFilters::new()
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::From(0), // All positions
|
||||
CharacterFilter::OneOf(vec!['0', '1']),
|
||||
));
|
||||
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_pattern_filters(binary_pattern)
|
||||
.with_max_length(16) // Allow up to 16 binary digits
|
||||
.build())
|
||||
}
|
||||
6 => {
|
||||
// 🎯 Complex ID (A1-B2C-3D4E) - Multiple overlapping rules
|
||||
// This showcases: Complex overlapping patterns and edge cases
|
||||
let complex_id_pattern = PatternFilters::new()
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Multiple(vec![0, 3, 6, 8]), // Letter positions
|
||||
CharacterFilter::Alphabetic,
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Multiple(vec![1, 4, 7, 9]), // Number positions
|
||||
CharacterFilter::Numeric,
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Multiple(vec![2, 5]), // Dashes
|
||||
CharacterFilter::Exact('-'),
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Single(5), // Special case: override dash with letter C
|
||||
CharacterFilter::Alphabetic, // This creates an interesting edge case
|
||||
));
|
||||
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_pattern_filters(complex_id_pattern)
|
||||
.with_max_length(10) // A1-B2C-3D4E = 10 characters
|
||||
.build())
|
||||
}
|
||||
7 => {
|
||||
// 🚀 Custom Pattern - Advanced logic with custom function
|
||||
// This showcases: Custom validation function for complex rules
|
||||
let custom_pattern = PatternFilters::new()
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::From(0),
|
||||
CharacterFilter::Custom(Arc::new(|c| {
|
||||
// Advanced rule: Alternating vowels and consonants!
|
||||
// Even positions (0,2,4...): vowels (a,e,i,o,u)
|
||||
// Odd positions (1,3,5...): consonants
|
||||
let vowels = ['a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U'];
|
||||
|
||||
// For demo purposes, we'll just accept alphabetic characters
|
||||
// In real usage, you'd implement the alternating logic based on position
|
||||
c.is_alphabetic()
|
||||
})),
|
||||
));
|
||||
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_pattern_filters(custom_pattern)
|
||||
.with_max_length(12) // Allow up to 12 characters
|
||||
.build())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Key handling (same structure as before)
|
||||
fn handle_key_press(
|
||||
key: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
editor: &mut AdvancedPatternFormEditor<AdvancedPatternData>,
|
||||
) -> anyhow::Result<bool> {
|
||||
let mode = editor.mode();
|
||||
|
||||
// Quit handling
|
||||
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL))
|
||||
|| (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL))
|
||||
|| key == KeyCode::F(10)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
match (mode, key, modifiers) {
|
||||
// Mode transitions
|
||||
(AppMode::ReadOnly, KeyCode::Char('i'), _) => { editor.enter_edit_mode(); editor.clear_command_buffer(); }
|
||||
(AppMode::ReadOnly, KeyCode::Char('a'), _) => { editor.enter_append_mode(); editor.clear_command_buffer(); }
|
||||
(AppMode::ReadOnly, KeyCode::Char('A'), _) => { editor.move_line_end(); editor.enter_edit_mode(); editor.clear_command_buffer(); }
|
||||
(_, KeyCode::Esc, _) => { if mode == AppMode::Edit { editor.exit_edit_mode(); } else { editor.clear_command_buffer(); } }
|
||||
|
||||
// Validation commands
|
||||
(AppMode::ReadOnly, KeyCode::F(1), _) => { editor.toggle_validation(); }
|
||||
|
||||
// Movement in ReadOnly mode
|
||||
(AppMode::ReadOnly, KeyCode::Char('h'), _) | (AppMode::ReadOnly, KeyCode::Left, _) => { editor.move_left(); editor.clear_command_buffer(); }
|
||||
(AppMode::ReadOnly, KeyCode::Char('l'), _) | (AppMode::ReadOnly, KeyCode::Right, _) => { editor.move_right(); editor.clear_command_buffer(); }
|
||||
(AppMode::ReadOnly, KeyCode::Char('j'), _) | (AppMode::ReadOnly, KeyCode::Down, _) => { editor.move_down(); editor.clear_command_buffer(); }
|
||||
(AppMode::ReadOnly, KeyCode::Char('k'), _) | (AppMode::ReadOnly, KeyCode::Up, _) => { editor.move_up(); editor.clear_command_buffer(); }
|
||||
|
||||
// Movement in Edit mode
|
||||
(AppMode::Edit, KeyCode::Left, _) => { editor.move_left(); }
|
||||
(AppMode::Edit, KeyCode::Right, _) => { editor.move_right(); }
|
||||
(AppMode::Edit, KeyCode::Up, _) => { editor.move_up(); }
|
||||
(AppMode::Edit, KeyCode::Down, _) => { editor.move_down(); }
|
||||
|
||||
// Delete operations
|
||||
(AppMode::Edit, KeyCode::Backspace, _) => { editor.delete_backward()?; }
|
||||
(AppMode::Edit, KeyCode::Delete, _) => { editor.delete_forward()?; }
|
||||
|
||||
// Tab navigation
|
||||
(_, KeyCode::Tab, _) => { editor.next_field(); }
|
||||
(_, KeyCode::BackTab, _) => { editor.prev_field(); }
|
||||
|
||||
// Character input
|
||||
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
|
||||
editor.insert_char(c)?;
|
||||
}
|
||||
|
||||
// Debug info
|
||||
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
|
||||
let summary = editor.editor.validation_summary();
|
||||
editor.set_debug_message(format!(
|
||||
"Field {}/{}, Pos {}, Mode: {:?}, Advanced patterns: {} configured",
|
||||
editor.current_field() + 1,
|
||||
editor.data_provider().field_count(),
|
||||
editor.cursor_position(),
|
||||
editor.mode(),
|
||||
summary.total_fields
|
||||
));
|
||||
}
|
||||
|
||||
_ => {
|
||||
if editor.has_pending_command() {
|
||||
editor.clear_command_buffer();
|
||||
editor.set_debug_message("Invalid command sequence".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn run_app<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
mut editor: AdvancedPatternFormEditor<AdvancedPatternData>,
|
||||
) -> io::Result<()> {
|
||||
loop {
|
||||
terminal.draw(|f| ui(f, &editor))?;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
match handle_key_press(key.code, key.modifiers, &mut editor) {
|
||||
Ok(should_continue) => {
|
||||
if !should_continue {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
editor.set_debug_message(format!("Error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ui(f: &mut Frame, editor: &AdvancedPatternFormEditor<AdvancedPatternData>) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(8), Constraint::Length(15)])
|
||||
.split(f.area());
|
||||
|
||||
render_canvas_default(f, chunks[0], &editor.editor);
|
||||
render_advanced_validation_status(f, chunks[1], editor);
|
||||
}
|
||||
|
||||
fn render_advanced_validation_status(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
editor: &AdvancedPatternFormEditor<AdvancedPatternData>,
|
||||
) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Status bar
|
||||
Constraint::Length(5), // Validation summary
|
||||
Constraint::Length(7), // Help
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Status bar
|
||||
let mode_text = match editor.mode() {
|
||||
AppMode::Edit => "INSERT",
|
||||
AppMode::ReadOnly => "NORMAL",
|
||||
_ => "OTHER",
|
||||
};
|
||||
|
||||
let validation_status = editor.get_validation_status();
|
||||
let status_text = format!("-- {} -- {} | Advanced Patterns: {}", mode_text, editor.debug_message(), validation_status);
|
||||
|
||||
let status = Paragraph::new(Line::from(Span::raw(status_text)))
|
||||
.block(Block::default().borders(Borders::ALL).title("🚀 Advanced Pattern Validation"));
|
||||
|
||||
f.render_widget(status, chunks[0]);
|
||||
|
||||
// Enhanced validation summary
|
||||
let summary = editor.editor.validation_summary();
|
||||
let field_info = match editor.current_field() {
|
||||
0 => "Time format (HH:MM) - Tests exact chars + numeric ranges",
|
||||
1 => "Hex color (#RRGGBB) - Tests OneOf filter with case insensitive",
|
||||
2 => "IPv4 address - Tests complex dot positioning",
|
||||
3 => "Product code (ABC-123-XYZ) - Tests section-based patterns",
|
||||
4 => "Date code (2024W15) - Tests From position filtering",
|
||||
5 => "Binary input - Tests limited character set (0,1 only)",
|
||||
6 => "Complex ID - Tests overlapping/conflicting rules",
|
||||
7 => "Custom pattern - Tests advanced custom validation logic",
|
||||
_ => "Unknown field",
|
||||
};
|
||||
|
||||
let summary_text = if editor.validation_enabled {
|
||||
format!(
|
||||
"📊 Advanced Pattern Summary: {} fields with complex rules\n\
|
||||
Current Field: {}\n\
|
||||
✅ Valid: {} ⚠️ Warnings: {} ❌ Errors: {} 📈 Progress: {:.0}%\n\
|
||||
🎯 Pattern Focus: {}",
|
||||
summary.total_fields,
|
||||
editor.current_field() + 1,
|
||||
summary.valid_fields,
|
||||
summary.warning_fields,
|
||||
summary.error_fields,
|
||||
summary.completion_percentage() * 100.0,
|
||||
field_info
|
||||
)
|
||||
} else {
|
||||
"❌ Advanced pattern validation is DISABLED\nPress F1 to enable and see the magic!".to_string()
|
||||
};
|
||||
|
||||
let summary_style = if summary.has_errors() {
|
||||
Style::default().fg(Color::Red)
|
||||
} else if summary.has_warnings() {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default().fg(Color::Green)
|
||||
};
|
||||
|
||||
let validation_summary = Paragraph::new(summary_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("🎯 Advanced Pattern Analysis"))
|
||||
.style(summary_style)
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
f.render_widget(validation_summary, chunks[1]);
|
||||
|
||||
// Enhanced help text
|
||||
let help_text = match editor.mode() {
|
||||
AppMode::ReadOnly => {
|
||||
"🚀 ADVANCED PATTERN SHOWCASE - Each field demonstrates different edge cases!\n\
|
||||
🕐 Time: Numeric+exact chars 🎨 Hex: OneOf with case-insensitive 🌐 IPv4: Complex positioning\n\
|
||||
🏷️ Product: Multi-section rules 📅 Date: From-position filtering 🔢 Binary: Limited charset\n\
|
||||
🎯 Complex: Overlapping rules 🚀 Custom: Advanced logic functions\n\
|
||||
\n\
|
||||
Movement: hjkl/arrows=move, Tab/Shift+Tab=fields, i/a=insert, F1=toggle, ?=info"
|
||||
}
|
||||
AppMode::Edit => {
|
||||
"✏️ INSERT MODE - Testing advanced pattern validation!\n\
|
||||
Each character is validated against complex rules in real-time\n\
|
||||
Try entering invalid characters to see detailed error messages\n\
|
||||
arrows=move, Backspace/Del=delete, Esc=normal, Tab=next field"
|
||||
}
|
||||
_ => "🚀 Advanced Pattern Validation Active!"
|
||||
};
|
||||
|
||||
let help = Paragraph::new(help_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("🎯 Advanced Pattern Commands & Info"))
|
||||
.style(Style::default().fg(Color::Gray))
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
f.render_widget(help, chunks[2]);
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("🚀 Canvas Advanced Pattern Validation Demo");
|
||||
println!("✅ validation feature: ENABLED");
|
||||
println!("✅ gui feature: ENABLED");
|
||||
println!("🎯 Advanced pattern filtering: ACTIVE");
|
||||
println!("🧪 Edge cases and complex patterns: READY");
|
||||
println!("💡 Each field showcases different validation capabilities!");
|
||||
println!();
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let data = AdvancedPatternData::new();
|
||||
let editor = AdvancedPatternFormEditor::new(data);
|
||||
|
||||
let res = run_app(&mut terminal, editor);
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(err) = res {
|
||||
println!("{:?}", err);
|
||||
}
|
||||
|
||||
println!("🚀 Advanced pattern validation demo completed!");
|
||||
println!("🎯 Hope you enjoyed seeing all the edge cases in action!");
|
||||
Ok(())
|
||||
}
|
||||
712
canvas/examples/validation_3.rs
Normal file
712
canvas/examples/validation_3.rs
Normal file
@@ -0,0 +1,712 @@
|
||||
// examples/validation_3.rs
|
||||
//! Comprehensive Display Mask Features Demo
|
||||
//!
|
||||
//! This example showcases the full power of the display mask system (Feature 3)
|
||||
//! demonstrating visual formatting that keeps business logic clean.
|
||||
//!
|
||||
//! Key Features Demonstrated:
|
||||
//! - Dynamic vs Template display modes
|
||||
//! - Custom patterns for different data types
|
||||
//! - Custom input characters and separators
|
||||
//! - Custom placeholder characters
|
||||
//! - Real-time visual formatting with clean raw data
|
||||
//! - Cursor movement through formatted displays
|
||||
//! - 🔥 CRITICAL: Perfect mask/character-limit coordination to prevent invisible character bugs
|
||||
//!
|
||||
//! ⚠️ IMPORTANT BUG PREVENTION:
|
||||
//! This example demonstrates the CORRECT way to configure masks with character limits.
|
||||
//! Each mask's input position count EXACTLY matches its character limit to prevent
|
||||
//! the critical bug where users can type more characters than they can see.
|
||||
//!
|
||||
//! Run with: cargo run --example validation_3 --features "gui,validation"
|
||||
|
||||
// REQUIRE validation and gui features for mask functionality
|
||||
#[cfg(not(all(feature = "validation", feature = "gui")))]
|
||||
compile_error!(
|
||||
"This example requires the 'validation' and 'gui' features. \
|
||||
Run with: cargo run --example validation_3 --features \"gui,validation\""
|
||||
);
|
||||
|
||||
use std::io;
|
||||
use crossterm::{
|
||||
event::{
|
||||
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
|
||||
},
|
||||
execute,
|
||||
terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
},
|
||||
};
|
||||
use ratatui::{
|
||||
backend::{Backend, CrosstermBackend},
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use canvas::{
|
||||
canvas::{
|
||||
gui::render_canvas_default,
|
||||
modes::AppMode,
|
||||
},
|
||||
DataProvider, FormEditor,
|
||||
ValidationConfig, ValidationConfigBuilder, DisplayMask,
|
||||
validation::mask::MaskDisplayMode,
|
||||
};
|
||||
|
||||
// Enhanced FormEditor wrapper for mask demonstration
|
||||
struct MaskDemoFormEditor<D: DataProvider> {
|
||||
editor: FormEditor<D>,
|
||||
debug_message: String,
|
||||
command_buffer: String,
|
||||
validation_enabled: bool,
|
||||
show_raw_data: bool,
|
||||
}
|
||||
|
||||
impl<D: DataProvider> MaskDemoFormEditor<D> {
|
||||
fn new(data_provider: D) -> Self {
|
||||
let mut editor = FormEditor::new(data_provider);
|
||||
editor.set_validation_enabled(true);
|
||||
|
||||
Self {
|
||||
editor,
|
||||
debug_message: "🎭 Display Mask Demo - Visual formatting with clean business logic!".to_string(),
|
||||
command_buffer: String::new(),
|
||||
validation_enabled: true,
|
||||
show_raw_data: false,
|
||||
}
|
||||
}
|
||||
|
||||
// === COMMAND BUFFER HANDLING ===
|
||||
fn clear_command_buffer(&mut self) { self.command_buffer.clear(); }
|
||||
fn add_to_command_buffer(&mut self, ch: char) { self.command_buffer.push(ch); }
|
||||
fn get_command_buffer(&self) -> &str { &self.command_buffer }
|
||||
fn has_pending_command(&self) -> bool { !self.command_buffer.is_empty() }
|
||||
|
||||
// === MASK CONTROL ===
|
||||
fn toggle_validation(&mut self) {
|
||||
self.validation_enabled = !self.validation_enabled;
|
||||
self.editor.set_validation_enabled(self.validation_enabled);
|
||||
if self.validation_enabled {
|
||||
self.debug_message = "✅ Display Masks ENABLED - See visual formatting in action!".to_string();
|
||||
} else {
|
||||
self.debug_message = "❌ Display Masks DISABLED - Raw text only".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_raw_data_view(&mut self) {
|
||||
self.show_raw_data = !self.show_raw_data;
|
||||
if self.show_raw_data {
|
||||
self.debug_message = "👁️ Showing RAW business data (what's actually stored)".to_string();
|
||||
} else {
|
||||
self.debug_message = "🎭 Showing FORMATTED display (what users see)".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_field_info(&self) -> (String, String, String) {
|
||||
let field_index = self.editor.current_field();
|
||||
let raw_data = self.editor.current_text();
|
||||
let display_data = if self.validation_enabled {
|
||||
self.editor.current_display_text()
|
||||
} else {
|
||||
raw_data.to_string()
|
||||
};
|
||||
|
||||
let mask_info = if let Some(config) = self.editor.validation_state().get_field_config(field_index) {
|
||||
if let Some(mask) = &config.display_mask {
|
||||
format!("Pattern: '{}', Mode: {:?}",
|
||||
mask.pattern(),
|
||||
mask.display_mode())
|
||||
} else {
|
||||
"No mask configured".to_string()
|
||||
}
|
||||
} else {
|
||||
"No validation config".to_string()
|
||||
};
|
||||
|
||||
(raw_data.to_string(), display_data, mask_info)
|
||||
}
|
||||
|
||||
// === ENHANCED MOVEMENT WITH MASK AWARENESS ===
|
||||
fn move_left(&mut self) {
|
||||
self.editor.move_left();
|
||||
self.update_cursor_info();
|
||||
}
|
||||
|
||||
fn move_right(&mut self) {
|
||||
self.editor.move_right();
|
||||
self.update_cursor_info();
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
match self.editor.move_up() {
|
||||
Ok(()) => { self.update_field_info(); }
|
||||
Err(e) => { self.debug_message = format!("🚫 Field switch blocked: {}", e); }
|
||||
}
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
match self.editor.move_down() {
|
||||
Ok(()) => { self.update_field_info(); }
|
||||
Err(e) => { self.debug_message = format!("🚫 Field switch blocked: {}", e); }
|
||||
}
|
||||
}
|
||||
|
||||
fn move_line_start(&mut self) {
|
||||
self.editor.move_line_start();
|
||||
self.update_cursor_info();
|
||||
}
|
||||
|
||||
fn move_line_end(&mut self) {
|
||||
self.editor.move_line_end();
|
||||
self.update_cursor_info();
|
||||
}
|
||||
|
||||
fn update_cursor_info(&mut self) {
|
||||
if self.validation_enabled {
|
||||
let raw_pos = self.editor.cursor_position();
|
||||
let display_pos = self.editor.display_cursor_position();
|
||||
if raw_pos != display_pos {
|
||||
self.debug_message = format!("📍 Cursor: Raw pos {} → Display pos {} (mask active)", raw_pos, display_pos);
|
||||
} else {
|
||||
self.debug_message = format!("📍 Cursor at position {} (no mask offset)", raw_pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_field_info(&mut self) {
|
||||
let field_name = self.editor.data_provider().field_name(self.editor.current_field());
|
||||
self.debug_message = format!("📝 Switched to: {}", field_name);
|
||||
}
|
||||
|
||||
// === MODE TRANSITIONS ===
|
||||
fn enter_edit_mode(&mut self) {
|
||||
self.editor.enter_edit_mode();
|
||||
self.debug_message = "✏️ INSERT MODE - Type to see mask formatting in real-time".to_string();
|
||||
}
|
||||
|
||||
fn enter_append_mode(&mut self) {
|
||||
self.editor.enter_append_mode();
|
||||
self.debug_message = "✏️ INSERT (append) - Mask formatting active".to_string();
|
||||
}
|
||||
|
||||
fn exit_edit_mode(&mut self) {
|
||||
self.editor.exit_edit_mode();
|
||||
self.debug_message = "🔒 NORMAL MODE - Press 'r' to see raw data, 'm' for mask info".to_string();
|
||||
}
|
||||
|
||||
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
|
||||
let result = self.editor.insert_char(ch);
|
||||
if result.is_ok() {
|
||||
let (raw, display, _) = self.get_current_field_info();
|
||||
if raw != display {
|
||||
self.debug_message = format!("✏️ Added '{}': Raw='{}' Display='{}'", ch, raw, display);
|
||||
} else {
|
||||
self.debug_message = format!("✏️ Added '{}': '{}'", ch, raw);
|
||||
}
|
||||
}
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
// === DELETE OPERATIONS ===
|
||||
fn delete_backward(&mut self) -> anyhow::Result<()> {
|
||||
let result = self.editor.delete_backward();
|
||||
if result.is_ok() {
|
||||
self.debug_message = "⌫ Character deleted".to_string();
|
||||
self.update_cursor_info();
|
||||
}
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
fn delete_forward(&mut self) -> anyhow::Result<()> {
|
||||
let result = self.editor.delete_forward();
|
||||
if result.is_ok() {
|
||||
self.debug_message = "⌦ Character deleted".to_string();
|
||||
self.update_cursor_info();
|
||||
}
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
// === DELEGATE TO ORIGINAL EDITOR ===
|
||||
fn current_field(&self) -> usize { self.editor.current_field() }
|
||||
fn cursor_position(&self) -> usize { self.editor.cursor_position() }
|
||||
fn mode(&self) -> AppMode { self.editor.mode() }
|
||||
fn current_text(&self) -> &str { self.editor.current_text() }
|
||||
fn data_provider(&self) -> &D { self.editor.data_provider() }
|
||||
fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() }
|
||||
fn set_mode(&mut self, mode: AppMode) { self.editor.set_mode(mode); }
|
||||
|
||||
fn next_field(&mut self) {
|
||||
match self.editor.next_field() {
|
||||
Ok(()) => { self.update_field_info(); }
|
||||
Err(e) => { self.debug_message = format!("🚫 Cannot move to next field: {}", e); }
|
||||
}
|
||||
}
|
||||
|
||||
fn prev_field(&mut self) {
|
||||
match self.editor.prev_field() {
|
||||
Ok(()) => { self.update_field_info(); }
|
||||
Err(e) => { self.debug_message = format!("🚫 Cannot move to previous field: {}", e); }
|
||||
}
|
||||
}
|
||||
|
||||
// === STATUS AND DEBUG ===
|
||||
fn set_debug_message(&mut self, msg: String) { self.debug_message = msg; }
|
||||
fn debug_message(&self) -> &str { &self.debug_message }
|
||||
|
||||
fn show_mask_details(&mut self) {
|
||||
let (raw, display, mask_info) = self.get_current_field_info();
|
||||
self.debug_message = format!("🔍 Field {}: {} | Raw: '{}' Display: '{}'",
|
||||
self.current_field() + 1, mask_info, raw, display);
|
||||
}
|
||||
|
||||
fn get_mask_status(&self) -> String {
|
||||
if !self.validation_enabled {
|
||||
return "❌ DISABLED".to_string();
|
||||
}
|
||||
|
||||
let field_count = self.editor.data_provider().field_count();
|
||||
let mut mask_count = 0;
|
||||
for i in 0..field_count {
|
||||
if let Some(config) = self.editor.validation_state().get_field_config(i) {
|
||||
if config.display_mask.is_some() {
|
||||
mask_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
format!("🎭 {} MASKS", mask_count)
|
||||
}
|
||||
}
|
||||
|
||||
// Demo data with comprehensive mask examples
|
||||
struct MaskDemoData {
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl MaskDemoData {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
fields: vec![
|
||||
("📞 Phone (Dynamic)".to_string(), "".to_string()),
|
||||
("📞 Phone (Template)".to_string(), "".to_string()),
|
||||
("📅 Date US (MM/DD/YYYY)".to_string(), "".to_string()),
|
||||
("📅 Date EU (DD.MM.YYYY)".to_string(), "".to_string()),
|
||||
("📅 Date ISO (YYYY-MM-DD)".to_string(), "".to_string()),
|
||||
("🏛️ SSN (XXX-XX-XXXX)".to_string(), "".to_string()),
|
||||
("💳 Credit Card".to_string(), "".to_string()),
|
||||
("🏢 Employee ID (EMP-####)".to_string(), "".to_string()),
|
||||
("📦 Product Code (ABC###XYZ)".to_string(), "".to_string()),
|
||||
("🌈 Custom Separators".to_string(), "".to_string()),
|
||||
("⭐ Custom Placeholders".to_string(), "".to_string()),
|
||||
("🎯 Mixed Input Chars".to_string(), "".to_string()),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DataProvider for MaskDemoData {
|
||||
fn field_count(&self) -> usize { self.fields.len() }
|
||||
fn field_name(&self, index: usize) -> &str { &self.fields[index].0 }
|
||||
fn field_value(&self, index: usize) -> &str { &self.fields[index].1 }
|
||||
fn set_field_value(&mut self, index: usize, value: String) { self.fields[index].1 = value; }
|
||||
|
||||
fn validation_config(&self, field_index: usize) -> Option<ValidationConfig> {
|
||||
match field_index {
|
||||
0 => {
|
||||
// 📞 Phone (Dynamic) - FIXED: Perfect mask/limit coordination
|
||||
let phone_mask = DisplayMask::new("(###) ###-####", '#');
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_display_mask(phone_mask)
|
||||
.with_max_length(10) // ✅ CRITICAL: Exactly matches 10 input positions
|
||||
.build())
|
||||
}
|
||||
1 => {
|
||||
// 📞 Phone (Template) - FIXED: Perfect mask/limit coordination
|
||||
let phone_template = DisplayMask::new("(###) ###-####", '#')
|
||||
.with_template('_');
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_display_mask(phone_template)
|
||||
.with_max_length(10) // ✅ CRITICAL: Exactly matches 10 input positions
|
||||
.build())
|
||||
}
|
||||
2 => {
|
||||
// 📅 Date US (MM/DD/YYYY) - American date format
|
||||
let us_date = DisplayMask::new("##/##/####", '#');
|
||||
Some(ValidationConfig::with_mask(us_date))
|
||||
}
|
||||
3 => {
|
||||
// 📅 Date EU (DD.MM.YYYY) - European date format with dots
|
||||
let eu_date = DisplayMask::new("##.##.####", '#')
|
||||
.with_template('•');
|
||||
Some(ValidationConfig::with_mask(eu_date))
|
||||
}
|
||||
4 => {
|
||||
// 📅 Date ISO (YYYY-MM-DD) - ISO date format
|
||||
let iso_date = DisplayMask::new("####-##-##", '#')
|
||||
.with_template('-');
|
||||
Some(ValidationConfig::with_mask(iso_date))
|
||||
}
|
||||
5 => {
|
||||
// 🏛️ SSN using custom input character 'X' - FIXED: Perfect coordination
|
||||
let ssn_mask = DisplayMask::new("XXX-XX-XXXX", 'X');
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_display_mask(ssn_mask)
|
||||
.with_max_length(9) // ✅ CRITICAL: Exactly matches 9 input positions
|
||||
.build())
|
||||
}
|
||||
6 => {
|
||||
// 💳 Credit Card (16 digits with spaces) - FIXED: Perfect coordination
|
||||
let cc_mask = DisplayMask::new("#### #### #### ####", '#')
|
||||
.with_template('•');
|
||||
Some(ValidationConfigBuilder::new()
|
||||
.with_display_mask(cc_mask)
|
||||
.with_max_length(16) // ✅ CRITICAL: Exactly matches 16 input positions
|
||||
.build())
|
||||
}
|
||||
7 => {
|
||||
// 🏢 Employee ID with business prefix
|
||||
let emp_id = DisplayMask::new("EMP-####", '#');
|
||||
Some(ValidationConfig::with_mask(emp_id))
|
||||
}
|
||||
8 => {
|
||||
// 📦 Product Code with mixed letters and numbers
|
||||
let product_code = DisplayMask::new("ABC###XYZ", '#');
|
||||
Some(ValidationConfig::with_mask(product_code))
|
||||
}
|
||||
9 => {
|
||||
// 🌈 Custom Separators - Using | and ~ as separators
|
||||
let custom_sep = DisplayMask::new("##|##~####", '#')
|
||||
.with_template('?');
|
||||
Some(ValidationConfig::with_mask(custom_sep))
|
||||
}
|
||||
10 => {
|
||||
// ⭐ Custom Placeholders - Using different placeholder characters
|
||||
let custom_placeholder = DisplayMask::new("##-##-##", '#')
|
||||
.with_template('★');
|
||||
Some(ValidationConfig::with_mask(custom_placeholder))
|
||||
}
|
||||
11 => {
|
||||
// 🎯 Mixed Input Characters - Using 'N' for numbers
|
||||
let mixed_input = DisplayMask::new("ID:NNN-NNN", 'N');
|
||||
Some(ValidationConfig::with_mask(mixed_input))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced key handling with mask-specific commands
|
||||
fn handle_key_press(
|
||||
key: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
editor: &mut MaskDemoFormEditor<MaskDemoData>,
|
||||
) -> anyhow::Result<bool> {
|
||||
let mode = editor.mode();
|
||||
|
||||
// Quit handling
|
||||
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL))
|
||||
|| (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL))
|
||||
|| key == KeyCode::F(10)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
match (mode, key, modifiers) {
|
||||
// === MODE TRANSITIONS ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('i'), _) => {
|
||||
editor.enter_edit_mode();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
|
||||
editor.enter_append_mode();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('A'), _) => {
|
||||
editor.move_line_end();
|
||||
editor.enter_edit_mode();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// Escape: Exit edit mode
|
||||
(_, KeyCode::Esc, _) => {
|
||||
if mode == AppMode::Edit {
|
||||
editor.exit_edit_mode();
|
||||
} else {
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
}
|
||||
|
||||
// === MASK SPECIFIC COMMANDS ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('m'), _) => {
|
||||
editor.show_mask_details();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('r'), _) => {
|
||||
editor.toggle_raw_data_view();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::F(1), _) => {
|
||||
editor.toggle_validation();
|
||||
}
|
||||
|
||||
// === MOVEMENT ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('h'), _) | (AppMode::ReadOnly, KeyCode::Left, _) => {
|
||||
editor.move_left();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('l'), _) | (AppMode::ReadOnly, KeyCode::Right, _) => {
|
||||
editor.move_right();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('j'), _) | (AppMode::ReadOnly, KeyCode::Down, _) => {
|
||||
editor.move_down();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('k'), _) | (AppMode::ReadOnly, KeyCode::Up, _) => {
|
||||
editor.move_up();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// Line movement
|
||||
(AppMode::ReadOnly, KeyCode::Char('0'), _) => {
|
||||
editor.move_line_start();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
(AppMode::ReadOnly, KeyCode::Char('$'), _) => {
|
||||
editor.move_line_end();
|
||||
editor.clear_command_buffer();
|
||||
}
|
||||
|
||||
// === EDIT MODE MOVEMENT ===
|
||||
(AppMode::Edit, KeyCode::Left, _) => { editor.move_left(); }
|
||||
(AppMode::Edit, KeyCode::Right, _) => { editor.move_right(); }
|
||||
(AppMode::Edit, KeyCode::Up, _) => { editor.move_up(); }
|
||||
(AppMode::Edit, KeyCode::Down, _) => { editor.move_down(); }
|
||||
|
||||
// === DELETE OPERATIONS ===
|
||||
(AppMode::Edit, KeyCode::Backspace, _) => { editor.delete_backward()?; }
|
||||
(AppMode::Edit, KeyCode::Delete, _) => { editor.delete_forward()?; }
|
||||
|
||||
// === TAB NAVIGATION ===
|
||||
(_, KeyCode::Tab, _) => { editor.next_field(); }
|
||||
(_, KeyCode::BackTab, _) => { editor.prev_field(); }
|
||||
|
||||
// === CHARACTER INPUT ===
|
||||
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
|
||||
editor.insert_char(c)?;
|
||||
}
|
||||
|
||||
// === DEBUG/INFO COMMANDS ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
|
||||
let (raw, display, mask_info) = editor.get_current_field_info();
|
||||
editor.set_debug_message(format!(
|
||||
"Field {}/{}, Cursor {}, {}, Raw: '{}', Display: '{}'",
|
||||
editor.current_field() + 1,
|
||||
editor.data_provider().field_count(),
|
||||
editor.cursor_position(),
|
||||
mask_info,
|
||||
raw,
|
||||
display
|
||||
));
|
||||
}
|
||||
|
||||
_ => {
|
||||
if editor.has_pending_command() {
|
||||
editor.clear_command_buffer();
|
||||
editor.set_debug_message("Invalid command sequence".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn run_app<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
mut editor: MaskDemoFormEditor<MaskDemoData>,
|
||||
) -> io::Result<()> {
|
||||
loop {
|
||||
terminal.draw(|f| ui(f, &editor))?;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
match handle_key_press(key.code, key.modifiers, &mut editor) {
|
||||
Ok(should_continue) => {
|
||||
if !should_continue {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
editor.set_debug_message(format!("Error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ui(f: &mut Frame, editor: &MaskDemoFormEditor<MaskDemoData>) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(8), Constraint::Length(16)])
|
||||
.split(f.area());
|
||||
|
||||
render_enhanced_canvas(f, chunks[0], editor);
|
||||
render_mask_status(f, chunks[1], editor);
|
||||
}
|
||||
|
||||
fn render_enhanced_canvas(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
editor: &MaskDemoFormEditor<MaskDemoData>,
|
||||
) {
|
||||
render_canvas_default(f, area, &editor.editor);
|
||||
}
|
||||
|
||||
fn render_mask_status(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
editor: &MaskDemoFormEditor<MaskDemoData>,
|
||||
) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Status bar
|
||||
Constraint::Length(6), // Data comparison
|
||||
Constraint::Length(7), // Help
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Status bar with mask information
|
||||
let mode_text = match editor.mode() {
|
||||
AppMode::Edit => "INSERT",
|
||||
AppMode::ReadOnly => "NORMAL",
|
||||
_ => "OTHER",
|
||||
};
|
||||
|
||||
let mask_status = editor.get_mask_status();
|
||||
let status_text = format!("-- {} -- {} | Masks: {} | View: {}",
|
||||
mode_text,
|
||||
editor.debug_message(),
|
||||
mask_status,
|
||||
if editor.show_raw_data { "RAW" } else { "FORMATTED" });
|
||||
|
||||
let status = Paragraph::new(Line::from(Span::raw(status_text)))
|
||||
.block(Block::default().borders(Borders::ALL).title("🎭 Display Mask Demo"));
|
||||
|
||||
f.render_widget(status, chunks[0]);
|
||||
|
||||
// Data comparison showing raw vs display
|
||||
let (raw_data, display_data, mask_info) = editor.get_current_field_info();
|
||||
let field_name = editor.data_provider().field_name(editor.current_field());
|
||||
|
||||
let comparison_text = format!(
|
||||
"📝 Current Field: {}\n\
|
||||
🔧 Mask Config: {}\n\
|
||||
\n\
|
||||
💾 Raw Business Data: '{}' ← What's actually stored in your database\n\
|
||||
🎭 Formatted Display: '{}' ← What users see in the interface\n\
|
||||
📍 Cursor: Raw pos {} → Display pos {}",
|
||||
field_name,
|
||||
mask_info,
|
||||
raw_data,
|
||||
display_data,
|
||||
editor.cursor_position(),
|
||||
editor.editor.display_cursor_position()
|
||||
);
|
||||
|
||||
let comparison_style = if raw_data != display_data {
|
||||
Style::default().fg(Color::Green) // Green when mask is active
|
||||
} else {
|
||||
Style::default().fg(Color::Gray) // Gray when no formatting
|
||||
};
|
||||
|
||||
let data_comparison = Paragraph::new(comparison_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("📊 Raw Data vs Display Formatting"))
|
||||
.style(comparison_style)
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
f.render_widget(data_comparison, chunks[1]);
|
||||
|
||||
// Enhanced help text
|
||||
let help_text = match editor.mode() {
|
||||
AppMode::ReadOnly => {
|
||||
"🎭 MASK DEMO: See how visual formatting keeps business logic clean!\n\
|
||||
\n\
|
||||
📱 Try different fields to see various mask patterns:\n\
|
||||
• Dynamic vs Template modes • Custom separators • Different input chars\n\
|
||||
\n\
|
||||
Commands: i/a=insert, m=mask details, r=toggle raw/display view\n\
|
||||
Movement: hjkl/arrows=move, 0=$=line start/end, Tab=next field, F1=toggle masks\n\
|
||||
?=detailed info, Ctrl+C=quit"
|
||||
}
|
||||
AppMode::Edit => {
|
||||
"✏️ INSERT MODE - Type to see real-time mask formatting!\n\
|
||||
\n\
|
||||
🔥 Key Features in Action:\n\
|
||||
• Separators auto-appear as you type • Cursor skips over separators\n\
|
||||
• Template fields show placeholders • Raw data stays clean for business logic\n\
|
||||
\n\
|
||||
arrows=move through mask, Backspace/Del=delete, Esc=normal, Tab=next field\n\
|
||||
Notice how cursor position maps between raw data and display!"
|
||||
}
|
||||
_ => "🎭 Display Mask Demo Active!"
|
||||
};
|
||||
|
||||
let help = Paragraph::new(help_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("🚀 Mask Features & Commands"))
|
||||
.style(Style::default().fg(Color::Gray))
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
f.render_widget(help, chunks[2]);
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Print feature status
|
||||
println!("🎭 Canvas Display Mask Demo (Feature 3)");
|
||||
println!("✅ validation feature: ENABLED");
|
||||
println!("✅ gui feature: ENABLED");
|
||||
println!("🎭 Display masks: ACTIVE");
|
||||
println!("🔥 Key Benefits Demonstrated:");
|
||||
println!(" • Clean separation: Visual formatting ≠ Business logic");
|
||||
println!(" • User-friendly: Pretty displays with automatic cursor handling");
|
||||
println!(" • Flexible: Custom patterns, separators, and placeholders");
|
||||
println!(" • Transparent: Library handles all complexity, API stays simple");
|
||||
println!();
|
||||
println!("💡 Try typing in different fields to see mask magic!");
|
||||
println!(" 📞 Phone fields show dynamic vs template modes");
|
||||
println!(" 📅 Date fields show different regional formats");
|
||||
println!(" 💳 Credit card shows spaced formatting");
|
||||
println!(" ⭐ Custom fields show advanced separator/placeholder options");
|
||||
println!();
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let data = MaskDemoData::new();
|
||||
let editor = MaskDemoFormEditor::new(data);
|
||||
|
||||
let res = run_app(&mut terminal, editor);
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(err) = res {
|
||||
println!("{:?}", err);
|
||||
}
|
||||
|
||||
println!("🎭 Display mask demo completed!");
|
||||
println!("🏆 You've seen how masks provide beautiful UX while keeping business logic clean!");
|
||||
Ok(())
|
||||
}
|
||||
738
canvas/examples/validation_4.rs
Normal file
738
canvas/examples/validation_4.rs
Normal file
@@ -0,0 +1,738 @@
|
||||
/* examples/validation_4.rs
|
||||
Enhanced Feature 4 Demo: Multiple custom formatters with comprehensive edge cases
|
||||
|
||||
Demonstrates:
|
||||
- Multiple formatter types: PSC, Phone, Credit Card, Date
|
||||
- Edge case handling: incomplete input, invalid chars, overflow
|
||||
- Real-time validation feedback and format preview
|
||||
- Advanced cursor position mapping
|
||||
- Raw vs formatted data separation
|
||||
- Error handling and fallback behavior
|
||||
|
||||
*/
|
||||
|
||||
#![allow(clippy::needless_return)]
|
||||
|
||||
#[cfg(not(all(feature = "validation", feature = "gui")))]
|
||||
compile_error!(
|
||||
"This example requires the 'validation' and 'gui' features. \
|
||||
Run with: cargo run --example validation_4 --features \"gui,validation\""
|
||||
);
|
||||
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crossterm::{
|
||||
event::{
|
||||
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
|
||||
},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{
|
||||
backend::{Backend, CrosstermBackend},
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use canvas::{
|
||||
canvas::{gui::render_canvas_default, modes::AppMode},
|
||||
DataProvider, FormEditor,
|
||||
ValidationConfig, ValidationConfigBuilder,
|
||||
CustomFormatter, FormattingResult,
|
||||
};
|
||||
|
||||
/// PSC (Postal Code) Formatter: "01001" -> "010 01"
|
||||
struct PSCFormatter;
|
||||
|
||||
impl CustomFormatter for PSCFormatter {
|
||||
fn format(&self, raw: &str) -> FormattingResult {
|
||||
if raw.is_empty() {
|
||||
return FormattingResult::success("");
|
||||
}
|
||||
|
||||
// Validate: only digits allowed
|
||||
if !raw.chars().all(|c| c.is_ascii_digit()) {
|
||||
return FormattingResult::error("PSC must contain only digits");
|
||||
}
|
||||
|
||||
let len = raw.chars().count();
|
||||
match len {
|
||||
0 => FormattingResult::success(""),
|
||||
1..=3 => FormattingResult::success(raw),
|
||||
4 => FormattingResult::warning(
|
||||
format!("{} ", &raw[..3]),
|
||||
"PSC incomplete (4/5 digits)"
|
||||
),
|
||||
5 => {
|
||||
let formatted = format!("{} {}", &raw[..3], &raw[3..]);
|
||||
if raw == "00000" {
|
||||
FormattingResult::warning(formatted, "Invalid PSC: 00000")
|
||||
} else {
|
||||
FormattingResult::success(formatted)
|
||||
}
|
||||
},
|
||||
_ => FormattingResult::error("PSC too long (max 5 digits)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Phone Number Formatter: "1234567890" -> "(123) 456-7890"
|
||||
struct PhoneFormatter;
|
||||
|
||||
impl CustomFormatter for PhoneFormatter {
|
||||
fn format(&self, raw: &str) -> FormattingResult {
|
||||
if raw.is_empty() {
|
||||
return FormattingResult::success("");
|
||||
}
|
||||
|
||||
// Only digits allowed
|
||||
if !raw.chars().all(|c| c.is_ascii_digit()) {
|
||||
return FormattingResult::error("Phone must contain only digits");
|
||||
}
|
||||
|
||||
let len = raw.chars().count();
|
||||
match len {
|
||||
0 => FormattingResult::success(""),
|
||||
1..=3 => FormattingResult::success(format!("({})", raw)),
|
||||
4..=6 => FormattingResult::success(format!("({}) {}", &raw[..3], &raw[3..])),
|
||||
7..=10 => FormattingResult::success(format!("({}) {}-{}", &raw[..3], &raw[3..6], &raw[6..])),
|
||||
10 => {
|
||||
let formatted = format!("({}) {}-{}", &raw[..3], &raw[3..6], &raw[6..]);
|
||||
FormattingResult::success(formatted)
|
||||
},
|
||||
_ => FormattingResult::warning(
|
||||
format!("({}) {}-{}", &raw[..3], &raw[3..6], &raw[6..10]),
|
||||
"Phone too long (extra digits ignored)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Credit Card Formatter: "1234567890123456" -> "1234 5678 9012 3456"
|
||||
struct CreditCardFormatter;
|
||||
|
||||
impl CustomFormatter for CreditCardFormatter {
|
||||
fn format(&self, raw: &str) -> FormattingResult {
|
||||
if raw.is_empty() {
|
||||
return FormattingResult::success("");
|
||||
}
|
||||
|
||||
if !raw.chars().all(|c| c.is_ascii_digit()) {
|
||||
return FormattingResult::error("Card number must contain only digits");
|
||||
}
|
||||
|
||||
let mut formatted = String::new();
|
||||
for (i, ch) in raw.chars().enumerate() {
|
||||
if i > 0 && i % 4 == 0 {
|
||||
formatted.push(' ');
|
||||
}
|
||||
formatted.push(ch);
|
||||
}
|
||||
|
||||
let len = raw.chars().count();
|
||||
match len {
|
||||
0..=15 => FormattingResult::warning(formatted, format!("Card incomplete ({}/16 digits)", len)),
|
||||
16 => FormattingResult::success(formatted),
|
||||
_ => FormattingResult::warning(formatted, "Card too long (extra digits shown)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/// Date Formatter: "12012024" -> "12/01/2024"
|
||||
struct DateFormatter;
|
||||
|
||||
impl CustomFormatter for DateFormatter {
|
||||
fn format(&self, raw: &str) -> FormattingResult {
|
||||
if raw.is_empty() {
|
||||
return FormattingResult::success("");
|
||||
}
|
||||
|
||||
if !raw.chars().all(|c| c.is_ascii_digit()) {
|
||||
return FormattingResult::error("Date must contain only digits");
|
||||
}
|
||||
|
||||
let len = raw.len();
|
||||
match len {
|
||||
0 => FormattingResult::success(""),
|
||||
1..=2 => FormattingResult::success(raw.to_string()),
|
||||
3..=4 => FormattingResult::success(format!("{}/{}", &raw[..2], &raw[2..])),
|
||||
5..=8 => FormattingResult::success(format!("{}/{}/{}", &raw[..2], &raw[2..4], &raw[4..])),
|
||||
8 => {
|
||||
let month = &raw[..2];
|
||||
let day = &raw[2..4];
|
||||
let year = &raw[4..];
|
||||
|
||||
// Basic validation
|
||||
let m: u32 = month.parse().unwrap_or(0);
|
||||
let d: u32 = day.parse().unwrap_or(0);
|
||||
|
||||
if m == 0 || m > 12 {
|
||||
FormattingResult::warning(
|
||||
format!("{}/{}/{}", month, day, year),
|
||||
"Invalid month (01-12)"
|
||||
)
|
||||
} else if d == 0 || d > 31 {
|
||||
FormattingResult::warning(
|
||||
format!("{}/{}/{}", month, day, year),
|
||||
"Invalid day (01-31)"
|
||||
)
|
||||
} else {
|
||||
FormattingResult::success(format!("{}/{}/{}", month, day, year))
|
||||
}
|
||||
},
|
||||
_ => FormattingResult::error("Date too long (MMDDYYYY format)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced demo data with multiple formatter types
|
||||
struct MultiFormatterDemoData {
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl MultiFormatterDemoData {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
fields: vec![
|
||||
("🏁 PSC (01001)".to_string(), "".to_string()),
|
||||
("📞 Phone (1234567890)".to_string(), "".to_string()),
|
||||
("💳 Credit Card (16 digits)".to_string(), "".to_string()),
|
||||
("📅 Date (12012024)".to_string(), "".to_string()),
|
||||
("📝 Plain Text".to_string(), "".to_string()),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DataProvider for MultiFormatterDemoData {
|
||||
fn field_count(&self) -> usize {
|
||||
self.fields.len()
|
||||
}
|
||||
|
||||
fn field_name(&self, index: usize) -> &str {
|
||||
&self.fields[index].0
|
||||
}
|
||||
|
||||
fn field_value(&self, index: usize) -> &str {
|
||||
&self.fields[index].1
|
||||
}
|
||||
|
||||
fn set_field_value(&mut self, index: usize, value: String) {
|
||||
self.fields[index].1 = value;
|
||||
}
|
||||
|
||||
#[cfg(feature = "validation")]
|
||||
fn validation_config(&self, field_index: usize) -> Option<ValidationConfig> {
|
||||
match field_index {
|
||||
0 => Some(ValidationConfigBuilder::new()
|
||||
.with_custom_formatter(Arc::new(PSCFormatter))
|
||||
.with_max_length(5)
|
||||
.build()),
|
||||
1 => Some(ValidationConfigBuilder::new()
|
||||
.with_custom_formatter(Arc::new(PhoneFormatter))
|
||||
.with_max_length(12)
|
||||
.build()),
|
||||
2 => Some(ValidationConfigBuilder::new()
|
||||
.with_custom_formatter(Arc::new(CreditCardFormatter))
|
||||
.with_max_length(20)
|
||||
.build()),
|
||||
3 => Some(ValidationConfigBuilder::new()
|
||||
.with_custom_formatter(Arc::new(DateFormatter))
|
||||
.with_max_length(8)
|
||||
.build()),
|
||||
4 => Some(ValidationConfigBuilder::new()
|
||||
.with_custom_formatter(Arc::new(DateFormatter))
|
||||
.with_max_length(8)
|
||||
.build()),
|
||||
_ => None, // Plain text field - no formatter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced demo editor with comprehensive status tracking
|
||||
struct EnhancedDemoEditor<D: DataProvider> {
|
||||
editor: FormEditor<D>,
|
||||
debug_message: String,
|
||||
validation_enabled: bool,
|
||||
show_raw_data: bool,
|
||||
show_cursor_details: bool,
|
||||
example_mode: usize,
|
||||
}
|
||||
|
||||
impl<D: DataProvider> EnhancedDemoEditor<D> {
|
||||
fn new(data_provider: D) -> Self {
|
||||
let mut editor = FormEditor::new(data_provider);
|
||||
editor.set_validation_enabled(true);
|
||||
|
||||
Self {
|
||||
editor,
|
||||
debug_message: "🧩 Enhanced Custom Formatter Demo - Multiple formatters with rich edge cases!".to_string(),
|
||||
validation_enabled: true,
|
||||
show_raw_data: false,
|
||||
show_cursor_details: false,
|
||||
example_mode: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Field type detection
|
||||
fn current_field_type(&self) -> &'static str {
|
||||
match self.editor.current_field() {
|
||||
0 => "PSC",
|
||||
1 => "Phone",
|
||||
2 => "Credit Card",
|
||||
3 => "Date",
|
||||
_ => "Plain Text",
|
||||
}
|
||||
}
|
||||
|
||||
fn has_formatter(&self) -> bool {
|
||||
self.editor.current_field() < 5 // First 5 fields have formatters
|
||||
}
|
||||
|
||||
fn get_input_rules(&self) -> &'static str {
|
||||
match self.editor.current_field() {
|
||||
0 => "5 digits only (PSC format)",
|
||||
1 => "10+ digits (US phone format)",
|
||||
2 => "16+ digits (credit card)",
|
||||
3 => "Digits as cents (12345 = $123.45)",
|
||||
4 => "8 digits MMDDYYYY (date format)",
|
||||
_ => "Any text (no formatting)",
|
||||
}
|
||||
}
|
||||
|
||||
fn cycle_example_data(&mut self) {
|
||||
let examples = [
|
||||
// PSC examples
|
||||
vec!["01001", "1234567890", "1234567890123456", "12345", "12012024", "Plain text here"],
|
||||
// Incomplete examples
|
||||
vec!["010", "123", "1234", "123", "1201", "More text"],
|
||||
// Invalid examples (will show error handling)
|
||||
vec!["0abc1", "12a45", "123abc", "abc", "ab01cd", "Special chars!"],
|
||||
// Edge cases
|
||||
vec!["00000", "0000000000", "0000000000000000", "99", "13012024", ""],
|
||||
];
|
||||
|
||||
self.example_mode = (self.example_mode + 1) % examples.len();
|
||||
let current_examples = &examples[self.example_mode];
|
||||
|
||||
for (i, example) in current_examples.iter().enumerate() {
|
||||
if i < self.editor.data_provider().field_count() {
|
||||
self.editor.data_provider_mut().set_field_value(i, example.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mode_names = ["Valid Examples", "Incomplete Input", "Invalid Characters", "Edge Cases"];
|
||||
self.debug_message = format!("📋 Loaded: {}", mode_names[self.example_mode]);
|
||||
}
|
||||
|
||||
// Enhanced status methods
|
||||
fn toggle_validation(&mut self) {
|
||||
self.validation_enabled = !self.validation_enabled;
|
||||
self.editor.set_validation_enabled(self.validation_enabled);
|
||||
self.debug_message = if self.validation_enabled {
|
||||
"✅ Custom Formatters ENABLED".to_string()
|
||||
} else {
|
||||
"❌ Custom Formatters DISABLED".to_string()
|
||||
};
|
||||
}
|
||||
|
||||
fn toggle_raw_data_view(&mut self) {
|
||||
self.show_raw_data = !self.show_raw_data;
|
||||
self.debug_message = if self.show_raw_data {
|
||||
"👁️ Showing RAW data focus".to_string()
|
||||
} else {
|
||||
"✨ Showing FORMATTED display focus".to_string()
|
||||
};
|
||||
}
|
||||
|
||||
fn toggle_cursor_details(&mut self) {
|
||||
self.show_cursor_details = !self.show_cursor_details;
|
||||
self.debug_message = if self.show_cursor_details {
|
||||
"📍 Detailed cursor mapping info ON".to_string()
|
||||
} else {
|
||||
"📍 Detailed cursor mapping info OFF".to_string()
|
||||
};
|
||||
}
|
||||
|
||||
fn get_current_field_analysis(&self) -> (String, String, String, Option<String>) {
|
||||
let raw = self.editor.current_text();
|
||||
let display = self.editor.current_display_text();
|
||||
|
||||
let status = if raw == display {
|
||||
if self.has_formatter() {
|
||||
if self.mode() == AppMode::Edit {
|
||||
"Raw (editing)".to_string()
|
||||
} else {
|
||||
"No formatting needed".to_string()
|
||||
}
|
||||
} else {
|
||||
"No formatter".to_string()
|
||||
}
|
||||
} else {
|
||||
"Custom formatted".to_string()
|
||||
};
|
||||
|
||||
let warning = if self.validation_enabled && self.has_formatter() {
|
||||
// Check if there are any formatting warnings
|
||||
if raw.len() > 0 {
|
||||
match self.editor.current_field() {
|
||||
0 if raw.len() < 5 => Some(format!("PSC incomplete: {}/5", raw.len())),
|
||||
1 if raw.len() < 10 => Some(format!("Phone incomplete: {}/10", raw.len())),
|
||||
2 if raw.len() < 16 => Some(format!("Card incomplete: {}/16", raw.len())),
|
||||
4 if raw.len() < 8 => Some(format!("Date incomplete: {}/8", raw.len())),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
(raw.to_string(), display, status, warning)
|
||||
}
|
||||
|
||||
// Delegate methods with enhanced feedback
|
||||
fn enter_edit_mode(&mut self) {
|
||||
self.editor.enter_edit_mode();
|
||||
let field_type = self.current_field_type();
|
||||
let rules = self.get_input_rules();
|
||||
self.debug_message = format!("✏️ EDITING {} - {}", field_type, rules);
|
||||
}
|
||||
|
||||
fn exit_edit_mode(&mut self) {
|
||||
self.editor.exit_edit_mode();
|
||||
let (raw, display, _, warning) = self.get_current_field_analysis();
|
||||
if let Some(warn) = warning {
|
||||
self.debug_message = format!("🔒 NORMAL - {} | ⚠️ {}", self.current_field_type(), warn);
|
||||
} else if raw != display {
|
||||
self.debug_message = format!("🔒 NORMAL - {} formatted successfully", self.current_field_type());
|
||||
} else {
|
||||
self.debug_message = "🔒 NORMAL MODE".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
|
||||
let result = self.editor.insert_char(ch);
|
||||
if result.is_ok() {
|
||||
let (raw, display, _, _) = self.get_current_field_analysis();
|
||||
if raw != display && self.validation_enabled {
|
||||
self.debug_message = format!("✏️ '{}' added - Real-time formatting active", ch);
|
||||
} else {
|
||||
self.debug_message = format!("✏️ '{}' added", ch);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// Position mapping demo
|
||||
fn show_position_mapping(&mut self) {
|
||||
if !self.has_formatter() {
|
||||
self.debug_message = "📍 No position mapping (plain text field)".to_string();
|
||||
return;
|
||||
}
|
||||
|
||||
let raw_pos = self.editor.cursor_position();
|
||||
let display_pos = self.editor.display_cursor_position();
|
||||
let raw = self.editor.current_text();
|
||||
let display = self.editor.current_display_text();
|
||||
|
||||
if raw_pos != display_pos {
|
||||
self.debug_message = format!(
|
||||
"🗺️ Position mapping: Raw[{}]='{}' ↔ Display[{}]='{}'",
|
||||
raw_pos,
|
||||
raw.chars().nth(raw_pos).unwrap_or('∅'),
|
||||
display_pos,
|
||||
display.chars().nth(display_pos).unwrap_or('∅')
|
||||
);
|
||||
} else {
|
||||
self.debug_message = format!("📍 Cursor at position {} (no mapping needed)", raw_pos);
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate remaining methods
|
||||
fn mode(&self) -> AppMode { self.editor.mode() }
|
||||
fn current_field(&self) -> usize { self.editor.current_field() }
|
||||
fn cursor_position(&self) -> usize { self.editor.cursor_position() }
|
||||
fn data_provider(&self) -> &D { self.editor.data_provider() }
|
||||
fn data_provider_mut(&mut self) -> &mut D { self.editor.data_provider_mut() }
|
||||
fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() }
|
||||
|
||||
fn move_up(&mut self) { let _ = self.editor.move_up(); }
|
||||
fn move_down(&mut self) { let _ = self.editor.move_down(); }
|
||||
fn move_left(&mut self) { let _ = self.editor.move_left(); }
|
||||
fn move_right(&mut self) { let _ = self.editor.move_right(); }
|
||||
fn delete_backward(&mut self) -> anyhow::Result<()> { self.editor.delete_backward() }
|
||||
fn delete_forward(&mut self) -> anyhow::Result<()> { self.editor.delete_forward() }
|
||||
fn next_field(&mut self) { let _ = self.editor.next_field(); }
|
||||
fn prev_field(&mut self) { let _ = self.editor.prev_field(); }
|
||||
}
|
||||
|
||||
// Enhanced key handling
|
||||
fn handle_key_press(
|
||||
key: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
editor: &mut EnhancedDemoEditor<MultiFormatterDemoData>,
|
||||
) -> anyhow::Result<bool> {
|
||||
let mode = editor.mode();
|
||||
|
||||
// Quit
|
||||
if matches!(key, KeyCode::F(10)) ||
|
||||
(key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL)) ||
|
||||
(key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
match (mode, key, modifiers) {
|
||||
// Mode transitions
|
||||
(AppMode::ReadOnly, KeyCode::Char('i'), _) => editor.enter_edit_mode(),
|
||||
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
|
||||
editor.editor.enter_append_mode();
|
||||
editor.debug_message = format!("✏️ APPEND {} - {}", editor.current_field_type(), editor.get_input_rules());
|
||||
},
|
||||
(_, KeyCode::Esc, _) => editor.exit_edit_mode(),
|
||||
|
||||
// Enhanced demo features
|
||||
(AppMode::ReadOnly, KeyCode::Char('e'), _) => editor.cycle_example_data(),
|
||||
(AppMode::ReadOnly, KeyCode::Char('r'), _) => editor.toggle_raw_data_view(),
|
||||
(AppMode::ReadOnly, KeyCode::Char('c'), _) => editor.toggle_cursor_details(),
|
||||
(AppMode::ReadOnly, KeyCode::Char('m'), _) => editor.show_position_mapping(),
|
||||
(AppMode::ReadOnly, KeyCode::F(1), _) => editor.toggle_validation(),
|
||||
|
||||
// Movement
|
||||
(_, KeyCode::Up, _) | (AppMode::ReadOnly, KeyCode::Char('k'), _) => editor.move_up(),
|
||||
(_, KeyCode::Down, _) | (AppMode::ReadOnly, KeyCode::Char('j'), _) => editor.move_down(),
|
||||
(_, KeyCode::Left, _) | (AppMode::ReadOnly, KeyCode::Char('h'), _) => editor.move_left(),
|
||||
(_, KeyCode::Right, _) | (AppMode::ReadOnly, KeyCode::Char('l'), _) => editor.move_right(),
|
||||
(_, KeyCode::Tab, _) => editor.next_field(),
|
||||
(_, KeyCode::BackTab, _) => editor.prev_field(),
|
||||
|
||||
// Editing
|
||||
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
|
||||
editor.insert_char(c)?;
|
||||
},
|
||||
(AppMode::Edit, KeyCode::Backspace, _) => { editor.delete_backward()?; },
|
||||
(AppMode::Edit, KeyCode::Delete, _) => { editor.delete_forward()?; },
|
||||
|
||||
// Field analysis
|
||||
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
|
||||
let (raw, display, status, warning) = editor.get_current_field_analysis();
|
||||
let warning_text = warning.map(|w| format!(" ⚠️ {}", w)).unwrap_or_default();
|
||||
editor.debug_message = format!(
|
||||
"🔍 Field {}: {} | Raw: '{}' | Display: '{}'{}",
|
||||
editor.current_field() + 1, status, raw, display, warning_text
|
||||
);
|
||||
},
|
||||
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn run_app<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
mut editor: EnhancedDemoEditor<MultiFormatterDemoData>,
|
||||
) -> io::Result<()> {
|
||||
loop {
|
||||
terminal.draw(|f| ui(f, &editor))?;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
match handle_key_press(key.code, key.modifiers, &mut editor) {
|
||||
Ok(should_continue) => {
|
||||
if !should_continue {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
editor.debug_message = format!("❌ Error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ui(f: &mut Frame, editor: &EnhancedDemoEditor<MultiFormatterDemoData>) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(8), Constraint::Length(18)])
|
||||
.split(f.area());
|
||||
|
||||
render_canvas_default(f, chunks[0], &editor.editor);
|
||||
render_enhanced_status(f, chunks[1], editor);
|
||||
}
|
||||
|
||||
fn render_enhanced_status(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
editor: &EnhancedDemoEditor<MultiFormatterDemoData>,
|
||||
) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Status bar
|
||||
Constraint::Length(6), // Current field analysis
|
||||
Constraint::Length(9), // Help
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Status bar
|
||||
let mode_text = match editor.mode() {
|
||||
AppMode::Edit => "INSERT",
|
||||
AppMode::ReadOnly => "NORMAL",
|
||||
_ => "OTHER",
|
||||
};
|
||||
|
||||
let formatter_count = (0..editor.data_provider().field_count())
|
||||
.filter(|&i| editor.data_provider().validation_config(i).is_some())
|
||||
.count();
|
||||
|
||||
let status_text = format!(
|
||||
"-- {} -- {} | Formatters: {}/{} active | View: {}{}",
|
||||
mode_text,
|
||||
editor.debug_message,
|
||||
formatter_count,
|
||||
editor.data_provider().field_count(),
|
||||
if editor.show_raw_data { "RAW" } else { "DISPLAY" },
|
||||
if editor.show_cursor_details { " | CURSOR+" } else { "" }
|
||||
);
|
||||
|
||||
let status = Paragraph::new(Line::from(Span::raw(status_text)))
|
||||
.block(Block::default().borders(Borders::ALL).title("🧩 Enhanced Custom Formatter Demo"));
|
||||
|
||||
f.render_widget(status, chunks[0]);
|
||||
|
||||
// Current field analysis
|
||||
let (raw, display, status, warning) = editor.get_current_field_analysis();
|
||||
let field_name = editor.data_provider().field_name(editor.current_field());
|
||||
let field_type = editor.current_field_type();
|
||||
|
||||
let mut analysis_lines = vec![
|
||||
format!("📝 Current: {} ({})", field_name, field_type),
|
||||
format!("🔧 Status: {}", status),
|
||||
];
|
||||
|
||||
if editor.show_raw_data || editor.mode() == AppMode::Edit {
|
||||
analysis_lines.push(format!("💾 Raw Data: '{}'", raw));
|
||||
analysis_lines.push(format!("✨ Display: '{}'", display));
|
||||
} else {
|
||||
analysis_lines.push(format!("✨ User Sees: '{}'", display));
|
||||
analysis_lines.push(format!("💾 Stored As: '{}'", raw));
|
||||
}
|
||||
|
||||
if editor.show_cursor_details {
|
||||
analysis_lines.push(format!(
|
||||
"📍 Cursor: Raw[{}] → Display[{}]",
|
||||
editor.cursor_position(),
|
||||
editor.editor.display_cursor_position()
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(ref warn) = warning {
|
||||
analysis_lines.push(format!("⚠️ Warning: {}", warn));
|
||||
}
|
||||
|
||||
let analysis_color = if warning.is_some() {
|
||||
Color::Yellow
|
||||
} else if raw != display && editor.validation_enabled {
|
||||
Color::Green
|
||||
} else {
|
||||
Color::Gray
|
||||
};
|
||||
|
||||
let analysis = Paragraph::new(analysis_lines.join("\n"))
|
||||
.block(Block::default().borders(Borders::ALL).title("🔍 Field Analysis"))
|
||||
.style(Style::default().fg(analysis_color))
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
f.render_widget(analysis, chunks[1]);
|
||||
|
||||
// Enhanced help
|
||||
let help_text = match editor.mode() {
|
||||
AppMode::ReadOnly => {
|
||||
"🧩 ENHANCED CUSTOM FORMATTER DEMO\n\
|
||||
\n\
|
||||
Try these formatters:
|
||||
• PSC: 01001 → 010 01 | Phone: 1234567890 → (123) 456-7890 | Card: 1234567890123456 → 1234 5678 9012 3456
|
||||
• Date: 12012024 → 12/01/2024 | Plain: no formatting
|
||||
\n\
|
||||
Commands: i=insert, e=cycle examples, r=toggle raw/display, c=cursor details, m=position mapping\n\
|
||||
Movement: hjkl/arrows, Tab=next field, ?=analyze current field, F1=toggle formatters\n\
|
||||
Ctrl+C/F10=quit"
|
||||
}
|
||||
AppMode::Edit => {
|
||||
"✏️ INSERT MODE - Real-time formatting as you type!\n\
|
||||
\n\
|
||||
Current field rules: {}\n\
|
||||
• Raw input is authoritative (what gets stored)\n\
|
||||
• Display formatting updates in real-time (what users see)\n\
|
||||
• Cursor position is mapped between raw and display\n\
|
||||
\n\
|
||||
Esc=normal mode, arrows=navigate, Backspace/Del=delete"
|
||||
}
|
||||
_ => "🧩 Enhanced Custom Formatter Demo"
|
||||
};
|
||||
|
||||
let formatted_help = if editor.mode() == AppMode::Edit {
|
||||
help_text.replace("{}", editor.get_input_rules())
|
||||
} else {
|
||||
help_text.to_string()
|
||||
};
|
||||
|
||||
let help = Paragraph::new(formatted_help)
|
||||
.block(Block::default().borders(Borders::ALL).title("🚀 Enhanced Features & Commands"))
|
||||
.style(Style::default().fg(Color::Gray))
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
f.render_widget(help, chunks[2]);
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("🧩 Enhanced Canvas Custom Formatter Demo (Feature 4)");
|
||||
println!("✅ validation feature: ENABLED");
|
||||
println!("✅ gui feature: ENABLED");
|
||||
println!("🧩 Enhanced features:");
|
||||
println!(" • 5 different custom formatters with edge cases");
|
||||
println!(" • Real-time format preview and validation");
|
||||
println!(" • Advanced cursor position mapping");
|
||||
println!(" • Comprehensive error handling and warnings");
|
||||
println!(" • Raw vs formatted data separation demos");
|
||||
println!();
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let data = MultiFormatterDemoData::new();
|
||||
let editor = EnhancedDemoEditor::new(data);
|
||||
|
||||
let res = run_app(&mut terminal, editor);
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(err) = res {
|
||||
println!("{:?}", err);
|
||||
}
|
||||
|
||||
println!("🧩 Enhanced custom formatter demo completed!");
|
||||
println!("🏆 You experienced comprehensive custom formatting with:");
|
||||
println!(" • Multiple formatter types (PSC, Phone, Credit Card, Date)");
|
||||
println!(" • Edge case handling (incomplete, invalid, overflow)");
|
||||
println!(" • Real-time format preview and cursor mapping");
|
||||
println!(" • Clear separation between raw business data and display formatting");
|
||||
Ok(())
|
||||
}
|
||||
1089
canvas/examples/validation_5.rs
Normal file
1089
canvas/examples/validation_5.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,93 +0,0 @@
|
||||
// src/autocomplete/actions.rs
|
||||
|
||||
use crate::canvas::state::{CanvasState, ActionContext};
|
||||
use crate::autocomplete::state::AutocompleteCanvasState;
|
||||
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
||||
use crate::canvas::actions::edit::handle_generic_canvas_action; // Import the core function
|
||||
use anyhow::Result;
|
||||
|
||||
/// Version for states that implement rich autocomplete
|
||||
pub async fn execute_canvas_action_with_autocomplete<S: CanvasState + AutocompleteCanvasState>(
|
||||
action: CanvasAction,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<ActionResult> {
|
||||
// 1. Try feature-specific handler first
|
||||
let context = ActionContext {
|
||||
key_code: None,
|
||||
ideal_cursor_column: *ideal_cursor_column,
|
||||
current_input: state.get_current_input().to_string(),
|
||||
current_field: state.current_field(),
|
||||
};
|
||||
|
||||
if let Some(result) = state.handle_feature_action(&action, &context) {
|
||||
return Ok(ActionResult::HandledByFeature(result));
|
||||
}
|
||||
|
||||
// 2. Handle rich autocomplete actions
|
||||
if let Some(result) = handle_rich_autocomplete_action(&action, state)? {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// 3. Handle generic canvas actions
|
||||
handle_generic_canvas_action(action, state, ideal_cursor_column).await
|
||||
}
|
||||
|
||||
/// Handle rich autocomplete actions for AutocompleteCanvasState
|
||||
fn handle_rich_autocomplete_action<S: CanvasState + AutocompleteCanvasState>(
|
||||
action: &CanvasAction,
|
||||
state: &mut S,
|
||||
) -> Result<Option<ActionResult>> {
|
||||
match action {
|
||||
CanvasAction::TriggerAutocomplete => {
|
||||
if state.supports_autocomplete(state.current_field()) {
|
||||
state.activate_autocomplete();
|
||||
Ok(Some(ActionResult::success_with_message("Autocomplete activated - fetching suggestions...")))
|
||||
} else {
|
||||
Ok(Some(ActionResult::error("Autocomplete not supported for this field")))
|
||||
}
|
||||
}
|
||||
|
||||
CanvasAction::SuggestionDown => {
|
||||
if state.is_autocomplete_ready() {
|
||||
if let Some(autocomplete_state) = state.autocomplete_state_mut() {
|
||||
autocomplete_state.select_next();
|
||||
return Ok(Some(ActionResult::success()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
CanvasAction::SuggestionUp => {
|
||||
if state.is_autocomplete_ready() {
|
||||
if let Some(autocomplete_state) = state.autocomplete_state_mut() {
|
||||
autocomplete_state.select_previous();
|
||||
return Ok(Some(ActionResult::success()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
CanvasAction::SelectSuggestion => {
|
||||
if state.is_autocomplete_ready() {
|
||||
if let Some(message) = state.apply_autocomplete_selection() {
|
||||
return Ok(Some(ActionResult::success_with_message(message)));
|
||||
} else {
|
||||
return Ok(Some(ActionResult::error("No suggestion selected")));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
CanvasAction::ExitSuggestions => {
|
||||
if state.is_autocomplete_active() {
|
||||
state.deactivate_autocomplete();
|
||||
Ok(Some(ActionResult::success_with_message("Autocomplete cancelled")))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// src/autocomplete/mod.rs
|
||||
pub mod types;
|
||||
pub mod gui;
|
||||
pub mod state;
|
||||
pub mod actions;
|
||||
|
||||
// Re-export autocomplete types
|
||||
pub use types::{SuggestionItem, AutocompleteState};
|
||||
pub use state::AutocompleteCanvasState;
|
||||
pub use actions::execute_canvas_action_with_autocomplete;
|
||||
@@ -1,96 +0,0 @@
|
||||
// canvas/src/state.rs
|
||||
|
||||
use crate::canvas::state::CanvasState;
|
||||
|
||||
/// OPTIONAL extension trait for states that want rich autocomplete functionality.
|
||||
/// Only implement this if you need the new autocomplete features.
|
||||
pub trait AutocompleteCanvasState: CanvasState {
|
||||
/// Associated type for suggestion data (e.g., Hit, String, CustomType)
|
||||
type SuggestionData: Clone + Send + 'static;
|
||||
|
||||
/// Check if a field supports autocomplete
|
||||
fn supports_autocomplete(&self, _field_index: usize) -> bool {
|
||||
false // Default: no autocomplete support
|
||||
}
|
||||
|
||||
/// Get autocomplete state (read-only)
|
||||
fn autocomplete_state(&self) -> Option<&crate::autocomplete::AutocompleteState<Self::SuggestionData>> {
|
||||
None // Default: no autocomplete state
|
||||
}
|
||||
|
||||
/// Get autocomplete state (mutable)
|
||||
fn autocomplete_state_mut(&mut self) -> Option<&mut crate::autocomplete::AutocompleteState<Self::SuggestionData>> {
|
||||
None // Default: no autocomplete state
|
||||
}
|
||||
|
||||
/// CLIENT API: Activate autocomplete for current field
|
||||
fn activate_autocomplete(&mut self) {
|
||||
let current_field = self.current_field(); // Get field first
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.activate(current_field); // Then use it
|
||||
}
|
||||
}
|
||||
|
||||
/// CLIENT API: Deactivate autocomplete
|
||||
fn deactivate_autocomplete(&mut self) {
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.deactivate();
|
||||
}
|
||||
}
|
||||
|
||||
/// CLIENT API: Set suggestions (called after async fetch completes)
|
||||
fn set_autocomplete_suggestions(&mut self, suggestions: Vec<crate::autocomplete::SuggestionItem<Self::SuggestionData>>) {
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.set_suggestions(suggestions);
|
||||
}
|
||||
}
|
||||
|
||||
/// CLIENT API: Set loading state
|
||||
fn set_autocomplete_loading(&mut self, loading: bool) {
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.is_loading = loading;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if autocomplete is currently active
|
||||
fn is_autocomplete_active(&self) -> bool {
|
||||
self.autocomplete_state()
|
||||
.map(|state| state.is_active)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check if autocomplete is ready for interaction
|
||||
fn is_autocomplete_ready(&self) -> bool {
|
||||
self.autocomplete_state()
|
||||
.map(|state| state.is_ready())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// INTERNAL: Apply selected autocomplete value to current field
|
||||
fn apply_autocomplete_selection(&mut self) -> Option<String> {
|
||||
// First, get the selected value and display text (if any)
|
||||
let selection_info = if let Some(state) = self.autocomplete_state() {
|
||||
state.get_selected().map(|selected| {
|
||||
(selected.value_to_store.clone(), selected.display_text.clone())
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Apply the selection if we have one
|
||||
if let Some((value, display)) = selection_info {
|
||||
// Apply the value to current field
|
||||
*self.get_current_input_mut() = value;
|
||||
self.set_has_unsaved_changes(true);
|
||||
|
||||
// Deactivate autocomplete
|
||||
if let Some(state_mut) = self.autocomplete_state_mut() {
|
||||
state_mut.deactivate();
|
||||
}
|
||||
|
||||
Some(format!("Selected: {}", display))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
// canvas/src/autocomplete.rs
|
||||
|
||||
/// Generic suggestion item that clients push to canvas
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SuggestionItem<T> {
|
||||
/// The underlying data (client-specific, e.g., Hit, String, etc.)
|
||||
pub data: T,
|
||||
/// Text to display in the dropdown
|
||||
pub display_text: String,
|
||||
/// Value to store in the form field when selected
|
||||
pub value_to_store: String,
|
||||
}
|
||||
|
||||
impl<T> SuggestionItem<T> {
|
||||
pub fn new(data: T, display_text: String, value_to_store: String) -> Self {
|
||||
Self {
|
||||
data,
|
||||
display_text,
|
||||
value_to_store,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience constructor for simple string suggestions
|
||||
pub fn simple(data: T, text: String) -> Self {
|
||||
Self {
|
||||
data,
|
||||
display_text: text.clone(),
|
||||
value_to_store: text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Autocomplete state managed by canvas
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutocompleteState<T> {
|
||||
/// Whether autocomplete is currently active/visible
|
||||
pub is_active: bool,
|
||||
/// Whether suggestions are being loaded (for spinner/loading indicator)
|
||||
pub is_loading: bool,
|
||||
/// Current suggestions to display
|
||||
pub suggestions: Vec<SuggestionItem<T>>,
|
||||
/// Currently selected suggestion index
|
||||
pub selected_index: Option<usize>,
|
||||
/// Field index that triggered autocomplete (for context)
|
||||
pub active_field: Option<usize>,
|
||||
}
|
||||
|
||||
impl<T> Default for AutocompleteState<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
is_active: false,
|
||||
is_loading: false,
|
||||
suggestions: Vec::new(),
|
||||
selected_index: None,
|
||||
active_field: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> AutocompleteState<T> {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Activate autocomplete for a specific field
|
||||
pub fn activate(&mut self, field_index: usize) {
|
||||
self.is_active = true;
|
||||
self.active_field = Some(field_index);
|
||||
self.selected_index = None;
|
||||
self.suggestions.clear();
|
||||
self.is_loading = true;
|
||||
}
|
||||
|
||||
/// Deactivate autocomplete and clear state
|
||||
pub fn deactivate(&mut self) {
|
||||
self.is_active = false;
|
||||
self.is_loading = false;
|
||||
self.suggestions.clear();
|
||||
self.selected_index = None;
|
||||
self.active_field = None;
|
||||
}
|
||||
|
||||
/// Set suggestions and stop loading
|
||||
pub fn set_suggestions(&mut self, suggestions: Vec<SuggestionItem<T>>) {
|
||||
self.suggestions = suggestions;
|
||||
self.is_loading = false;
|
||||
self.selected_index = if self.suggestions.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(0)
|
||||
};
|
||||
}
|
||||
|
||||
/// Move selection down
|
||||
pub fn select_next(&mut self) {
|
||||
if !self.suggestions.is_empty() {
|
||||
let current = self.selected_index.unwrap_or(0);
|
||||
self.selected_index = Some((current + 1) % self.suggestions.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// Move selection up
|
||||
pub fn select_previous(&mut self) {
|
||||
if !self.suggestions.is_empty() {
|
||||
let current = self.selected_index.unwrap_or(0);
|
||||
self.selected_index = Some(
|
||||
if current == 0 {
|
||||
self.suggestions.len() - 1
|
||||
} else {
|
||||
current - 1
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get currently selected suggestion
|
||||
pub fn get_selected(&self) -> Option<&SuggestionItem<T>> {
|
||||
self.selected_index
|
||||
.and_then(|idx| self.suggestions.get(idx))
|
||||
}
|
||||
|
||||
/// Check if autocomplete is ready for interaction (active and has suggestions)
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.is_active && !self.suggestions.is_empty() && !self.is_loading
|
||||
}
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
// canvas/src/actions/edit.rs
|
||||
|
||||
use crate::canvas::state::{CanvasState, ActionContext};
|
||||
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Execute a typed canvas action on any CanvasState implementation
|
||||
pub async fn execute_canvas_action<S: CanvasState>(
|
||||
action: CanvasAction,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<ActionResult> {
|
||||
let context = ActionContext {
|
||||
key_code: None,
|
||||
ideal_cursor_column: *ideal_cursor_column,
|
||||
current_input: state.get_current_input().to_string(),
|
||||
current_field: state.current_field(),
|
||||
};
|
||||
|
||||
if let Some(result) = state.handle_feature_action(&action, &context) {
|
||||
return Ok(ActionResult::HandledByFeature(result));
|
||||
}
|
||||
|
||||
handle_generic_canvas_action(action, state, ideal_cursor_column).await
|
||||
}
|
||||
|
||||
/// Handle core canvas actions with full type safety
|
||||
pub async fn handle_generic_canvas_action<S: CanvasState>(
|
||||
action: CanvasAction,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<ActionResult> {
|
||||
match action {
|
||||
CanvasAction::InsertChar(c) => {
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
let field_value = state.get_current_input_mut();
|
||||
let mut chars: Vec<char> = field_value.chars().collect();
|
||||
|
||||
if cursor_pos <= chars.len() {
|
||||
chars.insert(cursor_pos, c);
|
||||
*field_value = chars.into_iter().collect();
|
||||
state.set_current_cursor_pos(cursor_pos + 1);
|
||||
state.set_has_unsaved_changes(true);
|
||||
*ideal_cursor_column = state.current_cursor_pos();
|
||||
Ok(ActionResult::success())
|
||||
} else {
|
||||
Ok(ActionResult::error("Invalid cursor position for character insertion"))
|
||||
}
|
||||
}
|
||||
|
||||
CanvasAction::DeleteBackward => {
|
||||
if state.current_cursor_pos() > 0 {
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
let field_value = state.get_current_input_mut();
|
||||
let mut chars: Vec<char> = field_value.chars().collect();
|
||||
|
||||
if cursor_pos <= chars.len() {
|
||||
chars.remove(cursor_pos - 1);
|
||||
*field_value = chars.into_iter().collect();
|
||||
let new_pos = cursor_pos - 1;
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
state.set_has_unsaved_changes(true);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::DeleteForward => {
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
let field_value = state.get_current_input_mut();
|
||||
let mut chars: Vec<char> = field_value.chars().collect();
|
||||
|
||||
if cursor_pos < chars.len() {
|
||||
chars.remove(cursor_pos);
|
||||
*field_value = chars.into_iter().collect();
|
||||
state.set_has_unsaved_changes(true);
|
||||
*ideal_cursor_column = cursor_pos;
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::NextField => {
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields > 0 {
|
||||
let current_field = state.current_field();
|
||||
let new_field = (current_field + 1) % num_fields;
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::PrevField => {
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields > 0 {
|
||||
let current_field = state.current_field();
|
||||
let new_field = if current_field == 0 {
|
||||
num_fields - 1
|
||||
} else {
|
||||
current_field - 1
|
||||
};
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveLeft => {
|
||||
let new_pos = state.current_cursor_pos().saturating_sub(1);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveRight => {
|
||||
let current_input = state.get_current_input();
|
||||
let current_pos = state.current_cursor_pos();
|
||||
if current_pos < current_input.len() {
|
||||
let new_pos = current_pos + 1;
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveUp => {
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields > 0 {
|
||||
let current_field = state.current_field();
|
||||
let new_field = current_field.saturating_sub(1);
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveDown => {
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields > 0 {
|
||||
let new_field = (state.current_field() + 1).min(num_fields - 1);
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveLineStart => {
|
||||
state.set_current_cursor_pos(0);
|
||||
*ideal_cursor_column = 0;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveLineEnd => {
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = current_input.len();
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveFirstLine => {
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields > 0 {
|
||||
state.set_current_field(0);
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
|
||||
}
|
||||
Ok(ActionResult::success_with_message("Moved to first field"))
|
||||
}
|
||||
|
||||
CanvasAction::MoveLastLine => {
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields > 0 {
|
||||
let new_field = num_fields - 1;
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
|
||||
}
|
||||
Ok(ActionResult::success_with_message("Moved to last field"))
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordNext => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
|
||||
let final_pos = new_pos.min(current_input.len());
|
||||
state.set_current_cursor_pos(final_pos);
|
||||
*ideal_cursor_column = final_pos;
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordEnd => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let new_pos = find_word_end(current_input, current_pos);
|
||||
|
||||
let final_pos = if new_pos == current_pos {
|
||||
find_word_end(current_input, new_pos + 1)
|
||||
} else {
|
||||
new_pos
|
||||
};
|
||||
|
||||
let max_valid_index = current_input.len().saturating_sub(1);
|
||||
let clamped_pos = final_pos.min(max_valid_index);
|
||||
state.set_current_cursor_pos(clamped_pos);
|
||||
*ideal_cursor_column = clamped_pos;
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordPrev => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordEndPrev => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_end(current_input, state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok(ActionResult::success_with_message("Moved to previous word end"))
|
||||
}
|
||||
|
||||
CanvasAction::Custom(action_str) => {
|
||||
Ok(ActionResult::error(format!("Unknown or unhandled custom action: {}", action_str)))
|
||||
}
|
||||
|
||||
// Autocomplete actions are handled by the autocomplete module
|
||||
CanvasAction::TriggerAutocomplete | CanvasAction::SuggestionUp | CanvasAction::SuggestionDown |
|
||||
CanvasAction::SelectSuggestion | CanvasAction::ExitSuggestions => {
|
||||
Ok(ActionResult::error("Autocomplete actions should be handled by autocomplete module"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Word movement helper functions
|
||||
#[derive(PartialEq)]
|
||||
enum CharType {
|
||||
Whitespace,
|
||||
Alphanumeric,
|
||||
Punctuation,
|
||||
}
|
||||
|
||||
fn get_char_type(c: char) -> CharType {
|
||||
if c.is_whitespace() {
|
||||
CharType::Whitespace
|
||||
} else if c.is_alphanumeric() {
|
||||
CharType::Alphanumeric
|
||||
} else {
|
||||
CharType::Punctuation
|
||||
}
|
||||
}
|
||||
|
||||
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
if len == 0 || current_pos >= len {
|
||||
return len;
|
||||
}
|
||||
|
||||
let mut pos = current_pos;
|
||||
let initial_type = get_char_type(chars[pos]);
|
||||
|
||||
while pos < len && get_char_type(chars[pos]) == initial_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
while pos < len && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
fn find_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
if len == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.min(len - 1);
|
||||
|
||||
if get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos = find_next_word_start(text, pos);
|
||||
}
|
||||
|
||||
if pos >= len {
|
||||
return len.saturating_sub(1);
|
||||
}
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos < len && get_char_type(chars[pos]) == word_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos.saturating_sub(1).min(len.saturating_sub(1))
|
||||
}
|
||||
|
||||
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
if len == 0 || current_pos == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
return 0;
|
||||
}
|
||||
if pos == 0 && get_char_type(chars[pos]) != CharType::Whitespace {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos > 0 {
|
||||
pos - 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// canvas/src/canvas/actions/mod.rs
|
||||
pub mod types;
|
||||
pub mod edit;
|
||||
// src/canvas/actions/mod.rs
|
||||
|
||||
// Re-export the main types for convenience
|
||||
pub mod types;
|
||||
pub mod movement;
|
||||
|
||||
// Re-export the main API
|
||||
pub use types::{CanvasAction, ActionResult};
|
||||
pub use edit::execute_canvas_action; // Remove execute_edit_action
|
||||
|
||||
49
canvas/src/canvas/actions/movement/char.rs
Normal file
49
canvas/src/canvas/actions/movement/char.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
// src/canvas/actions/movement/char.rs
|
||||
|
||||
/// Calculate new position when moving left
|
||||
pub fn move_left(current_pos: usize) -> usize {
|
||||
current_pos.saturating_sub(1)
|
||||
}
|
||||
|
||||
/// Calculate new position when moving right
|
||||
pub fn move_right(current_pos: usize, text: &str, for_edit_mode: bool) -> usize {
|
||||
if text.is_empty() {
|
||||
return current_pos;
|
||||
}
|
||||
|
||||
if for_edit_mode {
|
||||
// Edit mode: can move past end of text
|
||||
(current_pos + 1).min(text.len())
|
||||
} else {
|
||||
// Read-only/highlight mode: stays within text bounds
|
||||
if current_pos < text.len().saturating_sub(1) {
|
||||
current_pos + 1
|
||||
} else {
|
||||
current_pos
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if cursor position is valid for the given mode
|
||||
pub fn is_valid_cursor_position(pos: usize, text: &str, for_edit_mode: bool) -> bool {
|
||||
if text.is_empty() {
|
||||
return pos == 0;
|
||||
}
|
||||
|
||||
if for_edit_mode {
|
||||
pos <= text.len()
|
||||
} else {
|
||||
pos < text.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp cursor position to valid bounds for the given mode
|
||||
pub fn clamp_cursor_position(pos: usize, text: &str, for_edit_mode: bool) -> usize {
|
||||
if text.is_empty() {
|
||||
0
|
||||
} else if for_edit_mode {
|
||||
pos.min(text.len())
|
||||
} else {
|
||||
pos.min(text.len().saturating_sub(1))
|
||||
}
|
||||
}
|
||||
32
canvas/src/canvas/actions/movement/line.rs
Normal file
32
canvas/src/canvas/actions/movement/line.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
// src/canvas/actions/movement/line.rs
|
||||
|
||||
/// Calculate cursor position for line start
|
||||
pub fn line_start_position() -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
/// Calculate cursor position for line end
|
||||
pub fn line_end_position(text: &str, for_edit_mode: bool) -> usize {
|
||||
if text.is_empty() {
|
||||
0
|
||||
} else if for_edit_mode {
|
||||
// Edit mode: cursor can go past end of text
|
||||
text.len()
|
||||
} else {
|
||||
// Read-only/highlight mode: cursor stays on last character
|
||||
text.len().saturating_sub(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate safe cursor position when switching fields
|
||||
pub fn safe_cursor_position(text: &str, ideal_column: usize, for_edit_mode: bool) -> usize {
|
||||
if text.is_empty() {
|
||||
0
|
||||
} else if for_edit_mode {
|
||||
// Edit mode: cursor can go past end
|
||||
ideal_column.min(text.len())
|
||||
} else {
|
||||
// Read-only/highlight mode: cursor stays within text
|
||||
ideal_column.min(text.len().saturating_sub(1))
|
||||
}
|
||||
}
|
||||
10
canvas/src/canvas/actions/movement/mod.rs
Normal file
10
canvas/src/canvas/actions/movement/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
// src/canvas/actions/movement/mod.rs
|
||||
|
||||
pub mod word;
|
||||
pub mod line;
|
||||
pub mod char;
|
||||
|
||||
// Re-export commonly used functions
|
||||
pub use word::{find_next_word_start, find_word_end, find_prev_word_start, find_prev_word_end};
|
||||
pub use line::{line_start_position, line_end_position, safe_cursor_position};
|
||||
pub use char::{move_left, move_right, is_valid_cursor_position, clamp_cursor_position};
|
||||
146
canvas/src/canvas/actions/movement/word.rs
Normal file
146
canvas/src/canvas/actions/movement/word.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
// src/canvas/actions/movement/word.rs
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum CharType {
|
||||
Whitespace,
|
||||
Alphanumeric,
|
||||
Punctuation,
|
||||
}
|
||||
|
||||
fn get_char_type(c: char) -> CharType {
|
||||
if c.is_whitespace() {
|
||||
CharType::Whitespace
|
||||
} else if c.is_alphanumeric() {
|
||||
CharType::Alphanumeric
|
||||
} else {
|
||||
CharType::Punctuation
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the start of the next word from the current position
|
||||
pub fn find_next_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let current_pos = current_pos.min(chars.len());
|
||||
|
||||
if current_pos == chars.len() {
|
||||
return current_pos;
|
||||
}
|
||||
|
||||
let mut pos = current_pos;
|
||||
let initial_type = get_char_type(chars[pos]);
|
||||
|
||||
// Skip current word/token
|
||||
while pos < chars.len() && get_char_type(chars[pos]) == initial_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
// Skip whitespace
|
||||
while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
/// Find the end of the current or next word
|
||||
pub fn find_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
if len == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.min(len - 1);
|
||||
let current_type = get_char_type(chars[pos]);
|
||||
|
||||
// If we're not on whitespace, move to end of current word
|
||||
if current_type != CharType::Whitespace {
|
||||
while pos < len && get_char_type(chars[pos]) == current_type {
|
||||
pos += 1;
|
||||
}
|
||||
return pos.saturating_sub(1);
|
||||
}
|
||||
|
||||
// If we're on whitespace, find next word and go to its end
|
||||
pos = find_next_word_start(text, pos);
|
||||
if pos >= len {
|
||||
return len.saturating_sub(1);
|
||||
}
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos < len && get_char_type(chars[pos]) == word_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos.saturating_sub(1).min(len.saturating_sub(1))
|
||||
}
|
||||
|
||||
/// Find the start of the previous word
|
||||
pub fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
|
||||
// Skip whitespace backwards
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
// Move to start of word
|
||||
if get_char_type(chars[pos]) != CharType::Whitespace {
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
|
||||
0
|
||||
} else {
|
||||
pos
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the end of the previous word
|
||||
pub fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
|
||||
// Skip whitespace backwards
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
|
||||
return 0;
|
||||
}
|
||||
if pos == 0 && get_char_type(chars[0]) != CharType::Whitespace {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
// Skip whitespace before this word
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos > 0 {
|
||||
pos - 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
@@ -1,237 +1,171 @@
|
||||
// canvas/src/actions/types.rs
|
||||
// src/canvas/actions/types.rs
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
/// All possible canvas actions, type-safe and exhaustive
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// All available canvas actions
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum CanvasAction {
|
||||
// Character input
|
||||
InsertChar(char),
|
||||
|
||||
// Deletion
|
||||
DeleteBackward,
|
||||
DeleteForward,
|
||||
|
||||
// Basic cursor movement
|
||||
// Movement actions
|
||||
MoveLeft,
|
||||
MoveRight,
|
||||
MoveUp,
|
||||
MoveDown,
|
||||
|
||||
// Word movement
|
||||
MoveWordNext,
|
||||
MoveWordPrev,
|
||||
MoveWordEnd,
|
||||
MoveWordEndPrev,
|
||||
|
||||
// Line movement
|
||||
MoveLineStart,
|
||||
MoveLineEnd,
|
||||
|
||||
// Field movement
|
||||
NextField,
|
||||
PrevField,
|
||||
MoveFirstLine,
|
||||
MoveLastLine,
|
||||
|
||||
// Word movement
|
||||
MoveWordNext,
|
||||
MoveWordEnd,
|
||||
MoveWordPrev,
|
||||
MoveWordEndPrev,
|
||||
// Editing actions
|
||||
InsertChar(char),
|
||||
DeleteBackward,
|
||||
DeleteForward,
|
||||
|
||||
// Field navigation
|
||||
NextField,
|
||||
PrevField,
|
||||
// Suggestions actions
|
||||
TriggerSuggestions,
|
||||
SuggestionUp,
|
||||
SuggestionDown,
|
||||
SelectSuggestion,
|
||||
ExitSuggestions,
|
||||
|
||||
// AUTOCOMPLETE ACTIONS (NEW)
|
||||
/// Manually trigger autocomplete for current field
|
||||
TriggerAutocomplete,
|
||||
/// Move to next suggestion
|
||||
SuggestionUp,
|
||||
/// Move to previous suggestion
|
||||
SuggestionDown,
|
||||
/// Select the currently highlighted suggestion
|
||||
SelectSuggestion,
|
||||
/// Cancel/exit autocomplete mode
|
||||
ExitSuggestions,
|
||||
|
||||
// Custom actions (escape hatch for feature-specific behavior)
|
||||
// Custom actions
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl CanvasAction {
|
||||
/// Convert a string action to typed action (for backwards compatibility during migration)
|
||||
pub fn from_string(action: &str) -> Self {
|
||||
match action {
|
||||
"insert_char" => {
|
||||
// This is a bit tricky - we need the char from context
|
||||
// For now, we'll use Custom until we refactor the call sites
|
||||
Self::Custom(action.to_string())
|
||||
}
|
||||
"delete_char_backward" => Self::DeleteBackward,
|
||||
"delete_char_forward" => Self::DeleteForward,
|
||||
"move_left" => Self::MoveLeft,
|
||||
"move_right" => Self::MoveRight,
|
||||
"move_up" => Self::MoveUp,
|
||||
"move_down" => Self::MoveDown,
|
||||
"move_line_start" => Self::MoveLineStart,
|
||||
"move_line_end" => Self::MoveLineEnd,
|
||||
"move_first_line" => Self::MoveFirstLine,
|
||||
"move_last_line" => Self::MoveLastLine,
|
||||
"move_word_next" => Self::MoveWordNext,
|
||||
"move_word_end" => Self::MoveWordEnd,
|
||||
"move_word_prev" => Self::MoveWordPrev,
|
||||
"move_word_end_prev" => Self::MoveWordEndPrev,
|
||||
"next_field" => Self::NextField,
|
||||
"prev_field" => Self::PrevField,
|
||||
// Autocomplete actions
|
||||
"trigger_autocomplete" => Self::TriggerAutocomplete,
|
||||
"suggestion_up" => Self::SuggestionUp,
|
||||
"suggestion_down" => Self::SuggestionDown,
|
||||
"select_suggestion" => Self::SelectSuggestion,
|
||||
"exit_suggestions" => Self::ExitSuggestions,
|
||||
_ => Self::Custom(action.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get string representation (for logging, debugging)
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::InsertChar(_) => "insert_char",
|
||||
Self::DeleteBackward => "delete_char_backward",
|
||||
Self::DeleteForward => "delete_char_forward",
|
||||
Self::MoveLeft => "move_left",
|
||||
Self::MoveRight => "move_right",
|
||||
Self::MoveUp => "move_up",
|
||||
Self::MoveDown => "move_down",
|
||||
Self::MoveLineStart => "move_line_start",
|
||||
Self::MoveLineEnd => "move_line_end",
|
||||
Self::MoveFirstLine => "move_first_line",
|
||||
Self::MoveLastLine => "move_last_line",
|
||||
Self::MoveWordNext => "move_word_next",
|
||||
Self::MoveWordEnd => "move_word_end",
|
||||
Self::MoveWordPrev => "move_word_prev",
|
||||
Self::MoveWordEndPrev => "move_word_end_prev",
|
||||
Self::NextField => "next_field",
|
||||
Self::PrevField => "prev_field",
|
||||
// Autocomplete actions
|
||||
Self::TriggerAutocomplete => "trigger_autocomplete",
|
||||
Self::SuggestionUp => "suggestion_up",
|
||||
Self::SuggestionDown => "suggestion_down",
|
||||
Self::SelectSuggestion => "select_suggestion",
|
||||
Self::ExitSuggestions => "exit_suggestions",
|
||||
Self::Custom(s) => s,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create action from KeyCode for common cases
|
||||
pub fn from_key(key: KeyCode) -> Option<Self> {
|
||||
match key {
|
||||
KeyCode::Char(c) => Some(Self::InsertChar(c)),
|
||||
KeyCode::Backspace => Some(Self::DeleteBackward),
|
||||
KeyCode::Delete => Some(Self::DeleteForward),
|
||||
KeyCode::Left => Some(Self::MoveLeft),
|
||||
KeyCode::Right => Some(Self::MoveRight),
|
||||
KeyCode::Up => Some(Self::MoveUp),
|
||||
KeyCode::Down => Some(Self::MoveDown),
|
||||
KeyCode::Home => Some(Self::MoveLineStart),
|
||||
KeyCode::End => Some(Self::MoveLineEnd),
|
||||
KeyCode::Tab => Some(Self::NextField),
|
||||
KeyCode::BackTab => Some(Self::PrevField),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this action modifies content
|
||||
pub fn is_modifying(&self) -> bool {
|
||||
matches!(self,
|
||||
Self::InsertChar(_) |
|
||||
Self::DeleteBackward |
|
||||
Self::DeleteForward |
|
||||
Self::SelectSuggestion
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if this action moves the cursor
|
||||
pub fn is_movement(&self) -> bool {
|
||||
matches!(self,
|
||||
Self::MoveLeft | Self::MoveRight | Self::MoveUp | Self::MoveDown |
|
||||
Self::MoveLineStart | Self::MoveLineEnd | Self::MoveFirstLine | Self::MoveLastLine |
|
||||
Self::MoveWordNext | Self::MoveWordEnd | Self::MoveWordPrev | Self::MoveWordEndPrev |
|
||||
Self::NextField | Self::PrevField
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if this is a suggestion-related action
|
||||
pub fn is_suggestion(&self) -> bool {
|
||||
matches!(self,
|
||||
Self::TriggerAutocomplete | Self::SuggestionUp | Self::SuggestionDown |
|
||||
Self::SelectSuggestion | Self::ExitSuggestions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of executing a canvas action
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// Result type for canvas actions
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ActionResult {
|
||||
/// Action completed successfully, optional message for user feedback
|
||||
Success(Option<String>),
|
||||
/// Action was handled by custom feature logic
|
||||
HandledByFeature(String),
|
||||
/// Action requires additional context or cannot be performed
|
||||
RequiresContext(String),
|
||||
/// Action failed with error message
|
||||
Success,
|
||||
Message(String),
|
||||
HandledByApp(String),
|
||||
HandledByFeature(String), // Keep for compatibility
|
||||
Error(String),
|
||||
}
|
||||
|
||||
impl ActionResult {
|
||||
pub fn success() -> Self {
|
||||
Self::Success(None)
|
||||
Self::Success
|
||||
}
|
||||
|
||||
pub fn success_with_message(msg: impl Into<String>) -> Self {
|
||||
Self::Success(Some(msg.into()))
|
||||
pub fn success_with_message(msg: &str) -> Self {
|
||||
Self::Message(msg.to_string())
|
||||
}
|
||||
|
||||
pub fn error(msg: impl Into<String>) -> Self {
|
||||
Self::Error(msg.into())
|
||||
pub fn handled_by_app(msg: &str) -> Self {
|
||||
Self::HandledByApp(msg.to_string())
|
||||
}
|
||||
|
||||
pub fn error(msg: &str) -> Self {
|
||||
Self::Error(msg.to_string())
|
||||
}
|
||||
|
||||
pub fn is_success(&self) -> bool {
|
||||
matches!(self, Self::Success(_) | Self::HandledByFeature(_))
|
||||
matches!(self, Self::Success | Self::Message(_) | Self::HandledByApp(_) | Self::HandledByFeature(_))
|
||||
}
|
||||
|
||||
pub fn message(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Success(msg) => msg.as_deref(),
|
||||
Self::HandledByFeature(msg) => Some(msg),
|
||||
Self::RequiresContext(msg) => Some(msg),
|
||||
Self::Error(msg) => Some(msg),
|
||||
Self::Message(msg) | Self::HandledByApp(msg) | Self::HandledByFeature(msg) | Self::Error(msg) => Some(msg),
|
||||
Self::Success => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_action_from_string() {
|
||||
assert_eq!(CanvasAction::from_string("move_left"), CanvasAction::MoveLeft);
|
||||
assert_eq!(CanvasAction::from_string("delete_char_backward"), CanvasAction::DeleteBackward);
|
||||
assert_eq!(CanvasAction::from_string("trigger_autocomplete"), CanvasAction::TriggerAutocomplete);
|
||||
assert_eq!(CanvasAction::from_string("unknown"), CanvasAction::Custom("unknown".to_string()));
|
||||
impl CanvasAction {
|
||||
/// Get a human-readable description of this action
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::MoveLeft => "move left",
|
||||
Self::MoveRight => "move right",
|
||||
Self::MoveUp => "move up",
|
||||
Self::MoveDown => "move down",
|
||||
Self::MoveWordNext => "next word",
|
||||
Self::MoveWordPrev => "previous word",
|
||||
Self::MoveWordEnd => "word end",
|
||||
Self::MoveWordEndPrev => "previous word end",
|
||||
Self::MoveLineStart => "line start",
|
||||
Self::MoveLineEnd => "line end",
|
||||
Self::NextField => "next field",
|
||||
Self::PrevField => "previous field",
|
||||
Self::MoveFirstLine => "first field",
|
||||
Self::MoveLastLine => "last field",
|
||||
Self::InsertChar(_c) => "insert character",
|
||||
Self::DeleteBackward => "delete backward",
|
||||
Self::DeleteForward => "delete forward",
|
||||
Self::TriggerSuggestions => "trigger suggestions",
|
||||
Self::SuggestionUp => "suggestion up",
|
||||
Self::SuggestionDown => "suggestion down",
|
||||
Self::SelectSuggestion => "select suggestion",
|
||||
Self::ExitSuggestions => "exit suggestions",
|
||||
Self::Custom(_name) => "custom action",
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_from_key() {
|
||||
assert_eq!(CanvasAction::from_key(KeyCode::Char('a')), Some(CanvasAction::InsertChar('a')));
|
||||
assert_eq!(CanvasAction::from_key(KeyCode::Left), Some(CanvasAction::MoveLeft));
|
||||
assert_eq!(CanvasAction::from_key(KeyCode::Backspace), Some(CanvasAction::DeleteBackward));
|
||||
assert_eq!(CanvasAction::from_key(KeyCode::F(1)), None);
|
||||
/// Get all movement-related actions
|
||||
pub fn movement_actions() -> Vec<CanvasAction> {
|
||||
vec![
|
||||
Self::MoveLeft,
|
||||
Self::MoveRight,
|
||||
Self::MoveUp,
|
||||
Self::MoveDown,
|
||||
Self::MoveWordNext,
|
||||
Self::MoveWordPrev,
|
||||
Self::MoveWordEnd,
|
||||
Self::MoveWordEndPrev,
|
||||
Self::MoveLineStart,
|
||||
Self::MoveLineEnd,
|
||||
Self::NextField,
|
||||
Self::PrevField,
|
||||
Self::MoveFirstLine,
|
||||
Self::MoveLastLine,
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_properties() {
|
||||
assert!(CanvasAction::InsertChar('a').is_modifying());
|
||||
assert!(!CanvasAction::MoveLeft.is_modifying());
|
||||
/// Get all editing-related actions
|
||||
pub fn editing_actions() -> Vec<CanvasAction> {
|
||||
vec![
|
||||
Self::InsertChar(' '), // Example char
|
||||
Self::DeleteBackward,
|
||||
Self::DeleteForward,
|
||||
]
|
||||
}
|
||||
|
||||
assert!(CanvasAction::MoveLeft.is_movement());
|
||||
assert!(!CanvasAction::InsertChar('a').is_movement());
|
||||
/// Get all suggestions-related actions
|
||||
pub fn suggestions_actions() -> Vec<CanvasAction> {
|
||||
vec![
|
||||
Self::TriggerSuggestions,
|
||||
Self::SuggestionUp,
|
||||
Self::SuggestionDown,
|
||||
Self::SelectSuggestion,
|
||||
Self::ExitSuggestions,
|
||||
]
|
||||
}
|
||||
|
||||
assert!(CanvasAction::SuggestionUp.is_suggestion());
|
||||
assert!(CanvasAction::TriggerAutocomplete.is_suggestion());
|
||||
assert!(!CanvasAction::MoveLeft.is_suggestion());
|
||||
/// Check if this action modifies text content
|
||||
pub fn is_editing_action(&self) -> bool {
|
||||
matches!(self,
|
||||
Self::InsertChar(_) |
|
||||
Self::DeleteBackward |
|
||||
Self::DeleteForward
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if this action moves the cursor
|
||||
pub fn is_movement_action(&self) -> bool {
|
||||
matches!(self,
|
||||
Self::MoveLeft | Self::MoveRight | Self::MoveUp | Self::MoveDown |
|
||||
Self::MoveWordNext | Self::MoveWordPrev | Self::MoveWordEnd | Self::MoveWordEndPrev |
|
||||
Self::MoveLineStart | Self::MoveLineEnd | Self::NextField | Self::PrevField |
|
||||
Self::MoveFirstLine | Self::MoveLastLine
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
45
canvas/src/canvas/cursor.rs
Normal file
45
canvas/src/canvas/cursor.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
// src/canvas/cursor.rs
|
||||
//! Cursor style management for different canvas modes
|
||||
|
||||
#[cfg(feature = "cursor-style")]
|
||||
use crossterm::{cursor::SetCursorStyle, execute};
|
||||
#[cfg(feature = "cursor-style")]
|
||||
use std::io;
|
||||
|
||||
use crate::canvas::modes::AppMode;
|
||||
|
||||
/// Manages cursor styles based on canvas modes
|
||||
pub struct CursorManager;
|
||||
|
||||
impl CursorManager {
|
||||
/// Update cursor style based on current mode
|
||||
#[cfg(feature = "cursor-style")]
|
||||
pub fn update_for_mode(mode: AppMode) -> io::Result<()> {
|
||||
let style = match mode {
|
||||
AppMode::Edit => SetCursorStyle::SteadyBar, // Thin line for insert
|
||||
AppMode::ReadOnly => SetCursorStyle::SteadyBlock, // Block for normal
|
||||
AppMode::Highlight => SetCursorStyle::BlinkingBlock, // Blinking for visual
|
||||
AppMode::General => SetCursorStyle::SteadyBlock, // Block for general
|
||||
AppMode::Command => SetCursorStyle::SteadyUnderScore, // Underscore for command
|
||||
};
|
||||
|
||||
execute!(io::stdout(), style)
|
||||
}
|
||||
|
||||
/// No-op when cursor-style feature is disabled
|
||||
#[cfg(not(feature = "cursor-style"))]
|
||||
pub fn update_for_mode(_mode: AppMode) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset cursor to default on cleanup
|
||||
#[cfg(feature = "cursor-style")]
|
||||
pub fn reset() -> io::Result<()> {
|
||||
execute!(io::stdout(), SetCursorStyle::DefaultUserShape)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "cursor-style"))]
|
||||
pub fn reset() -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
// canvas/src/canvas/gui.rs
|
||||
// src/canvas/gui.rs
|
||||
//! Canvas GUI updated to work with FormEditor
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use ratatui::{
|
||||
@@ -9,28 +10,62 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::canvas::state::CanvasState;
|
||||
use crate::canvas::modes::HighlightState;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use crate::canvas::theme::CanvasTheme;
|
||||
use crate::canvas::theme::{CanvasTheme, DefaultCanvasTheme};
|
||||
use crate::canvas::modes::HighlightState;
|
||||
use crate::data_provider::DataProvider;
|
||||
use crate::editor::FormEditor;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use std::cmp::{max, min};
|
||||
|
||||
/// Render ONLY the canvas form fields - no autocomplete
|
||||
/// Render ONLY the canvas form fields - no suggestions rendering here
|
||||
/// Updated to work with FormEditor instead of CanvasState trait
|
||||
#[cfg(feature = "gui")]
|
||||
pub fn render_canvas<T: CanvasTheme>(
|
||||
pub fn render_canvas<T: CanvasTheme, D: DataProvider>(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
form_state: &impl CanvasState,
|
||||
editor: &FormEditor<D>,
|
||||
theme: &T,
|
||||
) -> Option<Rect> {
|
||||
// Convert SelectionState to HighlightState
|
||||
let highlight_state = convert_selection_to_highlight(editor.ui_state().selection_state());
|
||||
render_canvas_with_highlight(f, area, editor, theme, &highlight_state)
|
||||
}
|
||||
|
||||
/// Render canvas with explicit highlight state (for advanced use)
|
||||
#[cfg(feature = "gui")]
|
||||
pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
editor: &FormEditor<D>,
|
||||
theme: &T,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
) -> Option<Rect> {
|
||||
let fields: Vec<&str> = form_state.fields();
|
||||
let current_field_idx = form_state.current_field();
|
||||
let inputs: Vec<&String> = form_state.inputs();
|
||||
let ui_state = editor.ui_state();
|
||||
let data_provider = editor.data_provider();
|
||||
|
||||
// Build field information
|
||||
let field_count = data_provider.field_count();
|
||||
let mut fields: Vec<&str> = Vec::with_capacity(field_count);
|
||||
let mut inputs: Vec<String> = Vec::with_capacity(field_count);
|
||||
|
||||
for i in 0..field_count {
|
||||
fields.push(data_provider.field_name(i));
|
||||
|
||||
// Use editor-provided effective display text per field (Feature 4/mask aware)
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
inputs.push(editor.display_text_for_field(i));
|
||||
}
|
||||
#[cfg(not(feature = "validation"))]
|
||||
{
|
||||
inputs.push(data_provider.field_value(i).to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let current_field_idx = ui_state.current_field();
|
||||
let is_edit_mode = matches!(ui_state.mode(), crate::canvas::modes::AppMode::Edit);
|
||||
|
||||
render_canvas_fields(
|
||||
f,
|
||||
@@ -41,13 +76,56 @@ pub fn render_canvas<T: CanvasTheme>(
|
||||
theme,
|
||||
is_edit_mode,
|
||||
highlight_state,
|
||||
form_state.current_cursor_pos(),
|
||||
form_state.has_unsaved_changes(),
|
||||
|i| form_state.get_display_value_for_field(i).to_string(),
|
||||
|i| form_state.has_display_override(i),
|
||||
editor.display_cursor_position(), // Use display cursor position for masks
|
||||
false, // TODO: track unsaved changes in editor
|
||||
|i| {
|
||||
// Get display value for field i using editor logic (Feature 4 + masks)
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
editor.display_text_for_field(i)
|
||||
}
|
||||
#[cfg(not(feature = "validation"))]
|
||||
{
|
||||
data_provider.field_value(i).to_string()
|
||||
}
|
||||
},
|
||||
|i| {
|
||||
// Check if field has display override (custom formatter or mask)
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
editor.ui_state().validation_state().get_field_config(i)
|
||||
.map(|cfg| {
|
||||
// Formatter takes precedence; if present, it's a display override
|
||||
#[allow(unused_mut)]
|
||||
let mut has_override = false;
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
has_override = cfg.custom_formatter.is_some();
|
||||
}
|
||||
has_override || cfg.display_mask.is_some()
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
#[cfg(not(feature = "validation"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Convert SelectionState to HighlightState for rendering
|
||||
#[cfg(feature = "gui")]
|
||||
fn convert_selection_to_highlight(selection: &crate::canvas::state::SelectionState) -> HighlightState {
|
||||
use crate::canvas::state::SelectionState;
|
||||
|
||||
match selection {
|
||||
SelectionState::None => HighlightState::Off,
|
||||
SelectionState::Characterwise { anchor } => HighlightState::Characterwise { anchor: *anchor },
|
||||
SelectionState::Linewise { anchor_field } => HighlightState::Linewise { anchor_line: *anchor_field },
|
||||
}
|
||||
}
|
||||
|
||||
/// Core canvas field rendering
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_canvas_fields<T: CanvasTheme, F1, F2>(
|
||||
@@ -55,7 +133,7 @@ fn render_canvas_fields<T: CanvasTheme, F1, F2>(
|
||||
area: Rect,
|
||||
fields: &[&str],
|
||||
current_field_idx: &usize,
|
||||
inputs: &[&String],
|
||||
inputs: &[String],
|
||||
theme: &T,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
@@ -112,7 +190,7 @@ where
|
||||
// Render field values and return active field rect
|
||||
render_field_values(
|
||||
f,
|
||||
input_rows.to_vec(), // Fix: Convert Rc<[Rect]> to Vec<Rect>
|
||||
input_rows.to_vec(),
|
||||
inputs,
|
||||
current_field_idx,
|
||||
theme,
|
||||
@@ -154,7 +232,7 @@ fn render_field_labels<T: CanvasTheme>(
|
||||
fn render_field_values<T: CanvasTheme, F1, F2>(
|
||||
f: &mut Frame,
|
||||
input_rows: Vec<Rect>,
|
||||
inputs: &[&String],
|
||||
inputs: &[String],
|
||||
current_field_idx: &usize,
|
||||
theme: &T,
|
||||
highlight_state: &HighlightState,
|
||||
@@ -171,7 +249,7 @@ where
|
||||
for (i, _input) in inputs.iter().enumerate() {
|
||||
let is_active = i == *current_field_idx;
|
||||
let text = get_display_value(i);
|
||||
|
||||
|
||||
// Apply highlighting
|
||||
let line = apply_highlighting(
|
||||
&text,
|
||||
@@ -205,7 +283,7 @@ fn apply_highlighting<'a, T: CanvasTheme>(
|
||||
current_cursor_pos: usize,
|
||||
highlight_state: &HighlightState,
|
||||
theme: &T,
|
||||
is_active: bool,
|
||||
_is_active: bool,
|
||||
) -> Line<'a> {
|
||||
let text_len = text.chars().count();
|
||||
|
||||
@@ -213,23 +291,19 @@ fn apply_highlighting<'a, T: CanvasTheme>(
|
||||
HighlightState::Off => {
|
||||
Line::from(Span::styled(
|
||||
text,
|
||||
if is_active {
|
||||
Style::default().fg(theme.highlight())
|
||||
} else {
|
||||
Style::default().fg(theme.fg())
|
||||
},
|
||||
Style::default().fg(theme.fg())
|
||||
))
|
||||
}
|
||||
HighlightState::Characterwise { anchor } => {
|
||||
apply_characterwise_highlighting(text, text_len, field_index, current_field_idx, current_cursor_pos, anchor, theme, is_active)
|
||||
apply_characterwise_highlighting(text, text_len, field_index, current_field_idx, current_cursor_pos, anchor, theme, _is_active)
|
||||
}
|
||||
HighlightState::Linewise { anchor_line } => {
|
||||
apply_linewise_highlighting(text, field_index, current_field_idx, anchor_line, theme, is_active)
|
||||
apply_linewise_highlighting(text, field_index, current_field_idx, anchor_line, theme, _is_active)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply characterwise highlighting
|
||||
/// Apply characterwise highlighting - PROPER VIM-LIKE VERSION
|
||||
#[cfg(feature = "gui")]
|
||||
fn apply_characterwise_highlighting<'a, T: CanvasTheme>(
|
||||
text: &'a str,
|
||||
@@ -239,21 +313,25 @@ fn apply_characterwise_highlighting<'a, T: CanvasTheme>(
|
||||
current_cursor_pos: usize,
|
||||
anchor: &(usize, usize),
|
||||
theme: &T,
|
||||
is_active: bool,
|
||||
_is_active: bool,
|
||||
) -> Line<'a> {
|
||||
let (anchor_field, anchor_char) = *anchor;
|
||||
let start_field = min(anchor_field, *current_field_idx);
|
||||
let end_field = max(anchor_field, *current_field_idx);
|
||||
|
||||
// Vim-like styling:
|
||||
// - Selected text: contrasting color + background (like vim visual selection)
|
||||
// - All other text: normal color (no special colors for active fields, etc.)
|
||||
let highlight_style = Style::default()
|
||||
.fg(theme.highlight())
|
||||
.bg(theme.highlight_bg())
|
||||
.fg(theme.highlight()) // ✅ Contrasting text color for selected text
|
||||
.bg(theme.highlight_bg()) // ✅ Background for selected text
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let normal_style_in_highlight = Style::default().fg(theme.highlight());
|
||||
let normal_style_outside = Style::default().fg(theme.fg());
|
||||
|
||||
let normal_style = Style::default().fg(theme.fg()); // ✅ Normal text color everywhere else
|
||||
|
||||
if field_index >= start_field && field_index <= end_field {
|
||||
if start_field == end_field {
|
||||
// Single field selection
|
||||
let (start_char, end_char) = if anchor_field == *current_field_idx {
|
||||
(min(anchor_char, current_cursor_pos), max(anchor_char, current_cursor_pos))
|
||||
} else if anchor_field < *current_field_idx {
|
||||
@@ -273,23 +351,64 @@ fn apply_characterwise_highlighting<'a, T: CanvasTheme>(
|
||||
let after: String = text.chars().skip(clamped_end + 1).collect();
|
||||
|
||||
Line::from(vec![
|
||||
Span::styled(before, normal_style_in_highlight),
|
||||
Span::styled(highlighted, highlight_style),
|
||||
Span::styled(after, normal_style_in_highlight),
|
||||
Span::styled(before, normal_style), // Normal text color
|
||||
Span::styled(highlighted, highlight_style), // Contrasting color + background
|
||||
Span::styled(after, normal_style), // Normal text color
|
||||
])
|
||||
} else {
|
||||
// Multi-field selection
|
||||
Line::from(Span::styled(text, highlight_style))
|
||||
if field_index == anchor_field {
|
||||
if anchor_field < *current_field_idx {
|
||||
let clamped_start = anchor_char.min(text_len);
|
||||
let before: String = text.chars().take(clamped_start).collect();
|
||||
let highlighted: String = text.chars().skip(clamped_start).collect();
|
||||
|
||||
Line::from(vec![
|
||||
Span::styled(before, normal_style),
|
||||
Span::styled(highlighted, highlight_style),
|
||||
])
|
||||
} else {
|
||||
let clamped_end = anchor_char.min(text_len);
|
||||
let highlighted: String = text.chars().take(clamped_end + 1).collect();
|
||||
let after: String = text.chars().skip(clamped_end + 1).collect();
|
||||
|
||||
Line::from(vec![
|
||||
Span::styled(highlighted, highlight_style),
|
||||
Span::styled(after, normal_style),
|
||||
])
|
||||
}
|
||||
} else if field_index == *current_field_idx {
|
||||
if anchor_field < *current_field_idx {
|
||||
let clamped_end = current_cursor_pos.min(text_len);
|
||||
let highlighted: String = text.chars().take(clamped_end + 1).collect();
|
||||
let after: String = text.chars().skip(clamped_end + 1).collect();
|
||||
|
||||
Line::from(vec![
|
||||
Span::styled(highlighted, highlight_style),
|
||||
Span::styled(after, normal_style),
|
||||
])
|
||||
} else {
|
||||
let clamped_start = current_cursor_pos.min(text_len);
|
||||
let before: String = text.chars().take(clamped_start).collect();
|
||||
let highlighted: String = text.chars().skip(clamped_start).collect();
|
||||
|
||||
Line::from(vec![
|
||||
Span::styled(before, normal_style),
|
||||
Span::styled(highlighted, highlight_style),
|
||||
])
|
||||
}
|
||||
} else {
|
||||
// Middle field: highlight entire field
|
||||
Line::from(Span::styled(text, highlight_style))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Line::from(Span::styled(
|
||||
text,
|
||||
if is_active { normal_style_in_highlight } else { normal_style_outside }
|
||||
))
|
||||
// Outside selection: always normal text color (no special active field color)
|
||||
Line::from(Span::styled(text, normal_style))
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply linewise highlighting
|
||||
/// Apply linewise highlighting - PROPER VIM-LIKE VERSION
|
||||
#[cfg(feature = "gui")]
|
||||
fn apply_linewise_highlighting<'a, T: CanvasTheme>(
|
||||
text: &'a str,
|
||||
@@ -297,25 +416,27 @@ fn apply_linewise_highlighting<'a, T: CanvasTheme>(
|
||||
current_field_idx: &usize,
|
||||
anchor_line: &usize,
|
||||
theme: &T,
|
||||
is_active: bool,
|
||||
_is_active: bool,
|
||||
) -> Line<'a> {
|
||||
let start_field = min(*anchor_line, *current_field_idx);
|
||||
let end_field = max(*anchor_line, *current_field_idx);
|
||||
|
||||
|
||||
// Vim-like styling:
|
||||
// - Selected lines: contrasting text color + background
|
||||
// - All other lines: normal text color (no special active field color)
|
||||
let highlight_style = Style::default()
|
||||
.fg(theme.highlight())
|
||||
.bg(theme.highlight_bg())
|
||||
.fg(theme.highlight()) // ✅ Contrasting text color for selected text
|
||||
.bg(theme.highlight_bg()) // ✅ Background for selected text
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let normal_style_in_highlight = Style::default().fg(theme.highlight());
|
||||
let normal_style_outside = Style::default().fg(theme.fg());
|
||||
|
||||
let normal_style = Style::default().fg(theme.fg()); // ✅ Normal text color everywhere else
|
||||
|
||||
if field_index >= start_field && field_index <= end_field {
|
||||
// Selected line: contrasting text color + background
|
||||
Line::from(Span::styled(text, highlight_style))
|
||||
} else {
|
||||
Line::from(Span::styled(
|
||||
text,
|
||||
if is_active { normal_style_in_highlight } else { normal_style_outside }
|
||||
))
|
||||
// Normal line: normal text color (no special active field color)
|
||||
Line::from(Span::styled(text, normal_style))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,11 +449,24 @@ fn set_cursor_position(
|
||||
current_cursor_pos: usize,
|
||||
has_display_override: bool,
|
||||
) {
|
||||
let cursor_x = if has_display_override {
|
||||
field_rect.x + text.chars().count() as u16
|
||||
} else {
|
||||
field_rect.x + current_cursor_pos as u16
|
||||
};
|
||||
// BUG FIX: Use the correct display cursor position, not end of text
|
||||
let cursor_x = field_rect.x + current_cursor_pos as u16;
|
||||
let cursor_y = field_rect.y;
|
||||
f.set_cursor_position((cursor_x, cursor_y));
|
||||
|
||||
// SAFETY: Ensure cursor doesn't go beyond field bounds
|
||||
let max_cursor_x = field_rect.x + field_rect.width.saturating_sub(1);
|
||||
let safe_cursor_x = cursor_x.min(max_cursor_x);
|
||||
|
||||
f.set_cursor_position((safe_cursor_x, cursor_y));
|
||||
}
|
||||
|
||||
/// Set default theme if custom not specified
|
||||
#[cfg(feature = "gui")]
|
||||
pub fn render_canvas_default<D: DataProvider>(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
editor: &FormEditor<D>,
|
||||
) -> Option<Rect> {
|
||||
let theme = DefaultCanvasTheme::default();
|
||||
render_canvas(f, area, editor, &theme)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
// src/canvas/mod.rs
|
||||
|
||||
pub mod actions;
|
||||
pub mod modes;
|
||||
pub mod gui;
|
||||
pub mod theme;
|
||||
pub mod state;
|
||||
pub mod modes;
|
||||
|
||||
// Re-export commonly used canvas types
|
||||
pub use actions::{CanvasAction, ActionResult};
|
||||
#[cfg(feature = "gui")]
|
||||
pub mod gui;
|
||||
#[cfg(feature = "gui")]
|
||||
pub mod theme;
|
||||
|
||||
#[cfg(feature = "cursor-style")]
|
||||
pub mod cursor;
|
||||
|
||||
// Keep these exports for current functionality
|
||||
pub use modes::{AppMode, ModeManager, HighlightState};
|
||||
pub use state::{CanvasState, ActionContext};
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
pub use theme::CanvasTheme;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
pub use gui::render_canvas;
|
||||
#[cfg(feature = "cursor-style")]
|
||||
pub use cursor::CursorManager;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// src/modes/handlers/mode_manager.rs
|
||||
// canvas/src/modes/manager.rs
|
||||
|
||||
#[cfg(feature = "cursor-style")]
|
||||
use crate::canvas::CursorManager;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AppMode {
|
||||
@@ -30,4 +32,39 @@ impl ModeManager {
|
||||
pub fn can_enter_highlight_mode(current_mode: AppMode) -> bool {
|
||||
matches!(current_mode, AppMode::ReadOnly)
|
||||
}
|
||||
|
||||
|
||||
/// Transition to new mode with automatic cursor update (when cursor-style feature enabled)
|
||||
pub fn transition_to_mode(current_mode: AppMode, new_mode: AppMode) -> std::io::Result<AppMode> {
|
||||
if current_mode != new_mode {
|
||||
#[cfg(feature = "cursor-style")]
|
||||
{
|
||||
let _ = CursorManager::update_for_mode(new_mode);
|
||||
}
|
||||
}
|
||||
Ok(new_mode)
|
||||
}
|
||||
|
||||
/// Enter highlight mode with cursor styling
|
||||
pub fn enter_highlight_mode_with_cursor(current_mode: AppMode) -> std::io::Result<bool> {
|
||||
if Self::can_enter_highlight_mode(current_mode) {
|
||||
#[cfg(feature = "cursor-style")]
|
||||
{
|
||||
let _ = CursorManager::update_for_mode(AppMode::Highlight);
|
||||
}
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Exit highlight mode with cursor styling
|
||||
pub fn exit_highlight_mode_with_cursor() -> std::io::Result<AppMode> {
|
||||
let new_mode = AppMode::ReadOnly;
|
||||
#[cfg(feature = "cursor-style")]
|
||||
{
|
||||
let _ = CursorManager::update_for_mode(new_mode);
|
||||
}
|
||||
Ok(new_mode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,150 @@
|
||||
// canvas/src/state.rs
|
||||
// src/canvas/state.rs
|
||||
//! Library-owned UI state - user never directly modifies this
|
||||
|
||||
use crate::canvas::actions::CanvasAction;
|
||||
use crate::canvas::modes::AppMode;
|
||||
|
||||
/// Context passed to feature-specific action handlers
|
||||
#[derive(Debug)]
|
||||
pub struct ActionContext {
|
||||
pub key_code: Option<crossterm::event::KeyCode>, // Kept for backwards compatibility
|
||||
pub ideal_cursor_column: usize,
|
||||
pub current_input: String,
|
||||
pub current_field: usize,
|
||||
/// Library-owned UI state - user never directly modifies this
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditorState {
|
||||
// Navigation state
|
||||
pub(crate) current_field: usize,
|
||||
pub(crate) cursor_pos: usize,
|
||||
pub(crate) ideal_cursor_column: usize,
|
||||
|
||||
// Mode state
|
||||
pub(crate) current_mode: AppMode,
|
||||
|
||||
// Suggestions dropdown state
|
||||
pub(crate) suggestions: SuggestionsUIState,
|
||||
|
||||
// Selection state (for vim visual mode)
|
||||
pub(crate) selection: SelectionState,
|
||||
|
||||
// Validation state (only available with validation feature)
|
||||
#[cfg(feature = "validation")]
|
||||
pub(crate) validation: crate::validation::ValidationState,
|
||||
}
|
||||
|
||||
/// Core trait that any form-like state must implement to work with the canvas system.
|
||||
/// This enables the same mode behaviors (edit, read-only, highlight) to work across
|
||||
/// any implementation - login forms, data entry forms, configuration screens, etc.
|
||||
pub trait CanvasState {
|
||||
// --- Core Navigation ---
|
||||
fn current_field(&self) -> usize;
|
||||
fn current_cursor_pos(&self) -> usize;
|
||||
fn set_current_field(&mut self, index: usize);
|
||||
fn set_current_cursor_pos(&mut self, pos: usize);
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SuggestionsUIState {
|
||||
pub(crate) is_active: bool,
|
||||
pub(crate) is_loading: bool,
|
||||
pub(crate) selected_index: Option<usize>,
|
||||
pub(crate) active_field: Option<usize>,
|
||||
}
|
||||
|
||||
// --- Data Access ---
|
||||
fn get_current_input(&self) -> &str;
|
||||
fn get_current_input_mut(&mut self) -> &mut String;
|
||||
fn inputs(&self) -> Vec<&String>;
|
||||
fn fields(&self) -> Vec<&str>;
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SelectionState {
|
||||
None,
|
||||
Characterwise { anchor: (usize, usize) },
|
||||
Linewise { anchor_field: usize },
|
||||
}
|
||||
|
||||
// --- State Management ---
|
||||
fn has_unsaved_changes(&self) -> bool;
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool);
|
||||
|
||||
// --- Feature-specific action handling ---
|
||||
|
||||
/// Feature-specific action handling (NEW: Type-safe)
|
||||
fn handle_feature_action(&mut self, _action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
None // Default: no feature-specific handling
|
||||
impl EditorState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
current_field: 0,
|
||||
cursor_pos: 0,
|
||||
ideal_cursor_column: 0,
|
||||
current_mode: AppMode::Edit,
|
||||
suggestions: SuggestionsUIState {
|
||||
is_active: false,
|
||||
is_loading: false,
|
||||
selected_index: None,
|
||||
active_field: None,
|
||||
},
|
||||
selection: SelectionState::None,
|
||||
#[cfg(feature = "validation")]
|
||||
validation: crate::validation::ValidationState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Display Overrides (for links, computed values, etc.) ---
|
||||
fn get_display_value_for_field(&self, index: usize) -> &str {
|
||||
self.inputs()
|
||||
.get(index)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
// ===================================================================
|
||||
// READ-ONLY ACCESS: User can fetch UI state for compatibility
|
||||
// ===================================================================
|
||||
|
||||
/// Get current field index (for user's business logic)
|
||||
pub fn current_field(&self) -> usize {
|
||||
self.current_field
|
||||
}
|
||||
|
||||
fn has_display_override(&self, _index: usize) -> bool {
|
||||
false
|
||||
/// Get current cursor position (for user's business logic)
|
||||
pub fn cursor_position(&self) -> usize {
|
||||
self.cursor_pos
|
||||
}
|
||||
|
||||
/// Get ideal cursor column (for vim-like behavior)
|
||||
pub fn ideal_cursor_column(&self) -> usize {
|
||||
self.ideal_cursor_column
|
||||
}
|
||||
|
||||
/// Get current mode (for user's business logic)
|
||||
pub fn mode(&self) -> AppMode {
|
||||
self.current_mode
|
||||
}
|
||||
|
||||
/// Check if suggestions dropdown is active (for user's business logic)
|
||||
pub fn is_suggestions_active(&self) -> bool {
|
||||
self.suggestions.is_active
|
||||
}
|
||||
|
||||
/// Check if suggestions dropdown is loading (for user's business logic)
|
||||
pub fn is_suggestions_loading(&self) -> bool {
|
||||
self.suggestions.is_loading
|
||||
}
|
||||
|
||||
/// Get selection state (for user's business logic)
|
||||
pub fn selection_state(&self) -> &SelectionState {
|
||||
&self.selection
|
||||
}
|
||||
|
||||
/// Get validation state (for user's business logic)
|
||||
/// Only available when the 'validation' feature is enabled
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn validation_state(&self) -> &crate::validation::ValidationState {
|
||||
&self.validation
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// INTERNAL MUTATIONS: Only library modifies these
|
||||
// ===================================================================
|
||||
|
||||
pub(crate) fn move_to_field(&mut self, field_index: usize, field_count: usize) {
|
||||
if field_index < field_count {
|
||||
self.current_field = field_index;
|
||||
// Reset cursor to safe position - will be clamped by movement logic
|
||||
self.cursor_pos = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_cursor(&mut self, position: usize, max_position: usize, for_edit_mode: bool) {
|
||||
if for_edit_mode {
|
||||
// Edit mode: can go past end for insertion
|
||||
self.cursor_pos = position.min(max_position);
|
||||
} else {
|
||||
// ReadOnly/Highlight: stay within text bounds
|
||||
self.cursor_pos = position.min(max_position.saturating_sub(1));
|
||||
}
|
||||
self.ideal_cursor_column = self.cursor_pos;
|
||||
}
|
||||
|
||||
pub(crate) fn activate_suggestions(&mut self, field_index: usize) {
|
||||
self.suggestions.is_active = true;
|
||||
self.suggestions.is_loading = true;
|
||||
self.suggestions.active_field = Some(field_index);
|
||||
self.suggestions.selected_index = None;
|
||||
}
|
||||
|
||||
pub(crate) fn deactivate_suggestions(&mut self) {
|
||||
self.suggestions.is_active = false;
|
||||
self.suggestions.is_loading = false;
|
||||
self.suggestions.active_field = None;
|
||||
self.suggestions.selected_index = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EditorState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,3 +15,36 @@ pub trait CanvasTheme {
|
||||
fn highlight_bg(&self) -> Color;
|
||||
fn warning(&self) -> Color;
|
||||
}
|
||||
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DefaultCanvasTheme;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
impl CanvasTheme for DefaultCanvasTheme {
|
||||
fn bg(&self) -> Color {
|
||||
Color::Black
|
||||
}
|
||||
fn fg(&self) -> Color {
|
||||
Color::White
|
||||
}
|
||||
fn border(&self) -> Color {
|
||||
Color::DarkGray
|
||||
}
|
||||
fn accent(&self) -> Color {
|
||||
Color::Cyan
|
||||
}
|
||||
fn secondary(&self) -> Color {
|
||||
Color::Gray
|
||||
}
|
||||
fn highlight(&self) -> Color {
|
||||
Color::Yellow
|
||||
}
|
||||
fn highlight_bg(&self) -> Color {
|
||||
Color::Blue
|
||||
}
|
||||
fn warning(&self) -> Color {
|
||||
Color::Red
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,480 +0,0 @@
|
||||
// canvas/src/config.rs
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CanvasConfig {
|
||||
#[serde(default)]
|
||||
pub keybindings: CanvasKeybindings,
|
||||
#[serde(default)]
|
||||
pub behavior: CanvasBehavior,
|
||||
#[serde(default)]
|
||||
pub appearance: CanvasAppearance,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CanvasKeybindings {
|
||||
#[serde(default)]
|
||||
pub read_only: HashMap<String, Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub edit: HashMap<String, Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub suggestions: HashMap<String, Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub global: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CanvasBehavior {
|
||||
#[serde(default = "default_wrap_around")]
|
||||
pub wrap_around_fields: bool,
|
||||
#[serde(default = "default_auto_save")]
|
||||
pub auto_save_on_field_change: bool,
|
||||
#[serde(default = "default_word_chars")]
|
||||
pub word_chars: String,
|
||||
#[serde(default = "default_suggestion_limit")]
|
||||
pub max_suggestions: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CanvasAppearance {
|
||||
#[serde(default = "default_cursor_style")]
|
||||
pub cursor_style: String, // "block", "bar", "underline"
|
||||
#[serde(default = "default_show_field_numbers")]
|
||||
pub show_field_numbers: bool,
|
||||
#[serde(default = "default_highlight_current_field")]
|
||||
pub highlight_current_field: bool,
|
||||
}
|
||||
|
||||
// Default values
|
||||
fn default_wrap_around() -> bool { true }
|
||||
fn default_auto_save() -> bool { false }
|
||||
fn default_word_chars() -> String { "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_".to_string() }
|
||||
fn default_suggestion_limit() -> usize { 10 }
|
||||
fn default_cursor_style() -> String { "block".to_string() }
|
||||
fn default_show_field_numbers() -> bool { false }
|
||||
fn default_highlight_current_field() -> bool { true }
|
||||
|
||||
impl Default for CanvasBehavior {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
wrap_around_fields: default_wrap_around(),
|
||||
auto_save_on_field_change: default_auto_save(),
|
||||
word_chars: default_word_chars(),
|
||||
max_suggestions: default_suggestion_limit(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CanvasAppearance {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cursor_style: default_cursor_style(),
|
||||
show_field_numbers: default_show_field_numbers(),
|
||||
highlight_current_field: default_highlight_current_field(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CanvasConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
keybindings: CanvasKeybindings::with_vim_defaults(),
|
||||
behavior: CanvasBehavior::default(),
|
||||
appearance: CanvasAppearance::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasKeybindings {
|
||||
pub fn with_vim_defaults() -> Self {
|
||||
let mut keybindings = Self::default();
|
||||
|
||||
// Read-only mode (vim-style navigation)
|
||||
keybindings.read_only.insert("move_left".to_string(), vec!["h".to_string()]);
|
||||
keybindings.read_only.insert("move_right".to_string(), vec!["l".to_string()]);
|
||||
keybindings.read_only.insert("move_up".to_string(), vec!["k".to_string()]);
|
||||
keybindings.read_only.insert("move_down".to_string(), vec!["j".to_string()]);
|
||||
keybindings.read_only.insert("move_word_next".to_string(), vec!["w".to_string()]);
|
||||
keybindings.read_only.insert("move_word_end".to_string(), vec!["e".to_string()]);
|
||||
keybindings.read_only.insert("move_word_prev".to_string(), vec!["b".to_string()]);
|
||||
keybindings.read_only.insert("move_word_end_prev".to_string(), vec!["ge".to_string()]);
|
||||
keybindings.read_only.insert("move_line_start".to_string(), vec!["0".to_string()]);
|
||||
keybindings.read_only.insert("move_line_end".to_string(), vec!["$".to_string()]);
|
||||
keybindings.read_only.insert("move_first_line".to_string(), vec!["gg".to_string()]);
|
||||
keybindings.read_only.insert("move_last_line".to_string(), vec!["G".to_string()]);
|
||||
keybindings.read_only.insert("next_field".to_string(), vec!["Tab".to_string()]);
|
||||
keybindings.read_only.insert("prev_field".to_string(), vec!["Shift+Tab".to_string()]);
|
||||
|
||||
// Edit mode
|
||||
keybindings.edit.insert("delete_char_backward".to_string(), vec!["Backspace".to_string()]);
|
||||
keybindings.edit.insert("delete_char_forward".to_string(), vec!["Delete".to_string()]);
|
||||
keybindings.edit.insert("move_left".to_string(), vec!["Left".to_string()]);
|
||||
keybindings.edit.insert("move_right".to_string(), vec!["Right".to_string()]);
|
||||
keybindings.edit.insert("move_up".to_string(), vec!["Up".to_string()]);
|
||||
keybindings.edit.insert("move_down".to_string(), vec!["Down".to_string()]);
|
||||
keybindings.edit.insert("move_line_start".to_string(), vec!["Home".to_string()]);
|
||||
keybindings.edit.insert("move_line_end".to_string(), vec!["End".to_string()]);
|
||||
keybindings.edit.insert("move_word_next".to_string(), vec!["Ctrl+Right".to_string()]);
|
||||
keybindings.edit.insert("move_word_prev".to_string(), vec!["Ctrl+Left".to_string()]);
|
||||
keybindings.edit.insert("next_field".to_string(), vec!["Tab".to_string()]);
|
||||
keybindings.edit.insert("prev_field".to_string(), vec!["Shift+Tab".to_string()]);
|
||||
|
||||
// Suggestions
|
||||
keybindings.suggestions.insert("suggestion_up".to_string(), vec!["Up".to_string(), "Ctrl+p".to_string()]);
|
||||
keybindings.suggestions.insert("suggestion_down".to_string(), vec!["Down".to_string(), "Ctrl+n".to_string()]);
|
||||
keybindings.suggestions.insert("select_suggestion".to_string(), vec!["Enter".to_string(), "Tab".to_string()]);
|
||||
keybindings.suggestions.insert("exit_suggestions".to_string(), vec!["Esc".to_string()]);
|
||||
|
||||
// Global (works in both modes)
|
||||
keybindings.global.insert("move_up".to_string(), vec!["Up".to_string()]);
|
||||
keybindings.global.insert("move_down".to_string(), vec!["Down".to_string()]);
|
||||
|
||||
keybindings
|
||||
}
|
||||
|
||||
pub fn with_emacs_defaults() -> Self {
|
||||
let mut keybindings = Self::default();
|
||||
|
||||
// Emacs-style bindings
|
||||
keybindings.read_only.insert("move_left".to_string(), vec!["Ctrl+b".to_string()]);
|
||||
keybindings.read_only.insert("move_right".to_string(), vec!["Ctrl+f".to_string()]);
|
||||
keybindings.read_only.insert("move_up".to_string(), vec!["Ctrl+p".to_string()]);
|
||||
keybindings.read_only.insert("move_down".to_string(), vec!["Ctrl+n".to_string()]);
|
||||
keybindings.read_only.insert("move_word_next".to_string(), vec!["Alt+f".to_string()]);
|
||||
keybindings.read_only.insert("move_word_prev".to_string(), vec!["Alt+b".to_string()]);
|
||||
keybindings.read_only.insert("move_line_start".to_string(), vec!["Ctrl+a".to_string()]);
|
||||
keybindings.read_only.insert("move_line_end".to_string(), vec!["Ctrl+e".to_string()]);
|
||||
|
||||
keybindings.edit.insert("delete_char_backward".to_string(), vec!["Ctrl+h".to_string(), "Backspace".to_string()]);
|
||||
keybindings.edit.insert("delete_char_forward".to_string(), vec!["Ctrl+d".to_string(), "Delete".to_string()]);
|
||||
|
||||
keybindings
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasConfig {
|
||||
/// Load from canvas_config.toml or fallback to vim defaults
|
||||
pub fn load() -> Self {
|
||||
// Try to load canvas_config.toml from current directory
|
||||
if let Ok(config) = Self::from_file(std::path::Path::new("canvas_config.toml")) {
|
||||
return config;
|
||||
}
|
||||
|
||||
// Fallback to vim defaults
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Load from TOML string
|
||||
pub fn from_toml(toml_str: &str) -> Result<Self> {
|
||||
toml::from_str(toml_str)
|
||||
.with_context(|| "Failed to parse canvas config TOML")
|
||||
}
|
||||
|
||||
/// Load from file
|
||||
pub fn from_file(path: &std::path::Path) -> Result<Self> {
|
||||
let contents = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read config file: {:?}", path))?;
|
||||
Self::from_toml(&contents)
|
||||
}
|
||||
|
||||
/// Get action for key in read-only mode
|
||||
pub fn get_read_only_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
||||
self.get_action_in_mode(&self.keybindings.read_only, key, modifiers)
|
||||
.or_else(|| self.get_action_in_mode(&self.keybindings.global, key, modifiers))
|
||||
}
|
||||
|
||||
/// Get action for key in edit mode
|
||||
pub fn get_edit_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
||||
self.get_action_in_mode(&self.keybindings.edit, key, modifiers)
|
||||
.or_else(|| self.get_action_in_mode(&self.keybindings.global, key, modifiers))
|
||||
}
|
||||
|
||||
/// Get action for key in suggestions mode
|
||||
pub fn get_suggestion_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
||||
self.get_action_in_mode(&self.keybindings.suggestions, key, modifiers)
|
||||
}
|
||||
|
||||
/// Get action for key (mode-aware)
|
||||
pub fn get_action_for_key(&self, key: KeyCode, modifiers: KeyModifiers, is_edit_mode: bool, has_suggestions: bool) -> Option<&str> {
|
||||
// Suggestions take priority when active
|
||||
if has_suggestions {
|
||||
if let Some(action) = self.get_suggestion_action(key, modifiers) {
|
||||
return Some(action);
|
||||
}
|
||||
}
|
||||
|
||||
// Then check mode-specific
|
||||
if is_edit_mode {
|
||||
self.get_edit_action(key, modifiers)
|
||||
} else {
|
||||
self.get_read_only_action(key, modifiers)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_action_in_mode<'a>(&self, mode_bindings: &'a HashMap<String, Vec<String>>, key: KeyCode, modifiers: KeyModifiers) -> Option<&'a str> {
|
||||
for (action, bindings) in mode_bindings {
|
||||
for binding in bindings {
|
||||
if self.matches_keybinding(binding, key, modifiers) {
|
||||
return Some(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn matches_keybinding(&self, binding: &str, key: KeyCode, modifiers: KeyModifiers) -> bool {
|
||||
// Special handling for shift+character combinations
|
||||
if binding.to_lowercase().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) {
|
||||
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() {
|
||||
// Navigation keys
|
||||
"left" => key == KeyCode::Left,
|
||||
"right" => key == KeyCode::Right,
|
||||
"up" => key == KeyCode::Up,
|
||||
"down" => key == KeyCode::Down,
|
||||
"home" => key == KeyCode::Home,
|
||||
"end" => key == KeyCode::End,
|
||||
"pageup" | "pgup" => key == KeyCode::PageUp,
|
||||
"pagedown" | "pgdn" => key == KeyCode::PageDown,
|
||||
|
||||
// Editing keys
|
||||
"insert" | "ins" => key == KeyCode::Insert,
|
||||
"delete" | "del" => key == KeyCode::Delete,
|
||||
"backspace" => key == KeyCode::Backspace,
|
||||
|
||||
// Tab keys
|
||||
"tab" => key == KeyCode::Tab,
|
||||
"backtab" => key == KeyCode::BackTab,
|
||||
|
||||
// Special keys
|
||||
"enter" | "return" => key == KeyCode::Enter,
|
||||
"escape" | "esc" => key == KeyCode::Esc,
|
||||
"space" => key == KeyCode::Char(' '),
|
||||
|
||||
// Function keys F1-F24
|
||||
"f1" => key == KeyCode::F(1),
|
||||
"f2" => key == KeyCode::F(2),
|
||||
"f3" => key == KeyCode::F(3),
|
||||
"f4" => key == KeyCode::F(4),
|
||||
"f5" => key == KeyCode::F(5),
|
||||
"f6" => key == KeyCode::F(6),
|
||||
"f7" => key == KeyCode::F(7),
|
||||
"f8" => key == KeyCode::F(8),
|
||||
"f9" => key == KeyCode::F(9),
|
||||
"f10" => key == KeyCode::F(10),
|
||||
"f11" => key == KeyCode::F(11),
|
||||
"f12" => key == KeyCode::F(12),
|
||||
"f13" => key == KeyCode::F(13),
|
||||
"f14" => key == KeyCode::F(14),
|
||||
"f15" => key == KeyCode::F(15),
|
||||
"f16" => key == KeyCode::F(16),
|
||||
"f17" => key == KeyCode::F(17),
|
||||
"f18" => key == KeyCode::F(18),
|
||||
"f19" => key == KeyCode::F(19),
|
||||
"f20" => key == KeyCode::F(20),
|
||||
"f21" => key == KeyCode::F(21),
|
||||
"f22" => key == KeyCode::F(22),
|
||||
"f23" => key == KeyCode::F(23),
|
||||
"f24" => key == KeyCode::F(24),
|
||||
|
||||
// Lock keys (may not work reliably in all terminals)
|
||||
"capslock" => key == KeyCode::CapsLock,
|
||||
"scrolllock" => key == KeyCode::ScrollLock,
|
||||
"numlock" => key == KeyCode::NumLock,
|
||||
|
||||
// System keys
|
||||
"printscreen" => key == KeyCode::PrintScreen,
|
||||
"pause" => key == KeyCode::Pause,
|
||||
"menu" => key == KeyCode::Menu,
|
||||
"keypadbegin" => key == KeyCode::KeypadBegin,
|
||||
|
||||
// Media keys (rarely supported but included for completeness)
|
||||
"mediaplay" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Play),
|
||||
"mediapause" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Pause),
|
||||
"mediaplaypause" => key == KeyCode::Media(crossterm::event::MediaKeyCode::PlayPause),
|
||||
"mediareverse" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Reverse),
|
||||
"mediastop" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Stop),
|
||||
"mediafastforward" => key == KeyCode::Media(crossterm::event::MediaKeyCode::FastForward),
|
||||
"mediarewind" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Rewind),
|
||||
"mediatracknext" => key == KeyCode::Media(crossterm::event::MediaKeyCode::TrackNext),
|
||||
"mediatrackprevious" => key == KeyCode::Media(crossterm::event::MediaKeyCode::TrackPrevious),
|
||||
"mediarecord" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Record),
|
||||
"medialowervolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::LowerVolume),
|
||||
"mediaraisevolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::RaiseVolume),
|
||||
"mediamutevolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::MuteVolume),
|
||||
|
||||
// Modifier keys (these work better as part of combinations)
|
||||
"leftshift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftShift),
|
||||
"leftcontrol" | "leftctrl" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftControl),
|
||||
"leftalt" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftAlt),
|
||||
"leftsuper" | "leftwindows" | "leftcmd" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftSuper),
|
||||
"lefthyper" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftHyper),
|
||||
"leftmeta" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftMeta),
|
||||
"rightshift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightShift),
|
||||
"rightcontrol" | "rightctrl" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightControl),
|
||||
"rightalt" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightAlt),
|
||||
"rightsuper" | "rightwindows" | "rightcmd" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightSuper),
|
||||
"righthyper" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightHyper),
|
||||
"rightmeta" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightMeta),
|
||||
"isolevel3shift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::IsoLevel3Shift),
|
||||
"isolevel5shift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::IsoLevel5Shift),
|
||||
|
||||
// Multi-key sequences need special handling
|
||||
"gg" => false, // This needs sequence handling
|
||||
_ => {
|
||||
// Handle single characters and punctuation
|
||||
if binding.len() == 1 {
|
||||
if let Some(c) = binding.chars().next() {
|
||||
key == KeyCode::Char(c)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Handle modifier combinations (like "Ctrl+F5", "Alt+Shift+A")
|
||||
let parts: Vec<&str> = binding.split('+').collect();
|
||||
let mut expected_modifiers = KeyModifiers::empty();
|
||||
let mut expected_key = None;
|
||||
|
||||
for part in parts {
|
||||
match part.to_lowercase().as_str() {
|
||||
// Modifiers
|
||||
"ctrl" | "control" => expected_modifiers |= KeyModifiers::CONTROL,
|
||||
"shift" => expected_modifiers |= KeyModifiers::SHIFT,
|
||||
"alt" => expected_modifiers |= KeyModifiers::ALT,
|
||||
"super" | "windows" | "cmd" => expected_modifiers |= KeyModifiers::SUPER,
|
||||
"hyper" => expected_modifiers |= KeyModifiers::HYPER,
|
||||
"meta" => expected_modifiers |= KeyModifiers::META,
|
||||
|
||||
// Navigation keys
|
||||
"left" => expected_key = Some(KeyCode::Left),
|
||||
"right" => expected_key = Some(KeyCode::Right),
|
||||
"up" => expected_key = Some(KeyCode::Up),
|
||||
"down" => expected_key = Some(KeyCode::Down),
|
||||
"home" => expected_key = Some(KeyCode::Home),
|
||||
"end" => expected_key = Some(KeyCode::End),
|
||||
"pageup" | "pgup" => expected_key = Some(KeyCode::PageUp),
|
||||
"pagedown" | "pgdn" => expected_key = Some(KeyCode::PageDown),
|
||||
|
||||
// Editing keys
|
||||
"insert" | "ins" => expected_key = Some(KeyCode::Insert),
|
||||
"delete" | "del" => expected_key = Some(KeyCode::Delete),
|
||||
"backspace" => expected_key = Some(KeyCode::Backspace),
|
||||
|
||||
// Tab keys
|
||||
"tab" => expected_key = Some(KeyCode::Tab),
|
||||
"backtab" => expected_key = Some(KeyCode::BackTab),
|
||||
|
||||
// Special keys
|
||||
"enter" | "return" => expected_key = Some(KeyCode::Enter),
|
||||
"escape" | "esc" => expected_key = Some(KeyCode::Esc),
|
||||
"space" => expected_key = Some(KeyCode::Char(' ')),
|
||||
|
||||
// Function keys
|
||||
"f1" => expected_key = Some(KeyCode::F(1)),
|
||||
"f2" => expected_key = Some(KeyCode::F(2)),
|
||||
"f3" => expected_key = Some(KeyCode::F(3)),
|
||||
"f4" => expected_key = Some(KeyCode::F(4)),
|
||||
"f5" => expected_key = Some(KeyCode::F(5)),
|
||||
"f6" => expected_key = Some(KeyCode::F(6)),
|
||||
"f7" => expected_key = Some(KeyCode::F(7)),
|
||||
"f8" => expected_key = Some(KeyCode::F(8)),
|
||||
"f9" => expected_key = Some(KeyCode::F(9)),
|
||||
"f10" => expected_key = Some(KeyCode::F(10)),
|
||||
"f11" => expected_key = Some(KeyCode::F(11)),
|
||||
"f12" => expected_key = Some(KeyCode::F(12)),
|
||||
"f13" => expected_key = Some(KeyCode::F(13)),
|
||||
"f14" => expected_key = Some(KeyCode::F(14)),
|
||||
"f15" => expected_key = Some(KeyCode::F(15)),
|
||||
"f16" => expected_key = Some(KeyCode::F(16)),
|
||||
"f17" => expected_key = Some(KeyCode::F(17)),
|
||||
"f18" => expected_key = Some(KeyCode::F(18)),
|
||||
"f19" => expected_key = Some(KeyCode::F(19)),
|
||||
"f20" => expected_key = Some(KeyCode::F(20)),
|
||||
"f21" => expected_key = Some(KeyCode::F(21)),
|
||||
"f22" => expected_key = Some(KeyCode::F(22)),
|
||||
"f23" => expected_key = Some(KeyCode::F(23)),
|
||||
"f24" => expected_key = Some(KeyCode::F(24)),
|
||||
|
||||
// Lock keys
|
||||
"capslock" => expected_key = Some(KeyCode::CapsLock),
|
||||
"scrolllock" => expected_key = Some(KeyCode::ScrollLock),
|
||||
"numlock" => expected_key = Some(KeyCode::NumLock),
|
||||
|
||||
// System keys
|
||||
"printscreen" => expected_key = Some(KeyCode::PrintScreen),
|
||||
"pause" => expected_key = Some(KeyCode::Pause),
|
||||
"menu" => expected_key = Some(KeyCode::Menu),
|
||||
"keypadbegin" => expected_key = Some(KeyCode::KeypadBegin),
|
||||
|
||||
// Single character (letters, numbers, punctuation)
|
||||
part => {
|
||||
if part.len() == 1 {
|
||||
if let Some(c) = part.chars().next() {
|
||||
expected_key = Some(KeyCode::Char(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
modifiers == expected_modifiers && Some(key) == expected_key
|
||||
}
|
||||
|
||||
/// Convenience method to create vim preset
|
||||
pub fn vim_preset() -> Self {
|
||||
Self {
|
||||
keybindings: CanvasKeybindings::with_vim_defaults(),
|
||||
behavior: CanvasBehavior::default(),
|
||||
appearance: CanvasAppearance::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience method to create emacs preset
|
||||
pub fn emacs_preset() -> Self {
|
||||
Self {
|
||||
keybindings: CanvasKeybindings::with_emacs_defaults(),
|
||||
behavior: CanvasBehavior::default(),
|
||||
appearance: CanvasAppearance::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Debug method to print loaded keybindings
|
||||
pub fn debug_keybindings(&self) {
|
||||
println!("📋 Canvas keybindings loaded:");
|
||||
println!(" Read-only: {} actions", self.keybindings.read_only.len());
|
||||
println!(" Edit: {} actions", self.keybindings.edit.len());
|
||||
println!(" Suggestions: {} actions", self.keybindings.suggestions.len());
|
||||
println!(" Global: {} actions", self.keybindings.global.len());
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
pub use crate::canvas::actions::CanvasAction;
|
||||
pub use crate::dispatcher::ActionDispatcher;
|
||||
51
canvas/src/data_provider.rs
Normal file
51
canvas/src/data_provider.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
// src/data_provider.rs
|
||||
//! Simplified user interface - only business data, no UI state
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// User implements this - only business data, no UI state
|
||||
pub trait DataProvider {
|
||||
/// How many fields in the form
|
||||
fn field_count(&self) -> usize;
|
||||
|
||||
/// Get field label/name
|
||||
fn field_name(&self, index: usize) -> &str;
|
||||
|
||||
/// Get field value
|
||||
fn field_value(&self, index: usize) -> &str;
|
||||
|
||||
/// Set field value (library calls this when text changes)
|
||||
fn set_field_value(&mut self, index: usize, value: String);
|
||||
|
||||
/// Check if field supports suggestions (optional)
|
||||
fn supports_suggestions(&self, _field_index: usize) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Get display value (for password masking, etc.) - optional
|
||||
fn display_value(&self, _index: usize) -> Option<&str> {
|
||||
None // Default: use actual value
|
||||
}
|
||||
|
||||
/// Get validation configuration for a field (optional)
|
||||
/// Only available when the 'validation' feature is enabled
|
||||
#[cfg(feature = "validation")]
|
||||
fn validation_config(&self, _field_index: usize) -> Option<crate::validation::ValidationConfig> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional: User implements this for suggestions data
|
||||
#[async_trait]
|
||||
pub trait SuggestionsProvider {
|
||||
/// Fetch suggestions (user's business logic)
|
||||
async fn fetch_suggestions(&mut self, field_index: usize, query: &str)
|
||||
-> Result<Vec<SuggestionItem>>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SuggestionItem {
|
||||
pub display_text: String,
|
||||
pub value_to_store: String,
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
// canvas/src/dispatcher.rs
|
||||
|
||||
use crate::canvas::state::CanvasState;
|
||||
use crate::canvas::actions::{CanvasAction, ActionResult, execute_canvas_action};
|
||||
|
||||
/// High-level action dispatcher that coordinates between different action types
|
||||
pub struct ActionDispatcher;
|
||||
|
||||
impl ActionDispatcher {
|
||||
/// Dispatch any action to the appropriate handler
|
||||
pub async fn dispatch<S: CanvasState>(
|
||||
action: CanvasAction,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> anyhow::Result<ActionResult> {
|
||||
execute_canvas_action(action, state, ideal_cursor_column).await
|
||||
}
|
||||
|
||||
/// Quick action dispatch from KeyCode
|
||||
pub async fn dispatch_key<S: CanvasState>(
|
||||
key: crossterm::event::KeyCode,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> anyhow::Result<Option<ActionResult>> {
|
||||
if let Some(action) = CanvasAction::from_key(key) {
|
||||
let result = Self::dispatch(action, state, ideal_cursor_column).await?;
|
||||
Ok(Some(result))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch dispatch multiple actions
|
||||
pub async fn dispatch_batch<S: CanvasState>(
|
||||
actions: Vec<CanvasAction>,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> anyhow::Result<Vec<ActionResult>> {
|
||||
let mut results = Vec::new();
|
||||
for action in actions {
|
||||
let result = Self::dispatch(action, state, ideal_cursor_column).await?;
|
||||
let is_success = result.is_success(); // Check success before moving
|
||||
results.push(result);
|
||||
|
||||
// Stop on first error
|
||||
if !is_success {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::actions::CanvasAction;
|
||||
|
||||
// Simple test implementation
|
||||
struct TestFormState {
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
inputs: Vec<String>,
|
||||
field_names: Vec<String>,
|
||||
has_changes: bool,
|
||||
}
|
||||
|
||||
impl TestFormState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
current_field: 0,
|
||||
cursor_pos: 0,
|
||||
inputs: vec!["".to_string(), "".to_string()],
|
||||
field_names: vec!["username".to_string(), "password".to_string()],
|
||||
has_changes: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasState for TestFormState {
|
||||
fn current_field(&self) -> usize { self.current_field }
|
||||
fn current_cursor_pos(&self) -> usize { self.cursor_pos }
|
||||
fn set_current_field(&mut self, index: usize) { self.current_field = index; }
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
|
||||
|
||||
fn get_current_input(&self) -> &str { &self.inputs[self.current_field] }
|
||||
fn get_current_input_mut(&mut self) -> &mut String { &mut self.inputs[self.current_field] }
|
||||
fn inputs(&self) -> Vec<&String> { self.inputs.iter().collect() }
|
||||
fn fields(&self) -> Vec<&str> { self.field_names.iter().map(|s| s.as_str()).collect() }
|
||||
|
||||
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
|
||||
|
||||
// Custom action handling for testing
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &crate::state::ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::Custom(s) if s == "test_custom" => {
|
||||
Some("Custom action handled".to_string())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_typed_action_dispatch() {
|
||||
let mut state = TestFormState::new();
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
// Test character insertion
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::InsertChar('a'),
|
||||
&mut state,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
|
||||
assert!(result.is_success());
|
||||
assert_eq!(state.get_current_input(), "a");
|
||||
assert_eq!(state.cursor_pos, 1);
|
||||
assert!(state.has_changes);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_key_dispatch() {
|
||||
let mut state = TestFormState::new();
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
let result = ActionDispatcher::dispatch_key(
|
||||
crossterm::event::KeyCode::Char('b'),
|
||||
&mut state,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
|
||||
assert!(result.is_some());
|
||||
assert!(result.unwrap().is_success());
|
||||
assert_eq!(state.get_current_input(), "b");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_custom_action() {
|
||||
let mut state = TestFormState::new();
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::Custom("test_custom".to_string()),
|
||||
&mut state,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
|
||||
match result {
|
||||
ActionResult::HandledByFeature(msg) => {
|
||||
assert_eq!(msg, "Custom action handled");
|
||||
}
|
||||
_ => panic!("Expected HandledByFeature result"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_batch_dispatch() {
|
||||
let mut state = TestFormState::new();
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
let actions = vec![
|
||||
CanvasAction::InsertChar('h'),
|
||||
CanvasAction::InsertChar('i'),
|
||||
CanvasAction::MoveLeft,
|
||||
CanvasAction::InsertChar('e'),
|
||||
];
|
||||
|
||||
let results = ActionDispatcher::dispatch_batch(
|
||||
actions,
|
||||
&mut state,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
|
||||
assert_eq!(results.len(), 4);
|
||||
assert!(results.iter().all(|r| r.is_success()));
|
||||
assert_eq!(state.get_current_input(), "hei");
|
||||
}
|
||||
}
|
||||
1203
canvas/src/editor.rs
Normal file
1203
canvas/src/editor.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,55 @@
|
||||
// src/lib.rs
|
||||
|
||||
pub mod canvas;
|
||||
pub mod autocomplete;
|
||||
pub mod config;
|
||||
pub mod dispatcher;
|
||||
pub mod editor;
|
||||
pub mod data_provider;
|
||||
|
||||
// Only include suggestions module if feature is enabled
|
||||
#[cfg(feature = "suggestions")]
|
||||
pub mod suggestions;
|
||||
|
||||
// Only include validation module if feature is enabled
|
||||
#[cfg(feature = "validation")]
|
||||
pub mod validation;
|
||||
|
||||
#[cfg(feature = "cursor-style")]
|
||||
pub use canvas::CursorManager;
|
||||
|
||||
// ===================================================================
|
||||
// NEW API: Library-owned state pattern
|
||||
// ===================================================================
|
||||
|
||||
// Main API exports
|
||||
pub use editor::FormEditor;
|
||||
pub use data_provider::{DataProvider, SuggestionsProvider, SuggestionItem};
|
||||
|
||||
// UI state (read-only access for users)
|
||||
pub use canvas::state::EditorState;
|
||||
pub use canvas::modes::AppMode;
|
||||
|
||||
// Actions and results (for users who want to handle actions manually)
|
||||
pub use canvas::actions::{CanvasAction, ActionResult};
|
||||
|
||||
// Validation exports (only when validation feature is enabled)
|
||||
#[cfg(feature = "validation")]
|
||||
pub use validation::{
|
||||
ValidationConfig, ValidationResult, ValidationError,
|
||||
CharacterLimits, ValidationConfigBuilder, ValidationState,
|
||||
ValidationSummary, PatternFilters, PositionFilter, PositionRange, CharacterFilter,
|
||||
DisplayMask, // Simple display mask instead of complex ReservedCharacters
|
||||
// Feature 4: custom formatting exports
|
||||
CustomFormatter, FormattingResult, PositionMapper, DefaultPositionMapper,
|
||||
};
|
||||
|
||||
// Theming and GUI
|
||||
#[cfg(feature = "gui")]
|
||||
pub use canvas::theme::{CanvasTheme, DefaultCanvasTheme};
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
pub use canvas::gui::render_canvas;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
pub use canvas::gui::render_canvas_default;
|
||||
|
||||
#[cfg(all(feature = "gui", feature = "suggestions"))]
|
||||
pub use suggestions::gui::render_suggestions_dropdown;
|
||||
|
||||
@@ -1,38 +1,41 @@
|
||||
// canvas/src/autocomplete/gui.rs
|
||||
// src/suggestions/gui.rs
|
||||
//! Suggestions dropdown GUI (not inline autocomplete) updated to work with FormEditor
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use ratatui::{
|
||||
layout::{Alignment, Rect},
|
||||
style::{Modifier, Style},
|
||||
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
|
||||
widgets::{Block, List, ListItem, ListState, Paragraph}, // Removed Borders
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::autocomplete::types::AutocompleteState;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use crate::canvas::theme::CanvasTheme;
|
||||
use crate::data_provider::{DataProvider, SuggestionItem};
|
||||
use crate::editor::FormEditor;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// Render autocomplete dropdown - call this AFTER rendering canvas
|
||||
/// Render suggestions dropdown for FormEditor - call this AFTER rendering canvas
|
||||
#[cfg(feature = "gui")]
|
||||
pub fn render_autocomplete_dropdown<T: CanvasTheme>(
|
||||
pub fn render_suggestions_dropdown<T: CanvasTheme, D: DataProvider>(
|
||||
f: &mut Frame,
|
||||
frame_area: Rect,
|
||||
input_rect: Rect,
|
||||
theme: &T,
|
||||
autocomplete_state: &AutocompleteState<impl Clone + Send + 'static>,
|
||||
editor: &FormEditor<D>,
|
||||
) {
|
||||
if !autocomplete_state.is_active {
|
||||
let ui_state = editor.ui_state();
|
||||
|
||||
if !ui_state.is_suggestions_active() {
|
||||
return;
|
||||
}
|
||||
|
||||
if autocomplete_state.is_loading {
|
||||
if ui_state.suggestions.is_loading {
|
||||
render_loading_indicator(f, frame_area, input_rect, theme);
|
||||
} else if !autocomplete_state.suggestions.is_empty() {
|
||||
render_suggestions_dropdown(f, frame_area, input_rect, theme, autocomplete_state);
|
||||
} else if !editor.suggestions().is_empty() {
|
||||
render_suggestions_dropdown_list(f, frame_area, input_rect, theme, editor.suggestions(), ui_state.suggestions.selected_index);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,14 +71,15 @@ fn render_loading_indicator<T: CanvasTheme>(
|
||||
|
||||
/// Show actual suggestions list
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_suggestions_dropdown<T: CanvasTheme>(
|
||||
fn render_suggestions_dropdown_list<T: CanvasTheme>(
|
||||
f: &mut Frame,
|
||||
frame_area: Rect,
|
||||
input_rect: Rect,
|
||||
theme: &T,
|
||||
autocomplete_state: &AutocompleteState<impl Clone + Send + 'static>,
|
||||
suggestions: &[SuggestionItem], // Fixed: Removed <String> generic parameter
|
||||
selected_index: Option<usize>,
|
||||
) {
|
||||
let display_texts: Vec<&str> = autocomplete_state.suggestions
|
||||
let display_texts: Vec<&str> = suggestions
|
||||
.iter()
|
||||
.map(|item| item.display_text.as_str())
|
||||
.collect();
|
||||
@@ -95,19 +99,19 @@ fn render_suggestions_dropdown<T: CanvasTheme>(
|
||||
// List items
|
||||
let items = create_suggestion_list_items(
|
||||
&display_texts,
|
||||
autocomplete_state.selected_index,
|
||||
selected_index,
|
||||
dropdown_dimensions.width,
|
||||
theme,
|
||||
);
|
||||
|
||||
let list = List::new(items).block(dropdown_block);
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(autocomplete_state.selected_index);
|
||||
list_state.select(selected_index);
|
||||
|
||||
f.render_stateful_widget(list, dropdown_area, &mut list_state);
|
||||
}
|
||||
|
||||
/// Calculate dropdown size based on suggestions - updated to match client dimensions
|
||||
/// Calculate dropdown size based on suggestions
|
||||
#[cfg(feature = "gui")]
|
||||
fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions {
|
||||
let max_width = display_texts
|
||||
@@ -116,9 +120,9 @@ fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions {
|
||||
.max()
|
||||
.unwrap_or(0) as u16;
|
||||
|
||||
let horizontal_padding = 2; // Changed from 4 to 2 to match client
|
||||
let width = (max_width + horizontal_padding).max(10); // Changed from 12 to 10 to match client
|
||||
let height = (display_texts.len() as u16).min(5); // Removed +2 since no borders
|
||||
let horizontal_padding = 2;
|
||||
let width = (max_width + horizontal_padding).max(10);
|
||||
let height = (display_texts.len() as u16).min(5);
|
||||
|
||||
DropdownDimensions { width, height }
|
||||
}
|
||||
@@ -151,7 +155,7 @@ fn calculate_dropdown_position(
|
||||
dropdown_area
|
||||
}
|
||||
|
||||
/// Create styled list items - updated to match client spacing
|
||||
/// Create styled list items
|
||||
#[cfg(feature = "gui")]
|
||||
fn create_suggestion_list_items<'a, T: CanvasTheme>(
|
||||
display_texts: &'a [&'a str],
|
||||
@@ -159,8 +163,7 @@ fn create_suggestion_list_items<'a, T: CanvasTheme>(
|
||||
dropdown_width: u16,
|
||||
theme: &T,
|
||||
) -> Vec<ListItem<'a>> {
|
||||
let horizontal_padding = 2; // Changed from 4 to 2 to match client
|
||||
let available_width = dropdown_width; // No border padding needed
|
||||
let available_width = dropdown_width;
|
||||
|
||||
display_texts
|
||||
.iter()
|
||||
12
canvas/src/suggestions/mod.rs
Normal file
12
canvas/src/suggestions/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
// src/suggestions/mod.rs
|
||||
|
||||
pub mod state;
|
||||
#[cfg(feature = "gui")]
|
||||
pub mod gui;
|
||||
|
||||
// Re-export the main suggestion types
|
||||
pub use state::{SuggestionsProvider, SuggestionItem};
|
||||
|
||||
// Re-export GUI functions if available
|
||||
#[cfg(feature = "gui")]
|
||||
pub use gui::render_suggestions_dropdown;
|
||||
5
canvas/src/suggestions/state.rs
Normal file
5
canvas/src/suggestions/state.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
// src/suggestions/state.rs
|
||||
//! Suggestions provider types (for dropdown suggestions, not real inline autocomplete)
|
||||
|
||||
// Re-export the main types from data_provider
|
||||
pub use crate::data_provider::{SuggestionsProvider, SuggestionItem};
|
||||
447
canvas/src/validation/config.rs
Normal file
447
canvas/src/validation/config.rs
Normal file
@@ -0,0 +1,447 @@
|
||||
// src/validation/config.rs
|
||||
//! Validation configuration types and builders
|
||||
|
||||
use crate::validation::{CharacterLimits, PatternFilters, DisplayMask};
|
||||
#[cfg(feature = "validation")]
|
||||
use crate::validation::{CustomFormatter, FormattingResult, PositionMapper};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Main validation configuration for a field
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ValidationConfig {
|
||||
/// Character limit configuration
|
||||
pub character_limits: Option<CharacterLimits>,
|
||||
|
||||
/// Pattern filtering configuration
|
||||
pub pattern_filters: Option<PatternFilters>,
|
||||
|
||||
/// User-defined display mask for visual formatting
|
||||
pub display_mask: Option<DisplayMask>,
|
||||
|
||||
/// Optional: user-provided custom formatter (feature 4)
|
||||
#[cfg(feature = "validation")]
|
||||
pub custom_formatter: Option<Arc<dyn CustomFormatter + Send + Sync>>,
|
||||
|
||||
/// Enable external validation indicator UI (feature 5)
|
||||
pub external_validation_enabled: bool,
|
||||
|
||||
/// Future: External validation
|
||||
pub external_validation: Option<()>, // Placeholder for future implementation
|
||||
}
|
||||
|
||||
/// Manual Debug to avoid requiring Debug on dyn CustomFormatter
|
||||
impl std::fmt::Debug for ValidationConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let mut ds = f.debug_struct("ValidationConfig");
|
||||
ds.field("character_limits", &self.character_limits)
|
||||
.field("pattern_filters", &self.pattern_filters)
|
||||
.field("display_mask", &self.display_mask)
|
||||
// Do not print the formatter itself to avoid requiring Debug
|
||||
.field(
|
||||
"custom_formatter",
|
||||
&{
|
||||
#[cfg(feature = "validation")]
|
||||
{
|
||||
if self.custom_formatter.is_some() { &"Some(<CustomFormatter>)" } else { &"None" }
|
||||
}
|
||||
#[cfg(not(feature = "validation"))]
|
||||
{
|
||||
&"N/A"
|
||||
}
|
||||
},
|
||||
)
|
||||
.field("external_validation_enabled", &self.external_validation_enabled)
|
||||
.field("external_validation", &self.external_validation)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ FIXED: Move function from struct definition to impl block
|
||||
impl ValidationConfig {
|
||||
/// If a custom formatter is configured, run it and return the formatted text,
|
||||
/// the position mapper and an optional warning message.
|
||||
///
|
||||
/// Returns None when no custom formatter is configured.
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn run_custom_formatter(
|
||||
&self,
|
||||
raw: &str,
|
||||
) -> Option<(String, Arc<dyn PositionMapper>, Option<String>)> {
|
||||
let formatter = self.custom_formatter.as_ref()?;
|
||||
match formatter.format(raw) {
|
||||
FormattingResult::Success { formatted, mapper } => {
|
||||
Some((formatted, mapper, None))
|
||||
}
|
||||
FormattingResult::Warning { formatted, message, mapper } => {
|
||||
Some((formatted, mapper, Some(message)))
|
||||
}
|
||||
FormattingResult::Error { .. } => None, // Fall back to raw display
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new empty validation configuration
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Create a configuration with just character limits
|
||||
pub fn with_max_length(max_length: usize) -> Self {
|
||||
ValidationConfigBuilder::new()
|
||||
.with_max_length(max_length)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Create a configuration with pattern filters
|
||||
pub fn with_patterns(patterns: PatternFilters) -> Self {
|
||||
ValidationConfigBuilder::new()
|
||||
.with_pattern_filters(patterns)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Create a configuration with user-defined display mask
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use canvas::{ValidationConfig, DisplayMask};
|
||||
///
|
||||
/// let phone_mask = DisplayMask::new("(###) ###-####", '#');
|
||||
/// let config = ValidationConfig::with_mask(phone_mask);
|
||||
/// ```
|
||||
pub fn with_mask(mask: DisplayMask) -> Self {
|
||||
ValidationConfigBuilder::new()
|
||||
.with_display_mask(mask)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Validate a character insertion at a specific position (raw text space).
|
||||
///
|
||||
/// Note: Display masks are visual-only and do not participate in validation.
|
||||
/// Editor logic is responsible for skipping mask separator positions; here we
|
||||
/// only validate the raw insertion against limits and patterns.
|
||||
pub fn validate_char_insertion(
|
||||
&self,
|
||||
current_text: &str,
|
||||
position: usize,
|
||||
character: char,
|
||||
) -> ValidationResult {
|
||||
// Character limits validation
|
||||
if let Some(ref limits) = self.character_limits {
|
||||
// ✅ FIXED: Explicit return type annotation
|
||||
if let Some(result) = limits.validate_insertion(current_text, position, character) {
|
||||
if !result.is_acceptable() {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern filters validation
|
||||
if let Some(ref patterns) = self.pattern_filters {
|
||||
// ✅ FIXED: Explicit error handling
|
||||
if let Err(message) = patterns.validate_char_at_position(position, character) {
|
||||
return ValidationResult::error(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Future: Add other validation types here
|
||||
|
||||
ValidationResult::Valid
|
||||
}
|
||||
|
||||
/// Validate the current text content (raw text space)
|
||||
pub fn validate_content(&self, text: &str) -> ValidationResult {
|
||||
// Character limits validation
|
||||
if let Some(ref limits) = self.character_limits {
|
||||
// ✅ FIXED: Explicit return type annotation
|
||||
if let Some(result) = limits.validate_content(text) {
|
||||
if !result.is_acceptable() {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern filters validation
|
||||
if let Some(ref patterns) = self.pattern_filters {
|
||||
// ✅ FIXED: Explicit error handling
|
||||
if let Err(message) = patterns.validate_text(text) {
|
||||
return ValidationResult::error(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Future: Add other validation types here
|
||||
|
||||
ValidationResult::Valid
|
||||
}
|
||||
|
||||
/// Check if any validation rules are configured
|
||||
pub fn has_validation(&self) -> bool {
|
||||
self.character_limits.is_some()
|
||||
|| self.pattern_filters.is_some()
|
||||
|| self.display_mask.is_some()
|
||||
|| {
|
||||
#[cfg(feature = "validation")]
|
||||
{ self.custom_formatter.is_some() }
|
||||
#[cfg(not(feature = "validation"))]
|
||||
{ false }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allows_field_switch(&self, text: &str) -> bool {
|
||||
// Character limits validation
|
||||
if let Some(ref limits) = self.character_limits {
|
||||
// ✅ FIXED: Direct boolean return
|
||||
if !limits.allows_field_switch(text) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Future: Add other validation types here
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Get reason why field switching is blocked (if any)
|
||||
pub fn field_switch_block_reason(&self, text: &str) -> Option<String> {
|
||||
// Character limits validation
|
||||
if let Some(ref limits) = self.character_limits {
|
||||
// ✅ FIXED: Direct option return
|
||||
if let Some(reason) = limits.field_switch_block_reason(text) {
|
||||
return Some(reason);
|
||||
}
|
||||
}
|
||||
|
||||
// Future: Add other validation types here
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for creating validation configurations
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ValidationConfigBuilder {
|
||||
config: ValidationConfig,
|
||||
}
|
||||
|
||||
impl ValidationConfigBuilder {
|
||||
/// Create a new validation config builder
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Set character limits for the field
|
||||
pub fn with_character_limits(mut self, limits: CharacterLimits) -> Self {
|
||||
self.config.character_limits = Some(limits);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set pattern filters for the field
|
||||
pub fn with_pattern_filters(mut self, filters: PatternFilters) -> Self {
|
||||
self.config.pattern_filters = Some(filters);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set user-defined display mask for visual formatting
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use canvas::{ValidationConfigBuilder, DisplayMask};
|
||||
///
|
||||
/// // Phone number with dynamic formatting
|
||||
/// let phone_mask = DisplayMask::new("(###) ###-####", '#');
|
||||
/// let config = ValidationConfigBuilder::new()
|
||||
/// .with_display_mask(phone_mask)
|
||||
/// .build();
|
||||
///
|
||||
/// // Date with template formatting
|
||||
/// let date_mask = DisplayMask::new("##/##/####", '#')
|
||||
/// .with_template('_');
|
||||
/// let config = ValidationConfigBuilder::new()
|
||||
/// .with_display_mask(date_mask)
|
||||
/// .build();
|
||||
///
|
||||
/// // Custom business format
|
||||
/// let employee_id = DisplayMask::new("EMP-####-##", '#')
|
||||
/// .with_template('•');
|
||||
/// let config = ValidationConfigBuilder::new()
|
||||
/// .with_display_mask(employee_id)
|
||||
/// .with_max_length(6) // Only store the 6 digits
|
||||
/// .build();
|
||||
/// ```
|
||||
pub fn with_display_mask(mut self, mask: DisplayMask) -> Self {
|
||||
self.config.display_mask = Some(mask);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set optional custom formatter (feature 4)
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn with_custom_formatter<F>(mut self, formatter: Arc<F>) -> Self
|
||||
where
|
||||
F: CustomFormatter + Send + Sync + 'static,
|
||||
{
|
||||
self.config.custom_formatter = Some(formatter);
|
||||
// When custom formatter is present, it takes precedence over display mask.
|
||||
self.config.display_mask = None;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set maximum number of characters (convenience method)
|
||||
pub fn with_max_length(mut self, max_length: usize) -> Self {
|
||||
self.config.character_limits = Some(CharacterLimits::new(max_length));
|
||||
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;
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the final validation configuration
|
||||
pub fn build(self) -> ValidationConfig {
|
||||
self.config
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a validation operation
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ValidationResult {
|
||||
/// Validation passed
|
||||
Valid,
|
||||
|
||||
/// Validation failed with warning (input still accepted)
|
||||
Warning { message: String },
|
||||
|
||||
/// Validation failed with error (input rejected)
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
impl ValidationResult {
|
||||
/// Check if the validation result allows the input
|
||||
pub fn is_acceptable(&self) -> bool {
|
||||
matches!(self, ValidationResult::Valid | ValidationResult::Warning { .. })
|
||||
}
|
||||
|
||||
/// Check if the validation result is an error
|
||||
pub fn is_error(&self) -> bool {
|
||||
matches!(self, ValidationResult::Error { .. })
|
||||
}
|
||||
|
||||
/// Get the message if there is one
|
||||
pub fn message(&self) -> Option<&str> {
|
||||
match self {
|
||||
ValidationResult::Valid => None,
|
||||
ValidationResult::Warning { message } => Some(message),
|
||||
ValidationResult::Error { message } => Some(message),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a warning result
|
||||
pub fn warning(message: impl Into<String>) -> Self {
|
||||
ValidationResult::Warning { message: message.into() }
|
||||
}
|
||||
|
||||
/// Create an error result
|
||||
pub fn error(message: impl Into<String>) -> Self {
|
||||
ValidationResult::Error { message: message.into() }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_config_with_user_defined_mask() {
|
||||
// User creates their own phone mask
|
||||
let phone_mask = DisplayMask::new("(###) ###-####", '#');
|
||||
let config = ValidationConfig::with_mask(phone_mask);
|
||||
|
||||
// has_validation should be true because mask is configured
|
||||
assert!(config.has_validation());
|
||||
|
||||
// Display mask is visual only; validation still focuses on raw content
|
||||
let result = config.validate_char_insertion("123", 3, '4');
|
||||
assert!(result.is_acceptable());
|
||||
|
||||
// Content validation unaffected by mask
|
||||
let result = config.validate_content("1234567890");
|
||||
assert!(result.is_acceptable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validation_config_builder() {
|
||||
let config = ValidationConfigBuilder::new()
|
||||
.with_max_length(10)
|
||||
.build();
|
||||
|
||||
assert!(config.character_limits.is_some());
|
||||
assert_eq!(config.character_limits.unwrap().max_length(), Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_builder_with_user_mask() {
|
||||
// User defines custom format
|
||||
let custom_mask = DisplayMask::new("##-##-##", '#').with_template('_');
|
||||
let config = ValidationConfigBuilder::new()
|
||||
.with_display_mask(custom_mask)
|
||||
.with_max_length(6)
|
||||
.build();
|
||||
|
||||
assert!(config.has_validation());
|
||||
assert!(config.character_limits.is_some());
|
||||
assert!(config.display_mask.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validation_result() {
|
||||
let valid = ValidationResult::Valid;
|
||||
assert!(valid.is_acceptable());
|
||||
assert!(!valid.is_error());
|
||||
assert_eq!(valid.message(), None);
|
||||
|
||||
let warning = ValidationResult::warning("Too long");
|
||||
assert!(warning.is_acceptable());
|
||||
assert!(!warning.is_error());
|
||||
assert_eq!(warning.message(), Some("Too long"));
|
||||
|
||||
let error = ValidationResult::error("Invalid");
|
||||
assert!(!error.is_acceptable());
|
||||
assert!(error.is_error());
|
||||
assert_eq!(error.message(), Some("Invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_with_max_length() {
|
||||
let config = ValidationConfig::with_max_length(5);
|
||||
assert!(config.has_validation());
|
||||
|
||||
// Test valid insertion
|
||||
let result = config.validate_char_insertion("test", 4, 'x');
|
||||
assert!(result.is_acceptable());
|
||||
|
||||
// Test invalid insertion (would exceed limit)
|
||||
let result = config.validate_char_insertion("tests", 5, 'x');
|
||||
assert!(!result.is_acceptable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_with_patterns() {
|
||||
use crate::validation::{PatternFilters, PositionFilter, PositionRange, CharacterFilter};
|
||||
|
||||
let patterns = PatternFilters::new()
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Range(0, 1),
|
||||
CharacterFilter::Alphabetic,
|
||||
));
|
||||
|
||||
let config = ValidationConfig::with_patterns(patterns);
|
||||
assert!(config.has_validation());
|
||||
|
||||
// Test valid pattern insertion
|
||||
let result = config.validate_char_insertion("", 0, 'A');
|
||||
assert!(result.is_acceptable());
|
||||
|
||||
// Test invalid pattern insertion
|
||||
let result = config.validate_char_insertion("", 0, '1');
|
||||
assert!(!result.is_acceptable());
|
||||
}
|
||||
}
|
||||
217
canvas/src/validation/formatting.rs
Normal file
217
canvas/src/validation/formatting.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
/* canvas/src/validation/formatting.rs
|
||||
Add new formatting module with CustomFormatter, PositionMapper, DefaultPositionMapper, and FormattingResult
|
||||
*/
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Bidirectional mapping between raw input positions and formatted display positions.
|
||||
///
|
||||
/// The library uses this to keep cursor/selection behavior intuitive when the UI
|
||||
/// shows a formatted transformation (e.g., "01001" -> "010 01") while the editor
|
||||
/// still stores raw text.
|
||||
pub trait PositionMapper: Send + Sync {
|
||||
/// Map a raw cursor position to a formatted cursor position.
|
||||
///
|
||||
/// raw_pos is an index into the raw text (0..=raw.len() in char positions).
|
||||
/// Implementations should return a position within 0..=formatted.len() (in char positions).
|
||||
fn raw_to_formatted(&self, raw: &str, formatted: &str, raw_pos: usize) -> usize;
|
||||
|
||||
/// Map a formatted cursor position to a raw cursor position.
|
||||
///
|
||||
/// formatted_pos is an index into the formatted text (0..=formatted.len()).
|
||||
/// Implementations should return a position within 0..=raw.len() (in char positions).
|
||||
fn formatted_to_raw(&self, raw: &str, formatted: &str, formatted_pos: usize) -> usize;
|
||||
}
|
||||
|
||||
/// A reasonable default mapper that works for "insert separators" style formatting,
|
||||
/// such as grouping digits or adding dashes/spaces.
|
||||
///
|
||||
/// Heuristic:
|
||||
/// - Treat letters and digits (is_alphanumeric) in the formatted string as user-entered characters
|
||||
/// corresponding to raw characters, in order.
|
||||
/// - Treat any non-alphanumeric characters as purely visual separators.
|
||||
/// - Raw positions are mapped by counting alphanumeric characters in the formatted string.
|
||||
/// - If the formatted contains fewer alphanumeric characters than the raw (shouldn't happen
|
||||
/// for plain grouping), we cap at the end of the formatted string.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct DefaultPositionMapper;
|
||||
|
||||
impl PositionMapper for DefaultPositionMapper {
|
||||
fn raw_to_formatted(&self, raw: &str, formatted: &str, raw_pos: usize) -> usize {
|
||||
// Convert to char indices for correctness in presence of UTF-8
|
||||
let raw_len = raw.chars().count();
|
||||
let clamped_raw_pos = raw_pos.min(raw_len);
|
||||
|
||||
// Count alphanumerics in formatted, find the index where we've seen `clamped_raw_pos` of them.
|
||||
let mut seen_user_chars = 0usize;
|
||||
for (idx, ch) in formatted.char_indices() {
|
||||
if ch.is_alphanumeric() {
|
||||
if seen_user_chars == clamped_raw_pos {
|
||||
// Cursor is positioned before this user character in the formatted view
|
||||
return idx;
|
||||
}
|
||||
seen_user_chars += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// If we consumed all alphanumeric chars and still haven't reached clamped_raw_pos,
|
||||
// place cursor at the end of the formatted string.
|
||||
formatted.len()
|
||||
}
|
||||
|
||||
fn formatted_to_raw(&self, raw: &str, formatted: &str, formatted_pos: usize) -> usize {
|
||||
let clamped_fmt_pos = formatted_pos.min(formatted.len());
|
||||
|
||||
// Count alphanumerics in formatted up to formatted_pos.
|
||||
let mut seen_user_chars = 0usize;
|
||||
for (idx, ch) in formatted.char_indices() {
|
||||
if idx >= clamped_fmt_pos {
|
||||
break;
|
||||
}
|
||||
if ch.is_alphanumeric() {
|
||||
seen_user_chars += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Map to raw position by clamping to raw char count
|
||||
let raw_len = raw.chars().count();
|
||||
seen_user_chars.min(raw_len)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of invoking a custom formatter on the raw input.
|
||||
///
|
||||
/// Success variants carry the formatted string and a position mapper to translate
|
||||
/// between raw and formatted cursor positions. If you don't provide a custom mapper,
|
||||
/// the library will fall back to DefaultPositionMapper.
|
||||
pub enum FormattingResult {
|
||||
/// Successfully produced a formatted display value and a position mapper.
|
||||
Success {
|
||||
formatted: String,
|
||||
/// Mapper to convert cursor positions between raw and formatted representations.
|
||||
mapper: Arc<dyn PositionMapper>,
|
||||
},
|
||||
/// Successfully produced a formatted value, but with a non-fatal warning message
|
||||
/// that can be shown in the UI (e.g., "incomplete value").
|
||||
Warning {
|
||||
formatted: String,
|
||||
message: String,
|
||||
mapper: Arc<dyn PositionMapper>,
|
||||
},
|
||||
/// Failed to produce a formatted display. The library will typically fall back to raw.
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl FormattingResult {
|
||||
/// Convenience to create a success result using the default mapper.
|
||||
pub fn success(formatted: impl Into<String>) -> Self {
|
||||
FormattingResult::Success {
|
||||
formatted: formatted.into(),
|
||||
mapper: Arc::new(DefaultPositionMapper::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience to create a warning result using the default mapper.
|
||||
pub fn warning(formatted: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
FormattingResult::Warning {
|
||||
formatted: formatted.into(),
|
||||
message: message.into(),
|
||||
mapper: Arc::new(DefaultPositionMapper::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience to create a success result with a custom mapper.
|
||||
pub fn success_with_mapper(
|
||||
formatted: impl Into<String>,
|
||||
mapper: Arc<dyn PositionMapper>,
|
||||
) -> Self {
|
||||
FormattingResult::Success {
|
||||
formatted: formatted.into(),
|
||||
mapper,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience to create a warning result with a custom mapper.
|
||||
pub fn warning_with_mapper(
|
||||
formatted: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
mapper: Arc<dyn PositionMapper>,
|
||||
) -> Self {
|
||||
FormattingResult::Warning {
|
||||
formatted: formatted.into(),
|
||||
message: message.into(),
|
||||
mapper,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience to create an error result.
|
||||
pub fn error(message: impl Into<String>) -> Self {
|
||||
FormattingResult::Error {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A user-implemented formatter that turns raw input into a formatted display string,
|
||||
/// optionally providing a custom cursor position mapper.
|
||||
///
|
||||
/// Notes:
|
||||
/// - The library will keep raw input authoritative for editing and validation.
|
||||
/// - The formatted value is only used for display.
|
||||
/// - If formatting fails, return Error; the library will show the raw value.
|
||||
/// - For common grouping (spaces/dashes), you can return Success/Warning and rely
|
||||
/// on DefaultPositionMapper, or provide your own mapper for advanced cases
|
||||
/// (reordering, compression, locale-specific rules, etc.).
|
||||
pub trait CustomFormatter: Send + Sync {
|
||||
fn format(&self, raw: &str) -> FormattingResult;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct GroupEvery3;
|
||||
impl CustomFormatter for GroupEvery3 {
|
||||
fn format(&self, raw: &str) -> FormattingResult {
|
||||
let mut out = String::new();
|
||||
for (i, ch) in raw.chars().enumerate() {
|
||||
if i > 0 && i % 3 == 0 {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
FormattingResult::success(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_mapper_roundtrip_basic() {
|
||||
let mapper = DefaultPositionMapper::default();
|
||||
let raw = "01001";
|
||||
let formatted = "010 01";
|
||||
|
||||
// raw_to_formatted monotonicity and bounds
|
||||
for rp in 0..=raw.chars().count() {
|
||||
let fp = mapper.raw_to_formatted(raw, formatted, rp);
|
||||
assert!(fp <= formatted.len());
|
||||
}
|
||||
|
||||
// formatted_to_raw bounds
|
||||
for fp in 0..=formatted.len() {
|
||||
let rp = mapper.formatted_to_raw(raw, formatted, fp);
|
||||
assert!(rp <= raw.chars().count());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatter_groups_every_3() {
|
||||
let f = GroupEvery3;
|
||||
match f.format("1234567") {
|
||||
FormattingResult::Success { formatted, .. } => {
|
||||
assert_eq!(formatted, "123 456 7");
|
||||
}
|
||||
_ => panic!("expected success"),
|
||||
}
|
||||
}
|
||||
}
|
||||
424
canvas/src/validation/limits.rs
Normal file
424
canvas/src/validation/limits.rs
Normal file
@@ -0,0 +1,424 @@
|
||||
// src/validation/limits.rs
|
||||
//! Character limits validation implementation
|
||||
|
||||
use crate::validation::ValidationResult;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// Character limits configuration for a field
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CharacterLimits {
|
||||
/// Maximum number of characters allowed (None = unlimited)
|
||||
max_length: Option<usize>,
|
||||
|
||||
/// Minimum number of characters required (None = no minimum)
|
||||
min_length: Option<usize>,
|
||||
|
||||
/// Warning threshold (warn when approaching max limit)
|
||||
warning_threshold: Option<usize>,
|
||||
|
||||
/// Count mode: characters vs display width
|
||||
count_mode: CountMode,
|
||||
}
|
||||
|
||||
/// How to count characters for limit checking
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum CountMode {
|
||||
/// Count actual characters (default)
|
||||
Characters,
|
||||
|
||||
/// Count display width (useful for CJK characters)
|
||||
DisplayWidth,
|
||||
|
||||
/// Count bytes (rarely used, but available)
|
||||
Bytes,
|
||||
}
|
||||
|
||||
impl Default for CountMode {
|
||||
fn default() -> Self {
|
||||
CountMode::Characters
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a character limit check
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LimitCheckResult {
|
||||
/// Within limits
|
||||
Ok,
|
||||
|
||||
/// Approaching limit (warning)
|
||||
Warning { current: usize, max: usize },
|
||||
|
||||
/// At or exceeding limit (error)
|
||||
Exceeded { current: usize, max: usize },
|
||||
|
||||
/// Below minimum length
|
||||
TooShort { current: usize, min: usize },
|
||||
}
|
||||
|
||||
impl CharacterLimits {
|
||||
/// Create new character limits with just max length
|
||||
pub fn new(max_length: usize) -> Self {
|
||||
Self {
|
||||
max_length: Some(max_length),
|
||||
min_length: None,
|
||||
warning_threshold: None,
|
||||
count_mode: CountMode::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create new character limits with min and max
|
||||
pub fn new_range(min_length: usize, max_length: usize) -> Self {
|
||||
Self {
|
||||
max_length: Some(max_length),
|
||||
min_length: Some(min_length),
|
||||
warning_threshold: None,
|
||||
count_mode: CountMode::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set warning threshold (when to show warning before hitting limit)
|
||||
pub fn with_warning_threshold(mut self, threshold: usize) -> Self {
|
||||
self.warning_threshold = Some(threshold);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set count mode (characters vs display width vs bytes)
|
||||
pub fn with_count_mode(mut self, mode: CountMode) -> Self {
|
||||
self.count_mode = mode;
|
||||
self
|
||||
}
|
||||
|
||||
/// Get maximum length
|
||||
pub fn max_length(&self) -> Option<usize> {
|
||||
self.max_length
|
||||
}
|
||||
|
||||
/// Get minimum length
|
||||
pub fn min_length(&self) -> Option<usize> {
|
||||
self.min_length
|
||||
}
|
||||
|
||||
/// Get warning threshold
|
||||
pub fn warning_threshold(&self) -> Option<usize> {
|
||||
self.warning_threshold
|
||||
}
|
||||
|
||||
/// Get count mode
|
||||
pub fn count_mode(&self) -> CountMode {
|
||||
self.count_mode
|
||||
}
|
||||
|
||||
/// Count characters/width/bytes according to the configured mode
|
||||
fn count(&self, text: &str) -> usize {
|
||||
match self.count_mode {
|
||||
CountMode::Characters => text.chars().count(),
|
||||
CountMode::DisplayWidth => text.width(),
|
||||
CountMode::Bytes => text.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if inserting a character would exceed limits
|
||||
pub fn validate_insertion(
|
||||
&self,
|
||||
current_text: &str,
|
||||
_position: usize,
|
||||
character: char,
|
||||
) -> Option<ValidationResult> {
|
||||
let current_count = self.count(current_text);
|
||||
let char_count = match self.count_mode {
|
||||
CountMode::Characters => 1,
|
||||
CountMode::DisplayWidth => {
|
||||
let char_str = character.to_string();
|
||||
char_str.width()
|
||||
},
|
||||
CountMode::Bytes => character.len_utf8(),
|
||||
};
|
||||
let new_count = current_count + char_count;
|
||||
|
||||
// Check max length
|
||||
if let Some(max) = self.max_length {
|
||||
if new_count > max {
|
||||
return Some(ValidationResult::error(format!(
|
||||
"Character limit exceeded: {}/{}",
|
||||
new_count,
|
||||
max
|
||||
)));
|
||||
}
|
||||
|
||||
// Check warning threshold
|
||||
if let Some(warning_threshold) = self.warning_threshold {
|
||||
if new_count >= warning_threshold && current_count < warning_threshold {
|
||||
return Some(ValidationResult::warning(format!(
|
||||
"Approaching character limit: {}/{}",
|
||||
new_count,
|
||||
max
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None // No validation issues
|
||||
}
|
||||
|
||||
/// Validate the current content
|
||||
pub fn validate_content(&self, text: &str) -> Option<ValidationResult> {
|
||||
let count = self.count(text);
|
||||
|
||||
// Check minimum length
|
||||
if let Some(min) = self.min_length {
|
||||
if count < min {
|
||||
return Some(ValidationResult::warning(format!(
|
||||
"Minimum length not met: {}/{}",
|
||||
count,
|
||||
min
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Check maximum length
|
||||
if let Some(max) = self.max_length {
|
||||
if count > max {
|
||||
return Some(ValidationResult::error(format!(
|
||||
"Character limit exceeded: {}/{}",
|
||||
count,
|
||||
max
|
||||
)));
|
||||
}
|
||||
|
||||
// Check warning threshold
|
||||
if let Some(warning_threshold) = self.warning_threshold {
|
||||
if count >= warning_threshold {
|
||||
return Some(ValidationResult::warning(format!(
|
||||
"Approaching character limit: {}/{}",
|
||||
count,
|
||||
max
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None // No validation issues
|
||||
}
|
||||
|
||||
/// Get the current status of the text against limits
|
||||
pub fn check_limits(&self, text: &str) -> LimitCheckResult {
|
||||
let count = self.count(text);
|
||||
|
||||
// Check max length first
|
||||
if let Some(max) = self.max_length {
|
||||
if count > max {
|
||||
return LimitCheckResult::Exceeded { current: count, max };
|
||||
}
|
||||
|
||||
// Check warning threshold
|
||||
if let Some(warning_threshold) = self.warning_threshold {
|
||||
if count >= warning_threshold {
|
||||
return LimitCheckResult::Warning { current: count, max };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check min length
|
||||
if let Some(min) = self.min_length {
|
||||
if count < min {
|
||||
return LimitCheckResult::TooShort { current: count, min };
|
||||
}
|
||||
}
|
||||
|
||||
LimitCheckResult::Ok
|
||||
}
|
||||
|
||||
/// Get a human-readable status string
|
||||
pub fn status_text(&self, text: &str) -> Option<String> {
|
||||
match self.check_limits(text) {
|
||||
LimitCheckResult::Ok => {
|
||||
// Show current/max if we have a max limit
|
||||
if let Some(max) = self.max_length {
|
||||
Some(format!("{}/{}", self.count(text), max))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
LimitCheckResult::Warning { current, max } => {
|
||||
Some(format!("{}/{} (approaching limit)", current, max))
|
||||
},
|
||||
LimitCheckResult::Exceeded { current, max } => {
|
||||
Some(format!("{}/{} (exceeded)", current, max))
|
||||
},
|
||||
LimitCheckResult::TooShort { current, min } => {
|
||||
Some(format!("{}/{} minimum", current, min))
|
||||
},
|
||||
}
|
||||
}
|
||||
pub fn allows_field_switch(&self, text: &str) -> bool {
|
||||
if let Some(min) = self.min_length {
|
||||
let count = self.count(text);
|
||||
// Allow switching if field is empty OR meets minimum requirement
|
||||
count == 0 || count >= min
|
||||
} else {
|
||||
true // No minimum requirement, always allow switching
|
||||
}
|
||||
}
|
||||
|
||||
/// Get reason why field switching is not allowed (if any)
|
||||
pub fn field_switch_block_reason(&self, text: &str) -> Option<String> {
|
||||
if let Some(min) = self.min_length {
|
||||
let count = self.count(text);
|
||||
if count > 0 && count < min {
|
||||
return Some(format!(
|
||||
"Field must be empty or have at least {} characters (currently: {})",
|
||||
min, count
|
||||
));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CharacterLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_length: Some(30), // Default 30 character limit as specified
|
||||
min_length: None,
|
||||
warning_threshold: None,
|
||||
count_mode: CountMode::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_character_limits_creation() {
|
||||
let limits = CharacterLimits::new(10);
|
||||
assert_eq!(limits.max_length(), Some(10));
|
||||
assert_eq!(limits.min_length(), None);
|
||||
|
||||
let range_limits = CharacterLimits::new_range(5, 15);
|
||||
assert_eq!(range_limits.min_length(), Some(5));
|
||||
assert_eq!(range_limits.max_length(), Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_limits() {
|
||||
let limits = CharacterLimits::default();
|
||||
assert_eq!(limits.max_length(), Some(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_character_counting() {
|
||||
let limits = CharacterLimits::new(5);
|
||||
|
||||
// Test character mode (default)
|
||||
assert_eq!(limits.count("hello"), 5);
|
||||
assert_eq!(limits.count("héllo"), 5); // Accented character counts as 1
|
||||
|
||||
// Test display width mode
|
||||
let limits = limits.with_count_mode(CountMode::DisplayWidth);
|
||||
assert_eq!(limits.count("hello"), 5);
|
||||
|
||||
// Test bytes mode
|
||||
let limits = limits.with_count_mode(CountMode::Bytes);
|
||||
assert_eq!(limits.count("hello"), 5);
|
||||
assert_eq!(limits.count("héllo"), 6); // é takes 2 bytes in UTF-8
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insertion_validation() {
|
||||
let limits = CharacterLimits::new(5);
|
||||
|
||||
// Valid insertion
|
||||
let result = limits.validate_insertion("test", 4, 'x');
|
||||
assert!(result.is_none()); // No validation issues
|
||||
|
||||
// Invalid insertion (would exceed limit)
|
||||
let result = limits.validate_insertion("tests", 5, 'x');
|
||||
assert!(result.is_some());
|
||||
assert!(!result.unwrap().is_acceptable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_validation() {
|
||||
let limits = CharacterLimits::new_range(3, 10);
|
||||
|
||||
// Too short
|
||||
let result = limits.validate_content("hi");
|
||||
assert!(result.is_some());
|
||||
assert!(result.unwrap().is_acceptable()); // Warning, not error
|
||||
|
||||
// Just right
|
||||
let result = limits.validate_content("hello");
|
||||
assert!(result.is_none());
|
||||
|
||||
// Too long
|
||||
let result = limits.validate_content("hello world!");
|
||||
assert!(result.is_some());
|
||||
assert!(!result.unwrap().is_acceptable()); // Error
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_warning_threshold() {
|
||||
let limits = CharacterLimits::new(10).with_warning_threshold(8);
|
||||
|
||||
// Below warning threshold
|
||||
let result = limits.validate_insertion("1234567", 7, 'x');
|
||||
assert!(result.is_none());
|
||||
|
||||
// At warning threshold
|
||||
let result = limits.validate_insertion("1234567", 7, 'x');
|
||||
assert!(result.is_none()); // This brings us to 8 chars
|
||||
|
||||
let result = limits.validate_insertion("12345678", 8, 'x');
|
||||
assert!(result.is_some());
|
||||
assert!(result.unwrap().is_acceptable()); // Warning, not error
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_text() {
|
||||
let limits = CharacterLimits::new(10);
|
||||
|
||||
assert_eq!(limits.status_text("hello"), Some("5/10".to_string()));
|
||||
|
||||
let limits = limits.with_warning_threshold(8);
|
||||
assert_eq!(limits.status_text("12345678"), Some("8/10 (approaching limit)".to_string()));
|
||||
assert_eq!(limits.status_text("1234567890x"), Some("11/10 (exceeded)".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_field_switch_blocking() {
|
||||
let limits = CharacterLimits::new_range(3, 10);
|
||||
|
||||
// Empty field: should allow switching
|
||||
assert!(limits.allows_field_switch(""));
|
||||
assert!(limits.field_switch_block_reason("").is_none());
|
||||
|
||||
// Field with content below minimum: should block switching
|
||||
assert!(!limits.allows_field_switch("hi"));
|
||||
assert!(limits.field_switch_block_reason("hi").is_some());
|
||||
assert!(limits.field_switch_block_reason("hi").unwrap().contains("at least 3 characters"));
|
||||
|
||||
// Field meeting minimum: should allow switching
|
||||
assert!(limits.allows_field_switch("hello"));
|
||||
assert!(limits.field_switch_block_reason("hello").is_none());
|
||||
|
||||
// Field exceeding maximum: should still allow switching (validation shows error but doesn't block)
|
||||
assert!(limits.allows_field_switch("this is way too long"));
|
||||
assert!(limits.field_switch_block_reason("this is way too long").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_field_switch_no_minimum() {
|
||||
let limits = CharacterLimits::new(10); // Only max, no minimum
|
||||
|
||||
// Should always allow switching when there's no minimum
|
||||
assert!(limits.allows_field_switch(""));
|
||||
assert!(limits.allows_field_switch("a"));
|
||||
assert!(limits.allows_field_switch("hello"));
|
||||
|
||||
assert!(limits.field_switch_block_reason("").is_none());
|
||||
assert!(limits.field_switch_block_reason("a").is_none());
|
||||
}
|
||||
}
|
||||
333
canvas/src/validation/mask.rs
Normal file
333
canvas/src/validation/mask.rs
Normal file
@@ -0,0 +1,333 @@
|
||||
// src/validation/mask.rs
|
||||
//! Pure display mask system - user-defined patterns only
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MaskDisplayMode {
|
||||
/// Only show separators as user types
|
||||
/// Example: "" → "", "123" → "123", "12345" → "(123) 45"
|
||||
Dynamic,
|
||||
|
||||
/// Show full template with placeholders from start
|
||||
/// Example: "" → "(___) ___-____", "123" → "(123) ___-____"
|
||||
Template {
|
||||
/// Character to use as placeholder for empty input positions
|
||||
placeholder: char
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for MaskDisplayMode {
|
||||
fn default() -> Self {
|
||||
MaskDisplayMode::Dynamic
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DisplayMask {
|
||||
/// Mask pattern like "##-##-####" where # = input position, others are visual separators
|
||||
pattern: String,
|
||||
/// Character used to represent input positions (usually '#')
|
||||
input_char: char,
|
||||
/// How to display the mask (dynamic vs template)
|
||||
display_mode: MaskDisplayMode,
|
||||
}
|
||||
|
||||
impl DisplayMask {
|
||||
/// Create a new display mask with dynamic mode (current behavior)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `pattern` - The mask pattern (e.g., "##-##-####", "(###) ###-####")
|
||||
/// * `input_char` - Character representing input positions (usually '#')
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// // Phone number format
|
||||
/// let phone_mask = DisplayMask::new("(###) ###-####", '#');
|
||||
///
|
||||
/// // Date format
|
||||
/// let date_mask = DisplayMask::new("##/##/####", '#');
|
||||
///
|
||||
/// // Custom business format
|
||||
/// let employee_id = DisplayMask::new("EMP-####-##", '#');
|
||||
/// ```
|
||||
pub fn new(pattern: impl Into<String>, input_char: char) -> Self {
|
||||
Self {
|
||||
pattern: pattern.into(),
|
||||
input_char,
|
||||
display_mode: MaskDisplayMode::Dynamic,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the display mode for this mask
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// let dynamic_mask = DisplayMask::new("##-##", '#')
|
||||
/// .with_mode(MaskDisplayMode::Dynamic);
|
||||
///
|
||||
/// let template_mask = DisplayMask::new("##-##", '#')
|
||||
/// .with_mode(MaskDisplayMode::Template { placeholder: '_' });
|
||||
/// ```
|
||||
pub fn with_mode(mut self, mode: MaskDisplayMode) -> Self {
|
||||
self.display_mode = mode;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set template mode with custom placeholder
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// let phone_template = DisplayMask::new("(###) ###-####", '#')
|
||||
/// .with_template('_'); // Shows "(___) ___-____" when empty
|
||||
///
|
||||
/// let date_dots = DisplayMask::new("##/##/####", '#')
|
||||
/// .with_template('•'); // Shows "••/••/••••" when empty
|
||||
/// ```
|
||||
pub fn with_template(self, placeholder: char) -> Self {
|
||||
self.with_mode(MaskDisplayMode::Template { placeholder })
|
||||
}
|
||||
|
||||
/// Apply mask to raw input, showing visual separators and handling display mode
|
||||
pub fn apply_to_display(&self, raw_input: &str) -> String {
|
||||
match &self.display_mode {
|
||||
MaskDisplayMode::Dynamic => self.apply_dynamic(raw_input),
|
||||
MaskDisplayMode::Template { placeholder } => self.apply_template(raw_input, *placeholder),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dynamic mode - only show separators as user types
|
||||
fn apply_dynamic(&self, raw_input: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut raw_chars = raw_input.chars();
|
||||
|
||||
for pattern_char in self.pattern.chars() {
|
||||
if pattern_char == self.input_char {
|
||||
// Input position - take from raw input
|
||||
if let Some(input_char) = raw_chars.next() {
|
||||
result.push(input_char);
|
||||
} else {
|
||||
// No more input - stop here in dynamic mode
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Visual separator - always show
|
||||
result.push(pattern_char);
|
||||
}
|
||||
}
|
||||
|
||||
// Append any remaining raw characters that don't fit the pattern
|
||||
for remaining_char in raw_chars {
|
||||
result.push(remaining_char);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Template mode - show full pattern with placeholders
|
||||
fn apply_template(&self, raw_input: &str, placeholder: char) -> String {
|
||||
let mut result = String::new();
|
||||
let mut raw_chars = raw_input.chars().peekable();
|
||||
|
||||
for pattern_char in self.pattern.chars() {
|
||||
if pattern_char == self.input_char {
|
||||
// Input position - take from raw input or use placeholder
|
||||
if let Some(input_char) = raw_chars.next() {
|
||||
result.push(input_char);
|
||||
} else {
|
||||
// No more input - use placeholder to show template
|
||||
result.push(placeholder);
|
||||
}
|
||||
} else {
|
||||
// Visual separator - always show in template mode
|
||||
result.push(pattern_char);
|
||||
}
|
||||
}
|
||||
|
||||
// In template mode, we don't append extra characters beyond the pattern
|
||||
// This keeps the template consistent
|
||||
result
|
||||
}
|
||||
|
||||
/// Check if a display position should accept cursor/input
|
||||
pub fn is_input_position(&self, display_position: usize) -> bool {
|
||||
self.pattern.chars()
|
||||
.nth(display_position)
|
||||
.map(|c| c == self.input_char)
|
||||
.unwrap_or(true) // Beyond pattern = accept input
|
||||
}
|
||||
|
||||
/// Map display position to raw position
|
||||
pub fn display_pos_to_raw_pos(&self, display_pos: usize) -> usize {
|
||||
let mut raw_pos = 0;
|
||||
|
||||
for (i, pattern_char) in self.pattern.chars().enumerate() {
|
||||
if i >= display_pos {
|
||||
break;
|
||||
}
|
||||
if pattern_char == self.input_char {
|
||||
raw_pos += 1;
|
||||
}
|
||||
}
|
||||
|
||||
raw_pos
|
||||
}
|
||||
|
||||
/// Map raw position to display position
|
||||
pub fn raw_pos_to_display_pos(&self, raw_pos: usize) -> usize {
|
||||
let mut input_positions_seen = 0;
|
||||
|
||||
for (display_pos, pattern_char) in self.pattern.chars().enumerate() {
|
||||
if pattern_char == self.input_char {
|
||||
if input_positions_seen == raw_pos {
|
||||
return display_pos;
|
||||
}
|
||||
input_positions_seen += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Beyond pattern, return position after pattern
|
||||
self.pattern.len() + (raw_pos - input_positions_seen)
|
||||
}
|
||||
|
||||
/// Find next input position at or after the given display position
|
||||
pub fn next_input_position(&self, display_pos: usize) -> usize {
|
||||
for (i, pattern_char) in self.pattern.chars().enumerate().skip(display_pos) {
|
||||
if pattern_char == self.input_char {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
// Beyond pattern = all positions are input positions
|
||||
display_pos.max(self.pattern.len())
|
||||
}
|
||||
|
||||
/// Find previous input position at or before the given display position
|
||||
pub fn prev_input_position(&self, display_pos: usize) -> Option<usize> {
|
||||
// Collect pattern chars with indices first, then search backwards
|
||||
let pattern_chars: Vec<(usize, char)> = self.pattern.chars().enumerate().collect();
|
||||
|
||||
// Search backwards from display_pos
|
||||
for &(i, pattern_char) in pattern_chars.iter().rev() {
|
||||
if i <= display_pos && pattern_char == self.input_char {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the display mode
|
||||
pub fn display_mode(&self) -> &MaskDisplayMode {
|
||||
&self.display_mode
|
||||
}
|
||||
|
||||
/// Check if this mask uses template mode
|
||||
pub fn is_template_mode(&self) -> bool {
|
||||
matches!(self.display_mode, MaskDisplayMode::Template { .. })
|
||||
}
|
||||
|
||||
/// Get the pattern string
|
||||
pub fn pattern(&self) -> &str {
|
||||
&self.pattern
|
||||
}
|
||||
|
||||
/// Get the position of the first input character in the pattern
|
||||
pub fn first_input_position(&self) -> usize {
|
||||
for (pos, ch) in self.pattern.chars().enumerate() {
|
||||
if ch == self.input_char {
|
||||
return pos;
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DisplayMask {
|
||||
fn default() -> Self {
|
||||
Self::new("", '#')
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_user_defined_phone_mask() {
|
||||
// User creates their own phone mask
|
||||
let dynamic = DisplayMask::new("(###) ###-####", '#');
|
||||
let template = DisplayMask::new("(###) ###-####", '#').with_template('_');
|
||||
|
||||
// Dynamic mode
|
||||
assert_eq!(dynamic.apply_to_display(""), "");
|
||||
assert_eq!(dynamic.apply_to_display("1234567890"), "(123) 456-7890");
|
||||
|
||||
// Template mode
|
||||
assert_eq!(template.apply_to_display(""), "(___) ___-____");
|
||||
assert_eq!(template.apply_to_display("123"), "(123) ___-____");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_defined_date_mask() {
|
||||
// User creates their own date formats
|
||||
let us_date = DisplayMask::new("##/##/####", '#');
|
||||
let eu_date = DisplayMask::new("##.##.####", '#');
|
||||
let iso_date = DisplayMask::new("####-##-##", '#');
|
||||
|
||||
assert_eq!(us_date.apply_to_display("12252024"), "12/25/2024");
|
||||
assert_eq!(eu_date.apply_to_display("25122024"), "25.12.2024");
|
||||
assert_eq!(iso_date.apply_to_display("20241225"), "2024-12-25");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_defined_business_formats() {
|
||||
// User creates custom business formats
|
||||
let employee_id = DisplayMask::new("EMP-####-##", '#');
|
||||
let product_code = DisplayMask::new("###-###-###", '#');
|
||||
let invoice = DisplayMask::new("INV####/##", '#');
|
||||
|
||||
assert_eq!(employee_id.apply_to_display("123456"), "EMP-1234-56");
|
||||
assert_eq!(product_code.apply_to_display("123456789"), "123-456-789");
|
||||
assert_eq!(invoice.apply_to_display("123456"), "INV1234/56");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_input_characters() {
|
||||
// User can define their own input character
|
||||
let mask_with_x = DisplayMask::new("XXX-XX-XXXX", 'X');
|
||||
let mask_with_hash = DisplayMask::new("###-##-####", '#');
|
||||
let mask_with_n = DisplayMask::new("NNN-NN-NNNN", 'N');
|
||||
|
||||
assert_eq!(mask_with_x.apply_to_display("123456789"), "123-45-6789");
|
||||
assert_eq!(mask_with_hash.apply_to_display("123456789"), "123-45-6789");
|
||||
assert_eq!(mask_with_n.apply_to_display("123456789"), "123-45-6789");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_placeholders() {
|
||||
// User can define custom placeholder characters
|
||||
let underscores = DisplayMask::new("##-##", '#').with_template('_');
|
||||
let dots = DisplayMask::new("##-##", '#').with_template('•');
|
||||
let dashes = DisplayMask::new("##-##", '#').with_template('-');
|
||||
|
||||
assert_eq!(underscores.apply_to_display(""), "__-__");
|
||||
assert_eq!(dots.apply_to_display(""), "••-••");
|
||||
assert_eq!(dashes.apply_to_display(""), "---"); // Note: dashes blend with separator
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_position_mapping_user_patterns() {
|
||||
let custom = DisplayMask::new("ABC-###-XYZ", '#');
|
||||
|
||||
// Position mapping should work correctly with any pattern
|
||||
assert_eq!(custom.raw_pos_to_display_pos(0), 4); // First # at position 4
|
||||
assert_eq!(custom.raw_pos_to_display_pos(1), 5); // Second # at position 5
|
||||
assert_eq!(custom.raw_pos_to_display_pos(2), 6); // Third # at position 6
|
||||
|
||||
assert_eq!(custom.display_pos_to_raw_pos(4), 0); // Position 4 -> first input
|
||||
assert_eq!(custom.display_pos_to_raw_pos(5), 1); // Position 5 -> second input
|
||||
assert_eq!(custom.display_pos_to_raw_pos(6), 2); // Position 6 -> third input
|
||||
|
||||
assert!(!custom.is_input_position(0)); // A
|
||||
assert!(!custom.is_input_position(3)); // -
|
||||
assert!(custom.is_input_position(4)); // #
|
||||
assert!(!custom.is_input_position(8)); // Y
|
||||
}
|
||||
}
|
||||
40
canvas/src/validation/mod.rs
Normal file
40
canvas/src/validation/mod.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
// src/validation/mod.rs
|
||||
|
||||
// Core validation modules
|
||||
pub mod config;
|
||||
pub mod limits;
|
||||
pub mod state;
|
||||
pub mod patterns;
|
||||
pub mod mask; // Simple display mask instead of complex reserved chars
|
||||
pub mod formatting; // Custom formatter and position mapping (feature 4)
|
||||
|
||||
// Re-export main types
|
||||
pub use config::{ValidationConfig, ValidationResult, ValidationConfigBuilder};
|
||||
pub use limits::{CharacterLimits, LimitCheckResult};
|
||||
pub use state::{ValidationState, ValidationSummary};
|
||||
pub use patterns::{PatternFilters, PositionFilter, PositionRange, CharacterFilter};
|
||||
pub use mask::DisplayMask; // Simple mask instead of ReservedCharacters
|
||||
pub use formatting::{CustomFormatter, FormattingResult, PositionMapper, DefaultPositionMapper};
|
||||
|
||||
/// External validation UI state (Feature 5)
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ExternalValidationState {
|
||||
NotValidated,
|
||||
Validating,
|
||||
Valid(Option<String>),
|
||||
Invalid { message: String, suggestion: Option<String> },
|
||||
Warning { message: String },
|
||||
}
|
||||
|
||||
/// Validation error types
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum ValidationError {
|
||||
#[error("Character limit exceeded: {message}")]
|
||||
LimitExceeded { message: String },
|
||||
|
||||
#[error("Pattern validation failed: {message}")]
|
||||
PatternFailed { message: String },
|
||||
|
||||
#[error("Custom validation failed: {message}")]
|
||||
CustomFailed { message: String },
|
||||
}
|
||||
326
canvas/src/validation/patterns.rs
Normal file
326
canvas/src/validation/patterns.rs
Normal file
@@ -0,0 +1,326 @@
|
||||
// src/validation/patterns.rs
|
||||
//! Position-based pattern filtering for validation
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A filter that applies to specific character positions in a field
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PositionFilter {
|
||||
/// Which positions this filter applies to
|
||||
pub positions: PositionRange,
|
||||
/// What type of character filter to apply
|
||||
pub filter: CharacterFilter,
|
||||
}
|
||||
|
||||
/// Defines which character positions a filter applies to
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum PositionRange {
|
||||
/// Single position (e.g., position 3 only)
|
||||
Single(usize),
|
||||
/// Range of positions (e.g., positions 0-2, inclusive)
|
||||
Range(usize, usize),
|
||||
/// From position onwards (e.g., position 4 and beyond)
|
||||
From(usize),
|
||||
/// Multiple specific positions (e.g., positions 0, 2, 5)
|
||||
Multiple(Vec<usize>),
|
||||
}
|
||||
|
||||
/// Types of character filters that can be applied
|
||||
pub enum CharacterFilter {
|
||||
/// Allow only alphabetic characters (a-z, A-Z)
|
||||
Alphabetic,
|
||||
/// Allow only numeric characters (0-9)
|
||||
Numeric,
|
||||
/// Allow alphanumeric characters (a-z, A-Z, 0-9)
|
||||
Alphanumeric,
|
||||
/// Allow only exact character match
|
||||
Exact(char),
|
||||
/// Allow any character from the provided set
|
||||
OneOf(Vec<char>),
|
||||
/// Custom user-defined filter function
|
||||
Custom(Arc<dyn Fn(char) -> bool + Send + Sync>),
|
||||
}
|
||||
|
||||
// Manual implementations for Debug and Clone
|
||||
impl std::fmt::Debug for CharacterFilter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CharacterFilter::Alphabetic => write!(f, "Alphabetic"),
|
||||
CharacterFilter::Numeric => write!(f, "Numeric"),
|
||||
CharacterFilter::Alphanumeric => write!(f, "Alphanumeric"),
|
||||
CharacterFilter::Exact(ch) => write!(f, "Exact('{}')", ch),
|
||||
CharacterFilter::OneOf(chars) => write!(f, "OneOf({:?})", chars),
|
||||
CharacterFilter::Custom(_) => write!(f, "Custom(<function>)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for CharacterFilter {
|
||||
fn clone(&self) -> Self {
|
||||
match self {
|
||||
CharacterFilter::Alphabetic => CharacterFilter::Alphabetic,
|
||||
CharacterFilter::Numeric => CharacterFilter::Numeric,
|
||||
CharacterFilter::Alphanumeric => CharacterFilter::Alphanumeric,
|
||||
CharacterFilter::Exact(ch) => CharacterFilter::Exact(*ch),
|
||||
CharacterFilter::OneOf(chars) => CharacterFilter::OneOf(chars.clone()),
|
||||
CharacterFilter::Custom(func) => CharacterFilter::Custom(Arc::clone(func)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PositionRange {
|
||||
/// Check if a position is included in this range
|
||||
pub fn contains(&self, position: usize) -> bool {
|
||||
match self {
|
||||
PositionRange::Single(pos) => position == *pos,
|
||||
PositionRange::Range(start, end) => position >= *start && position <= *end,
|
||||
PositionRange::From(start) => position >= *start,
|
||||
PositionRange::Multiple(positions) => positions.contains(&position),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all positions up to a given length that this range covers
|
||||
pub fn positions_up_to(&self, max_length: usize) -> Vec<usize> {
|
||||
match self {
|
||||
PositionRange::Single(pos) => {
|
||||
if *pos < max_length { vec![*pos] } else { vec![] }
|
||||
},
|
||||
PositionRange::Range(start, end) => {
|
||||
let actual_end = (*end).min(max_length.saturating_sub(1));
|
||||
if *start <= actual_end {
|
||||
(*start..=actual_end).collect()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
},
|
||||
PositionRange::From(start) => {
|
||||
if *start < max_length {
|
||||
(*start..max_length).collect()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
},
|
||||
PositionRange::Multiple(positions) => {
|
||||
positions.iter()
|
||||
.filter(|&&pos| pos < max_length)
|
||||
.copied()
|
||||
.collect()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CharacterFilter {
|
||||
/// Test if a character passes this filter
|
||||
pub fn accepts(&self, ch: char) -> bool {
|
||||
match self {
|
||||
CharacterFilter::Alphabetic => ch.is_alphabetic(),
|
||||
CharacterFilter::Numeric => ch.is_numeric(),
|
||||
CharacterFilter::Alphanumeric => ch.is_alphanumeric(),
|
||||
CharacterFilter::Exact(expected) => ch == *expected,
|
||||
CharacterFilter::OneOf(chars) => chars.contains(&ch),
|
||||
CharacterFilter::Custom(func) => func(ch),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a human-readable description of this filter
|
||||
pub fn description(&self) -> String {
|
||||
match self {
|
||||
CharacterFilter::Alphabetic => "alphabetic characters (a-z, A-Z)".to_string(),
|
||||
CharacterFilter::Numeric => "numeric characters (0-9)".to_string(),
|
||||
CharacterFilter::Alphanumeric => "alphanumeric characters (a-z, A-Z, 0-9)".to_string(),
|
||||
CharacterFilter::Exact(ch) => format!("exactly '{}'", ch),
|
||||
CharacterFilter::OneOf(chars) => {
|
||||
let char_list: String = chars.iter().collect();
|
||||
format!("one of: {}", char_list)
|
||||
},
|
||||
CharacterFilter::Custom(_) => "custom filter".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PositionFilter {
|
||||
/// Create a new position filter
|
||||
pub fn new(positions: PositionRange, filter: CharacterFilter) -> Self {
|
||||
Self { positions, filter }
|
||||
}
|
||||
|
||||
/// Validate a character at a specific position
|
||||
pub fn validate_position(&self, position: usize, character: char) -> bool {
|
||||
if self.positions.contains(position) {
|
||||
self.filter.accepts(character)
|
||||
} else {
|
||||
true // Position not covered by this filter, allow any character
|
||||
}
|
||||
}
|
||||
|
||||
/// Get error message for invalid character at position
|
||||
pub fn error_message(&self, position: usize, character: char) -> Option<String> {
|
||||
if self.positions.contains(position) && !self.filter.accepts(character) {
|
||||
Some(format!(
|
||||
"Position {} requires {} but got '{}'",
|
||||
position,
|
||||
self.filter.description(),
|
||||
character
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A collection of position filters for a field
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PatternFilters {
|
||||
filters: Vec<PositionFilter>,
|
||||
}
|
||||
|
||||
impl PatternFilters {
|
||||
/// Create empty pattern filters
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Add a position filter
|
||||
pub fn add_filter(mut self, filter: PositionFilter) -> Self {
|
||||
self.filters.push(filter);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add multiple filters
|
||||
pub fn add_filters(mut self, filters: Vec<PositionFilter>) -> Self {
|
||||
self.filters.extend(filters);
|
||||
self
|
||||
}
|
||||
|
||||
/// Validate a character at a specific position against all applicable filters
|
||||
pub fn validate_char_at_position(&self, position: usize, character: char) -> Result<(), String> {
|
||||
for filter in &self.filters {
|
||||
if let Some(error) = filter.error_message(position, character) {
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate entire text against all filters
|
||||
pub fn validate_text(&self, text: &str) -> Result<(), String> {
|
||||
for (position, character) in text.char_indices() {
|
||||
if let Err(error) = self.validate_char_at_position(position, character) {
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if any filters are configured
|
||||
pub fn has_filters(&self) -> bool {
|
||||
!self.filters.is_empty()
|
||||
}
|
||||
|
||||
/// Get all configured filters
|
||||
pub fn filters(&self) -> &[PositionFilter] {
|
||||
&self.filters
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_position_range_contains() {
|
||||
assert!(PositionRange::Single(3).contains(3));
|
||||
assert!(!PositionRange::Single(3).contains(2));
|
||||
|
||||
assert!(PositionRange::Range(1, 4).contains(3));
|
||||
assert!(!PositionRange::Range(1, 4).contains(5));
|
||||
|
||||
assert!(PositionRange::From(2).contains(5));
|
||||
assert!(!PositionRange::From(2).contains(1));
|
||||
|
||||
assert!(PositionRange::Multiple(vec![0, 2, 5]).contains(2));
|
||||
assert!(!PositionRange::Multiple(vec![0, 2, 5]).contains(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_position_range_positions_up_to() {
|
||||
assert_eq!(PositionRange::Single(3).positions_up_to(5), vec![3]);
|
||||
assert_eq!(PositionRange::Single(5).positions_up_to(3), vec![]);
|
||||
|
||||
assert_eq!(PositionRange::Range(1, 3).positions_up_to(5), vec![1, 2, 3]);
|
||||
assert_eq!(PositionRange::Range(1, 5).positions_up_to(3), vec![1, 2]);
|
||||
|
||||
assert_eq!(PositionRange::From(2).positions_up_to(5), vec![2, 3, 4]);
|
||||
|
||||
assert_eq!(PositionRange::Multiple(vec![0, 2, 5]).positions_up_to(4), vec![0, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_character_filter_accepts() {
|
||||
assert!(CharacterFilter::Alphabetic.accepts('a'));
|
||||
assert!(CharacterFilter::Alphabetic.accepts('Z'));
|
||||
assert!(!CharacterFilter::Alphabetic.accepts('1'));
|
||||
|
||||
assert!(CharacterFilter::Numeric.accepts('5'));
|
||||
assert!(!CharacterFilter::Numeric.accepts('a'));
|
||||
|
||||
assert!(CharacterFilter::Alphanumeric.accepts('a'));
|
||||
assert!(CharacterFilter::Alphanumeric.accepts('5'));
|
||||
assert!(!CharacterFilter::Alphanumeric.accepts('-'));
|
||||
|
||||
assert!(CharacterFilter::Exact('x').accepts('x'));
|
||||
assert!(!CharacterFilter::Exact('x').accepts('y'));
|
||||
|
||||
assert!(CharacterFilter::OneOf(vec!['a', 'b', 'c']).accepts('b'));
|
||||
assert!(!CharacterFilter::OneOf(vec!['a', 'b', 'c']).accepts('d'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_position_filter_validation() {
|
||||
let filter = PositionFilter::new(
|
||||
PositionRange::Range(0, 1),
|
||||
CharacterFilter::Alphabetic,
|
||||
);
|
||||
|
||||
assert!(filter.validate_position(0, 'A'));
|
||||
assert!(filter.validate_position(1, 'b'));
|
||||
assert!(!filter.validate_position(0, '1'));
|
||||
assert!(filter.validate_position(2, '1')); // Position 2 not covered, allow anything
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pattern_filters_validation() {
|
||||
let patterns = PatternFilters::new()
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Range(0, 1),
|
||||
CharacterFilter::Alphabetic,
|
||||
))
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::Range(2, 4),
|
||||
CharacterFilter::Numeric,
|
||||
));
|
||||
|
||||
// Valid pattern: AB123
|
||||
assert!(patterns.validate_text("AB123").is_ok());
|
||||
|
||||
// Invalid: number in alphabetic position
|
||||
assert!(patterns.validate_text("A1123").is_err());
|
||||
|
||||
// Invalid: letter in numeric position
|
||||
assert!(patterns.validate_text("AB1A3").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_filter() {
|
||||
let pattern = PatternFilters::new()
|
||||
.add_filter(PositionFilter::new(
|
||||
PositionRange::From(0),
|
||||
CharacterFilter::Custom(Arc::new(|c| c.is_lowercase())),
|
||||
));
|
||||
|
||||
assert!(pattern.validate_text("hello").is_ok());
|
||||
assert!(pattern.validate_text("Hello").is_err()); // Uppercase not allowed
|
||||
}
|
||||
}
|
||||
441
canvas/src/validation/state.rs
Normal file
441
canvas/src/validation/state.rs
Normal file
@@ -0,0 +1,441 @@
|
||||
// src/validation/state.rs
|
||||
//! Validation state management
|
||||
|
||||
use crate::validation::{ValidationConfig, ValidationResult, ExternalValidationState};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Validation state for all fields in a form
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ValidationState {
|
||||
/// Validation configurations per field index
|
||||
field_configs: HashMap<usize, ValidationConfig>,
|
||||
|
||||
/// Current validation results per field index
|
||||
field_results: HashMap<usize, ValidationResult>,
|
||||
|
||||
/// Track which fields have been validated
|
||||
validated_fields: std::collections::HashSet<usize>,
|
||||
|
||||
/// Global validation enabled/disabled
|
||||
enabled: bool,
|
||||
|
||||
/// External validation results per field (Feature 5)
|
||||
external_results: HashMap<usize, ExternalValidationState>,
|
||||
}
|
||||
|
||||
impl ValidationState {
|
||||
/// Create a new validation state
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
field_configs: HashMap::new(),
|
||||
field_results: HashMap::new(),
|
||||
validated_fields: std::collections::HashSet::new(),
|
||||
enabled: true,
|
||||
external_results: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable validation globally
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
if !enabled {
|
||||
// Clear all validation results when disabled
|
||||
self.field_results.clear();
|
||||
self.validated_fields.clear();
|
||||
self.external_results.clear(); // Also clear external results
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if validation is enabled
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
/// Set validation configuration for a field
|
||||
pub fn set_field_config(&mut self, field_index: usize, config: ValidationConfig) {
|
||||
if config.has_validation() || config.external_validation_enabled {
|
||||
self.field_configs.insert(field_index, config);
|
||||
} else {
|
||||
self.field_configs.remove(&field_index);
|
||||
self.field_results.remove(&field_index);
|
||||
self.validated_fields.remove(&field_index);
|
||||
self.external_results.remove(&field_index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get validation configuration for a field
|
||||
pub fn get_field_config(&self, field_index: usize) -> Option<&ValidationConfig> {
|
||||
self.field_configs.get(&field_index)
|
||||
}
|
||||
|
||||
/// Remove validation configuration for a field
|
||||
pub fn remove_field_config(&mut self, field_index: usize) {
|
||||
self.field_configs.remove(&field_index);
|
||||
self.field_results.remove(&field_index);
|
||||
self.validated_fields.remove(&field_index);
|
||||
self.external_results.remove(&field_index);
|
||||
}
|
||||
|
||||
/// Set external validation state for a field (Feature 5)
|
||||
pub fn set_external_validation(&mut self, field_index: usize, state: ExternalValidationState) {
|
||||
self.external_results.insert(field_index, state);
|
||||
}
|
||||
|
||||
/// Get current external validation state for a field
|
||||
pub fn get_external_validation(&self, field_index: usize) -> ExternalValidationState {
|
||||
self.external_results
|
||||
.get(&field_index)
|
||||
.cloned()
|
||||
.unwrap_or(ExternalValidationState::NotValidated)
|
||||
}
|
||||
|
||||
/// Clear external validation state for a field
|
||||
pub fn clear_external_validation(&mut self, field_index: usize) {
|
||||
self.external_results.remove(&field_index);
|
||||
}
|
||||
|
||||
/// Clear all external validation states
|
||||
pub fn clear_all_external_validation(&mut self) {
|
||||
self.external_results.clear();
|
||||
}
|
||||
|
||||
/// Validate character insertion for a field
|
||||
pub fn validate_char_insertion(
|
||||
&mut self,
|
||||
field_index: usize,
|
||||
current_text: &str,
|
||||
position: usize,
|
||||
character: char,
|
||||
) -> ValidationResult {
|
||||
if !self.enabled {
|
||||
return ValidationResult::Valid;
|
||||
}
|
||||
|
||||
if let Some(config) = self.field_configs.get(&field_index) {
|
||||
let result = config.validate_char_insertion(current_text, position, character);
|
||||
|
||||
// Store the validation result
|
||||
self.field_results.insert(field_index, result.clone());
|
||||
self.validated_fields.insert(field_index);
|
||||
|
||||
result
|
||||
} else {
|
||||
ValidationResult::Valid
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate field content
|
||||
pub fn validate_field_content(
|
||||
&mut self,
|
||||
field_index: usize,
|
||||
text: &str,
|
||||
) -> ValidationResult {
|
||||
if !self.enabled {
|
||||
return ValidationResult::Valid;
|
||||
}
|
||||
|
||||
if let Some(config) = self.field_configs.get(&field_index) {
|
||||
let result = config.validate_content(text);
|
||||
|
||||
// Store the validation result
|
||||
self.field_results.insert(field_index, result.clone());
|
||||
self.validated_fields.insert(field_index);
|
||||
|
||||
result
|
||||
} else {
|
||||
ValidationResult::Valid
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current validation result for a field
|
||||
pub fn get_field_result(&self, field_index: usize) -> Option<&ValidationResult> {
|
||||
self.field_results.get(&field_index)
|
||||
}
|
||||
|
||||
/// Get formatted display for a field if a custom formatter is configured.
|
||||
/// Returns (formatted_text, position_mapper, optional_warning_message).
|
||||
#[cfg(feature = "validation")]
|
||||
pub fn formatted_for(
|
||||
&self,
|
||||
field_index: usize,
|
||||
raw: &str,
|
||||
) -> Option<(String, std::sync::Arc<dyn crate::validation::PositionMapper>, Option<String>)> {
|
||||
let config = self.field_configs.get(&field_index)?;
|
||||
config.run_custom_formatter(raw)
|
||||
}
|
||||
|
||||
/// Check if a field has been validated
|
||||
pub fn is_field_validated(&self, field_index: usize) -> bool {
|
||||
self.validated_fields.contains(&field_index)
|
||||
}
|
||||
|
||||
/// Clear validation result for a field
|
||||
pub fn clear_field_result(&mut self, field_index: usize) {
|
||||
self.field_results.remove(&field_index);
|
||||
self.validated_fields.remove(&field_index);
|
||||
}
|
||||
|
||||
/// Clear all validation results
|
||||
pub fn clear_all_results(&mut self) {
|
||||
self.field_results.clear();
|
||||
self.validated_fields.clear();
|
||||
}
|
||||
|
||||
/// Get all field indices that have validation configured
|
||||
pub fn validated_field_indices(&self) -> impl Iterator<Item = usize> + '_ {
|
||||
self.field_configs.keys().copied()
|
||||
}
|
||||
|
||||
/// Get all field indices with validation errors
|
||||
pub fn fields_with_errors(&self) -> impl Iterator<Item = usize> + '_ {
|
||||
self.field_results
|
||||
.iter()
|
||||
.filter(|(_, result)| result.is_error())
|
||||
.map(|(index, _)| *index)
|
||||
}
|
||||
|
||||
/// Get all field indices with validation warnings
|
||||
pub fn fields_with_warnings(&self) -> impl Iterator<Item = usize> + '_ {
|
||||
self.field_results
|
||||
.iter()
|
||||
.filter(|(_, result)| matches!(result, ValidationResult::Warning { .. }))
|
||||
.map(|(index, _)| *index)
|
||||
}
|
||||
|
||||
/// Check if any field has validation errors
|
||||
pub fn has_errors(&self) -> bool {
|
||||
self.field_results.values().any(|result| result.is_error())
|
||||
}
|
||||
|
||||
/// Check if any field has validation warnings
|
||||
pub fn has_warnings(&self) -> bool {
|
||||
self.field_results.values().any(|result| matches!(result, ValidationResult::Warning { .. }))
|
||||
}
|
||||
|
||||
/// Get total count of fields with validation configured
|
||||
pub fn validated_field_count(&self) -> usize {
|
||||
self.field_configs.len()
|
||||
}
|
||||
|
||||
/// Check if field switching is allowed for a specific field
|
||||
pub fn allows_field_switch(&self, field_index: usize, text: &str) -> bool {
|
||||
if !self.enabled {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(config) = self.field_configs.get(&field_index) {
|
||||
config.allows_field_switch(text)
|
||||
} else {
|
||||
true // No validation configured, allow switching
|
||||
}
|
||||
}
|
||||
|
||||
/// Get reason why field switching is blocked (if any)
|
||||
pub fn field_switch_block_reason(&self, field_index: usize, text: &str) -> Option<String> {
|
||||
if !self.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(config) = self.field_configs.get(&field_index) {
|
||||
config.field_switch_block_reason(text)
|
||||
} else {
|
||||
None // No validation configured
|
||||
}
|
||||
}
|
||||
pub fn summary(&self) -> ValidationSummary {
|
||||
let total_validated = self.validated_fields.len();
|
||||
let errors = self.fields_with_errors().count();
|
||||
let warnings = self.fields_with_warnings().count();
|
||||
let valid = total_validated - errors - warnings;
|
||||
|
||||
ValidationSummary {
|
||||
total_fields: self.field_configs.len(),
|
||||
validated_fields: total_validated,
|
||||
valid_fields: valid,
|
||||
warning_fields: warnings,
|
||||
error_fields: errors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary of validation state across all fields
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ValidationSummary {
|
||||
/// Total number of fields with validation configured
|
||||
pub total_fields: usize,
|
||||
|
||||
/// Number of fields that have been validated
|
||||
pub validated_fields: usize,
|
||||
|
||||
/// Number of fields with valid validation results
|
||||
pub valid_fields: usize,
|
||||
|
||||
/// Number of fields with warnings
|
||||
pub warning_fields: usize,
|
||||
|
||||
/// Number of fields with errors
|
||||
pub error_fields: usize,
|
||||
}
|
||||
|
||||
impl ValidationSummary {
|
||||
/// Check if all configured fields are valid
|
||||
pub fn is_all_valid(&self) -> bool {
|
||||
self.error_fields == 0 && self.validated_fields == self.total_fields
|
||||
}
|
||||
|
||||
/// Check if there are any errors
|
||||
pub fn has_errors(&self) -> bool {
|
||||
self.error_fields > 0
|
||||
}
|
||||
|
||||
/// Check if there are any warnings
|
||||
pub fn has_warnings(&self) -> bool {
|
||||
self.warning_fields > 0
|
||||
}
|
||||
|
||||
/// Get completion percentage (validated fields / total fields)
|
||||
pub fn completion_percentage(&self) -> f32 {
|
||||
if self.total_fields == 0 {
|
||||
1.0
|
||||
} else {
|
||||
self.validated_fields as f32 / self.total_fields as f32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::validation::{CharacterLimits, ValidationConfigBuilder};
|
||||
|
||||
#[test]
|
||||
fn test_validation_state_creation() {
|
||||
let state = ValidationState::new();
|
||||
assert!(state.is_enabled());
|
||||
assert_eq!(state.validated_field_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enable_disable() {
|
||||
let mut state = ValidationState::new();
|
||||
|
||||
// Add some validation config
|
||||
let config = ValidationConfigBuilder::new()
|
||||
.with_max_length(10)
|
||||
.build();
|
||||
state.set_field_config(0, config);
|
||||
|
||||
// Validate something
|
||||
let result = state.validate_field_content(0, "test");
|
||||
assert!(result.is_acceptable());
|
||||
assert!(state.is_field_validated(0));
|
||||
|
||||
// Disable validation
|
||||
state.set_enabled(false);
|
||||
assert!(!state.is_enabled());
|
||||
assert!(!state.is_field_validated(0)); // Should be cleared
|
||||
|
||||
// Validation should now return valid regardless
|
||||
let result = state.validate_field_content(0, "this is way too long for the limit");
|
||||
assert!(result.is_acceptable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_field_config_management() {
|
||||
let mut state = ValidationState::new();
|
||||
|
||||
let config = ValidationConfigBuilder::new()
|
||||
.with_max_length(5)
|
||||
.build();
|
||||
|
||||
// Set config
|
||||
state.set_field_config(0, config);
|
||||
assert_eq!(state.validated_field_count(), 1);
|
||||
assert!(state.get_field_config(0).is_some());
|
||||
|
||||
// Remove config
|
||||
state.remove_field_config(0);
|
||||
assert_eq!(state.validated_field_count(), 0);
|
||||
assert!(state.get_field_config(0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_character_insertion_validation() {
|
||||
let mut state = ValidationState::new();
|
||||
|
||||
let config = ValidationConfigBuilder::new()
|
||||
.with_max_length(5)
|
||||
.build();
|
||||
state.set_field_config(0, config);
|
||||
|
||||
// Valid insertion
|
||||
let result = state.validate_char_insertion(0, "test", 4, 'x');
|
||||
assert!(result.is_acceptable());
|
||||
|
||||
// Invalid insertion
|
||||
let result = state.validate_char_insertion(0, "tests", 5, 'x');
|
||||
assert!(!result.is_acceptable());
|
||||
|
||||
// Check that result was stored
|
||||
assert!(state.is_field_validated(0));
|
||||
let stored_result = state.get_field_result(0);
|
||||
assert!(stored_result.is_some());
|
||||
assert!(!stored_result.unwrap().is_acceptable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validation_summary() {
|
||||
let mut state = ValidationState::new();
|
||||
|
||||
// Configure two fields
|
||||
let config1 = ValidationConfigBuilder::new().with_max_length(5).build();
|
||||
let config2 = ValidationConfigBuilder::new().with_max_length(10).build();
|
||||
state.set_field_config(0, config1);
|
||||
state.set_field_config(1, config2);
|
||||
|
||||
// Validate field 0 (valid)
|
||||
state.validate_field_content(0, "test");
|
||||
|
||||
// Validate field 1 (error)
|
||||
state.validate_field_content(1, "this is too long");
|
||||
|
||||
let summary = state.summary();
|
||||
assert_eq!(summary.total_fields, 2);
|
||||
assert_eq!(summary.validated_fields, 2);
|
||||
assert_eq!(summary.valid_fields, 1);
|
||||
assert_eq!(summary.error_fields, 1);
|
||||
assert_eq!(summary.warning_fields, 0);
|
||||
|
||||
assert!(!summary.is_all_valid());
|
||||
assert!(summary.has_errors());
|
||||
assert!(!summary.has_warnings());
|
||||
assert_eq!(summary.completion_percentage(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_and_warning_tracking() {
|
||||
let mut state = ValidationState::new();
|
||||
|
||||
let config = ValidationConfigBuilder::new()
|
||||
.with_character_limits(
|
||||
CharacterLimits::new_range(3, 10).with_warning_threshold(8)
|
||||
)
|
||||
.build();
|
||||
state.set_field_config(0, config);
|
||||
|
||||
// Too short (warning)
|
||||
state.validate_field_content(0, "hi");
|
||||
assert!(state.has_warnings());
|
||||
assert!(!state.has_errors());
|
||||
|
||||
// Just right
|
||||
state.validate_field_content(0, "hello");
|
||||
assert!(!state.has_warnings());
|
||||
assert!(!state.has_errors());
|
||||
|
||||
// Too long (error)
|
||||
state.validate_field_content(0, "hello world!");
|
||||
assert!(!state.has_warnings());
|
||||
assert!(state.has_errors());
|
||||
}
|
||||
}
|
||||
55
canvas/view_docs.sh
Executable file
55
canvas/view_docs.sh
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Enhanced documentation viewer for your canvas library
|
||||
echo "=========================================="
|
||||
echo "CANVAS LIBRARY DOCUMENTATION"
|
||||
echo "=========================================="
|
||||
|
||||
# Function to display module docs with colors
|
||||
show_module() {
|
||||
local module=$1
|
||||
local title=$2
|
||||
|
||||
echo -e "\n\033[1;34m=== $title ===\033[0m"
|
||||
echo -e "\033[33mFiles in $module:\033[0m"
|
||||
find src/$module -name "*.rs" 2>/dev/null | sort
|
||||
echo
|
||||
|
||||
# Show doc comments for this module
|
||||
find src/$module -name "*.rs" 2>/dev/null | while read file; do
|
||||
if grep -q "///" "$file"; then
|
||||
echo -e "\033[32m--- $file ---\033[0m"
|
||||
grep -n "^\s*///" "$file" | sed 's/^\([0-9]*:\)\s*\/\/\/ /\1 /' | head -10
|
||||
echo
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Main modules
|
||||
show_module "canvas" "CANVAS SYSTEM"
|
||||
show_module "suggestions" "SUGGESTIONS SYSTEM"
|
||||
show_module "config" "CONFIGURATION SYSTEM"
|
||||
|
||||
# Show lib.rs and other root files
|
||||
echo -e "\n\033[1;34m=== ROOT DOCUMENTATION ===\033[0m"
|
||||
if [ -f "src/lib.rs" ]; then
|
||||
echo -e "\033[32m--- src/lib.rs ---\033[0m"
|
||||
grep -n "^\s*///" src/lib.rs | sed 's/^\([0-9]*:\)\s*\/\/\/ /\1 /' 2>/dev/null
|
||||
fi
|
||||
|
||||
if [ -f "src/dispatcher.rs" ]; then
|
||||
echo -e "\033[32m--- src/dispatcher.rs ---\033[0m"
|
||||
grep -n "^\s*///" src/dispatcher.rs | sed 's/^\([0-9]*:\)\s*\/\/\/ /\1 /' 2>/dev/null
|
||||
fi
|
||||
|
||||
echo -e "\n\033[1;36m=========================================="
|
||||
echo "To view specific module documentation:"
|
||||
echo " ./view_canvas_docs.sh canvas"
|
||||
echo " ./view_canvas_docs.sh suggestions"
|
||||
echo " ./view_canvas_docs.sh config"
|
||||
echo "==========================================\033[0m"
|
||||
|
||||
# If specific module requested
|
||||
if [ $# -eq 1 ]; then
|
||||
show_module "$1" "$(echo $1 | tr '[:lower:]' '[:upper:]') MODULE DETAILS"
|
||||
fi
|
||||
1
client/.gitignore
vendored
Normal file
1
client/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
canvas_config.toml.txt
|
||||
@@ -1,57 +0,0 @@
|
||||
# canvas_config.toml - Complete Canvas Configuration
|
||||
|
||||
[behavior]
|
||||
wrap_around_fields = true
|
||||
auto_save_on_field_change = false
|
||||
word_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"
|
||||
max_suggestions = 6
|
||||
|
||||
[appearance]
|
||||
cursor_style = "block" # "block", "bar", "underline"
|
||||
show_field_numbers = false
|
||||
highlight_current_field = true
|
||||
|
||||
# Read-only mode keybindings (vim-style)
|
||||
[keybindings.read_only]
|
||||
move_left = ["h"]
|
||||
move_right = ["l"]
|
||||
move_up = ["k"]
|
||||
move_down = ["j"]
|
||||
move_word_next = ["w"]
|
||||
move_word_end = ["e"]
|
||||
move_word_prev = ["b"]
|
||||
move_word_end_prev = ["ge"]
|
||||
move_line_start = ["0"]
|
||||
move_line_end = ["$"]
|
||||
move_first_line = ["gg"]
|
||||
move_last_line = ["shift+g"]
|
||||
next_field = ["Tab"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
|
||||
# Edit mode keybindings
|
||||
[keybindings.edit]
|
||||
delete_char_backward = ["Backspace"]
|
||||
delete_char_forward = ["Delete"]
|
||||
move_left = ["Left"]
|
||||
move_right = ["Right"]
|
||||
move_up = ["Up"]
|
||||
move_down = ["Down"]
|
||||
move_line_start = ["Home"]
|
||||
move_line_end = ["End"]
|
||||
move_word_next = ["Ctrl+Right"]
|
||||
move_word_prev = ["Ctrl+Left"]
|
||||
next_field = ["Tab"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
|
||||
# Suggestion/autocomplete keybindings
|
||||
[keybindings.suggestions]
|
||||
suggestion_up = ["Up", "Ctrl+p"]
|
||||
suggestion_down = ["Down", "Ctrl+n"]
|
||||
select_suggestion = ["Enter", "Tab"]
|
||||
exit_suggestions = ["Esc"]
|
||||
trigger_autocomplete = ["Tab"]
|
||||
|
||||
# Global keybindings (work in both modes)
|
||||
[keybindings.global]
|
||||
move_up = ["Up"]
|
||||
move_down = ["Down"]
|
||||
@@ -39,25 +39,45 @@ enter_edit_mode_after = ["a"]
|
||||
previous_entry = ["left","q"]
|
||||
next_entry = ["right","1"]
|
||||
|
||||
move_left = ["h"]
|
||||
move_right = ["l"]
|
||||
move_up = ["k"]
|
||||
move_down = ["j"]
|
||||
move_word_next = ["w"]
|
||||
move_word_end = ["e"]
|
||||
move_word_prev = ["b"]
|
||||
move_word_end_prev = ["ge"]
|
||||
move_line_start = ["0"]
|
||||
move_line_end = ["$"]
|
||||
move_first_line = ["gg"]
|
||||
move_last_line = ["x"]
|
||||
enter_highlight_mode = ["v"]
|
||||
enter_highlight_mode_linewise = ["ctrl+v"]
|
||||
|
||||
### AUTOGENERATED CANVAS CONFIG
|
||||
# Required
|
||||
move_up = ["k", "Up"]
|
||||
move_left = ["h", "Left"]
|
||||
move_right = ["l", "Right"]
|
||||
move_down = ["j", "Down"]
|
||||
# Optional
|
||||
move_line_end = ["$"]
|
||||
# move_word_next = ["w"]
|
||||
next_field = ["Tab"]
|
||||
move_word_prev = ["b"]
|
||||
move_word_end = ["e"]
|
||||
move_last_line = ["shift+g"]
|
||||
move_word_end_prev = ["ge"]
|
||||
move_line_start = ["0"]
|
||||
move_first_line = ["g+g"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
|
||||
[keybindings.highlight]
|
||||
exit_highlight_mode = ["esc"]
|
||||
enter_highlight_mode_linewise = ["ctrl+v"]
|
||||
|
||||
### AUTOGENERATED CANVAS CONFIG
|
||||
# Required
|
||||
move_left = ["h", "Left"]
|
||||
move_right = ["l", "Right"]
|
||||
move_up = ["k", "Up"]
|
||||
move_down = ["j", "Down"]
|
||||
# Optional
|
||||
move_word_next = ["w"]
|
||||
move_line_start = ["0"]
|
||||
move_line_end = ["$"]
|
||||
move_word_prev = ["b"]
|
||||
move_word_end = ["e"]
|
||||
|
||||
|
||||
[keybindings.edit]
|
||||
# BIG CHANGES NOW EXIT HANDLES EITHER IF THOSE
|
||||
# exit_edit_mode = ["esc","ctrl+e"]
|
||||
@@ -65,15 +85,30 @@ enter_highlight_mode_linewise = ["ctrl+v"]
|
||||
# select_suggestion = ["enter"]
|
||||
# next_field = ["enter"]
|
||||
enter_decider = ["enter"]
|
||||
prev_field = ["shift+enter"]
|
||||
exit = ["esc", "ctrl+e"]
|
||||
delete_char_forward = ["delete"]
|
||||
delete_char_backward = ["backspace"]
|
||||
move_left = ["left"]
|
||||
move_right = ["right"]
|
||||
suggestion_down = ["ctrl+n", "tab"]
|
||||
suggestion_up = ["ctrl+p", "shift+tab"]
|
||||
|
||||
### AUTOGENERATED CANVAS CONFIG
|
||||
# Required
|
||||
move_right = ["Right", "l"]
|
||||
delete_char_backward = ["Backspace"]
|
||||
next_field = ["Tab", "Enter"]
|
||||
move_up = ["Up", "k"]
|
||||
move_down = ["Down", "j"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
move_left = ["Left", "h"]
|
||||
# Optional
|
||||
move_last_line = ["Ctrl+End", "G"]
|
||||
delete_char_forward = ["Delete"]
|
||||
move_word_prev = ["Ctrl+Left", "b"]
|
||||
move_word_end = ["e"]
|
||||
move_word_end_prev = ["ge"]
|
||||
move_first_line = ["Ctrl+Home", "gg"]
|
||||
move_word_next = ["Ctrl+Right", "w"]
|
||||
move_line_start = ["Home", "0"]
|
||||
move_line_end = ["End", "$"]
|
||||
|
||||
[keybindings.command]
|
||||
exit_command_mode = ["ctrl+g", "esc"]
|
||||
command_execute = ["enter"]
|
||||
@@ -91,3 +126,9 @@ keybinding_mode = "vim" # Options: "default", "vim", "emacs"
|
||||
[colors]
|
||||
theme = "dark"
|
||||
# Options: "light", "dark", "high_contrast"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::add_logic::{AddLogicFocus, AddLogicState};
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use canvas::canvas::{render_canvas, CanvasState, HighlightState as CanvasHighlightState}; // Use canvas library
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
@@ -11,10 +11,18 @@ use ratatui::{
|
||||
widgets::{Block, BorderType, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use crate::components::handlers::canvas::render_canvas;
|
||||
use crate::components::common::{dialog, autocomplete}; // Added autocomplete
|
||||
use crate::config::binds::config::EditorKeybindingMode;
|
||||
|
||||
// Helper function to convert between HighlightState types
|
||||
fn convert_highlight_state(local: &HighlightState) -> CanvasHighlightState {
|
||||
match local {
|
||||
HighlightState::Off => CanvasHighlightState::Off,
|
||||
HighlightState::Characterwise { anchor } => CanvasHighlightState::Characterwise { anchor: *anchor },
|
||||
HighlightState::Linewise { anchor_line } => CanvasHighlightState::Linewise { anchor_line: *anchor_line },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_add_logic(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
@@ -77,18 +85,18 @@ pub fn render_add_logic(
|
||||
let editor_borrow = add_logic_state.script_content_editor.borrow();
|
||||
editor_borrow.cursor() // Returns (row, col) as (usize, usize)
|
||||
};
|
||||
|
||||
|
||||
let (cursor_line, cursor_col) = current_cursor;
|
||||
|
||||
|
||||
// Account for TextArea's block borders (1 for each side)
|
||||
let block_offset_x = 1;
|
||||
let block_offset_y = 1;
|
||||
|
||||
|
||||
// Position autocomplete at current cursor position
|
||||
// Add 1 to column to position dropdown right after the cursor
|
||||
let autocomplete_x = cursor_col + 1;
|
||||
let autocomplete_y = cursor_line;
|
||||
|
||||
|
||||
let input_rect = Rect {
|
||||
x: (inner_area.x + block_offset_x + autocomplete_x as u16).min(inner_area.right().saturating_sub(20)),
|
||||
y: (inner_area.y + block_offset_y + autocomplete_y as u16).min(inner_area.bottom().saturating_sub(5)),
|
||||
@@ -152,40 +160,37 @@ pub fn render_add_logic(
|
||||
);
|
||||
f.render_widget(profile_text, top_info_area);
|
||||
|
||||
// Canvas
|
||||
// Canvas - USING CANVAS LIBRARY
|
||||
let focus_on_canvas_inputs = matches!(
|
||||
add_logic_state.current_focus,
|
||||
AddLogicFocus::InputLogicName
|
||||
| AddLogicFocus::InputTargetColumn
|
||||
| AddLogicFocus::InputDescription
|
||||
);
|
||||
// Call render_canvas and get the active_field_rect
|
||||
|
||||
let canvas_highlight_state = convert_highlight_state(highlight_state);
|
||||
let active_field_rect = render_canvas(
|
||||
f,
|
||||
canvas_area,
|
||||
add_logic_state, // Pass the whole state as it impl CanvasState
|
||||
&add_logic_state.fields(),
|
||||
&add_logic_state.current_field(),
|
||||
&add_logic_state.inputs(),
|
||||
theme,
|
||||
is_edit_mode && focus_on_canvas_inputs, // is_edit_mode for canvas fields
|
||||
highlight_state,
|
||||
add_logic_state, // AddLogicState implements CanvasState
|
||||
theme, // Theme implements CanvasTheme
|
||||
is_edit_mode && focus_on_canvas_inputs,
|
||||
&canvas_highlight_state,
|
||||
);
|
||||
|
||||
// --- Render Autocomplete for Target Column ---
|
||||
// `is_edit_mode` here refers to the general edit mode of the EventHandler
|
||||
if is_edit_mode && add_logic_state.current_field() == 1 { // Target Column field
|
||||
if let Some(suggestions) = add_logic_state.get_suggestions() { // Uses CanvasState impl
|
||||
let selected = add_logic_state.get_selected_suggestion_index();
|
||||
if !suggestions.is_empty() { // Only render if there are suggestions to show
|
||||
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,
|
||||
suggestions,
|
||||
selected,
|
||||
&add_logic_state.target_column_suggestions,
|
||||
add_logic_state.selected_target_column_suggestion_index,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@ use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::add_table::{AddTableFocus, AddTableState};
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
// use crate::state::pages::add_table::{ColumnDefinition, LinkDefinition}; // Not directly used here
|
||||
use canvas::canvas::{render_canvas, CanvasState, HighlightState as CanvasHighlightState};
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
@@ -12,16 +11,24 @@ use ratatui::{
|
||||
widgets::{Block, BorderType, Borders, Cell, Paragraph, Row, Table},
|
||||
Frame,
|
||||
};
|
||||
use crate::components::handlers::canvas::render_canvas;
|
||||
use crate::components::common::dialog;
|
||||
|
||||
// Helper function to convert between HighlightState types
|
||||
fn convert_highlight_state(local: &HighlightState) -> CanvasHighlightState {
|
||||
match local {
|
||||
HighlightState::Off => CanvasHighlightState::Off,
|
||||
HighlightState::Characterwise { anchor } => CanvasHighlightState::Characterwise { anchor: *anchor },
|
||||
HighlightState::Linewise { anchor_line } => CanvasHighlightState::Linewise { anchor_line: *anchor_line },
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the Add New Table page layout, structuring the display of table information,
|
||||
/// input fields, and action buttons. Adapts layout based on terminal width.
|
||||
pub fn render_add_table(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
app_state: &AppState, // Currently unused, might be needed later
|
||||
app_state: &AppState,
|
||||
add_table_state: &mut AddTableState,
|
||||
is_edit_mode: bool, // Determines if canvas inputs are in edit mode
|
||||
highlight_state: &HighlightState, // For text highlighting in canvas
|
||||
@@ -349,17 +356,15 @@ pub fn render_add_table(
|
||||
&mut add_table_state.column_table_state,
|
||||
);
|
||||
|
||||
// --- Canvas Rendering (Column Definition Input) ---
|
||||
// --- Canvas Rendering (Column Definition Input) - USING CANVAS LIBRARY ---
|
||||
let canvas_highlight_state = convert_highlight_state(highlight_state);
|
||||
let _active_field_rect = render_canvas(
|
||||
f,
|
||||
canvas_area,
|
||||
add_table_state,
|
||||
&add_table_state.fields(),
|
||||
&add_table_state.current_field(),
|
||||
&add_table_state.inputs(),
|
||||
theme,
|
||||
add_table_state, // AddTableState implements CanvasState
|
||||
theme, // Theme implements CanvasTheme
|
||||
is_edit_mode && focus_on_canvas_inputs,
|
||||
highlight_state,
|
||||
&canvas_highlight_state,
|
||||
);
|
||||
|
||||
// --- Button Style Helpers ---
|
||||
@@ -557,7 +562,7 @@ pub fn render_add_table(
|
||||
|
||||
// --- DIALOG ---
|
||||
// Render the dialog overlay if it's active
|
||||
if app_state.ui.dialog.dialog_show { // Use the passed-in app_state
|
||||
if app_state.ui.dialog.dialog_show {
|
||||
dialog::render_dialog(
|
||||
f,
|
||||
f.area(), // Render over the whole frame area
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// src/components/handlers.rs
|
||||
pub mod canvas;
|
||||
pub mod sidebar;
|
||||
pub mod buffer_list;
|
||||
|
||||
pub use canvas::*;
|
||||
pub use sidebar::*;
|
||||
pub use buffer_list::*;
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
// src/components/handlers/canvas.rs
|
||||
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use crate::state::pages::canvas_state::CanvasState as LegacyCanvasState;
|
||||
use canvas::canvas::CanvasState as LibraryCanvasState;
|
||||
use std::cmp::{max, min};
|
||||
|
||||
/// Render canvas for legacy CanvasState (AddTableState, LoginState, RegisterState, AddLogicState)
|
||||
pub fn render_canvas(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
form_state: &impl LegacyCanvasState,
|
||||
fields: &[&str],
|
||||
current_field_idx: &usize,
|
||||
inputs: &[&String],
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
) -> Option<Rect> {
|
||||
render_canvas_impl(
|
||||
f,
|
||||
area,
|
||||
fields,
|
||||
current_field_idx,
|
||||
inputs,
|
||||
theme,
|
||||
is_edit_mode,
|
||||
highlight_state,
|
||||
form_state.current_cursor_pos(),
|
||||
form_state.has_unsaved_changes(),
|
||||
|i| form_state.get_display_value_for_field(i).to_string(),
|
||||
|i| form_state.has_display_override(i),
|
||||
)
|
||||
}
|
||||
|
||||
/// Render canvas for library CanvasState (FormState)
|
||||
pub fn render_canvas_library(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
form_state: &impl LibraryCanvasState,
|
||||
fields: &[&str],
|
||||
current_field_idx: &usize,
|
||||
inputs: &[&String],
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
) -> Option<Rect> {
|
||||
render_canvas_impl(
|
||||
f,
|
||||
area,
|
||||
fields,
|
||||
current_field_idx,
|
||||
inputs,
|
||||
theme,
|
||||
is_edit_mode,
|
||||
highlight_state,
|
||||
form_state.current_cursor_pos(),
|
||||
form_state.has_unsaved_changes(),
|
||||
|i| form_state.get_display_value_for_field(i).to_string(),
|
||||
|i| form_state.has_display_override(i),
|
||||
)
|
||||
}
|
||||
|
||||
/// Internal implementation shared by both render functions
|
||||
fn render_canvas_impl<F1, F2>(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
fields: &[&str],
|
||||
current_field_idx: &usize,
|
||||
inputs: &[&String],
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
current_cursor_pos: usize,
|
||||
has_unsaved_changes: bool,
|
||||
get_display_value: F1,
|
||||
has_display_override: F2,
|
||||
) -> Option<Rect>
|
||||
where
|
||||
F1: Fn(usize) -> String,
|
||||
F2: Fn(usize) -> bool,
|
||||
{
|
||||
let columns = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
|
||||
.split(area);
|
||||
|
||||
let border_style = if has_unsaved_changes {
|
||||
Style::default().fg(theme.warning)
|
||||
} else if is_edit_mode {
|
||||
Style::default().fg(theme.accent)
|
||||
} else {
|
||||
Style::default().fg(theme.secondary)
|
||||
};
|
||||
let input_container = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style)
|
||||
.style(Style::default().bg(theme.bg));
|
||||
|
||||
let input_block = Rect {
|
||||
x: columns[1].x,
|
||||
y: columns[1].y,
|
||||
width: columns[1].width,
|
||||
height: fields.len() as u16 + 2,
|
||||
};
|
||||
|
||||
f.render_widget(&input_container, input_block);
|
||||
|
||||
let input_area = input_container.inner(input_block);
|
||||
let input_rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(vec![Constraint::Length(1); fields.len()])
|
||||
.split(input_area);
|
||||
|
||||
let mut active_field_input_rect = None;
|
||||
|
||||
for (i, field) in fields.iter().enumerate() {
|
||||
let label = Paragraph::new(Line::from(Span::styled(
|
||||
format!("{}:", field),
|
||||
Style::default().fg(theme.fg),
|
||||
)));
|
||||
f.render_widget(
|
||||
label,
|
||||
Rect {
|
||||
x: columns[0].x,
|
||||
y: input_block.y + 1 + i as u16,
|
||||
width: columns[0].width,
|
||||
height: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
for (i, _input) in inputs.iter().enumerate() {
|
||||
let is_active = i == *current_field_idx;
|
||||
|
||||
// Use the provided closure to get display value
|
||||
let text = get_display_value(i);
|
||||
let text_len = text.chars().count();
|
||||
let line: Line;
|
||||
|
||||
match highlight_state {
|
||||
HighlightState::Off => {
|
||||
line = Line::from(Span::styled(
|
||||
&text,
|
||||
if is_active {
|
||||
Style::default().fg(theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(theme.fg)
|
||||
},
|
||||
));
|
||||
}
|
||||
HighlightState::Characterwise { anchor } => {
|
||||
let (anchor_field, anchor_char) = *anchor;
|
||||
let start_field = min(anchor_field, *current_field_idx);
|
||||
let end_field = max(anchor_field, *current_field_idx);
|
||||
|
||||
let (start_char, end_char) = if anchor_field == *current_field_idx {
|
||||
(min(anchor_char, current_cursor_pos), max(anchor_char, current_cursor_pos))
|
||||
} else if anchor_field < *current_field_idx {
|
||||
(anchor_char, current_cursor_pos)
|
||||
} else {
|
||||
(current_cursor_pos, anchor_char)
|
||||
};
|
||||
|
||||
let highlight_style = Style::default().fg(theme.highlight).bg(theme.highlight_bg).add_modifier(Modifier::BOLD);
|
||||
let normal_style_in_highlight = Style::default().fg(theme.highlight);
|
||||
let normal_style_outside = Style::default().fg(theme.fg);
|
||||
|
||||
if i >= start_field && i <= end_field {
|
||||
if start_field == end_field {
|
||||
let clamped_start = start_char.min(text_len);
|
||||
let clamped_end = end_char.min(text_len);
|
||||
|
||||
let before: String = text.chars().take(clamped_start).collect();
|
||||
let highlighted: String = text.chars().skip(clamped_start).take(clamped_end.saturating_sub(clamped_start) + 1).collect();
|
||||
let after: String = text.chars().skip(clamped_end + 1).collect();
|
||||
|
||||
line = Line::from(vec![
|
||||
Span::styled(before, normal_style_in_highlight),
|
||||
Span::styled(highlighted, highlight_style),
|
||||
Span::styled(after, normal_style_in_highlight),
|
||||
]);
|
||||
} else if i == start_field {
|
||||
let safe_start = start_char.min(text_len);
|
||||
let before: String = text.chars().take(safe_start).collect();
|
||||
let highlighted: String = text.chars().skip(safe_start).collect();
|
||||
line = Line::from(vec![
|
||||
Span::styled(before, normal_style_in_highlight),
|
||||
Span::styled(highlighted, highlight_style),
|
||||
]);
|
||||
} else if i == end_field {
|
||||
let safe_end_inclusive = if text_len > 0 { end_char.min(text_len - 1) } else { 0 };
|
||||
let highlighted: String = text.chars().take(safe_end_inclusive + 1).collect();
|
||||
let after: String = text.chars().skip(safe_end_inclusive + 1).collect();
|
||||
line = Line::from(vec![
|
||||
Span::styled(highlighted, highlight_style),
|
||||
Span::styled(after, normal_style_in_highlight),
|
||||
]);
|
||||
} else {
|
||||
line = Line::from(Span::styled(&text, highlight_style));
|
||||
}
|
||||
} else {
|
||||
line = Line::from(Span::styled(
|
||||
&text,
|
||||
if is_active { normal_style_in_highlight } else { normal_style_outside }
|
||||
));
|
||||
}
|
||||
}
|
||||
HighlightState::Linewise { anchor_line } => {
|
||||
let start_field = min(*anchor_line, *current_field_idx);
|
||||
let end_field = max(*anchor_line, *current_field_idx);
|
||||
let highlight_style = Style::default().fg(theme.highlight).bg(theme.highlight_bg).add_modifier(Modifier::BOLD);
|
||||
let normal_style_in_highlight = Style::default().fg(theme.highlight);
|
||||
let normal_style_outside = Style::default().fg(theme.fg);
|
||||
|
||||
if i >= start_field && i <= end_field {
|
||||
line = Line::from(Span::styled(&text, highlight_style));
|
||||
} else {
|
||||
line = Line::from(Span::styled(
|
||||
&text,
|
||||
if is_active { normal_style_in_highlight } else { normal_style_outside }
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let input_display = Paragraph::new(line).alignment(Alignment::Left);
|
||||
f.render_widget(input_display, input_rows[i]);
|
||||
|
||||
if is_active {
|
||||
active_field_input_rect = Some(input_rows[i]);
|
||||
|
||||
// Use the provided closure to check for display override
|
||||
let cursor_x = if has_display_override(i) {
|
||||
// If an override exists, place the cursor at the end.
|
||||
input_rows[i].x + text.chars().count() as u16
|
||||
} else {
|
||||
// Otherwise, use the real cursor position.
|
||||
input_rows[i].x + current_cursor_pos as u16
|
||||
};
|
||||
let cursor_y = input_rows[i].y;
|
||||
f.set_cursor_position((cursor_x, cursor_y));
|
||||
}
|
||||
}
|
||||
|
||||
active_field_input_rect
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
// src/functions/modes.rs
|
||||
|
||||
pub mod read_only;
|
||||
pub mod edit;
|
||||
pub mod navigation;
|
||||
|
||||
pub use read_only::*;
|
||||
pub use edit::*;
|
||||
pub use navigation::*;
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
// src/functions/modes/edit.rs
|
||||
|
||||
pub mod form_e;
|
||||
pub mod auth_e;
|
||||
pub mod add_table_e;
|
||||
pub mod add_logic_e;
|
||||
@@ -1,135 +0,0 @@
|
||||
// src/functions/modes/edit/add_logic_e.rs
|
||||
use crate::state::pages::add_logic::AddLogicState;
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
|
||||
pub async fn execute_edit_action(
|
||||
action: &str,
|
||||
key: KeyEvent, // Keep key for insert_char
|
||||
state: &mut AddLogicState,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<String> {
|
||||
let mut message = String::new();
|
||||
|
||||
match action {
|
||||
"next_field" => {
|
||||
let current_field = state.current_field();
|
||||
let next_field = (current_field + 1) % AddLogicState::INPUT_FIELD_COUNT;
|
||||
state.set_current_field(next_field);
|
||||
*ideal_cursor_column = state.current_cursor_pos();
|
||||
message = format!("Focus on field {}", state.fields()[next_field]);
|
||||
}
|
||||
"prev_field" => {
|
||||
let current_field = state.current_field();
|
||||
let prev_field = if current_field == 0 {
|
||||
AddLogicState::INPUT_FIELD_COUNT - 1
|
||||
} else {
|
||||
current_field - 1
|
||||
};
|
||||
state.set_current_field(prev_field);
|
||||
*ideal_cursor_column = state.current_cursor_pos();
|
||||
message = format!("Focus on field {}", state.fields()[prev_field]);
|
||||
}
|
||||
"delete_char_forward" => {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let current_input_mut = state.get_current_input_mut();
|
||||
if current_pos < current_input_mut.len() {
|
||||
current_input_mut.remove(current_pos);
|
||||
state.set_has_unsaved_changes(true);
|
||||
if state.current_field() == 1 { state.update_target_column_suggestions(); }
|
||||
}
|
||||
}
|
||||
"delete_char_backward" => {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
if current_pos > 0 {
|
||||
let new_pos = current_pos - 1;
|
||||
state.get_current_input_mut().remove(new_pos);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
state.set_has_unsaved_changes(true);
|
||||
if state.current_field() == 1 { state.update_target_column_suggestions(); }
|
||||
}
|
||||
}
|
||||
"move_left" => {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
if current_pos > 0 {
|
||||
let new_pos = current_pos - 1;
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
}
|
||||
"move_right" => {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let input_len = state.get_current_input().len();
|
||||
if current_pos < input_len {
|
||||
let new_pos = current_pos + 1;
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
}
|
||||
"insert_char" => {
|
||||
if let KeyCode::Char(c) = key.code {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
state.get_current_input_mut().insert(current_pos, c);
|
||||
let new_pos = current_pos + 1;
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
state.set_has_unsaved_changes(true);
|
||||
if state.current_field() == 1 {
|
||||
state.update_target_column_suggestions();
|
||||
}
|
||||
}
|
||||
}
|
||||
"suggestion_down" => {
|
||||
if state.in_target_column_suggestion_mode && !state.target_column_suggestions.is_empty() {
|
||||
let current_selection = state.selected_target_column_suggestion_index.unwrap_or(0);
|
||||
let next_selection = (current_selection + 1) % state.target_column_suggestions.len();
|
||||
state.selected_target_column_suggestion_index = Some(next_selection);
|
||||
}
|
||||
}
|
||||
"suggestion_up" => {
|
||||
if state.in_target_column_suggestion_mode && !state.target_column_suggestions.is_empty() {
|
||||
let current_selection = state.selected_target_column_suggestion_index.unwrap_or(0);
|
||||
let prev_selection = if current_selection == 0 {
|
||||
state.target_column_suggestions.len() - 1
|
||||
} else {
|
||||
current_selection - 1
|
||||
};
|
||||
state.selected_target_column_suggestion_index = Some(prev_selection);
|
||||
}
|
||||
}
|
||||
"select_suggestion" => {
|
||||
if state.in_target_column_suggestion_mode {
|
||||
let mut selected_suggestion_text: Option<String> = None;
|
||||
|
||||
if let Some(selected_idx) = state.selected_target_column_suggestion_index {
|
||||
if let Some(suggestion) = state.target_column_suggestions.get(selected_idx) {
|
||||
selected_suggestion_text = Some(suggestion.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(suggestion_text) = selected_suggestion_text {
|
||||
state.target_column_input = suggestion_text.clone();
|
||||
state.target_column_cursor_pos = state.target_column_input.len();
|
||||
*ideal_cursor_column = state.target_column_cursor_pos;
|
||||
state.set_has_unsaved_changes(true);
|
||||
message = format!("Selected column: '{}'", suggestion_text);
|
||||
}
|
||||
|
||||
state.in_target_column_suggestion_mode = false;
|
||||
state.show_target_column_suggestions = false;
|
||||
state.selected_target_column_suggestion_index = None;
|
||||
state.update_target_column_suggestions();
|
||||
} else {
|
||||
let current_field = state.current_field();
|
||||
let next_field = (current_field + 1) % AddLogicState::INPUT_FIELD_COUNT;
|
||||
state.set_current_field(next_field);
|
||||
*ideal_cursor_column = state.current_cursor_pos();
|
||||
message = format!("Focus on field {}", state.fields()[next_field]);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(message)
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
// src/functions/modes/edit/add_table_e.rs
|
||||
use crate::state::pages::add_table::AddTableState;
|
||||
use crate::state::pages::canvas_state::CanvasState; // Use trait
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use anyhow::Result;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum CharType {
|
||||
Whitespace,
|
||||
Alphanumeric,
|
||||
Punctuation,
|
||||
}
|
||||
|
||||
fn get_char_type(c: char) -> CharType {
|
||||
if c.is_whitespace() {
|
||||
CharType::Whitespace
|
||||
} else if c.is_alphanumeric() {
|
||||
CharType::Alphanumeric
|
||||
} else {
|
||||
CharType::Punctuation
|
||||
}
|
||||
}
|
||||
|
||||
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
if len == 0 || current_pos >= len {
|
||||
return len;
|
||||
}
|
||||
|
||||
let mut pos = current_pos;
|
||||
let initial_type = get_char_type(chars[pos]);
|
||||
|
||||
while pos < len && get_char_type(chars[pos]) == initial_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
while pos < len && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
fn find_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
if len == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.min(len - 1);
|
||||
|
||||
if get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos = find_next_word_start(text, pos);
|
||||
}
|
||||
|
||||
if pos >= len {
|
||||
return len.saturating_sub(1);
|
||||
}
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos < len && get_char_type(chars[pos]) == word_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos.saturating_sub(1).min(len.saturating_sub(1))
|
||||
}
|
||||
|
||||
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
if len == 0 || current_pos == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
return 0;
|
||||
}
|
||||
if pos == 0 && get_char_type(chars[pos]) != CharType::Whitespace {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos > 0 {
|
||||
pos - 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes edit actions for the AddTable view canvas.
|
||||
pub async fn execute_edit_action(
|
||||
action: &str,
|
||||
key: KeyEvent, // Needed for insert_char
|
||||
state: &mut AddTableState,
|
||||
ideal_cursor_column: &mut usize,
|
||||
// Add other params like grpc_client if needed for future actions (e.g., validation)
|
||||
) -> Result<String> {
|
||||
// Use the CanvasState trait methods implemented for AddTableState
|
||||
match action {
|
||||
"insert_char" => {
|
||||
if let KeyCode::Char(c) = key.code {
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
let field_value = state.get_current_input_mut();
|
||||
let mut chars: Vec<char> = field_value.chars().collect();
|
||||
if cursor_pos <= chars.len() {
|
||||
chars.insert(cursor_pos, c);
|
||||
*field_value = chars.into_iter().collect();
|
||||
state.set_current_cursor_pos(cursor_pos + 1);
|
||||
state.set_has_unsaved_changes(true);
|
||||
*ideal_cursor_column = state.current_cursor_pos();
|
||||
}
|
||||
} else {
|
||||
return Ok("Error: insert_char called without a char key.".to_string());
|
||||
}
|
||||
Ok("".to_string()) // No message needed for char insertion
|
||||
}
|
||||
"delete_char_backward" => {
|
||||
if state.current_cursor_pos() > 0 {
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
let field_value = state.get_current_input_mut();
|
||||
let mut chars: Vec<char> = field_value.chars().collect();
|
||||
if cursor_pos <= chars.len() {
|
||||
chars.remove(cursor_pos - 1);
|
||||
*field_value = chars.into_iter().collect();
|
||||
let new_pos = cursor_pos - 1;
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
state.set_has_unsaved_changes(true);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"delete_char_forward" => {
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
let field_value = state.get_current_input_mut();
|
||||
let mut chars: Vec<char> = field_value.chars().collect();
|
||||
if cursor_pos < chars.len() {
|
||||
chars.remove(cursor_pos);
|
||||
*field_value = chars.into_iter().collect();
|
||||
state.set_has_unsaved_changes(true);
|
||||
*ideal_cursor_column = cursor_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"next_field" => {
|
||||
let num_fields = AddTableState::INPUT_FIELD_COUNT;
|
||||
if num_fields > 0 {
|
||||
let current_field = state.current_field();
|
||||
let last_field_index = num_fields - 1;
|
||||
// Prevent cycling forward
|
||||
if current_field < last_field_index {
|
||||
state.set_current_field(current_field + 1);
|
||||
}
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"prev_field" => {
|
||||
let num_fields = AddTableState::INPUT_FIELD_COUNT;
|
||||
if num_fields > 0 {
|
||||
let current_field = state.current_field();
|
||||
if current_field > 0 {
|
||||
state.set_current_field(current_field - 1);
|
||||
}
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_left" => {
|
||||
let new_pos = state.current_cursor_pos().saturating_sub(1);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_right" => {
|
||||
let current_input = state.get_current_input();
|
||||
let current_pos = state.current_cursor_pos();
|
||||
if current_pos < current_input.len() {
|
||||
let new_pos = current_pos + 1;
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_up" => {
|
||||
let current_field = state.current_field();
|
||||
// Prevent moving up from the first field
|
||||
if current_field > 0 {
|
||||
let new_field = current_field - 1;
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
|
||||
}
|
||||
Ok("ahoj".to_string())
|
||||
}
|
||||
"move_down" => {
|
||||
let num_fields = AddTableState::INPUT_FIELD_COUNT;
|
||||
if num_fields > 0 {
|
||||
let current_field = state.current_field();
|
||||
let last_field_index = num_fields - 1;
|
||||
if current_field < last_field_index {
|
||||
let new_field = current_field + 1;
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
|
||||
}
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_line_start" => {
|
||||
state.set_current_cursor_pos(0);
|
||||
*ideal_cursor_column = 0;
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_line_end" => {
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = current_input.len();
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_first_line" => {
|
||||
if AddTableState::INPUT_FIELD_COUNT > 0 {
|
||||
state.set_current_field(0);
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_last_line" => {
|
||||
let num_fields = AddTableState::INPUT_FIELD_COUNT;
|
||||
if num_fields > 0 {
|
||||
let new_field = num_fields - 1;
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_next" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
|
||||
let final_pos = new_pos.min(current_input.len());
|
||||
state.set_current_cursor_pos(final_pos);
|
||||
*ideal_cursor_column = final_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_end" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let new_pos = find_word_end(current_input, current_pos);
|
||||
|
||||
let final_pos = if new_pos == current_pos {
|
||||
find_word_end(current_input, new_pos + 1)
|
||||
} else {
|
||||
new_pos
|
||||
};
|
||||
|
||||
let max_valid_index = current_input.len().saturating_sub(1);
|
||||
let clamped_pos = final_pos.min(max_valid_index);
|
||||
state.set_current_cursor_pos(clamped_pos);
|
||||
*ideal_cursor_column = clamped_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_prev" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_end_prev" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_end(current_input, state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
// Actions handled by main event loop (mode changes, save, revert)
|
||||
"exit_edit_mode" | "save" | "revert" => {
|
||||
Ok("Action handled by main loop".to_string())
|
||||
}
|
||||
_ => Ok(format!("Unknown or unhandled edit action: {}", action)),
|
||||
}
|
||||
}
|
||||
@@ -1,466 +0,0 @@
|
||||
// src/functions/modes/edit/auth_e.rs
|
||||
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::state::pages::auth::RegisterState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::tui::functions::common::form::{revert, save};
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use canvas::autocomplete::AutocompleteCanvasState;
|
||||
use canvas::canvas::CanvasState;
|
||||
use std::any::Any;
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn execute_common_action<S: CanvasState + Any>(
|
||||
action: &str,
|
||||
state: &mut S,
|
||||
grpc_client: &mut GrpcClient,
|
||||
app_state: &AppState,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<String> {
|
||||
match action {
|
||||
"save" | "revert" => {
|
||||
if !state.has_unsaved_changes() {
|
||||
return Ok("No changes to save or revert.".to_string());
|
||||
}
|
||||
if let Some(form_state) =
|
||||
(state as &mut dyn Any).downcast_mut::<FormState>()
|
||||
{
|
||||
match action {
|
||||
"save" => {
|
||||
let outcome = save(
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
)
|
||||
.await?;
|
||||
let message = format!("Save successful: {:?}", outcome); // Simple message for now
|
||||
Ok(message)
|
||||
}
|
||||
"revert" => {
|
||||
revert(
|
||||
form_state,
|
||||
grpc_client,
|
||||
)
|
||||
.await
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
} else {
|
||||
Ok(format!(
|
||||
"Action '{}' not implemented for this state type.",
|
||||
action
|
||||
))
|
||||
}
|
||||
}
|
||||
_ => Ok(format!("Common action '{}' not handled here.", action)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_edit_action<S: CanvasState + Any + Send>(
|
||||
action: &str,
|
||||
key: KeyEvent,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<String> {
|
||||
match action {
|
||||
"insert_char" => {
|
||||
if let KeyCode::Char(c) = key.code {
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
let field_value = state.get_current_input_mut();
|
||||
let mut chars: Vec<char> = field_value.chars().collect();
|
||||
if cursor_pos <= chars.len() {
|
||||
chars.insert(cursor_pos, c);
|
||||
*field_value = chars.into_iter().collect();
|
||||
state.set_current_cursor_pos(cursor_pos + 1);
|
||||
state.set_has_unsaved_changes(true);
|
||||
*ideal_cursor_column = state.current_cursor_pos();
|
||||
}
|
||||
} else {
|
||||
return Ok("Error: insert_char called without a char key."
|
||||
.to_string());
|
||||
}
|
||||
Ok("working?".to_string())
|
||||
}
|
||||
|
||||
"delete_char_backward" => {
|
||||
if state.current_cursor_pos() > 0 {
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
let field_value = state.get_current_input_mut();
|
||||
let mut chars: Vec<char> = field_value.chars().collect();
|
||||
if cursor_pos <= chars.len() {
|
||||
chars.remove(cursor_pos - 1);
|
||||
*field_value = chars.into_iter().collect();
|
||||
let new_pos = cursor_pos - 1;
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
state.set_has_unsaved_changes(true);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"delete_char_forward" => {
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
let field_value = state.get_current_input_mut();
|
||||
let mut chars: Vec<char> = field_value.chars().collect();
|
||||
if cursor_pos < chars.len() {
|
||||
chars.remove(cursor_pos);
|
||||
*field_value = chars.into_iter().collect();
|
||||
state.set_has_unsaved_changes(true);
|
||||
*ideal_cursor_column = cursor_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"next_field" => {
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields > 0 {
|
||||
let current_field = state.current_field();
|
||||
let new_field = (current_field + 1).min(num_fields - 1);
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos(
|
||||
(*ideal_cursor_column).min(max_pos),
|
||||
);
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"prev_field" => {
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields > 0 {
|
||||
let current_field = state.current_field();
|
||||
let new_field = current_field.saturating_sub(1);
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos(
|
||||
(*ideal_cursor_column).min(max_pos),
|
||||
);
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"move_left" => {
|
||||
let new_pos = state.current_cursor_pos().saturating_sub(1);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"move_right" => {
|
||||
let current_input = state.get_current_input();
|
||||
let current_pos = state.current_cursor_pos();
|
||||
if current_pos < current_input.len() {
|
||||
let new_pos = current_pos + 1;
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"move_up" => {
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields > 0 {
|
||||
let current_field = state.current_field();
|
||||
let new_field = current_field.saturating_sub(1);
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos(
|
||||
(*ideal_cursor_column).min(max_pos),
|
||||
);
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"move_down" => {
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields > 0 {
|
||||
let new_field = (state.current_field() + 1).min(num_fields - 1);
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos(
|
||||
(*ideal_cursor_column).min(max_pos),
|
||||
);
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"move_line_start" => {
|
||||
state.set_current_cursor_pos(0);
|
||||
*ideal_cursor_column = 0;
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"move_line_end" => {
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = current_input.len();
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"move_first_line" => {
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields > 0 {
|
||||
state.set_current_field(0);
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos(
|
||||
(*ideal_cursor_column).min(max_pos),
|
||||
);
|
||||
}
|
||||
Ok("Moved to first field".to_string())
|
||||
}
|
||||
|
||||
"move_last_line" => {
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields > 0 {
|
||||
let new_field = num_fields - 1;
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos(
|
||||
(*ideal_cursor_column).min(max_pos),
|
||||
);
|
||||
}
|
||||
Ok("Moved to last field".to_string())
|
||||
}
|
||||
|
||||
"move_word_next" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_next_word_start(
|
||||
current_input,
|
||||
state.current_cursor_pos(),
|
||||
);
|
||||
let final_pos = new_pos.min(current_input.len());
|
||||
state.set_current_cursor_pos(final_pos);
|
||||
*ideal_cursor_column = final_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"move_word_end" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let new_pos = find_word_end(current_input, current_pos);
|
||||
|
||||
let final_pos = if new_pos == current_pos {
|
||||
find_word_end(current_input, new_pos + 1)
|
||||
} else {
|
||||
new_pos
|
||||
};
|
||||
|
||||
let max_valid_index = current_input.len().saturating_sub(1);
|
||||
let clamped_pos = final_pos.min(max_valid_index);
|
||||
state.set_current_cursor_pos(clamped_pos);
|
||||
*ideal_cursor_column = clamped_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"move_word_prev" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_start(
|
||||
current_input,
|
||||
state.current_cursor_pos(),
|
||||
);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"move_word_end_prev" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_end(
|
||||
current_input,
|
||||
state.current_cursor_pos(),
|
||||
);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("Moved to previous word end".to_string())
|
||||
}
|
||||
|
||||
// --- Autocomplete Actions ---
|
||||
"suggestion_down" | "suggestion_up" | "select_suggestion" | "exit_suggestion_mode" => {
|
||||
// Attempt to downcast to RegisterState to handle suggestion logic here
|
||||
if let Some(register_state) = (state as &mut dyn Any).downcast_mut::<RegisterState>() {
|
||||
// Only handle if it's the role field (index 4) and autocomplete is active
|
||||
if register_state.current_field() == 4 && register_state.is_autocomplete_active() {
|
||||
match action {
|
||||
"suggestion_down" => {
|
||||
if let Some(autocomplete_state) = register_state.autocomplete_state_mut() {
|
||||
autocomplete_state.select_next();
|
||||
Ok("Suggestion changed down".to_string())
|
||||
} else {
|
||||
Ok("No autocomplete state".to_string())
|
||||
}
|
||||
}
|
||||
"suggestion_up" => {
|
||||
if let Some(autocomplete_state) = register_state.autocomplete_state_mut() {
|
||||
autocomplete_state.select_previous();
|
||||
Ok("Suggestion changed up".to_string())
|
||||
} else {
|
||||
Ok("No autocomplete state".to_string())
|
||||
}
|
||||
}
|
||||
"select_suggestion" => {
|
||||
if let Some(message) = register_state.apply_autocomplete_selection() {
|
||||
Ok(message)
|
||||
} else {
|
||||
Ok("No suggestion selected".to_string())
|
||||
}
|
||||
}
|
||||
"exit_suggestion_mode" => {
|
||||
register_state.deactivate_autocomplete();
|
||||
Ok("Suggestions hidden".to_string())
|
||||
}
|
||||
_ => Ok("Suggestion action ignored: State mismatch.".to_string())
|
||||
}
|
||||
} else {
|
||||
Ok("Suggestion action ignored: Not on role field or autocomplete not active.".to_string())
|
||||
}
|
||||
} else {
|
||||
Ok(format!("Action '{}' not applicable for this state type.", action))
|
||||
}
|
||||
}
|
||||
// --- End Autocomplete Actions ---
|
||||
|
||||
|
||||
_ => Ok(format!("Unknown or unhandled edit action: {}", action)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum CharType {
|
||||
Whitespace,
|
||||
Alphanumeric,
|
||||
Punctuation,
|
||||
}
|
||||
|
||||
fn get_char_type(c: char) -> CharType {
|
||||
if c.is_whitespace() {
|
||||
CharType::Whitespace
|
||||
} else if c.is_alphanumeric() {
|
||||
CharType::Alphanumeric
|
||||
} else {
|
||||
CharType::Punctuation
|
||||
}
|
||||
}
|
||||
|
||||
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
if len == 0 || current_pos >= len {
|
||||
return len;
|
||||
}
|
||||
|
||||
let mut pos = current_pos;
|
||||
let initial_type = get_char_type(chars[pos]);
|
||||
|
||||
while pos < len && get_char_type(chars[pos]) == initial_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
while pos < len && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
fn find_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
if len == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.min(len - 1);
|
||||
|
||||
if get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos = find_next_word_start(text, pos);
|
||||
}
|
||||
|
||||
if pos >= len {
|
||||
return len.saturating_sub(1);
|
||||
}
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos < len && get_char_type(chars[pos]) == word_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos.saturating_sub(1).min(len.saturating_sub(1))
|
||||
}
|
||||
|
||||
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
if len == 0 || current_pos == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
return 0;
|
||||
}
|
||||
if pos == 0 && get_char_type(chars[pos]) != CharType::Whitespace {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos > 0 {
|
||||
pos - 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
@@ -1,435 +0,0 @@
|
||||
// src/functions/modes/edit/form_e.rs
|
||||
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::tui::functions::common::form::{revert, save};
|
||||
use crate::tui::functions::common::form::SaveOutcome;
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use canvas::canvas::CanvasState;
|
||||
use std::any::Any;
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn execute_common_action<S: CanvasState + Any>(
|
||||
action: &str,
|
||||
state: &mut S,
|
||||
grpc_client: &mut GrpcClient,
|
||||
app_state: &AppState,
|
||||
) -> Result<EventOutcome> {
|
||||
match action {
|
||||
"save" | "revert" => {
|
||||
if !state.has_unsaved_changes() {
|
||||
return Ok(EventOutcome::Ok("No changes to save or revert.".to_string()));
|
||||
}
|
||||
if let Some(form_state) =
|
||||
(state as &mut dyn Any).downcast_mut::<FormState>()
|
||||
{
|
||||
match action {
|
||||
"save" => {
|
||||
let save_result = save(
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
).await;
|
||||
|
||||
match save_result {
|
||||
Ok(save_outcome) => {
|
||||
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))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
"revert" => {
|
||||
let revert_result = revert(
|
||||
form_state,
|
||||
grpc_client,
|
||||
).await;
|
||||
|
||||
match revert_result {
|
||||
Ok(message) => Ok(EventOutcome::Ok(message)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
} else {
|
||||
Ok(EventOutcome::Ok(format!(
|
||||
"Action '{}' not implemented for this state type.",
|
||||
action
|
||||
)))
|
||||
}
|
||||
}
|
||||
_ => Ok(EventOutcome::Ok(format!("Common action '{}' not handled here.", action))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_edit_action<S: CanvasState>(
|
||||
action: &str,
|
||||
key: KeyEvent,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<String> {
|
||||
match action {
|
||||
"insert_char" => {
|
||||
if let KeyCode::Char(c) = key.code {
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
let field_value = state.get_current_input_mut();
|
||||
let mut chars: Vec<char> = field_value.chars().collect();
|
||||
if cursor_pos <= chars.len() {
|
||||
chars.insert(cursor_pos, c);
|
||||
*field_value = chars.into_iter().collect();
|
||||
state.set_current_cursor_pos(cursor_pos + 1);
|
||||
state.set_has_unsaved_changes(true);
|
||||
*ideal_cursor_column = state.current_cursor_pos();
|
||||
}
|
||||
} else {
|
||||
return Ok("Error: insert_char called without a char key."
|
||||
.to_string());
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"delete_char_backward" => {
|
||||
if state.current_cursor_pos() > 0 {
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
let field_value = state.get_current_input_mut();
|
||||
let mut chars: Vec<char> = field_value.chars().collect();
|
||||
if cursor_pos <= chars.len() {
|
||||
chars.remove(cursor_pos - 1);
|
||||
*field_value = chars.into_iter().collect();
|
||||
let new_pos = cursor_pos - 1;
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
state.set_has_unsaved_changes(true);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"delete_char_forward" => {
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
let field_value = state.get_current_input_mut();
|
||||
let mut chars: Vec<char> = field_value.chars().collect();
|
||||
if cursor_pos < chars.len() {
|
||||
chars.remove(cursor_pos);
|
||||
*field_value = chars.into_iter().collect();
|
||||
state.set_has_unsaved_changes(true);
|
||||
*ideal_cursor_column = cursor_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"next_field" => {
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields > 0 {
|
||||
let current_field = state.current_field();
|
||||
let new_field = (current_field + 1) % num_fields;
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos(
|
||||
(*ideal_cursor_column).min(max_pos),
|
||||
);
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"prev_field" => {
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields > 0 {
|
||||
let current_field = state.current_field();
|
||||
let new_field = if current_field == 0 {
|
||||
num_fields - 1
|
||||
} else {
|
||||
current_field - 1
|
||||
};
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos(
|
||||
(*ideal_cursor_column).min(max_pos),
|
||||
);
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"move_left" => {
|
||||
let new_pos = state.current_cursor_pos().saturating_sub(1);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"move_right" => {
|
||||
let current_input = state.get_current_input();
|
||||
let current_pos = state.current_cursor_pos();
|
||||
if current_pos < current_input.len() {
|
||||
let new_pos = current_pos + 1;
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"move_up" => {
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields > 0 {
|
||||
let current_field = state.current_field();
|
||||
let new_field = current_field.saturating_sub(1);
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos(
|
||||
(*ideal_cursor_column).min(max_pos),
|
||||
);
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"move_down" => {
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields > 0 {
|
||||
let new_field = (state.current_field() + 1).min(num_fields - 1);
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos(
|
||||
(*ideal_cursor_column).min(max_pos),
|
||||
);
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"move_line_start" => {
|
||||
state.set_current_cursor_pos(0);
|
||||
*ideal_cursor_column = 0;
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"move_line_end" => {
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = current_input.len();
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"move_first_line" => {
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields > 0 {
|
||||
state.set_current_field(0);
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos(
|
||||
(*ideal_cursor_column).min(max_pos),
|
||||
);
|
||||
}
|
||||
Ok("Moved to first field".to_string())
|
||||
}
|
||||
|
||||
"move_last_line" => {
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields > 0 {
|
||||
let new_field = num_fields - 1;
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_pos = current_input.len();
|
||||
state.set_current_cursor_pos(
|
||||
(*ideal_cursor_column).min(max_pos),
|
||||
);
|
||||
}
|
||||
Ok("Moved to last field".to_string())
|
||||
}
|
||||
|
||||
"move_word_next" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_next_word_start(
|
||||
current_input,
|
||||
state.current_cursor_pos(),
|
||||
);
|
||||
let final_pos = new_pos.min(current_input.len());
|
||||
state.set_current_cursor_pos(final_pos);
|
||||
*ideal_cursor_column = final_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"move_word_end" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let new_pos = find_word_end(current_input, current_pos);
|
||||
|
||||
let final_pos = if new_pos == current_pos {
|
||||
find_word_end(current_input, new_pos + 1)
|
||||
} else {
|
||||
new_pos
|
||||
};
|
||||
|
||||
let max_valid_index = current_input.len().saturating_sub(1);
|
||||
let clamped_pos = final_pos.min(max_valid_index);
|
||||
state.set_current_cursor_pos(clamped_pos);
|
||||
*ideal_cursor_column = clamped_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"move_word_prev" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_start(
|
||||
current_input,
|
||||
state.current_cursor_pos(),
|
||||
);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
|
||||
"move_word_end_prev" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_end(
|
||||
current_input,
|
||||
state.current_cursor_pos(),
|
||||
);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("Moved to previous word end".to_string())
|
||||
}
|
||||
|
||||
_ => Ok(format!("Unknown or unhandled edit action: {}", action)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum CharType {
|
||||
Whitespace,
|
||||
Alphanumeric,
|
||||
Punctuation,
|
||||
}
|
||||
|
||||
fn get_char_type(c: char) -> CharType {
|
||||
if c.is_whitespace() {
|
||||
CharType::Whitespace
|
||||
} else if c.is_alphanumeric() {
|
||||
CharType::Alphanumeric
|
||||
} else {
|
||||
CharType::Punctuation
|
||||
}
|
||||
}
|
||||
|
||||
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
if len == 0 || current_pos >= len {
|
||||
return len;
|
||||
}
|
||||
|
||||
let mut pos = current_pos;
|
||||
let initial_type = get_char_type(chars[pos]);
|
||||
|
||||
while pos < len && get_char_type(chars[pos]) == initial_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
while pos < len && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
fn find_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
if len == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.min(len - 1);
|
||||
|
||||
if get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos = find_next_word_start(text, pos);
|
||||
}
|
||||
|
||||
if pos >= len {
|
||||
return len.saturating_sub(1);
|
||||
}
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos < len && get_char_type(chars[pos]) == word_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos.saturating_sub(1).min(len.saturating_sub(1))
|
||||
}
|
||||
|
||||
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
if len == 0 || current_pos == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
return 0;
|
||||
}
|
||||
if pos == 0 && get_char_type(chars[pos]) != CharType::Whitespace {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos > 0 {
|
||||
pos - 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// src/functions/modes/read_only.rs
|
||||
|
||||
pub mod auth_ro;
|
||||
pub mod form_ro;
|
||||
pub mod add_table_ro;
|
||||
pub mod add_logic_ro;
|
||||
@@ -1,235 +0,0 @@
|
||||
// src/functions/modes/read_only/add_logic_ro.rs
|
||||
use crate::config::binds::key_sequences::KeySequenceTracker;
|
||||
use crate::state::pages::add_logic::AddLogicState; // Changed
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use crate::state::app::state::AppState;
|
||||
use anyhow::Result;
|
||||
|
||||
// Word navigation helpers (get_char_type, find_next_word_start, etc.)
|
||||
// can be kept as they are generic.
|
||||
#[derive(PartialEq)]
|
||||
enum CharType {
|
||||
Whitespace,
|
||||
Alphanumeric,
|
||||
Punctuation,
|
||||
}
|
||||
|
||||
fn get_char_type(c: char) -> CharType {
|
||||
if c.is_whitespace() { CharType::Whitespace }
|
||||
else if c.is_alphanumeric() { CharType::Alphanumeric }
|
||||
else { CharType::Punctuation }
|
||||
}
|
||||
|
||||
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
if len == 0 || current_pos >= len { return len; }
|
||||
let mut pos = current_pos;
|
||||
let initial_type = get_char_type(chars[pos]);
|
||||
while pos < len && get_char_type(chars[pos]) == initial_type { pos += 1; }
|
||||
while pos < len && get_char_type(chars[pos]) == CharType::Whitespace { pos += 1; }
|
||||
pos
|
||||
}
|
||||
|
||||
fn find_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
if len == 0 { return 0; }
|
||||
let mut pos = current_pos.min(len - 1);
|
||||
if get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos = find_next_word_start(text, pos);
|
||||
}
|
||||
if pos >= len { return len.saturating_sub(1); }
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos < len && get_char_type(chars[pos]) == word_type { pos += 1; }
|
||||
pos.saturating_sub(1).min(len.saturating_sub(1))
|
||||
}
|
||||
|
||||
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos == 0 { return 0; }
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace { pos -= 1; }
|
||||
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace { return 0; }
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type { pos -= 1; }
|
||||
pos
|
||||
}
|
||||
|
||||
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let prev_start = find_prev_word_start(text, current_pos);
|
||||
if prev_start == 0 { return 0; }
|
||||
find_word_end(text, prev_start.saturating_sub(1))
|
||||
}
|
||||
|
||||
|
||||
/// Executes read-only actions for the AddLogic view canvas.
|
||||
pub async fn execute_action(
|
||||
action: &str,
|
||||
app_state: &mut AppState,
|
||||
state: &mut AddLogicState,
|
||||
ideal_cursor_column: &mut usize,
|
||||
key_sequence_tracker: &mut KeySequenceTracker,
|
||||
command_message: &mut String,
|
||||
) -> Result<String> {
|
||||
match action {
|
||||
"move_up" => {
|
||||
key_sequence_tracker.reset();
|
||||
let num_fields = AddLogicState::INPUT_FIELD_COUNT;
|
||||
if num_fields == 0 { return Ok("No fields.".to_string()); }
|
||||
let current_field = state.current_field();
|
||||
|
||||
if current_field > 0 {
|
||||
let new_field = current_field - 1;
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
|
||||
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
} else {
|
||||
*command_message = "At top of form.".to_string();
|
||||
}
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"move_down" => {
|
||||
key_sequence_tracker.reset();
|
||||
let num_fields = AddLogicState::INPUT_FIELD_COUNT;
|
||||
if num_fields == 0 { return Ok("No fields.".to_string()); }
|
||||
let current_field = state.current_field();
|
||||
let last_field_index = num_fields - 1;
|
||||
|
||||
if current_field < last_field_index {
|
||||
let new_field = current_field + 1;
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
|
||||
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
} else {
|
||||
// Move focus outside canvas when moving down from the last field
|
||||
// FIX: Go to ScriptContentPreview instead of SaveButton
|
||||
app_state.ui.focus_outside_canvas = true;
|
||||
state.last_canvas_field = 2;
|
||||
state.current_focus = crate::state::pages::add_logic::AddLogicFocus::ScriptContentPreview; // FIXED!
|
||||
*command_message = "Focus moved to script preview".to_string();
|
||||
}
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
// ... (rest of the actions remain the same) ...
|
||||
"move_first_line" => {
|
||||
key_sequence_tracker.reset();
|
||||
if AddLogicState::INPUT_FIELD_COUNT > 0 {
|
||||
state.set_current_field(0);
|
||||
let current_input = state.get_current_input();
|
||||
let max_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
|
||||
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_last_line" => {
|
||||
key_sequence_tracker.reset();
|
||||
let num_fields = AddLogicState::INPUT_FIELD_COUNT;
|
||||
if num_fields > 0 {
|
||||
let last_field_index = num_fields - 1;
|
||||
state.set_current_field(last_field_index);
|
||||
let current_input = state.get_current_input();
|
||||
let max_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
|
||||
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_left" => {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let new_pos = current_pos.saturating_sub(1);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_right" => {
|
||||
let current_input = state.get_current_input();
|
||||
let current_pos = state.current_cursor_pos();
|
||||
if !current_input.is_empty() && current_pos < current_input.len().saturating_sub(1) {
|
||||
let new_pos = current_pos + 1;
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_next" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
|
||||
let final_pos = new_pos.min(current_input.len().saturating_sub(1));
|
||||
state.set_current_cursor_pos(final_pos);
|
||||
*ideal_cursor_column = final_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_end" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let new_pos = find_word_end(current_input, current_pos);
|
||||
let final_pos = if new_pos == current_pos && current_pos < current_input.len().saturating_sub(1) {
|
||||
find_word_end(current_input, current_pos + 1)
|
||||
} else {
|
||||
new_pos
|
||||
};
|
||||
let max_valid_index = current_input.len().saturating_sub(1);
|
||||
let clamped_pos = final_pos.min(max_valid_index);
|
||||
state.set_current_cursor_pos(clamped_pos);
|
||||
*ideal_cursor_column = clamped_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_prev" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_end_prev" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_end(current_input, state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_line_start" => {
|
||||
state.set_current_cursor_pos(0);
|
||||
*ideal_cursor_column = 0;
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_line_end" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = current_input.len().saturating_sub(1);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
} else {
|
||||
state.set_current_cursor_pos(0);
|
||||
*ideal_cursor_column = 0;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"enter_edit_mode_before" | "enter_edit_mode_after" | "enter_command_mode" | "exit_highlight_mode" => {
|
||||
key_sequence_tracker.reset();
|
||||
Ok("Mode change handled by main loop".to_string())
|
||||
}
|
||||
_ => {
|
||||
key_sequence_tracker.reset();
|
||||
command_message.clear();
|
||||
Ok(format!("Unknown read-only action: {}", action))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
// src/functions/modes/read_only/add_table_ro.rs
|
||||
use crate::config::binds::key_sequences::KeySequenceTracker;
|
||||
use crate::state::pages::add_table::AddTableState;
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use crate::state::app::state::AppState;
|
||||
use anyhow::Result;
|
||||
|
||||
// Re-use word navigation helpers if they are public or move them to a common module
|
||||
// For now, duplicating them here for simplicity. Consider refactoring later.
|
||||
#[derive(PartialEq)]
|
||||
enum CharType {
|
||||
Whitespace,
|
||||
Alphanumeric,
|
||||
Punctuation,
|
||||
}
|
||||
|
||||
fn get_char_type(c: char) -> CharType {
|
||||
if c.is_whitespace() { CharType::Whitespace }
|
||||
else if c.is_alphanumeric() { CharType::Alphanumeric }
|
||||
else { CharType::Punctuation }
|
||||
}
|
||||
|
||||
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
if len == 0 || current_pos >= len { return len; }
|
||||
let mut pos = current_pos;
|
||||
let initial_type = get_char_type(chars[pos]);
|
||||
while pos < len && get_char_type(chars[pos]) == initial_type { pos += 1; }
|
||||
while pos < len && get_char_type(chars[pos]) == CharType::Whitespace { pos += 1; }
|
||||
pos
|
||||
}
|
||||
|
||||
fn find_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
if len == 0 { return 0; }
|
||||
let mut pos = current_pos.min(len - 1);
|
||||
if get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos = find_next_word_start(text, pos);
|
||||
}
|
||||
if pos >= len { return len.saturating_sub(1); }
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos < len && get_char_type(chars[pos]) == word_type { pos += 1; }
|
||||
pos.saturating_sub(1).min(len.saturating_sub(1))
|
||||
}
|
||||
|
||||
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos == 0 { return 0; }
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace { pos -= 1; }
|
||||
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace { return 0; }
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type { pos -= 1; }
|
||||
pos
|
||||
}
|
||||
|
||||
// Note: find_prev_word_end might need adjustments based on desired behavior.
|
||||
// This version finds the end of the word *before* the previous word start.
|
||||
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let prev_start = find_prev_word_start(text, current_pos);
|
||||
if prev_start == 0 { return 0; }
|
||||
// Find the end of the word that starts at prev_start - 1
|
||||
find_word_end(text, prev_start.saturating_sub(1))
|
||||
}
|
||||
|
||||
|
||||
/// Executes read-only actions for the AddTable view canvas.
|
||||
pub async fn execute_action(
|
||||
action: &str,
|
||||
app_state: &mut AppState, // Needed for focus_outside_canvas
|
||||
state: &mut AddTableState,
|
||||
ideal_cursor_column: &mut usize,
|
||||
key_sequence_tracker: &mut KeySequenceTracker,
|
||||
command_message: &mut String, // Keep for potential messages
|
||||
) -> Result<String> {
|
||||
// Use the CanvasState trait methods implemented for AddTableState
|
||||
match action {
|
||||
"move_up" => {
|
||||
key_sequence_tracker.reset();
|
||||
let num_fields = AddTableState::INPUT_FIELD_COUNT;
|
||||
if num_fields == 0 {
|
||||
*command_message = "No fields.".to_string();
|
||||
return Ok(command_message.clone());
|
||||
}
|
||||
let current_field = state.current_field(); // Gets the index (0, 1, or 2)
|
||||
|
||||
if current_field > 0 {
|
||||
// This handles moving from field 2 -> 1, or 1 -> 0
|
||||
let new_field = current_field - 1;
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_cursor_pos = current_input.len(); // Allow cursor at end
|
||||
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos; // Update ideal column as cursor moved
|
||||
*command_message = "".to_string(); // Clear message for successful internal navigation
|
||||
} else {
|
||||
// current_field is 0 (InputTableName), and user pressed Up.
|
||||
// Forbid moving up. Do not change focus or cursor.
|
||||
*command_message = "At top of form.".to_string();
|
||||
}
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"move_down" => {
|
||||
key_sequence_tracker.reset();
|
||||
let num_fields = AddTableState::INPUT_FIELD_COUNT;
|
||||
if num_fields == 0 {
|
||||
*command_message = "No fields.".to_string();
|
||||
return Ok(command_message.clone());
|
||||
}
|
||||
let current_field = state.current_field();
|
||||
let last_field_index = num_fields - 1;
|
||||
|
||||
if current_field < last_field_index {
|
||||
let new_field = current_field + 1;
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_cursor_pos = current_input.len(); // Allow cursor at end
|
||||
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos; // Update ideal column
|
||||
*command_message = "".to_string();
|
||||
} else {
|
||||
// Move focus outside canvas when moving down from the last field
|
||||
app_state.ui.focus_outside_canvas = true;
|
||||
// Set focus to the first element outside canvas (AddColumnButton)
|
||||
state.current_focus =
|
||||
crate::state::pages::add_table::AddTableFocus::AddColumnButton;
|
||||
*command_message = "Focus moved below canvas".to_string();
|
||||
}
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
// ... (other actions like "move_first_line", "move_left", etc. remain the same) ...
|
||||
"move_first_line" => {
|
||||
key_sequence_tracker.reset();
|
||||
if AddTableState::INPUT_FIELD_COUNT > 0 {
|
||||
state.set_current_field(0);
|
||||
let current_input = state.get_current_input();
|
||||
let max_cursor_pos = current_input.len();
|
||||
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos; // Update ideal column
|
||||
}
|
||||
*command_message = "".to_string();
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"move_last_line" => {
|
||||
key_sequence_tracker.reset();
|
||||
let num_fields = AddTableState::INPUT_FIELD_COUNT;
|
||||
if num_fields > 0 {
|
||||
let last_field_index = num_fields - 1;
|
||||
state.set_current_field(last_field_index);
|
||||
let current_input = state.get_current_input();
|
||||
let max_cursor_pos = current_input.len();
|
||||
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos; // Update ideal column
|
||||
}
|
||||
*command_message = "".to_string();
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"move_left" => {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let new_pos = current_pos.saturating_sub(1);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
*command_message = "".to_string();
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"move_right" => {
|
||||
let current_input = state.get_current_input();
|
||||
let current_pos = state.current_cursor_pos();
|
||||
// Allow moving cursor one position past the end
|
||||
if current_pos < current_input.len() {
|
||||
let new_pos = current_pos + 1;
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
*command_message = "".to_string();
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"move_word_next" => {
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = find_next_word_start(
|
||||
current_input,
|
||||
state.current_cursor_pos(),
|
||||
);
|
||||
let final_pos = new_pos.min(current_input.len()); // Allow cursor at end
|
||||
state.set_current_cursor_pos(final_pos);
|
||||
*ideal_cursor_column = final_pos;
|
||||
*command_message = "".to_string();
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"move_word_end" => {
|
||||
let current_input = state.get_current_input();
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let new_pos = find_word_end(current_input, current_pos);
|
||||
// If find_word_end returns current_pos, try starting search from next char
|
||||
let final_pos =
|
||||
if new_pos == current_pos && current_pos < current_input.len() {
|
||||
find_word_end(current_input, current_pos + 1)
|
||||
} else {
|
||||
new_pos
|
||||
};
|
||||
let max_valid_index = current_input.len(); // Allow cursor at end
|
||||
let clamped_pos = final_pos.min(max_valid_index);
|
||||
state.set_current_cursor_pos(clamped_pos);
|
||||
*ideal_cursor_column = clamped_pos;
|
||||
*command_message = "".to_string();
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"move_word_prev" => {
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = find_prev_word_start(
|
||||
current_input,
|
||||
state.current_cursor_pos(),
|
||||
);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
*command_message = "".to_string();
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"move_word_end_prev" => {
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = find_prev_word_end(
|
||||
current_input,
|
||||
state.current_cursor_pos(),
|
||||
);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
*command_message = "".to_string();
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"move_line_start" => {
|
||||
state.set_current_cursor_pos(0);
|
||||
*ideal_cursor_column = 0;
|
||||
*command_message = "".to_string();
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"move_line_end" => {
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = current_input.len(); // Allow cursor at end
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
*command_message = "".to_string();
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
// Actions handled by main event loop (mode changes)
|
||||
"enter_edit_mode_before" | "enter_edit_mode_after"
|
||||
| "enter_command_mode" | "exit_highlight_mode" => {
|
||||
key_sequence_tracker.reset();
|
||||
// These actions are primarily mode changes handled by the main event loop.
|
||||
// The message here might be overridden by the main loop's message for mode change.
|
||||
*command_message = "Mode change initiated".to_string();
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
_ => {
|
||||
key_sequence_tracker.reset();
|
||||
*command_message =
|
||||
format!("Unknown read-only action: {}", action);
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
// src/functions/modes/read_only/auth_ro.rs
|
||||
|
||||
use crate::config::binds::key_sequences::KeySequenceTracker;
|
||||
use crate::state::app::state::AppState;
|
||||
use canvas::canvas::CanvasState;
|
||||
use anyhow::Result;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum CharType {
|
||||
Whitespace,
|
||||
Alphanumeric,
|
||||
Punctuation,
|
||||
}
|
||||
|
||||
pub async fn execute_action<S: CanvasState>(
|
||||
action: &str,
|
||||
app_state: &mut AppState,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
key_sequence_tracker: &mut KeySequenceTracker,
|
||||
command_message: &mut String,
|
||||
) -> Result<String> {
|
||||
match action {
|
||||
"previous_entry" | "next_entry" => {
|
||||
key_sequence_tracker.reset();
|
||||
Ok(format!(
|
||||
"Action '{}' should be handled by context-specific logic",
|
||||
action
|
||||
))
|
||||
}
|
||||
"move_up" => {
|
||||
key_sequence_tracker.reset();
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields == 0 {
|
||||
return Ok("No fields to navigate.".to_string());
|
||||
}
|
||||
let current_field = state.current_field();
|
||||
let new_field = current_field.saturating_sub(1);
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_cursor_pos = if current_input.is_empty() {
|
||||
0
|
||||
} else {
|
||||
current_input.len().saturating_sub(1)
|
||||
};
|
||||
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
|
||||
Ok("move up from functions/modes/read_only/auth_ro.rs".to_string())
|
||||
}
|
||||
"move_down" => {
|
||||
key_sequence_tracker.reset();
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields == 0 {
|
||||
return Ok("No fields to navigate.".to_string());
|
||||
}
|
||||
let current_field = state.current_field();
|
||||
let last_field_index = num_fields - 1;
|
||||
|
||||
if current_field == last_field_index {
|
||||
// Already on the last field, move focus outside
|
||||
app_state.ui.focus_outside_canvas = true;
|
||||
app_state.focused_button_index= 0;
|
||||
key_sequence_tracker.reset();
|
||||
Ok("Focus moved below canvas".to_string())
|
||||
} else {
|
||||
// Move to the next field within the canvas
|
||||
let new_field = (current_field + 1).min(last_field_index);
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_cursor_pos = if current_input.is_empty() {
|
||||
0
|
||||
} else {
|
||||
current_input.len().saturating_sub(1)
|
||||
};
|
||||
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
Ok("".to_string()) // Clear previous debug message
|
||||
}
|
||||
}
|
||||
"move_first_line" => {
|
||||
key_sequence_tracker.reset();
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields == 0 {
|
||||
return Ok("No fields to navigate to.".to_string());
|
||||
}
|
||||
state.set_current_field(0);
|
||||
let current_input = state.get_current_input();
|
||||
let max_cursor_pos = if current_input.is_empty() {
|
||||
0
|
||||
} else {
|
||||
current_input.len().saturating_sub(1)
|
||||
};
|
||||
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_last_line" => {
|
||||
key_sequence_tracker.reset();
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields == 0 {
|
||||
return Ok("No fields to navigate to.".to_string());
|
||||
}
|
||||
let last_field_index = num_fields - 1;
|
||||
state.set_current_field(last_field_index);
|
||||
let current_input = state.get_current_input();
|
||||
let max_cursor_pos = if current_input.is_empty() {
|
||||
0
|
||||
} else {
|
||||
current_input.len().saturating_sub(1)
|
||||
};
|
||||
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok("".to_string())
|
||||
}
|
||||
"exit_edit_mode" => {
|
||||
key_sequence_tracker.reset();
|
||||
command_message.clear();
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_left" => {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let new_pos = current_pos.saturating_sub(1);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_right" => {
|
||||
let current_input = state.get_current_input();
|
||||
let current_pos = state.current_cursor_pos();
|
||||
if !current_input.is_empty()
|
||||
&& current_pos < current_input.len().saturating_sub(1)
|
||||
{
|
||||
let new_pos = current_pos + 1;
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_next" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos =
|
||||
find_next_word_start(current_input, state.current_cursor_pos());
|
||||
let final_pos = new_pos.min(current_input.len().saturating_sub(1));
|
||||
state.set_current_cursor_pos(final_pos);
|
||||
*ideal_cursor_column = final_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_end" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let new_pos = find_word_end(current_input, current_pos);
|
||||
|
||||
let final_pos = if new_pos != current_pos {
|
||||
new_pos
|
||||
} else {
|
||||
find_word_end(current_input, new_pos + 1)
|
||||
};
|
||||
|
||||
let max_valid_index = current_input.len().saturating_sub(1);
|
||||
let clamped_pos = final_pos.min(max_valid_index);
|
||||
state.set_current_cursor_pos(clamped_pos);
|
||||
*ideal_cursor_column = clamped_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_prev" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_start(
|
||||
current_input,
|
||||
state.current_cursor_pos(),
|
||||
);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_end_prev" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_end(
|
||||
current_input,
|
||||
state.current_cursor_pos(),
|
||||
);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("Moved to previous word end".to_string())
|
||||
}
|
||||
"move_line_start" => {
|
||||
state.set_current_cursor_pos(0);
|
||||
*ideal_cursor_column = 0;
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_line_end" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = current_input.len().saturating_sub(1);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
} else {
|
||||
state.set_current_cursor_pos(0);
|
||||
*ideal_cursor_column = 0;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
_ => {
|
||||
key_sequence_tracker.reset();
|
||||
Ok(format!("Unknown read-only action: {}", action))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn get_char_type(c: char) -> CharType {
|
||||
if c.is_whitespace() {
|
||||
CharType::Whitespace
|
||||
} else if c.is_alphanumeric() {
|
||||
CharType::Alphanumeric
|
||||
} else {
|
||||
CharType::Punctuation
|
||||
}
|
||||
}
|
||||
|
||||
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let current_pos = current_pos.min(chars.len());
|
||||
|
||||
if current_pos == chars.len() {
|
||||
return current_pos;
|
||||
}
|
||||
|
||||
let mut pos = current_pos;
|
||||
let initial_type = get_char_type(chars[pos]);
|
||||
|
||||
while pos < chars.len() && get_char_type(chars[pos]) == initial_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
fn find_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
if len == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.min(len - 1);
|
||||
let current_type = get_char_type(chars[pos]);
|
||||
if current_type != CharType::Whitespace {
|
||||
while pos < len && get_char_type(chars[pos]) == current_type {
|
||||
pos += 1;
|
||||
}
|
||||
return pos.saturating_sub(1);
|
||||
}
|
||||
|
||||
pos = find_next_word_start(text, pos);
|
||||
if pos >= len {
|
||||
return len.saturating_sub(1);
|
||||
}
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos < len && get_char_type(chars[pos]) == word_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos.saturating_sub(1).min(len.saturating_sub(1))
|
||||
}
|
||||
|
||||
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if get_char_type(chars[pos]) != CharType::Whitespace {
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
|
||||
0
|
||||
} else {
|
||||
pos
|
||||
}
|
||||
}
|
||||
|
||||
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
|
||||
return 0;
|
||||
}
|
||||
if pos == 0 && get_char_type(chars[0]) != CharType::Whitespace {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos > 0 {
|
||||
pos - 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
// src/functions/modes/read_only/form_ro.rs
|
||||
|
||||
use crate::config::binds::key_sequences::KeySequenceTracker;
|
||||
use canvas::canvas::CanvasState;
|
||||
use anyhow::Result;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum CharType {
|
||||
Whitespace,
|
||||
Alphanumeric,
|
||||
Punctuation,
|
||||
}
|
||||
|
||||
pub async fn execute_action<S: CanvasState>(
|
||||
action: &str,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
key_sequence_tracker: &mut KeySequenceTracker,
|
||||
command_message: &mut String,
|
||||
) -> Result<String> {
|
||||
match action {
|
||||
"previous_entry" | "next_entry" => {
|
||||
key_sequence_tracker.reset();
|
||||
Ok(format!(
|
||||
"Action '{}' should be handled by context-specific logic",
|
||||
action
|
||||
))
|
||||
}
|
||||
"move_up" => {
|
||||
key_sequence_tracker.reset();
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields == 0 {
|
||||
return Ok("No fields to navigate.".to_string());
|
||||
}
|
||||
let current_field = state.current_field();
|
||||
let new_field = current_field.saturating_sub(1);
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_cursor_pos = if current_input.is_empty() {
|
||||
0
|
||||
} else {
|
||||
current_input.len().saturating_sub(1)
|
||||
};
|
||||
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_down" => {
|
||||
key_sequence_tracker.reset();
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields == 0 {
|
||||
return Ok("No fields to navigate.".to_string());
|
||||
}
|
||||
let current_field = state.current_field();
|
||||
let new_field = (current_field + 1).min(num_fields - 1);
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_cursor_pos = if current_input.is_empty() {
|
||||
0
|
||||
} else {
|
||||
current_input.len().saturating_sub(1)
|
||||
};
|
||||
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_first_line" => {
|
||||
key_sequence_tracker.reset();
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields == 0 {
|
||||
return Ok("No fields to navigate to.".to_string());
|
||||
}
|
||||
state.set_current_field(0);
|
||||
let current_input = state.get_current_input();
|
||||
let max_cursor_pos = if current_input.is_empty() {
|
||||
0
|
||||
} else {
|
||||
current_input.len().saturating_sub(1)
|
||||
};
|
||||
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_last_line" => {
|
||||
key_sequence_tracker.reset();
|
||||
let num_fields = state.fields().len();
|
||||
if num_fields == 0 {
|
||||
return Ok("No fields to navigate to.".to_string());
|
||||
}
|
||||
let last_field_index = num_fields - 1;
|
||||
state.set_current_field(last_field_index);
|
||||
let current_input = state.get_current_input();
|
||||
let max_cursor_pos = if current_input.is_empty() {
|
||||
0
|
||||
} else {
|
||||
current_input.len().saturating_sub(1)
|
||||
};
|
||||
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok("".to_string())
|
||||
}
|
||||
"exit_edit_mode" => {
|
||||
key_sequence_tracker.reset();
|
||||
command_message.clear();
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_left" => {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let new_pos = current_pos.saturating_sub(1);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_right" => {
|
||||
let current_input = state.get_current_input();
|
||||
let current_pos = state.current_cursor_pos();
|
||||
if !current_input.is_empty()
|
||||
&& current_pos < current_input.len().saturating_sub(1)
|
||||
{
|
||||
let new_pos = current_pos + 1;
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_next" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos =
|
||||
find_next_word_start(current_input, state.current_cursor_pos());
|
||||
let final_pos = new_pos.min(current_input.len().saturating_sub(1));
|
||||
state.set_current_cursor_pos(final_pos);
|
||||
*ideal_cursor_column = final_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_end" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let new_pos = find_word_end(current_input, current_pos);
|
||||
|
||||
let final_pos = if new_pos != current_pos {
|
||||
new_pos
|
||||
} else {
|
||||
find_word_end(current_input, new_pos + 1)
|
||||
};
|
||||
|
||||
let max_valid_index = current_input.len().saturating_sub(1);
|
||||
let clamped_pos = final_pos.min(max_valid_index);
|
||||
state.set_current_cursor_pos(clamped_pos);
|
||||
*ideal_cursor_column = clamped_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_prev" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_start(
|
||||
current_input,
|
||||
state.current_cursor_pos(),
|
||||
);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_end_prev" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_end(
|
||||
current_input,
|
||||
state.current_cursor_pos(),
|
||||
);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("Moved to previous word end".to_string())
|
||||
}
|
||||
"move_line_start" => {
|
||||
state.set_current_cursor_pos(0);
|
||||
*ideal_cursor_column = 0;
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_line_end" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = current_input.len().saturating_sub(1);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
} else {
|
||||
state.set_current_cursor_pos(0);
|
||||
*ideal_cursor_column = 0;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
_ => {
|
||||
key_sequence_tracker.reset();
|
||||
Ok(format!("Unknown read-only action: {}", action))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn get_char_type(c: char) -> CharType {
|
||||
if c.is_whitespace() {
|
||||
CharType::Whitespace
|
||||
} else if c.is_alphanumeric() {
|
||||
CharType::Alphanumeric
|
||||
} else {
|
||||
CharType::Punctuation
|
||||
}
|
||||
}
|
||||
|
||||
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let current_pos = current_pos.min(chars.len());
|
||||
|
||||
if current_pos == chars.len() {
|
||||
return current_pos;
|
||||
}
|
||||
|
||||
let mut pos = current_pos;
|
||||
let initial_type = get_char_type(chars[pos]);
|
||||
|
||||
while pos < chars.len() && get_char_type(chars[pos]) == initial_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
fn find_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
if len == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.min(len - 1);
|
||||
let current_type = get_char_type(chars[pos]);
|
||||
if current_type != CharType::Whitespace {
|
||||
while pos < len && get_char_type(chars[pos]) == current_type {
|
||||
pos += 1;
|
||||
}
|
||||
return pos.saturating_sub(1);
|
||||
}
|
||||
|
||||
pos = find_next_word_start(text, pos);
|
||||
if pos >= len {
|
||||
return len.saturating_sub(1);
|
||||
}
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos < len && get_char_type(chars[pos]) == word_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos.saturating_sub(1).min(len.saturating_sub(1))
|
||||
}
|
||||
|
||||
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if get_char_type(chars[pos]) != CharType::Whitespace {
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
|
||||
0
|
||||
} else {
|
||||
pos
|
||||
}
|
||||
}
|
||||
|
||||
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
|
||||
return 0;
|
||||
}
|
||||
if pos == 0 && get_char_type(chars[0]) != CharType::Whitespace {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos > 0 {
|
||||
pos - 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
// src/modes/canvas/edit.rs
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::functions::modes::edit::{
|
||||
add_logic_e, add_table_e, form_e,
|
||||
};
|
||||
use crate::modes::handlers::event::EventHandler;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::app::state::AppState;
|
||||
@@ -15,7 +12,7 @@ use canvas::canvas::CanvasState;
|
||||
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher, canvas::ActionResult};
|
||||
use anyhow::Result;
|
||||
use common::proto::komp_ac::search::search_response::Hit;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::info;
|
||||
|
||||
@@ -82,7 +79,9 @@ pub async fn handle_form_edit_with_canvas(
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<String> {
|
||||
// Try canvas action from key first
|
||||
if let Some(canvas_action) = CanvasAction::from_key(key_event.code) {
|
||||
let canvas_config = canvas::config::CanvasConfig::load();
|
||||
if let Some(action_name) = canvas_config.get_edit_action(key_event.code, key_event.modifiers) {
|
||||
let canvas_action = CanvasAction::from_string(action_name);
|
||||
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
@@ -127,16 +126,65 @@ pub async fn handle_form_edit_with_canvas(
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
/// NEW: Unified canvas action handler for any CanvasState (LoginState, RegisterState, etc.)
|
||||
/// This replaces the old auth_e::execute_edit_action calls with the new canvas library
|
||||
/// Helper function to execute a specific action using canvas library
|
||||
async fn execute_canvas_action(
|
||||
action: &str,
|
||||
key: KeyEvent,
|
||||
form_state: &mut FormState,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<String> {
|
||||
let canvas_action = CanvasAction::from_string(action);
|
||||
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => Ok(msg.unwrap_or_default()),
|
||||
Ok(ActionResult::HandledByFeature(msg)) => Ok(msg),
|
||||
Ok(ActionResult::Error(msg)) => Ok(format!("Error: {}", msg)),
|
||||
Ok(ActionResult::RequiresContext(msg)) => Ok(format!("Context needed: {}", msg)),
|
||||
Err(e) => Ok(format!("Action failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// FIXED: Unified canvas action handler with proper priority order for edit mode
|
||||
async fn handle_canvas_state_edit<S: CanvasState>(
|
||||
key: KeyEvent,
|
||||
config: &Config,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<String> {
|
||||
// Try direct key mapping first (same pattern as FormState)
|
||||
if let Some(canvas_action) = CanvasAction::from_key(key.code) {
|
||||
// println!("DEBUG: Key pressed: {:?}", key); // DEBUG
|
||||
|
||||
// PRIORITY 1: Character insertion in edit mode comes FIRST
|
||||
if let KeyCode::Char(c) = key.code {
|
||||
// Only insert if no modifiers or just shift (for uppercase)
|
||||
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT {
|
||||
// println!("DEBUG: Using character insertion priority for: {}", c); // DEBUG
|
||||
let canvas_action = CanvasAction::InsertChar(c);
|
||||
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
}
|
||||
Ok(ActionResult::HandledByFeature(msg)) => {
|
||||
return Ok(msg);
|
||||
}
|
||||
Ok(ActionResult::Error(msg)) => {
|
||||
return Ok(format!("Error: {}", msg));
|
||||
}
|
||||
Ok(ActionResult::RequiresContext(msg)) => {
|
||||
return Ok(format!("Context needed: {}", msg));
|
||||
}
|
||||
Err(e) => {
|
||||
// println!("DEBUG: Character insertion failed: {:?}, trying config", e);
|
||||
// Fall through to try config mappings
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PRIORITY 2: Check canvas config for special keys/combinations
|
||||
let canvas_config = canvas::config::CanvasConfig::load();
|
||||
if let Some(action_name) = canvas_config.get_edit_action(key.code, key.modifiers) {
|
||||
// println!("DEBUG: Canvas config mapped to: {}", action_name); // DEBUG
|
||||
let canvas_action = CanvasAction::from_string(action_name);
|
||||
|
||||
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
@@ -151,33 +199,43 @@ async fn handle_canvas_state_edit<S: CanvasState>(
|
||||
return Ok(format!("Context needed: {}", msg));
|
||||
}
|
||||
Err(_) => {
|
||||
// Fall through to try config mapping
|
||||
// println!("DEBUG: Canvas action failed, trying client config"); // DEBUG
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// println!("DEBUG: No canvas config mapping found"); // DEBUG
|
||||
}
|
||||
|
||||
// Try config-mapped action (same pattern as FormState)
|
||||
if let Some(action_str) = config.get_edit_action_for_key(key.code, key.modifiers) {
|
||||
let canvas_action = CanvasAction::from_string(&action_str);
|
||||
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
}
|
||||
Ok(ActionResult::HandledByFeature(msg)) => {
|
||||
return Ok(msg);
|
||||
}
|
||||
Ok(ActionResult::Error(msg)) => {
|
||||
return Ok(format!("Error: {}", msg));
|
||||
}
|
||||
Ok(ActionResult::RequiresContext(msg)) => {
|
||||
return Ok(format!("Context needed: {}", msg));
|
||||
}
|
||||
Err(e) => {
|
||||
return Ok(format!("Action failed: {}", e));
|
||||
// PRIORITY 3: Check client config ONLY for non-character keys or modified keys
|
||||
if !matches!(key.code, KeyCode::Char(_)) || !key.modifiers.is_empty() {
|
||||
if let Some(action_str) = config.get_edit_action_for_key(key.code, key.modifiers) {
|
||||
// println!("DEBUG: Client config mapped to: {} (for non-char key)", action_str); // DEBUG
|
||||
let canvas_action = CanvasAction::from_string(&action_str);
|
||||
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
}
|
||||
Ok(ActionResult::HandledByFeature(msg)) => {
|
||||
return Ok(msg);
|
||||
}
|
||||
Ok(ActionResult::Error(msg)) => {
|
||||
return Ok(format!("Error: {}", msg));
|
||||
}
|
||||
Ok(ActionResult::RequiresContext(msg)) => {
|
||||
return Ok(format!("Context needed: {}", msg));
|
||||
}
|
||||
Err(e) => {
|
||||
return Ok(format!("Action failed: {}", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// println!("DEBUG: No client config mapping found for non-char key"); // DEBUG
|
||||
}
|
||||
} else {
|
||||
// println!("DEBUG: Skipping client config for character key in edit mode"); // DEBUG
|
||||
}
|
||||
|
||||
// println!("DEBUG: No action taken for key: {:?}", key); // DEBUG
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
@@ -291,8 +349,8 @@ pub async fn handle_edit_event(
|
||||
} else {
|
||||
"insert_char"
|
||||
};
|
||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
||||
form_e::execute_edit_action(
|
||||
// FIXED: Use canvas library instead of form_e::execute_edit_action
|
||||
execute_canvas_action(
|
||||
action,
|
||||
key,
|
||||
form_state,
|
||||
@@ -321,8 +379,8 @@ pub async fn handle_edit_event(
|
||||
{
|
||||
// Handle Enter key (next field)
|
||||
if action_str == "enter_decider" {
|
||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
||||
let msg = form_e::execute_edit_action(
|
||||
// FIXED: Use canvas library instead of form_e::execute_edit_action
|
||||
let msg = execute_canvas_action(
|
||||
"next_field",
|
||||
key,
|
||||
form_state,
|
||||
@@ -348,19 +406,19 @@ pub async fn handle_edit_event(
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_add_table {
|
||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
||||
add_table_e::execute_edit_action(
|
||||
action_str,
|
||||
// NEW: Use unified canvas handler instead of add_table_e::execute_edit_action
|
||||
handle_canvas_state_edit(
|
||||
key,
|
||||
config,
|
||||
&mut admin_state.add_table_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_add_logic {
|
||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
||||
add_logic_e::execute_edit_action(
|
||||
action_str,
|
||||
// NEW: Use unified canvas handler instead of add_logic_e::execute_edit_action
|
||||
handle_canvas_state_edit(
|
||||
key,
|
||||
config,
|
||||
&mut admin_state.add_logic_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
@@ -375,8 +433,8 @@ pub async fn handle_edit_event(
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
||||
form_e::execute_edit_action(
|
||||
// FIXED: Use canvas library instead of form_e::execute_edit_action
|
||||
execute_canvas_action(
|
||||
action_str,
|
||||
key,
|
||||
form_state,
|
||||
@@ -399,19 +457,19 @@ pub async fn handle_edit_event(
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_add_table {
|
||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
||||
add_table_e::execute_edit_action(
|
||||
"insert_char",
|
||||
// NEW: Use unified canvas handler instead of add_table_e::execute_edit_action
|
||||
handle_canvas_state_edit(
|
||||
key,
|
||||
config,
|
||||
&mut admin_state.add_table_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_add_logic {
|
||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
||||
add_logic_e::execute_edit_action(
|
||||
"insert_char",
|
||||
// NEW: Use unified canvas handler instead of add_logic_e::execute_edit_action
|
||||
handle_canvas_state_edit(
|
||||
key,
|
||||
config,
|
||||
&mut admin_state.add_logic_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
@@ -426,8 +484,8 @@ pub async fn handle_edit_event(
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
||||
form_e::execute_edit_action(
|
||||
// FIXED: Use canvas library instead of form_e::execute_edit_action
|
||||
execute_canvas_action(
|
||||
"insert_char",
|
||||
key,
|
||||
form_state,
|
||||
|
||||
@@ -5,16 +5,85 @@ use crate::config::binds::key_sequences::KeySequenceTracker;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::pages::auth::LoginState;
|
||||
use crate::state::pages::auth::RegisterState;
|
||||
use crate::state::pages::canvas_state::CanvasState as LocalCanvasState;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::state::pages::add_logic::AddLogicState;
|
||||
use crate::state::pages::add_table::AddTableState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::functions::modes::read_only::{add_logic_ro, auth_ro, form_ro, add_table_ro};
|
||||
use canvas::{canvas::{CanvasAction, CanvasState, ActionResult}, dispatcher::ActionDispatcher};
|
||||
use crossterm::event::KeyEvent;
|
||||
use anyhow::Result;
|
||||
|
||||
/// Helper function to dispatch canvas action for any CanvasState
|
||||
async fn dispatch_canvas_action<S: CanvasState>(
|
||||
action: &str,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> String {
|
||||
let canvas_action = CanvasAction::from_string(action);
|
||||
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => msg.unwrap_or_default(),
|
||||
Ok(ActionResult::HandledByFeature(msg)) => msg,
|
||||
Ok(ActionResult::Error(msg)) => format!("Error: {}", msg),
|
||||
Ok(ActionResult::RequiresContext(msg)) => format!("Context needed: {}", msg),
|
||||
Err(e) => format!("Action failed: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to dispatch canvas action to the appropriate state based on UI
|
||||
async fn dispatch_to_active_state(
|
||||
action: &str,
|
||||
app_state: &AppState,
|
||||
form_state: &mut FormState,
|
||||
login_state: &mut LoginState,
|
||||
register_state: &mut RegisterState,
|
||||
add_table_state: &mut AddTableState,
|
||||
add_logic_state: &mut AddLogicState,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> String {
|
||||
if app_state.ui.show_add_table {
|
||||
dispatch_canvas_action(action, add_table_state, ideal_cursor_column).await
|
||||
} else if app_state.ui.show_add_logic {
|
||||
dispatch_canvas_action(action, add_logic_state, ideal_cursor_column).await
|
||||
} else if app_state.ui.show_register {
|
||||
dispatch_canvas_action(action, register_state, ideal_cursor_column).await
|
||||
} else if app_state.ui.show_login {
|
||||
dispatch_canvas_action(action, login_state, ideal_cursor_column).await
|
||||
} else {
|
||||
dispatch_canvas_action(action, form_state, ideal_cursor_column).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to handle context-specific actions that need special treatment
|
||||
async fn handle_context_action(
|
||||
action: &str,
|
||||
app_state: &AppState,
|
||||
form_state: &mut FormState,
|
||||
grpc_client: &mut GrpcClient,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<Option<String>> {
|
||||
const CONTEXT_ACTIONS_FORM: &[&str] = &[
|
||||
"previous_entry",
|
||||
"next_entry",
|
||||
];
|
||||
const CONTEXT_ACTIONS_LOGIN: &[&str] = &[
|
||||
"previous_entry",
|
||||
"next_entry",
|
||||
];
|
||||
|
||||
if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) {
|
||||
Ok(Some(crate::tui::functions::form::handle_action(
|
||||
action,
|
||||
form_state,
|
||||
grpc_client,
|
||||
ideal_cursor_column,
|
||||
).await?))
|
||||
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
|
||||
Ok(Some(crate::tui::functions::login::handle_action(action).await?))
|
||||
} else {
|
||||
Ok(None) // Not a context action, use regular canvas dispatch
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_form_readonly_with_canvas(
|
||||
key_event: KeyEvent,
|
||||
config: &Config,
|
||||
@@ -22,7 +91,9 @@ pub async fn handle_form_readonly_with_canvas(
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<String> {
|
||||
// Try canvas action from key first
|
||||
if let Some(canvas_action) = CanvasAction::from_key(key_event.code) {
|
||||
let canvas_config = canvas::config::CanvasConfig::load();
|
||||
if let Some(action_name) = canvas_config.get_read_only_action(key_event.code, key_event.modifiers) {
|
||||
let canvas_action = CanvasAction::from_string(action_name);
|
||||
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
@@ -89,8 +160,7 @@ pub async fn handle_read_only_event(
|
||||
}
|
||||
|
||||
if config.is_enter_edit_mode_after(key.code, key.modifiers) {
|
||||
// Determine target state to adjust cursor
|
||||
|
||||
// Determine target state to adjust cursor - all states now use CanvasState trait
|
||||
if app_state.ui.show_login {
|
||||
let current_input = login_state.get_current_input();
|
||||
let current_pos = login_state.current_cursor_pos();
|
||||
@@ -120,8 +190,7 @@ pub async fn handle_read_only_event(
|
||||
*ideal_cursor_column = add_table_state.current_cursor_pos();
|
||||
}
|
||||
} else {
|
||||
// Handle FormState (uses library CanvasState)
|
||||
use canvas::canvas::CanvasState as LibraryCanvasState; // Import at the top of the function
|
||||
// Handle FormState
|
||||
let current_input = form_state.get_current_input();
|
||||
let current_pos = form_state.current_cursor_pos();
|
||||
if !current_input.is_empty() && current_pos < current_input.len() {
|
||||
@@ -135,76 +204,31 @@ pub async fn handle_read_only_event(
|
||||
return Ok((false, command_message.clone()));
|
||||
}
|
||||
|
||||
const CONTEXT_ACTIONS_FORM: &[&str] = &[
|
||||
"previous_entry",
|
||||
"next_entry",
|
||||
];
|
||||
const CONTEXT_ACTIONS_LOGIN: &[&str] = &[
|
||||
"previous_entry",
|
||||
"next_entry",
|
||||
];
|
||||
|
||||
if key.modifiers.is_empty() {
|
||||
key_sequence_tracker.add_key(key.code);
|
||||
let sequence = key_sequence_tracker.get_sequence();
|
||||
|
||||
if let Some(action) = config.matches_key_sequence_generalized(&sequence).as_deref() {
|
||||
let result = if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) {
|
||||
crate::tui::functions::form::handle_action(
|
||||
// Try context-specific actions first, otherwise use canvas dispatch
|
||||
let result = if let Some(context_result) = handle_context_action(
|
||||
action,
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
ideal_cursor_column,
|
||||
).await? {
|
||||
context_result
|
||||
} else {
|
||||
dispatch_to_active_state(
|
||||
action,
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
|
||||
crate::tui::functions::login::handle_action(action).await?
|
||||
} else if app_state.ui.show_add_table {
|
||||
add_table_ro::execute_action(
|
||||
action,
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
add_table_state,
|
||||
ideal_cursor_column,
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
).await?
|
||||
} else if app_state.ui.show_add_logic {
|
||||
add_logic_ro::execute_action(
|
||||
action,
|
||||
app_state,
|
||||
add_logic_state,
|
||||
ideal_cursor_column,
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
).await?
|
||||
} else if app_state.ui.show_register{
|
||||
auth_ro::execute_action(
|
||||
action,
|
||||
app_state,
|
||||
register_state,
|
||||
ideal_cursor_column,
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
).await?
|
||||
} else if app_state.ui.show_login {
|
||||
auth_ro::execute_action(
|
||||
action,
|
||||
app_state,
|
||||
login_state,
|
||||
ideal_cursor_column,
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
form_ro::execute_action(
|
||||
action,
|
||||
form_state,
|
||||
ideal_cursor_column,
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
)
|
||||
.await?
|
||||
).await
|
||||
};
|
||||
key_sequence_tracker.reset();
|
||||
return Ok((false, result));
|
||||
@@ -216,62 +240,26 @@ pub async fn handle_read_only_event(
|
||||
|
||||
if sequence.len() == 1 && !config.is_key_sequence_prefix(&sequence) {
|
||||
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers).as_deref() {
|
||||
let result = if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) {
|
||||
crate::tui::functions::form::handle_action(
|
||||
// Try context-specific actions first, otherwise use canvas dispatch
|
||||
let result = if let Some(context_result) = handle_context_action(
|
||||
action,
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
ideal_cursor_column,
|
||||
).await? {
|
||||
context_result
|
||||
} else {
|
||||
dispatch_to_active_state(
|
||||
action,
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
|
||||
crate::tui::functions::login::handle_action(action).await?
|
||||
} else if app_state.ui.show_add_table {
|
||||
add_table_ro::execute_action(
|
||||
action,
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
add_table_state,
|
||||
ideal_cursor_column,
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
).await?
|
||||
} else if app_state.ui.show_add_logic {
|
||||
add_logic_ro::execute_action(
|
||||
action,
|
||||
app_state,
|
||||
add_logic_state,
|
||||
ideal_cursor_column,
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
).await?
|
||||
} else if app_state.ui.show_register {
|
||||
auth_ro::execute_action(
|
||||
action,
|
||||
app_state,
|
||||
register_state,
|
||||
ideal_cursor_column,
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
).await?
|
||||
} else if app_state.ui.show_login {
|
||||
auth_ro::execute_action(
|
||||
action,
|
||||
app_state,
|
||||
login_state,
|
||||
ideal_cursor_column,
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
form_ro::execute_action(
|
||||
action,
|
||||
form_state,
|
||||
ideal_cursor_column,
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
)
|
||||
.await?
|
||||
).await
|
||||
};
|
||||
key_sequence_tracker.reset();
|
||||
return Ok((false, result));
|
||||
@@ -282,62 +270,26 @@ pub async fn handle_read_only_event(
|
||||
key_sequence_tracker.reset();
|
||||
|
||||
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers).as_deref() {
|
||||
let result = if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) {
|
||||
crate::tui::functions::form::handle_action(
|
||||
// Try context-specific actions first, otherwise use canvas dispatch
|
||||
let result = if let Some(context_result) = handle_context_action(
|
||||
action,
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
ideal_cursor_column,
|
||||
).await? {
|
||||
context_result
|
||||
} else {
|
||||
dispatch_to_active_state(
|
||||
action,
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
|
||||
crate::tui::functions::login::handle_action(action).await?
|
||||
} else if app_state.ui.show_add_table {
|
||||
add_table_ro::execute_action(
|
||||
action,
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
add_table_state,
|
||||
ideal_cursor_column,
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
).await?
|
||||
} else if app_state.ui.show_add_logic {
|
||||
add_logic_ro::execute_action(
|
||||
action,
|
||||
app_state,
|
||||
add_logic_state,
|
||||
ideal_cursor_column,
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
).await?
|
||||
} else if app_state.ui.show_register {
|
||||
auth_ro::execute_action(
|
||||
action,
|
||||
app_state,
|
||||
register_state,
|
||||
ideal_cursor_column,
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
).await?
|
||||
} else if app_state.ui.show_login {
|
||||
auth_ro::execute_action(
|
||||
action,
|
||||
app_state,
|
||||
login_state,
|
||||
ideal_cursor_column,
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
form_ro::execute_action(
|
||||
action,
|
||||
form_state,
|
||||
ideal_cursor_column,
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
)
|
||||
.await?
|
||||
).await
|
||||
};
|
||||
return Ok((false, result));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// src/modes/handlers.rs
|
||||
pub mod event;
|
||||
pub mod event_helper;
|
||||
pub mod mode_manager;
|
||||
|
||||
@@ -15,23 +15,20 @@ use crate::modes::{
|
||||
general::{dialog, navigation},
|
||||
handlers::mode_manager::{AppMode, ModeManager},
|
||||
};
|
||||
use crate::state::pages::canvas_state::CanvasState as LegacyCanvasState;
|
||||
use crate::services::auth::AuthClient;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher};
|
||||
use canvas::canvas::CanvasState as LibraryCanvasState;
|
||||
use super::event_helper::*;
|
||||
use canvas::canvas::CanvasState; // Only need this import now
|
||||
use crate::state::{
|
||||
app::{
|
||||
buffer::{AppView, BufferState},
|
||||
highlight::HighlightState,
|
||||
search::SearchState, // Correctly imported
|
||||
search::SearchState,
|
||||
state::AppState,
|
||||
},
|
||||
pages::{
|
||||
admin::AdminState,
|
||||
auth::{AuthState, LoginState, RegisterState},
|
||||
canvas_state::CanvasState,
|
||||
form::FormState,
|
||||
intro::IntroState,
|
||||
},
|
||||
@@ -48,6 +45,7 @@ use crate::ui::handlers::rat_state::UiStateHandler;
|
||||
use anyhow::Result;
|
||||
use common::proto::komp_ac::search::search_response::Hit;
|
||||
use crossterm::cursor::SetCursorStyle;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
@@ -89,7 +87,6 @@ pub struct EventHandler {
|
||||
pub navigation_state: NavigationState,
|
||||
pub search_result_sender: mpsc::UnboundedSender<Vec<Hit>>,
|
||||
pub search_result_receiver: mpsc::UnboundedReceiver<Vec<Hit>>,
|
||||
// --- ADDED FOR LIVE AUTOCOMPLETE ---
|
||||
pub autocomplete_result_sender: mpsc::UnboundedSender<Vec<Hit>>,
|
||||
pub autocomplete_result_receiver: mpsc::UnboundedReceiver<Vec<Hit>>,
|
||||
}
|
||||
@@ -103,7 +100,7 @@ impl EventHandler {
|
||||
grpc_client: GrpcClient,
|
||||
) -> Result<Self> {
|
||||
let (search_tx, search_rx) = unbounded_channel();
|
||||
let (autocomplete_tx, autocomplete_rx) = unbounded_channel(); // ADDED
|
||||
let (autocomplete_tx, autocomplete_rx) = unbounded_channel();
|
||||
Ok(EventHandler {
|
||||
command_mode: false,
|
||||
command_input: String::new(),
|
||||
@@ -122,7 +119,6 @@ impl EventHandler {
|
||||
navigation_state: NavigationState::new(),
|
||||
search_result_sender: search_tx,
|
||||
search_result_receiver: search_rx,
|
||||
// --- ADDED ---
|
||||
autocomplete_result_sender: autocomplete_tx,
|
||||
autocomplete_result_receiver: autocomplete_rx,
|
||||
})
|
||||
@@ -136,6 +132,95 @@ impl EventHandler {
|
||||
self.navigation_state.activate_find_file(options);
|
||||
}
|
||||
|
||||
// Helper functions - replace the removed event_helper functions
|
||||
fn get_current_field_for_state(
|
||||
app_state: &AppState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
form_state: &FormState,
|
||||
) -> usize {
|
||||
if app_state.ui.show_login {
|
||||
login_state.current_field()
|
||||
} else if app_state.ui.show_register {
|
||||
register_state.current_field()
|
||||
} else {
|
||||
form_state.current_field()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_cursor_pos_for_state(
|
||||
app_state: &AppState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
form_state: &FormState,
|
||||
) -> usize {
|
||||
if app_state.ui.show_login {
|
||||
login_state.current_cursor_pos()
|
||||
} else if app_state.ui.show_register {
|
||||
register_state.current_cursor_pos()
|
||||
} else {
|
||||
form_state.current_cursor_pos()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_has_unsaved_changes_for_state(
|
||||
app_state: &AppState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
form_state: &FormState,
|
||||
) -> bool {
|
||||
if app_state.ui.show_login {
|
||||
login_state.has_unsaved_changes()
|
||||
} else if app_state.ui.show_register {
|
||||
register_state.has_unsaved_changes()
|
||||
} else {
|
||||
form_state.has_unsaved_changes()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_input_for_state<'a>(
|
||||
app_state: &AppState,
|
||||
login_state: &'a LoginState,
|
||||
register_state: &'a RegisterState,
|
||||
form_state: &'a FormState,
|
||||
) -> &'a str {
|
||||
if app_state.ui.show_login {
|
||||
login_state.get_current_input()
|
||||
} else if app_state.ui.show_register {
|
||||
register_state.get_current_input()
|
||||
} else {
|
||||
form_state.get_current_input()
|
||||
}
|
||||
}
|
||||
|
||||
fn set_current_cursor_pos_for_state(
|
||||
app_state: &AppState,
|
||||
login_state: &mut LoginState,
|
||||
register_state: &mut RegisterState,
|
||||
form_state: &mut FormState,
|
||||
pos: usize,
|
||||
) {
|
||||
if app_state.ui.show_login {
|
||||
login_state.set_current_cursor_pos(pos);
|
||||
} else if app_state.ui.show_register {
|
||||
register_state.set_current_cursor_pos(pos);
|
||||
} else {
|
||||
form_state.set_current_cursor_pos(pos);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_cursor_pos_for_mixed_state(
|
||||
app_state: &AppState,
|
||||
login_state: &LoginState,
|
||||
form_state: &FormState,
|
||||
) -> usize {
|
||||
if app_state.ui.show_login || app_state.ui.show_register {
|
||||
login_state.current_cursor_pos()
|
||||
} else {
|
||||
form_state.current_cursor_pos()
|
||||
}
|
||||
}
|
||||
|
||||
// This function handles state changes.
|
||||
async fn handle_search_palette_event(
|
||||
&mut self,
|
||||
@@ -199,7 +284,6 @@ impl EventHandler {
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// --- START CORRECTED LOGIC ---
|
||||
if trigger_search {
|
||||
search_state.is_loading = true;
|
||||
search_state.results.clear();
|
||||
@@ -214,7 +298,6 @@ impl EventHandler {
|
||||
"--- 1. Spawning search task for query: '{}' ---",
|
||||
query
|
||||
);
|
||||
// We now move the grpc_client into the task, just like with login.
|
||||
tokio::spawn(async move {
|
||||
info!("--- 2. Background task started. ---");
|
||||
match grpc_client.search_table(table_name, query).await {
|
||||
@@ -226,7 +309,6 @@ impl EventHandler {
|
||||
let _ = sender.send(response.hits);
|
||||
}
|
||||
Err(e) => {
|
||||
// THE FIX: Use the debug formatter `{:?}` to print the full error chain.
|
||||
error!("--- 3b. gRPC call failed: {:?} ---", e);
|
||||
let _ = sender.send(vec![]);
|
||||
}
|
||||
@@ -235,8 +317,6 @@ impl EventHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// The borrow on `app_state.search_state` ends here.
|
||||
// Now we can safely modify the Option itself.
|
||||
if should_close {
|
||||
app_state.search_state = None;
|
||||
app_state.ui.show_search_palette = false;
|
||||
@@ -264,7 +344,6 @@ impl EventHandler {
|
||||
) -> Result<EventOutcome> {
|
||||
if app_state.ui.show_search_palette {
|
||||
if let Event::Key(key_event) = event {
|
||||
// The call no longer passes grpc_client
|
||||
return self
|
||||
.handle_search_palette_event(
|
||||
key_event,
|
||||
@@ -581,7 +660,7 @@ impl EventHandler {
|
||||
if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise")
|
||||
&& ModeManager::can_enter_highlight_mode(current_mode)
|
||||
{
|
||||
let current_field_index = get_current_field_for_state(
|
||||
let current_field_index = Self::get_current_field_for_state(
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
@@ -596,13 +675,13 @@ impl EventHandler {
|
||||
else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode")
|
||||
&& ModeManager::can_enter_highlight_mode(current_mode)
|
||||
{
|
||||
let current_field_index = get_current_field_for_state(
|
||||
let current_field_index = Self::get_current_field_for_state(
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
form_state
|
||||
);
|
||||
let current_cursor_pos = get_current_cursor_pos_for_state(
|
||||
let current_cursor_pos = Self::get_current_cursor_pos_for_state(
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
@@ -627,13 +706,13 @@ impl EventHandler {
|
||||
else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_after")
|
||||
&& ModeManager::can_enter_edit_mode(current_mode)
|
||||
{
|
||||
let current_input = get_current_input_for_state(
|
||||
let current_input = Self::get_current_input_for_state(
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
form_state
|
||||
);
|
||||
let current_cursor_pos = get_cursor_pos_for_mixed_state(
|
||||
let current_cursor_pos = Self::get_cursor_pos_for_mixed_state(
|
||||
app_state,
|
||||
login_state,
|
||||
form_state
|
||||
@@ -642,14 +721,14 @@ impl EventHandler {
|
||||
// Move cursor forward if possible
|
||||
if !current_input.is_empty() && current_cursor_pos < current_input.len() {
|
||||
let new_cursor_pos = current_cursor_pos + 1;
|
||||
set_current_cursor_pos_for_state(
|
||||
Self::set_current_cursor_pos_for_state(
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
form_state,
|
||||
new_cursor_pos
|
||||
);
|
||||
self.ideal_cursor_column = get_current_cursor_pos_for_state(
|
||||
self.ideal_cursor_column = Self::get_current_cursor_pos_for_state(
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
@@ -694,13 +773,12 @@ impl EventHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Try canvas action for form first (NEW: Canvas library integration)
|
||||
// Try canvas action for form first
|
||||
if app_state.ui.show_form {
|
||||
if let Ok(Some(canvas_message)) = self.handle_form_canvas_action(
|
||||
key_event,
|
||||
config,
|
||||
form_state,
|
||||
false, // not edit mode
|
||||
false,
|
||||
).await {
|
||||
return Ok(EventOutcome::Ok(canvas_message));
|
||||
}
|
||||
@@ -753,7 +831,7 @@ impl EventHandler {
|
||||
&mut admin_state.add_table_state,
|
||||
&mut admin_state.add_logic_state,
|
||||
&mut self.key_sequence_tracker,
|
||||
&mut self.grpc_client, // <-- FIX 2
|
||||
&mut self.grpc_client,
|
||||
&mut self.command_message,
|
||||
&mut self.edit_mode_cooldown,
|
||||
&mut self.ideal_cursor_column,
|
||||
@@ -784,13 +862,12 @@ impl EventHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Try canvas action for form first (NEW: Canvas library integration)
|
||||
// Try canvas action for form first
|
||||
if app_state.ui.show_form {
|
||||
if let Ok(Some(canvas_message)) = self.handle_form_canvas_action(
|
||||
key_event,
|
||||
config,
|
||||
form_state,
|
||||
true, // edit mode
|
||||
true,
|
||||
).await {
|
||||
if !canvas_message.is_empty() {
|
||||
self.command_message = canvas_message.clone();
|
||||
@@ -823,7 +900,7 @@ impl EventHandler {
|
||||
self.edit_mode_cooldown = true;
|
||||
|
||||
// Check for unsaved changes across all states
|
||||
let has_changes = get_has_unsaved_changes_for_state(
|
||||
let has_changes = Self::get_has_unsaved_changes_for_state(
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
@@ -840,13 +917,13 @@ impl EventHandler {
|
||||
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
|
||||
|
||||
// Get current input and cursor position
|
||||
let current_input = get_current_input_for_state(
|
||||
let current_input = Self::get_current_input_for_state(
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
form_state
|
||||
);
|
||||
let current_cursor_pos = get_current_cursor_pos_for_state(
|
||||
let current_cursor_pos = Self::get_current_cursor_pos_for_state(
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
@@ -856,7 +933,7 @@ impl EventHandler {
|
||||
// Adjust cursor if it's beyond the input length
|
||||
if !current_input.is_empty() && current_cursor_pos >= current_input.len() {
|
||||
let new_pos = current_input.len() - 1;
|
||||
set_current_cursor_pos_for_state(
|
||||
Self::set_current_cursor_pos_for_state(
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
@@ -906,7 +983,7 @@ impl EventHandler {
|
||||
form_state,
|
||||
&mut self.command_input,
|
||||
&mut self.command_message,
|
||||
&mut self.grpc_client, // <-- FIX 5
|
||||
&mut self.grpc_client,
|
||||
command_handler,
|
||||
terminal,
|
||||
&mut current_position,
|
||||
@@ -1024,80 +1101,49 @@ impl EventHandler {
|
||||
async fn handle_form_canvas_action(
|
||||
&mut self,
|
||||
key_event: KeyEvent,
|
||||
_config: &Config, // Not used anymore - canvas has its own config
|
||||
form_state: &mut FormState,
|
||||
is_edit_mode: bool,
|
||||
) -> Result<Option<String>> {
|
||||
// Load canvas config (canvas_config.toml or vim defaults)
|
||||
let canvas_config = canvas::config::CanvasConfig::load();
|
||||
|
||||
// Handle suggestion actions first if suggestions are active
|
||||
if form_state.autocomplete_active {
|
||||
if let Some(action_str) = canvas_config.get_suggestion_action(key_event.code, key_event.modifiers) {
|
||||
let canvas_action = CanvasAction::from_string(&action_str);
|
||||
match ActionDispatcher::dispatch(canvas_action, form_state, &mut self.ideal_cursor_column).await {
|
||||
Ok(result) => return Ok(Some(result.message().unwrap_or("").to_string())),
|
||||
Err(_) => return Ok(Some("Suggestion action failed".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback hardcoded suggestion handling
|
||||
match key_event.code {
|
||||
KeyCode::Up => {
|
||||
if let Ok(result) = ActionDispatcher::dispatch(
|
||||
CanvasAction::SuggestionUp,
|
||||
// PRIORITY 1: Handle character insertion in edit mode FIRST
|
||||
if is_edit_mode {
|
||||
if let KeyCode::Char(c) = key_event.code {
|
||||
// Only insert if it's not a special modifier combination
|
||||
if key_event.modifiers.is_empty() || key_event.modifiers == KeyModifiers::SHIFT {
|
||||
let canvas_action = CanvasAction::InsertChar(c);
|
||||
match ActionDispatcher::dispatch(
|
||||
canvas_action,
|
||||
form_state,
|
||||
&mut self.ideal_cursor_column,
|
||||
).await {
|
||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
||||
Ok(result) => {
|
||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
||||
}
|
||||
Err(_) => {
|
||||
return Ok(Some("Character insertion failed".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if let Ok(result) = ActionDispatcher::dispatch(
|
||||
CanvasAction::SuggestionDown,
|
||||
form_state,
|
||||
&mut self.ideal_cursor_column,
|
||||
).await {
|
||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Ok(result) = ActionDispatcher::dispatch(
|
||||
CanvasAction::SelectSuggestion,
|
||||
form_state,
|
||||
&mut self.ideal_cursor_column,
|
||||
).await {
|
||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
||||
}
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
if let Ok(result) = ActionDispatcher::dispatch(
|
||||
CanvasAction::ExitSuggestions,
|
||||
form_state,
|
||||
&mut self.ideal_cursor_column,
|
||||
).await {
|
||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// FIXED: Use canvas config instead of client config
|
||||
// PRIORITY 2: Handle config-mapped actions for non-character keys
|
||||
let action_str = canvas_config.get_action_for_key(
|
||||
key_event.code,
|
||||
key_event.modifiers,
|
||||
is_edit_mode,
|
||||
form_state.autocomplete_active
|
||||
form_state.autocomplete_active,
|
||||
);
|
||||
|
||||
if let Some(action_str) = action_str {
|
||||
// Filter out mode transition actions - let legacy handlers deal with these
|
||||
// Skip mode transition actions - let the main event handler deal with them
|
||||
if Self::is_mode_transition_action(action_str) {
|
||||
return Ok(None); // Let legacy handler handle mode transitions
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let canvas_action = CanvasAction::from_string(&action_str);
|
||||
// Execute the config-mapped action
|
||||
let canvas_action = CanvasAction::from_string(action_str);
|
||||
match ActionDispatcher::dispatch(
|
||||
canvas_action,
|
||||
form_state,
|
||||
@@ -1112,59 +1158,10 @@ impl EventHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to automatic key handling for edit mode
|
||||
if is_edit_mode {
|
||||
if let Some(canvas_action) = CanvasAction::from_key(key_event.code) {
|
||||
match ActionDispatcher::dispatch(
|
||||
canvas_action,
|
||||
form_state,
|
||||
&mut self.ideal_cursor_column,
|
||||
).await {
|
||||
Ok(result) => {
|
||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
||||
}
|
||||
Err(_) => {
|
||||
return Ok(Some("Auto action failed".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// In read-only mode, only handle non-character keys
|
||||
let canvas_action = match key_event.code {
|
||||
// Only handle special keys that don't conflict with vim bindings
|
||||
KeyCode::Left => Some(CanvasAction::MoveLeft),
|
||||
KeyCode::Right => Some(CanvasAction::MoveRight),
|
||||
KeyCode::Up => Some(CanvasAction::MoveUp),
|
||||
KeyCode::Down => Some(CanvasAction::MoveDown),
|
||||
KeyCode::Home => Some(CanvasAction::MoveLineStart),
|
||||
KeyCode::End => Some(CanvasAction::MoveLineEnd),
|
||||
KeyCode::Tab => Some(CanvasAction::NextField),
|
||||
KeyCode::BackTab => Some(CanvasAction::PrevField),
|
||||
KeyCode::Delete => Some(CanvasAction::DeleteForward),
|
||||
KeyCode::Backspace => Some(CanvasAction::DeleteBackward),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(canvas_action) = canvas_action {
|
||||
match ActionDispatcher::dispatch(
|
||||
canvas_action,
|
||||
form_state,
|
||||
&mut self.ideal_cursor_column,
|
||||
).await {
|
||||
Ok(result) => {
|
||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
||||
}
|
||||
Err(_) => {
|
||||
return Ok(Some("Action failed".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No action found
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
// ADDED: Helper function to identify mode transition actions
|
||||
fn is_mode_transition_action(action: &str) -> bool {
|
||||
matches!(action,
|
||||
"exit" |
|
||||
@@ -1181,11 +1178,11 @@ impl EventHandler {
|
||||
"force_quit" |
|
||||
"save_and_quit" |
|
||||
"revert" |
|
||||
"enter_decider" | // This is also handled specially by legacy system
|
||||
"trigger_autocomplete" | // This is handled specially by legacy system
|
||||
"suggestion_up" | // These are handled above in suggestion logic
|
||||
"enter_decider" |
|
||||
"trigger_autocomplete" |
|
||||
"suggestion_up" |
|
||||
"suggestion_down" |
|
||||
"previous_entry" | // Navigation between records
|
||||
"previous_entry" |
|
||||
"next_entry" |
|
||||
"toggle_sidebar" |
|
||||
"toggle_buffer_list" |
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
|
||||
// src/modes/handlers/event_helper.rs
|
||||
//! Helper functions to handle the differences between legacy and library CanvasState traits
|
||||
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::{
|
||||
form::FormState,
|
||||
auth::{LoginState, RegisterState},
|
||||
};
|
||||
use crate::state::pages::canvas_state::CanvasState as LegacyCanvasState;
|
||||
use canvas::canvas::CanvasState as LibraryCanvasState;
|
||||
|
||||
/// Get the current field index from the appropriate state based on which UI is active
|
||||
pub fn get_current_field_for_state(
|
||||
app_state: &AppState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
form_state: &FormState,
|
||||
) -> usize {
|
||||
if app_state.ui.show_login {
|
||||
login_state.current_field() // Uses LegacyCanvasState
|
||||
} else if app_state.ui.show_register {
|
||||
register_state.current_field() // Uses LegacyCanvasState
|
||||
} else {
|
||||
form_state.current_field() // Uses LibraryCanvasState
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current cursor position from the appropriate state based on which UI is active
|
||||
pub fn get_current_cursor_pos_for_state(
|
||||
app_state: &AppState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
form_state: &FormState,
|
||||
) -> usize {
|
||||
if app_state.ui.show_login {
|
||||
login_state.current_cursor_pos() // Uses LegacyCanvasState
|
||||
} else if app_state.ui.show_register {
|
||||
register_state.current_cursor_pos() // Uses LegacyCanvasState
|
||||
} else {
|
||||
form_state.current_cursor_pos() // Uses LibraryCanvasState
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the appropriate state has unsaved changes based on which UI is active
|
||||
pub fn get_has_unsaved_changes_for_state(
|
||||
app_state: &AppState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
form_state: &FormState,
|
||||
) -> bool {
|
||||
if app_state.ui.show_login {
|
||||
login_state.has_unsaved_changes() // Uses LegacyCanvasState
|
||||
} else if app_state.ui.show_register {
|
||||
register_state.has_unsaved_changes() // Uses LegacyCanvasState
|
||||
} else {
|
||||
form_state.has_unsaved_changes() // Uses LibraryCanvasState
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current input from the appropriate state based on which UI is active
|
||||
pub fn get_current_input_for_state<'a>(
|
||||
app_state: &AppState,
|
||||
login_state: &'a LoginState,
|
||||
register_state: &'a RegisterState,
|
||||
form_state: &'a FormState,
|
||||
) -> &'a str {
|
||||
if app_state.ui.show_login {
|
||||
login_state.get_current_input() // Uses LegacyCanvasState
|
||||
} else if app_state.ui.show_register {
|
||||
register_state.get_current_input() // Uses LegacyCanvasState
|
||||
} else {
|
||||
form_state.get_current_input() // Uses LibraryCanvasState
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the cursor position for the appropriate state based on which UI is active
|
||||
pub fn set_current_cursor_pos_for_state(
|
||||
app_state: &AppState,
|
||||
login_state: &mut LoginState,
|
||||
register_state: &mut RegisterState,
|
||||
form_state: &mut FormState,
|
||||
pos: usize,
|
||||
) {
|
||||
if app_state.ui.show_login {
|
||||
login_state.set_current_cursor_pos(pos); // Uses LegacyCanvasState
|
||||
} else if app_state.ui.show_register {
|
||||
register_state.set_current_cursor_pos(pos); // Uses LegacyCanvasState
|
||||
} else {
|
||||
form_state.set_current_cursor_pos(pos); // Uses LibraryCanvasState
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cursor position for mixed login/register vs form logic
|
||||
pub fn get_cursor_pos_for_mixed_state(
|
||||
app_state: &AppState,
|
||||
login_state: &LoginState,
|
||||
form_state: &FormState,
|
||||
) -> usize {
|
||||
if app_state.ui.show_login || app_state.ui.show_register {
|
||||
login_state.current_cursor_pos() // Uses LegacyCanvasState
|
||||
} else {
|
||||
form_state.current_cursor_pos() // Uses LibraryCanvasState
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,3 @@ pub mod admin;
|
||||
pub mod intro;
|
||||
pub mod add_table;
|
||||
pub mod add_logic;
|
||||
pub mod canvas_state;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/state/pages/add_logic.rs
|
||||
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use canvas::canvas::{CanvasState, ActionContext, CanvasAction, AppMode};
|
||||
use crate::components::common::text_editor::{TextEditor, VimState};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
@@ -50,10 +50,11 @@ pub struct AddLogicState {
|
||||
pub script_editor_trigger_position: Option<(usize, usize)>, // (line, column)
|
||||
pub all_table_names: Vec<String>,
|
||||
pub script_editor_filter_text: String,
|
||||
|
||||
|
||||
// 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,
|
||||
}
|
||||
|
||||
impl AddLogicState {
|
||||
@@ -88,9 +89,10 @@ impl AddLogicState {
|
||||
script_editor_trigger_position: None,
|
||||
all_table_names: Vec::new(),
|
||||
script_editor_filter_text: String::new(),
|
||||
|
||||
|
||||
same_profile_table_names: Vec::new(),
|
||||
script_editor_awaiting_column_autocomplete: None,
|
||||
app_mode: AppMode::Edit,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +183,7 @@ impl AddLogicState {
|
||||
}
|
||||
self.same_profile_table_names.contains(&suggestion.to_string())
|
||||
}
|
||||
|
||||
|
||||
/// Sets table columns for autocomplete suggestions
|
||||
pub fn set_table_columns(&mut self, columns: Vec<String>) {
|
||||
self.table_columns_for_suggestions = columns.clone();
|
||||
@@ -225,67 +227,68 @@ impl AddLogicState {
|
||||
self.script_editor_trigger_position = None;
|
||||
self.script_editor_filter_text.clear();
|
||||
}
|
||||
|
||||
/// Helper method to validate and save logic
|
||||
pub fn save_logic(&mut self) -> Option<String> {
|
||||
if self.logic_name_input.trim().is_empty() {
|
||||
return Some("Logic name is required".to_string());
|
||||
}
|
||||
|
||||
if self.target_column_input.trim().is_empty() {
|
||||
return Some("Target column is required".to_string());
|
||||
}
|
||||
|
||||
let script_content = {
|
||||
let editor_borrow = self.script_content_editor.borrow();
|
||||
editor_borrow.lines().join("\n")
|
||||
};
|
||||
|
||||
if script_content.trim().is_empty() {
|
||||
return Some("Script content is required".to_string());
|
||||
}
|
||||
|
||||
// Here you would typically save to database/storage
|
||||
// For now, just clear the form and mark as saved
|
||||
self.has_unsaved_changes = false;
|
||||
Some(format!("Logic '{}' saved successfully", self.logic_name_input.trim()))
|
||||
}
|
||||
|
||||
/// Helper method to clear the form
|
||||
pub fn clear_form(&mut self) -> Option<String> {
|
||||
let profile = self.profile_name.clone();
|
||||
let table_id = self.selected_table_id;
|
||||
let table_name = self.selected_table_name.clone();
|
||||
let editor_config = EditorConfig::default(); // You might want to preserve the actual config
|
||||
|
||||
*self = Self::new(&editor_config);
|
||||
self.profile_name = profile;
|
||||
self.selected_table_id = table_id;
|
||||
self.selected_table_name = table_name;
|
||||
|
||||
Some("Form cleared".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AddLogicState {
|
||||
fn default() -> Self {
|
||||
Self::new(&EditorConfig::default())
|
||||
let mut state = Self::new(&EditorConfig::default());
|
||||
state.app_mode = AppMode::Edit;
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
// Implement external library's CanvasState for AddLogicState
|
||||
impl CanvasState for AddLogicState {
|
||||
fn current_field(&self) -> usize {
|
||||
match self.current_focus {
|
||||
AddLogicFocus::InputLogicName => 0,
|
||||
AddLogicFocus::InputTargetColumn => 1,
|
||||
AddLogicFocus::InputDescription => 2,
|
||||
// If focus is elsewhere, return the last canvas field used
|
||||
_ => self.last_canvas_field,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_cursor_pos(&self) -> usize {
|
||||
match self.current_focus {
|
||||
AddLogicFocus::InputLogicName => self.logic_name_cursor_pos,
|
||||
AddLogicFocus::InputTargetColumn => self.target_column_cursor_pos,
|
||||
AddLogicFocus::InputDescription => self.description_cursor_pos,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_unsaved_changes(&self) -> bool {
|
||||
self.has_unsaved_changes
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> {
|
||||
vec![
|
||||
&self.logic_name_input,
|
||||
&self.target_column_input,
|
||||
&self.description_input,
|
||||
]
|
||||
}
|
||||
|
||||
fn get_current_input(&self) -> &str {
|
||||
match self.current_focus {
|
||||
AddLogicFocus::InputLogicName => &self.logic_name_input,
|
||||
AddLogicFocus::InputTargetColumn => &self.target_column_input,
|
||||
AddLogicFocus::InputDescription => &self.description_input,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_input_mut(&mut self) -> &mut String {
|
||||
match self.current_focus {
|
||||
AddLogicFocus::InputLogicName => &mut self.logic_name_input,
|
||||
AddLogicFocus::InputTargetColumn => &mut self.target_column_input,
|
||||
AddLogicFocus::InputDescription => &mut self.description_input,
|
||||
_ => &mut self.logic_name_input,
|
||||
}
|
||||
}
|
||||
|
||||
fn fields(&self) -> Vec<&str> {
|
||||
vec!["Logic Name", "Target Column", "Description"]
|
||||
}
|
||||
|
||||
fn set_current_field(&mut self, index: usize) {
|
||||
let new_focus = match index {
|
||||
0 => AddLogicFocus::InputLogicName,
|
||||
@@ -303,6 +306,15 @@ impl CanvasState for AddLogicState {
|
||||
}
|
||||
}
|
||||
|
||||
fn current_cursor_pos(&self) -> usize {
|
||||
match self.current_focus {
|
||||
AddLogicFocus::InputLogicName => self.logic_name_cursor_pos,
|
||||
AddLogicFocus::InputTargetColumn => self.target_column_cursor_pos,
|
||||
AddLogicFocus::InputDescription => self.description_cursor_pos,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||
match self.current_focus {
|
||||
AddLogicFocus::InputLogicName => {
|
||||
@@ -318,29 +330,121 @@ impl CanvasState for AddLogicState {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_input(&self) -> &str {
|
||||
match self.current_focus {
|
||||
AddLogicFocus::InputLogicName => &self.logic_name_input,
|
||||
AddLogicFocus::InputTargetColumn => &self.target_column_input,
|
||||
AddLogicFocus::InputDescription => &self.description_input,
|
||||
_ => "", // Should not happen if called correctly
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_input_mut(&mut self) -> &mut String {
|
||||
match self.current_focus {
|
||||
AddLogicFocus::InputLogicName => &mut self.logic_name_input,
|
||||
AddLogicFocus::InputTargetColumn => &mut self.target_column_input,
|
||||
AddLogicFocus::InputDescription => &mut self.description_input,
|
||||
_ => &mut self.logic_name_input, // Fallback
|
||||
}
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> {
|
||||
vec![
|
||||
&self.logic_name_input,
|
||||
&self.target_column_input,
|
||||
&self.description_input,
|
||||
]
|
||||
}
|
||||
|
||||
fn fields(&self) -> Vec<&str> {
|
||||
vec!["Logic Name", "Target Column", "Description"]
|
||||
}
|
||||
|
||||
fn has_unsaved_changes(&self) -> bool {
|
||||
self.has_unsaved_changes
|
||||
}
|
||||
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
||||
self.has_unsaved_changes = changed;
|
||||
}
|
||||
|
||||
fn get_suggestions(&self) -> Option<&[String]> {
|
||||
if self.current_field() == 1
|
||||
&& self.in_target_column_suggestion_mode
|
||||
&& self.show_target_column_suggestions
|
||||
{
|
||||
Some(&self.target_column_suggestions)
|
||||
} else {
|
||||
None
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
// Handle saving logic script
|
||||
CanvasAction::Custom(action_str) if action_str == "save_logic" => {
|
||||
self.save_logic()
|
||||
}
|
||||
|
||||
// Handle clearing the form
|
||||
CanvasAction::Custom(action_str) if action_str == "clear_form" => {
|
||||
self.clear_form()
|
||||
}
|
||||
|
||||
// Handle target column autocomplete activation
|
||||
CanvasAction::Custom(action_str) if action_str == "activate_autocomplete" => {
|
||||
if self.current_field() == 1 { // Target Column field
|
||||
self.in_target_column_suggestion_mode = true;
|
||||
self.update_target_column_suggestions();
|
||||
Some("Autocomplete activated".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// Handle target column suggestion selection
|
||||
CanvasAction::Custom(action_str) if action_str == "select_suggestion" => {
|
||||
if self.current_field() == 1 && self.in_target_column_suggestion_mode {
|
||||
if let Some(selected_idx) = self.selected_target_column_suggestion_index {
|
||||
if let Some(suggestion) = self.target_column_suggestions.get(selected_idx) {
|
||||
self.target_column_input = suggestion.clone();
|
||||
self.target_column_cursor_pos = suggestion.len();
|
||||
self.in_target_column_suggestion_mode = false;
|
||||
self.show_target_column_suggestions = false;
|
||||
self.has_unsaved_changes = true;
|
||||
return Some(format!("Selected: {}", suggestion));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// Custom validation when moving between fields
|
||||
CanvasAction::NextField => {
|
||||
match self.current_field() {
|
||||
0 => { // Logic Name field
|
||||
if self.logic_name_input.trim().is_empty() {
|
||||
Some("Logic name cannot be empty".to_string())
|
||||
} else {
|
||||
None // Let canvas library handle the normal field movement
|
||||
}
|
||||
}
|
||||
1 => { // Target Column field
|
||||
// Update suggestions when entering target column field
|
||||
self.update_target_column_suggestions();
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle character insertion with validation
|
||||
CanvasAction::InsertChar(c) => {
|
||||
if self.current_field() == 1 { // Target Column field
|
||||
// Update suggestions after character insertion
|
||||
// Note: Canvas library will handle the actual insertion
|
||||
// This is just for triggering suggestion updates
|
||||
None // Let canvas handle insertion, then we'll update suggestions
|
||||
} else {
|
||||
None // Let canvas handle normally
|
||||
}
|
||||
}
|
||||
|
||||
// Let canvas library handle everything else
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
||||
if self.current_field() == 1
|
||||
&& self.in_target_column_suggestion_mode
|
||||
&& self.show_target_column_suggestions
|
||||
{
|
||||
self.selected_target_column_suggestion_index
|
||||
} else {
|
||||
None
|
||||
}
|
||||
fn current_mode(&self) -> AppMode {
|
||||
self.app_mode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/state/pages/add_table.rs
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use canvas::canvas::{CanvasState, ActionContext, CanvasAction, AppMode};
|
||||
use ratatui::widgets::TableState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -63,11 +63,11 @@ pub struct AddTableState {
|
||||
pub column_name_cursor_pos: usize,
|
||||
pub column_type_cursor_pos: usize,
|
||||
pub has_unsaved_changes: bool,
|
||||
pub app_mode: AppMode,
|
||||
}
|
||||
|
||||
impl Default for AddTableState {
|
||||
fn default() -> Self {
|
||||
// Initialize with some dummy data for demonstration
|
||||
AddTableState {
|
||||
profile_name: "default".to_string(),
|
||||
table_name: String::new(),
|
||||
@@ -86,22 +86,98 @@ impl Default for AddTableState {
|
||||
column_name_cursor_pos: 0,
|
||||
column_type_cursor_pos: 0,
|
||||
has_unsaved_changes: false,
|
||||
app_mode: AppMode::Edit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AddTableState {
|
||||
pub const INPUT_FIELD_COUNT: usize = 3;
|
||||
|
||||
/// 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());
|
||||
}
|
||||
|
||||
// Check for duplicate column names
|
||||
if self.columns.iter().any(|col| col.name == self.column_name_input.trim()) {
|
||||
return Some("Column name already exists".to_string());
|
||||
}
|
||||
|
||||
// Add the column
|
||||
self.columns.push(ColumnDefinition {
|
||||
name: self.column_name_input.trim().to_string(),
|
||||
data_type: self.column_type_input.trim().to_string(),
|
||||
selected: false,
|
||||
});
|
||||
|
||||
// Clear inputs and reset focus to column name for next entry
|
||||
self.column_name_input.clear();
|
||||
self.column_type_input.clear();
|
||||
self.column_name_cursor_pos = 0;
|
||||
self.column_type_cursor_pos = 0;
|
||||
self.current_focus = AddTableFocus::InputColumnName;
|
||||
self.last_canvas_field = 1;
|
||||
self.has_unsaved_changes = true;
|
||||
|
||||
Some(format!("Column '{}' added successfully", self.columns.last().unwrap().name))
|
||||
}
|
||||
|
||||
/// Helper method to delete selected items
|
||||
pub fn delete_selected_items(&mut self) -> Option<String> {
|
||||
let mut deleted_items = 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
|
||||
}
|
||||
});
|
||||
|
||||
// Remove selected indexes
|
||||
let initial_index_count = self.indexes.len();
|
||||
self.indexes.retain(|idx| {
|
||||
if idx.selected {
|
||||
deleted_items.push(format!("index '{}'", idx.name));
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
|
||||
// Remove selected links
|
||||
let initial_link_count = self.links.len();
|
||||
self.links.retain(|link| {
|
||||
if link.selected {
|
||||
deleted_items.push(format!("link to '{}'", link.linked_table_name));
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
|
||||
if deleted_items.is_empty() {
|
||||
Some("No items selected for deletion".to_string())
|
||||
} else {
|
||||
self.has_unsaved_changes = true;
|
||||
Some(format!("Deleted: {}", deleted_items.join(", ")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Implement CanvasState for the input fields
|
||||
// Implement external library's CanvasState for AddTableState
|
||||
impl CanvasState for AddTableState {
|
||||
fn current_field(&self) -> usize {
|
||||
match self.current_focus {
|
||||
AddTableFocus::InputTableName => 0,
|
||||
AddTableFocus::InputColumnName => 1,
|
||||
AddTableFocus::InputColumnType => 2,
|
||||
// If focus is elsewhere, default to the first field for canvas rendering logic
|
||||
// If focus is elsewhere, return the last canvas field used
|
||||
_ => self.last_canvas_field,
|
||||
}
|
||||
}
|
||||
@@ -115,37 +191,6 @@ impl CanvasState for AddTableState {
|
||||
}
|
||||
}
|
||||
|
||||
fn has_unsaved_changes(&self) -> bool {
|
||||
self.has_unsaved_changes
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> {
|
||||
vec![&self.table_name_input, &self.column_name_input, &self.column_type_input]
|
||||
}
|
||||
|
||||
fn get_current_input(&self) -> &str {
|
||||
match self.current_focus {
|
||||
AddTableFocus::InputTableName => &self.table_name_input,
|
||||
AddTableFocus::InputColumnName => &self.column_name_input,
|
||||
AddTableFocus::InputColumnType => &self.column_type_input,
|
||||
_ => "", // Should not happen if called correctly
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_input_mut(&mut self) -> &mut String {
|
||||
match self.current_focus {
|
||||
AddTableFocus::InputTableName => &mut self.table_name_input,
|
||||
AddTableFocus::InputColumnName => &mut self.column_name_input,
|
||||
AddTableFocus::InputColumnType => &mut self.column_type_input,
|
||||
_ => &mut self.table_name_input,
|
||||
}
|
||||
}
|
||||
|
||||
fn fields(&self) -> Vec<&str> {
|
||||
// These must match the order used in render_add_table
|
||||
vec!["Table name", "Name", "Type"]
|
||||
}
|
||||
|
||||
fn set_current_field(&mut self, index: usize) {
|
||||
// Update both current focus and last canvas field
|
||||
self.current_focus = match index {
|
||||
@@ -174,17 +219,88 @@ impl CanvasState for AddTableState {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_input(&self) -> &str {
|
||||
match self.current_focus {
|
||||
AddTableFocus::InputTableName => &self.table_name_input,
|
||||
AddTableFocus::InputColumnName => &self.column_name_input,
|
||||
AddTableFocus::InputColumnType => &self.column_type_input,
|
||||
_ => "", // Should not happen if called correctly
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_input_mut(&mut self) -> &mut String {
|
||||
match self.current_focus {
|
||||
AddTableFocus::InputTableName => &mut self.table_name_input,
|
||||
AddTableFocus::InputColumnName => &mut self.column_name_input,
|
||||
AddTableFocus::InputColumnType => &mut self.column_type_input,
|
||||
_ => &mut self.table_name_input, // Fallback
|
||||
}
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> {
|
||||
vec![&self.table_name_input, &self.column_name_input, &self.column_type_input]
|
||||
}
|
||||
|
||||
fn fields(&self) -> Vec<&str> {
|
||||
// These must match the order used in render_add_table
|
||||
vec!["Table name", "Name", "Type"]
|
||||
}
|
||||
|
||||
fn has_unsaved_changes(&self) -> bool {
|
||||
self.has_unsaved_changes
|
||||
}
|
||||
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
||||
self.has_unsaved_changes = changed;
|
||||
}
|
||||
|
||||
// --- Autocomplete Support (Not needed for this form yet) ---
|
||||
fn get_suggestions(&self) -> Option<&[String]> {
|
||||
None
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
// Handle adding column when user presses Enter on the Add button or uses specific action
|
||||
CanvasAction::Custom(action_str) if action_str == "add_column" => {
|
||||
self.add_column_from_inputs()
|
||||
}
|
||||
|
||||
// Handle table saving
|
||||
CanvasAction::Custom(action_str) if action_str == "save_table" => {
|
||||
if self.table_name_input.trim().is_empty() {
|
||||
Some("Table name is required".to_string())
|
||||
} else if self.columns.is_empty() {
|
||||
Some("At least one column is required".to_string())
|
||||
} else {
|
||||
Some(format!("Saving table: {}", self.table_name_input))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle deleting selected items
|
||||
CanvasAction::Custom(action_str) if action_str == "delete_selected" => {
|
||||
self.delete_selected_items()
|
||||
}
|
||||
|
||||
// Handle canceling (clear form)
|
||||
CanvasAction::Custom(action_str) if action_str == "cancel" => {
|
||||
// Reset to defaults but keep profile_name
|
||||
let profile = self.profile_name.clone();
|
||||
*self = Self::default();
|
||||
self.profile_name = profile;
|
||||
Some("Form cleared".to_string())
|
||||
}
|
||||
|
||||
// Custom validation when moving between fields
|
||||
CanvasAction::NextField => {
|
||||
// When leaving table name field, update the table_name for display
|
||||
if self.current_field() == 0 && !self.table_name_input.trim().is_empty() {
|
||||
self.table_name = self.table_name_input.trim().to_string();
|
||||
}
|
||||
None // Let canvas library handle the normal field movement
|
||||
}
|
||||
|
||||
// Let canvas library handle everything else
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
||||
None
|
||||
fn current_mode(&self) -> AppMode {
|
||||
self.app_mode
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/state/pages/auth.rs
|
||||
use canvas::canvas::{CanvasState, ActionContext, CanvasAction};
|
||||
use canvas::canvas::{CanvasState, ActionContext, CanvasAction, AppMode};
|
||||
use canvas::autocomplete::{AutocompleteCanvasState, AutocompleteState, SuggestionItem};
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
@@ -22,7 +22,6 @@ pub struct AuthState {
|
||||
}
|
||||
|
||||
/// Represents the state of the Login form UI
|
||||
#[derive(Default)]
|
||||
pub struct LoginState {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
@@ -31,10 +30,26 @@ pub struct LoginState {
|
||||
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: AppMode::Edit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the state of the Registration form UI
|
||||
#[derive(Default, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct RegisterState {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
@@ -45,8 +60,26 @@ pub struct RegisterState {
|
||||
pub current_field: usize,
|
||||
pub current_cursor_pos: usize,
|
||||
pub has_unsaved_changes: bool,
|
||||
// NEW: Replace old autocomplete with external library's system
|
||||
pub autocomplete: AutocompleteState<String>,
|
||||
pub app_mode: 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,
|
||||
autocomplete: AutocompleteState::new(),
|
||||
app_mode: AppMode::Edit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthState {
|
||||
@@ -57,7 +90,10 @@ impl AuthState {
|
||||
|
||||
impl LoginState {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
Self {
|
||||
app_mode: AppMode::Edit,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +101,7 @@ impl RegisterState {
|
||||
pub fn new() -> Self {
|
||||
let mut state = Self {
|
||||
autocomplete: AutocompleteState::new(),
|
||||
app_mode: AppMode::Edit,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -146,6 +183,10 @@ impl CanvasState for LoginState {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_mode(&self) -> AppMode {
|
||||
self.app_mode
|
||||
}
|
||||
}
|
||||
|
||||
// Implement external library's CanvasState for RegisterState
|
||||
@@ -237,6 +278,10 @@ impl CanvasState for RegisterState {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_mode(&self) -> AppMode {
|
||||
self.app_mode
|
||||
}
|
||||
}
|
||||
|
||||
// Add autocomplete support for RegisterState
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// src/state/pages/canvas_state.rs
|
||||
|
||||
use common::proto::komp_ac::search::search_response::Hit;
|
||||
|
||||
pub trait CanvasState {
|
||||
// --- Existing methods (unchanged) ---
|
||||
fn current_field(&self) -> usize;
|
||||
fn current_cursor_pos(&self) -> usize;
|
||||
fn has_unsaved_changes(&self) -> bool;
|
||||
fn inputs(&self) -> Vec<&String>;
|
||||
fn get_current_input(&self) -> &str;
|
||||
fn get_current_input_mut(&mut self) -> &mut String;
|
||||
fn fields(&self) -> Vec<&str>;
|
||||
fn set_current_field(&mut self, index: usize);
|
||||
fn set_current_cursor_pos(&mut self, pos: usize);
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool);
|
||||
fn get_suggestions(&self) -> Option<&[String]>;
|
||||
fn get_selected_suggestion_index(&self) -> Option<usize>;
|
||||
fn get_rich_suggestions(&self) -> Option<&[Hit]> {
|
||||
None
|
||||
}
|
||||
|
||||
fn get_display_value_for_field(&self, index: usize) -> &str {
|
||||
self.inputs()
|
||||
.get(index)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
}
|
||||
fn has_display_override(&self, _index: usize) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/state/pages/form.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use canvas::canvas::{CanvasState, CanvasAction, ActionContext, HighlightState};
|
||||
use canvas::canvas::{CanvasState, CanvasAction, ActionContext, HighlightState, AppMode};
|
||||
use common::proto::komp_ac::search::search_response::Hit;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::Frame;
|
||||
@@ -41,6 +41,7 @@ pub struct FormState {
|
||||
pub selected_suggestion_index: Option<usize>,
|
||||
pub autocomplete_loading: bool,
|
||||
pub link_display_map: HashMap<usize, String>,
|
||||
pub app_mode: AppMode,
|
||||
}
|
||||
|
||||
impl FormState {
|
||||
@@ -74,6 +75,7 @@ impl FormState {
|
||||
selected_suggestion_index: None,
|
||||
autocomplete_loading: false,
|
||||
link_display_map: HashMap::new(),
|
||||
app_mode: AppMode::Edit,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +233,15 @@ impl FormState {
|
||||
self.selected_suggestion_index = if self.autocomplete_active { Some(0) } else { None };
|
||||
self.autocomplete_loading = false;
|
||||
}
|
||||
|
||||
// NEW: Add these methods to change modes
|
||||
pub fn set_edit_mode(&mut self) {
|
||||
self.app_mode = AppMode::Edit;
|
||||
}
|
||||
|
||||
pub fn set_readonly_mode(&mut self) {
|
||||
self.app_mode = AppMode::ReadOnly;
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasState for FormState {
|
||||
@@ -320,4 +331,8 @@ impl CanvasState for FormState {
|
||||
fn has_display_override(&self, index: usize) -> bool {
|
||||
self.link_display_map.contains_key(&index)
|
||||
}
|
||||
|
||||
fn current_mode(&self) -> AppMode {
|
||||
self.app_mode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,10 @@ use crate::components::{
|
||||
};
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::modes::general::command_navigation::NavigationState;
|
||||
use crate::state::pages::canvas_state::CanvasState as LocalCanvasState; // Keep local one with alias
|
||||
use canvas::canvas::CanvasState; // Import external library's CanvasState trait
|
||||
use canvas::canvas::CanvasState;
|
||||
use crate::state::app::buffer::BufferState;
|
||||
use crate::state::app::highlight::HighlightState as LocalHighlightState; // CHANGED: Alias local version
|
||||
use canvas::canvas::HighlightState as CanvasHighlightState; // CHANGED: Import canvas version with alias
|
||||
use crate::state::app::highlight::HighlightState as LocalHighlightState;
|
||||
use canvas::canvas::HighlightState as CanvasHighlightState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use crate::state::pages::auth::AuthState;
|
||||
|
||||
@@ -8,9 +8,8 @@ use crate::config::storage::storage::load_auth_data;
|
||||
use crate::modes::common::commands::CommandHandler;
|
||||
use crate::modes::handlers::event::{EventHandler, EventOutcome};
|
||||
use crate::modes::handlers::mode_manager::{AppMode, ModeManager};
|
||||
use crate::state::pages::canvas_state::CanvasState as LocalCanvasState; // Keep local one with alias
|
||||
use canvas::canvas::CanvasState; // Import external library's CanvasState trait
|
||||
use crate::state::pages::form::{FormState, FieldDefinition}; // Import FieldDefinition
|
||||
use canvas::canvas::CanvasState; // Only external library import
|
||||
use crate::state::pages::form::{FormState, FieldDefinition};
|
||||
use crate::state::pages::auth::AuthState;
|
||||
use crate::state::pages::auth::LoginState;
|
||||
use crate::state::pages::auth::RegisterState;
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
use rstest::{fixture, rstest};
|
||||
use std::collections::HashMap;
|
||||
use client::state::pages::form::{FormState, FieldDefinition};
|
||||
use canvas::state::CanvasState
|
||||
use client::state::pages::canvas_state::CanvasState;
|
||||
use canvas::canvas::CanvasState;
|
||||
|
||||
#[fixture]
|
||||
fn test_form_state() -> FormState {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
pub use rstest::{fixture, rstest};
|
||||
pub use client::services::grpc_client::GrpcClient;
|
||||
pub use client::state::pages::form::FormState;
|
||||
pub use client::state::pages::canvas_state::CanvasState;
|
||||
pub use canvas::canvas::CanvasState;
|
||||
pub use prost_types::Value;
|
||||
pub use prost_types::value::Kind;
|
||||
pub use std::collections::HashMap;
|
||||
|
||||
@@ -15,6 +15,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
"proto/tables_data.proto",
|
||||
"proto/table_script.proto",
|
||||
"proto/search.proto",
|
||||
"proto/search2.proto",
|
||||
],
|
||||
&["proto"],
|
||||
)?;
|
||||
|
||||
46
common/proto/search2.proto
Normal file
46
common/proto/search2.proto
Normal file
@@ -0,0 +1,46 @@
|
||||
// In common/proto/search2.proto
|
||||
syntax = "proto3";
|
||||
package komp_ac.search2;
|
||||
|
||||
service Search2 {
|
||||
rpc SearchTable(Search2Request) returns (Search2Response);
|
||||
}
|
||||
|
||||
message Search2Request {
|
||||
string profile_name = 1;
|
||||
string table_name = 2;
|
||||
repeated ColumnFilter column_filters = 3;
|
||||
optional string text_query = 4; // Optional fallback text search
|
||||
optional int32 limit = 5;
|
||||
optional string order_by = 6;
|
||||
optional bool order_desc = 7;
|
||||
}
|
||||
|
||||
message ColumnFilter {
|
||||
string column_name = 1;
|
||||
FilterType filter_type = 2;
|
||||
string value = 3;
|
||||
optional string value2 = 4; // For range queries
|
||||
}
|
||||
|
||||
enum FilterType {
|
||||
EQUALS = 0;
|
||||
CONTAINS = 1;
|
||||
STARTS_WITH = 2;
|
||||
ENDS_WITH = 3;
|
||||
RANGE = 4;
|
||||
GREATER_THAN = 5;
|
||||
LESS_THAN = 6;
|
||||
IS_NULL = 7;
|
||||
IS_NOT_NULL = 8;
|
||||
}
|
||||
|
||||
message Search2Response {
|
||||
message Hit {
|
||||
int64 id = 1;
|
||||
string content_json = 2; // No score - this is SQL-based
|
||||
optional string match_info = 3; // Info about which columns matched
|
||||
}
|
||||
repeated Hit hits = 1;
|
||||
int32 total_count = 2; // Total matching records (for pagination)
|
||||
}
|
||||
@@ -31,6 +31,9 @@ pub mod proto {
|
||||
pub mod search {
|
||||
include!("proto/komp_ac.search.rs");
|
||||
}
|
||||
pub mod search2 {
|
||||
include!("proto/komp_ac.search2.rs");
|
||||
}
|
||||
pub const FILE_DESCRIPTOR_SET: &[u8] =
|
||||
include_bytes!("proto/descriptor.bin");
|
||||
}
|
||||
|
||||
Binary file not shown.
394
common/src/proto/komp_ac.search2.rs
Normal file
394
common/src/proto/komp_ac.search2.rs
Normal file
@@ -0,0 +1,394 @@
|
||||
// This file is @generated by prost-build.
|
||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||
pub struct Search2Request {
|
||||
#[prost(string, tag = "1")]
|
||||
pub profile_name: ::prost::alloc::string::String,
|
||||
#[prost(string, tag = "2")]
|
||||
pub table_name: ::prost::alloc::string::String,
|
||||
#[prost(message, repeated, tag = "3")]
|
||||
pub column_filters: ::prost::alloc::vec::Vec<ColumnFilter>,
|
||||
/// Optional fallback text search
|
||||
#[prost(string, optional, tag = "4")]
|
||||
pub text_query: ::core::option::Option<::prost::alloc::string::String>,
|
||||
#[prost(int32, optional, tag = "5")]
|
||||
pub limit: ::core::option::Option<i32>,
|
||||
#[prost(string, optional, tag = "6")]
|
||||
pub order_by: ::core::option::Option<::prost::alloc::string::String>,
|
||||
#[prost(bool, optional, tag = "7")]
|
||||
pub order_desc: ::core::option::Option<bool>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||
pub struct ColumnFilter {
|
||||
#[prost(string, tag = "1")]
|
||||
pub column_name: ::prost::alloc::string::String,
|
||||
#[prost(enumeration = "FilterType", tag = "2")]
|
||||
pub filter_type: i32,
|
||||
#[prost(string, tag = "3")]
|
||||
pub value: ::prost::alloc::string::String,
|
||||
/// For range queries
|
||||
#[prost(string, optional, tag = "4")]
|
||||
pub value2: ::core::option::Option<::prost::alloc::string::String>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||
pub struct Search2Response {
|
||||
#[prost(message, repeated, tag = "1")]
|
||||
pub hits: ::prost::alloc::vec::Vec<search2_response::Hit>,
|
||||
/// Total matching records (for pagination)
|
||||
#[prost(int32, tag = "2")]
|
||||
pub total_count: i32,
|
||||
}
|
||||
/// Nested message and enum types in `Search2Response`.
|
||||
pub mod search2_response {
|
||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||
pub struct Hit {
|
||||
#[prost(int64, tag = "1")]
|
||||
pub id: i64,
|
||||
/// No score - this is SQL-based
|
||||
#[prost(string, tag = "2")]
|
||||
pub content_json: ::prost::alloc::string::String,
|
||||
/// Info about which columns matched
|
||||
#[prost(string, optional, tag = "3")]
|
||||
pub match_info: ::core::option::Option<::prost::alloc::string::String>,
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
|
||||
#[repr(i32)]
|
||||
pub enum FilterType {
|
||||
Equals = 0,
|
||||
Contains = 1,
|
||||
StartsWith = 2,
|
||||
EndsWith = 3,
|
||||
Range = 4,
|
||||
GreaterThan = 5,
|
||||
LessThan = 6,
|
||||
IsNull = 7,
|
||||
IsNotNull = 8,
|
||||
}
|
||||
impl FilterType {
|
||||
/// String value of the enum field names used in the ProtoBuf definition.
|
||||
///
|
||||
/// The values are not transformed in any way and thus are considered stable
|
||||
/// (if the ProtoBuf definition does not change) and safe for programmatic use.
|
||||
pub fn as_str_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Equals => "EQUALS",
|
||||
Self::Contains => "CONTAINS",
|
||||
Self::StartsWith => "STARTS_WITH",
|
||||
Self::EndsWith => "ENDS_WITH",
|
||||
Self::Range => "RANGE",
|
||||
Self::GreaterThan => "GREATER_THAN",
|
||||
Self::LessThan => "LESS_THAN",
|
||||
Self::IsNull => "IS_NULL",
|
||||
Self::IsNotNull => "IS_NOT_NULL",
|
||||
}
|
||||
}
|
||||
/// Creates an enum from field names used in the ProtoBuf definition.
|
||||
pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {
|
||||
match value {
|
||||
"EQUALS" => Some(Self::Equals),
|
||||
"CONTAINS" => Some(Self::Contains),
|
||||
"STARTS_WITH" => Some(Self::StartsWith),
|
||||
"ENDS_WITH" => Some(Self::EndsWith),
|
||||
"RANGE" => Some(Self::Range),
|
||||
"GREATER_THAN" => Some(Self::GreaterThan),
|
||||
"LESS_THAN" => Some(Self::LessThan),
|
||||
"IS_NULL" => Some(Self::IsNull),
|
||||
"IS_NOT_NULL" => Some(Self::IsNotNull),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Generated client implementations.
|
||||
pub mod search2_client {
|
||||
#![allow(
|
||||
unused_variables,
|
||||
dead_code,
|
||||
missing_docs,
|
||||
clippy::wildcard_imports,
|
||||
clippy::let_unit_value,
|
||||
)]
|
||||
use tonic::codegen::*;
|
||||
use tonic::codegen::http::Uri;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Search2Client<T> {
|
||||
inner: tonic::client::Grpc<T>,
|
||||
}
|
||||
impl Search2Client<tonic::transport::Channel> {
|
||||
/// Attempt to create a new client by connecting to a given endpoint.
|
||||
pub async fn connect<D>(dst: D) -> Result<Self, tonic::transport::Error>
|
||||
where
|
||||
D: TryInto<tonic::transport::Endpoint>,
|
||||
D::Error: Into<StdError>,
|
||||
{
|
||||
let conn = tonic::transport::Endpoint::new(dst)?.connect().await?;
|
||||
Ok(Self::new(conn))
|
||||
}
|
||||
}
|
||||
impl<T> Search2Client<T>
|
||||
where
|
||||
T: tonic::client::GrpcService<tonic::body::Body>,
|
||||
T::Error: Into<StdError>,
|
||||
T::ResponseBody: Body<Data = Bytes> + std::marker::Send + 'static,
|
||||
<T::ResponseBody as Body>::Error: Into<StdError> + std::marker::Send,
|
||||
{
|
||||
pub fn new(inner: T) -> Self {
|
||||
let inner = tonic::client::Grpc::new(inner);
|
||||
Self { inner }
|
||||
}
|
||||
pub fn with_origin(inner: T, origin: Uri) -> Self {
|
||||
let inner = tonic::client::Grpc::with_origin(inner, origin);
|
||||
Self { inner }
|
||||
}
|
||||
pub fn with_interceptor<F>(
|
||||
inner: T,
|
||||
interceptor: F,
|
||||
) -> Search2Client<InterceptedService<T, F>>
|
||||
where
|
||||
F: tonic::service::Interceptor,
|
||||
T::ResponseBody: Default,
|
||||
T: tonic::codegen::Service<
|
||||
http::Request<tonic::body::Body>,
|
||||
Response = http::Response<
|
||||
<T as tonic::client::GrpcService<tonic::body::Body>>::ResponseBody,
|
||||
>,
|
||||
>,
|
||||
<T as tonic::codegen::Service<
|
||||
http::Request<tonic::body::Body>,
|
||||
>>::Error: Into<StdError> + std::marker::Send + std::marker::Sync,
|
||||
{
|
||||
Search2Client::new(InterceptedService::new(inner, interceptor))
|
||||
}
|
||||
/// Compress requests with the given encoding.
|
||||
///
|
||||
/// This requires the server to support it otherwise it might respond with an
|
||||
/// error.
|
||||
#[must_use]
|
||||
pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self {
|
||||
self.inner = self.inner.send_compressed(encoding);
|
||||
self
|
||||
}
|
||||
/// Enable decompressing responses.
|
||||
#[must_use]
|
||||
pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self {
|
||||
self.inner = self.inner.accept_compressed(encoding);
|
||||
self
|
||||
}
|
||||
/// Limits the maximum size of a decoded message.
|
||||
///
|
||||
/// Default: `4MB`
|
||||
#[must_use]
|
||||
pub fn max_decoding_message_size(mut self, limit: usize) -> Self {
|
||||
self.inner = self.inner.max_decoding_message_size(limit);
|
||||
self
|
||||
}
|
||||
/// Limits the maximum size of an encoded message.
|
||||
///
|
||||
/// Default: `usize::MAX`
|
||||
#[must_use]
|
||||
pub fn max_encoding_message_size(mut self, limit: usize) -> Self {
|
||||
self.inner = self.inner.max_encoding_message_size(limit);
|
||||
self
|
||||
}
|
||||
pub async fn search_table(
|
||||
&mut self,
|
||||
request: impl tonic::IntoRequest<super::Search2Request>,
|
||||
) -> std::result::Result<
|
||||
tonic::Response<super::Search2Response>,
|
||||
tonic::Status,
|
||||
> {
|
||||
self.inner
|
||||
.ready()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tonic::Status::unknown(
|
||||
format!("Service was not ready: {}", e.into()),
|
||||
)
|
||||
})?;
|
||||
let codec = tonic::codec::ProstCodec::default();
|
||||
let path = http::uri::PathAndQuery::from_static(
|
||||
"/komp_ac.search2.Search2/SearchTable",
|
||||
);
|
||||
let mut req = request.into_request();
|
||||
req.extensions_mut()
|
||||
.insert(GrpcMethod::new("komp_ac.search2.Search2", "SearchTable"));
|
||||
self.inner.unary(req, path, codec).await
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Generated server implementations.
|
||||
pub mod search2_server {
|
||||
#![allow(
|
||||
unused_variables,
|
||||
dead_code,
|
||||
missing_docs,
|
||||
clippy::wildcard_imports,
|
||||
clippy::let_unit_value,
|
||||
)]
|
||||
use tonic::codegen::*;
|
||||
/// Generated trait containing gRPC methods that should be implemented for use with Search2Server.
|
||||
#[async_trait]
|
||||
pub trait Search2: std::marker::Send + std::marker::Sync + 'static {
|
||||
async fn search_table(
|
||||
&self,
|
||||
request: tonic::Request<super::Search2Request>,
|
||||
) -> std::result::Result<tonic::Response<super::Search2Response>, tonic::Status>;
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct Search2Server<T> {
|
||||
inner: Arc<T>,
|
||||
accept_compression_encodings: EnabledCompressionEncodings,
|
||||
send_compression_encodings: EnabledCompressionEncodings,
|
||||
max_decoding_message_size: Option<usize>,
|
||||
max_encoding_message_size: Option<usize>,
|
||||
}
|
||||
impl<T> Search2Server<T> {
|
||||
pub fn new(inner: T) -> Self {
|
||||
Self::from_arc(Arc::new(inner))
|
||||
}
|
||||
pub fn from_arc(inner: Arc<T>) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
accept_compression_encodings: Default::default(),
|
||||
send_compression_encodings: Default::default(),
|
||||
max_decoding_message_size: None,
|
||||
max_encoding_message_size: None,
|
||||
}
|
||||
}
|
||||
pub fn with_interceptor<F>(
|
||||
inner: T,
|
||||
interceptor: F,
|
||||
) -> InterceptedService<Self, F>
|
||||
where
|
||||
F: tonic::service::Interceptor,
|
||||
{
|
||||
InterceptedService::new(Self::new(inner), interceptor)
|
||||
}
|
||||
/// Enable decompressing requests with the given encoding.
|
||||
#[must_use]
|
||||
pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self {
|
||||
self.accept_compression_encodings.enable(encoding);
|
||||
self
|
||||
}
|
||||
/// Compress responses with the given encoding, if the client supports it.
|
||||
#[must_use]
|
||||
pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self {
|
||||
self.send_compression_encodings.enable(encoding);
|
||||
self
|
||||
}
|
||||
/// Limits the maximum size of a decoded message.
|
||||
///
|
||||
/// Default: `4MB`
|
||||
#[must_use]
|
||||
pub fn max_decoding_message_size(mut self, limit: usize) -> Self {
|
||||
self.max_decoding_message_size = Some(limit);
|
||||
self
|
||||
}
|
||||
/// Limits the maximum size of an encoded message.
|
||||
///
|
||||
/// Default: `usize::MAX`
|
||||
#[must_use]
|
||||
pub fn max_encoding_message_size(mut self, limit: usize) -> Self {
|
||||
self.max_encoding_message_size = Some(limit);
|
||||
self
|
||||
}
|
||||
}
|
||||
impl<T, B> tonic::codegen::Service<http::Request<B>> for Search2Server<T>
|
||||
where
|
||||
T: Search2,
|
||||
B: Body + std::marker::Send + 'static,
|
||||
B::Error: Into<StdError> + std::marker::Send + 'static,
|
||||
{
|
||||
type Response = http::Response<tonic::body::Body>;
|
||||
type Error = std::convert::Infallible;
|
||||
type Future = BoxFuture<Self::Response, Self::Error>;
|
||||
fn poll_ready(
|
||||
&mut self,
|
||||
_cx: &mut Context<'_>,
|
||||
) -> Poll<std::result::Result<(), Self::Error>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
fn call(&mut self, req: http::Request<B>) -> Self::Future {
|
||||
match req.uri().path() {
|
||||
"/komp_ac.search2.Search2/SearchTable" => {
|
||||
#[allow(non_camel_case_types)]
|
||||
struct SearchTableSvc<T: Search2>(pub Arc<T>);
|
||||
impl<T: Search2> tonic::server::UnaryService<super::Search2Request>
|
||||
for SearchTableSvc<T> {
|
||||
type Response = super::Search2Response;
|
||||
type Future = BoxFuture<
|
||||
tonic::Response<Self::Response>,
|
||||
tonic::Status,
|
||||
>;
|
||||
fn call(
|
||||
&mut self,
|
||||
request: tonic::Request<super::Search2Request>,
|
||||
) -> Self::Future {
|
||||
let inner = Arc::clone(&self.0);
|
||||
let fut = async move {
|
||||
<T as Search2>::search_table(&inner, request).await
|
||||
};
|
||||
Box::pin(fut)
|
||||
}
|
||||
}
|
||||
let accept_compression_encodings = self.accept_compression_encodings;
|
||||
let send_compression_encodings = self.send_compression_encodings;
|
||||
let max_decoding_message_size = self.max_decoding_message_size;
|
||||
let max_encoding_message_size = self.max_encoding_message_size;
|
||||
let inner = self.inner.clone();
|
||||
let fut = async move {
|
||||
let method = SearchTableSvc(inner);
|
||||
let codec = tonic::codec::ProstCodec::default();
|
||||
let mut grpc = tonic::server::Grpc::new(codec)
|
||||
.apply_compression_config(
|
||||
accept_compression_encodings,
|
||||
send_compression_encodings,
|
||||
)
|
||||
.apply_max_message_size_config(
|
||||
max_decoding_message_size,
|
||||
max_encoding_message_size,
|
||||
);
|
||||
let res = grpc.unary(method, req).await;
|
||||
Ok(res)
|
||||
};
|
||||
Box::pin(fut)
|
||||
}
|
||||
_ => {
|
||||
Box::pin(async move {
|
||||
let mut response = http::Response::new(
|
||||
tonic::body::Body::default(),
|
||||
);
|
||||
let headers = response.headers_mut();
|
||||
headers
|
||||
.insert(
|
||||
tonic::Status::GRPC_STATUS,
|
||||
(tonic::Code::Unimplemented as i32).into(),
|
||||
);
|
||||
headers
|
||||
.insert(
|
||||
http::header::CONTENT_TYPE,
|
||||
tonic::metadata::GRPC_CONTENT_TYPE,
|
||||
);
|
||||
Ok(response)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<T> Clone for Search2Server<T> {
|
||||
fn clone(&self) -> Self {
|
||||
let inner = self.inner.clone();
|
||||
Self {
|
||||
inner,
|
||||
accept_compression_encodings: self.accept_compression_encodings,
|
||||
send_compression_encodings: self.send_compression_encodings,
|
||||
max_decoding_message_size: self.max_decoding_message_size,
|
||||
max_encoding_message_size: self.max_encoding_message_size,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Generated gRPC service name
|
||||
pub const SERVICE_NAME: &str = "komp_ac.search2.Search2";
|
||||
impl<T> tonic::server::NamedService for Search2Server<T> {
|
||||
const NAME: &'static str = SERVICE_NAME;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,11 @@ pub async fn register(
|
||||
pool: &PgPool,
|
||||
payload: RegisterRequest,
|
||||
) -> Result<Response<AuthResponse>, Status> {
|
||||
// Validate required fields
|
||||
if payload.email.trim().is_empty() {
|
||||
return Err(Status::invalid_argument("Email is required"));
|
||||
}
|
||||
|
||||
// Validate passwords match
|
||||
if payload.password != payload.password_confirmation {
|
||||
return Err(Status::invalid_argument(AuthError::PasswordMismatch.to_string()));
|
||||
@@ -41,6 +46,15 @@ pub async fn register(
|
||||
if db_err.constraint() == Some("valid_roles") {
|
||||
return Status::invalid_argument(format!("Invalid role specified: '{}'", role_to_insert));
|
||||
}
|
||||
// Check for specific constraint violations
|
||||
if let Some(constraint) = db_err.constraint() {
|
||||
if constraint.contains("users_username_key") {
|
||||
return Status::already_exists("Username already exists".to_string());
|
||||
}
|
||||
if constraint.contains("users_email_key") {
|
||||
return Status::already_exists("Email already exists".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if e.to_string().contains("duplicate key") {
|
||||
Status::already_exists(AuthError::UserExists.to_string())
|
||||
|
||||
@@ -10,14 +10,16 @@ use crate::server::services::{
|
||||
TableDefinitionService,
|
||||
TablesDataService,
|
||||
TableScriptService,
|
||||
AuthServiceImpl
|
||||
AuthServiceImpl,
|
||||
Search2Service,
|
||||
};
|
||||
use common::proto::komp_ac::{
|
||||
table_structure::table_structure_service_server::TableStructureServiceServer,
|
||||
table_definition::table_definition_server::TableDefinitionServer,
|
||||
tables_data::tables_data_server::TablesDataServer,
|
||||
table_script::table_script_server::TableScriptServer,
|
||||
auth::auth_service_server::AuthServiceServer
|
||||
auth::auth_service_server::AuthServiceServer,
|
||||
search2::search2_server::Search2Server,
|
||||
};
|
||||
use search::{SearcherService, SearcherServer};
|
||||
|
||||
@@ -47,9 +49,8 @@ pub async fn run_server(db_pool: sqlx::PgPool) -> Result<(), Box<dyn std::error:
|
||||
};
|
||||
let table_script_service = TableScriptService { db_pool: db_pool.clone() };
|
||||
let auth_service = AuthServiceImpl { db_pool: db_pool.clone() };
|
||||
|
||||
// MODIFIED: Instantiate SearcherService with the database pool
|
||||
let search_service = SearcherService { pool: db_pool.clone() };
|
||||
let search2_service = Search2Service { db_pool: db_pool.clone() };
|
||||
|
||||
Server::builder()
|
||||
.add_service(TableStructureServiceServer::new(TableStructureHandler { db_pool: db_pool.clone() }))
|
||||
@@ -58,6 +59,7 @@ pub async fn run_server(db_pool: sqlx::PgPool) -> Result<(), Box<dyn std::error:
|
||||
.add_service(TableScriptServer::new(table_script_service))
|
||||
.add_service(AuthServiceServer::new(auth_service))
|
||||
.add_service(SearcherServer::new(search_service))
|
||||
.add_service(Search2Server::new(search2_service))
|
||||
.add_service(reflection_service)
|
||||
.serve(addr)
|
||||
.await?;
|
||||
|
||||
@@ -5,9 +5,11 @@ pub mod table_definition_service;
|
||||
pub mod tables_data_service;
|
||||
pub mod table_script_service;
|
||||
pub mod auth_service;
|
||||
pub mod search2_service;
|
||||
|
||||
pub use table_structure_service::TableStructureHandler;
|
||||
pub use table_definition_service::TableDefinitionService;
|
||||
pub use tables_data_service::TablesDataService;
|
||||
pub use table_script_service::TableScriptService;
|
||||
pub use auth_service::AuthServiceImpl;
|
||||
pub use search2_service::*;
|
||||
|
||||
202
server/src/server/services/search2_service.rs
Normal file
202
server/src/server/services/search2_service.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
// src/server/services/search2_service.rs
|
||||
use tonic::{Request, Response, Status};
|
||||
use sqlx::PgPool;
|
||||
use sqlx::Row;
|
||||
use common::proto::komp_ac::search2::{
|
||||
search2_server::Search2,
|
||||
Search2Request, Search2Response, ColumnFilter, FilterType,
|
||||
search2_response::Hit,
|
||||
};
|
||||
use crate::shared::schema_qualifier::qualify_table_name_for_data;
|
||||
|
||||
pub struct Search2Service {
|
||||
pub db_pool: PgPool,
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl Search2 for Search2Service {
|
||||
async fn search_table(
|
||||
&self,
|
||||
request: Request<Search2Request>,
|
||||
) -> Result<Response<Search2Response>, Status> {
|
||||
let req = request.into_inner();
|
||||
|
||||
// Build SQL query - NOW PASS DB_POOL AND AWAIT
|
||||
let (sql, bind_values, count_sql) = build_search_query(&self.db_pool, &req).await
|
||||
.map_err(|e| Status::invalid_argument(format!("Query build error: {}", e)))?;
|
||||
|
||||
// Execute main query
|
||||
let rows = execute_query(&self.db_pool, &sql, &bind_values).await
|
||||
.map_err(|e| Status::internal(format!("Database error: {}", e)))?;
|
||||
|
||||
// Execute count query for pagination
|
||||
let total_count = execute_count_query(&self.db_pool, &count_sql, &bind_values).await
|
||||
.map_err(|e| Status::internal(format!("Count query error: {}", e)))?;
|
||||
|
||||
// Convert to response format
|
||||
let hits = rows.into_iter().map(|row| {
|
||||
Hit {
|
||||
id: row.id,
|
||||
content_json: row.content_json,
|
||||
match_info: Some(format!("SQL search on table: {}", req.table_name)),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
Ok(Response::new(Search2Response {
|
||||
hits,
|
||||
total_count,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct QueryRow {
|
||||
id: i64,
|
||||
content_json: String,
|
||||
}
|
||||
|
||||
// MAKE THIS FUNCTION ASYNC AND ADD DB_POOL PARAMETER
|
||||
async fn build_search_query(
|
||||
db_pool: &PgPool,
|
||||
request: &Search2Request
|
||||
) -> Result<(String, Vec<String>, String), String> {
|
||||
|
||||
// Since Search2Request doesn't have profile_name, use "default"
|
||||
// You can add profile_name to the proto later if needed
|
||||
let profile_name = "default";
|
||||
|
||||
// NOW AWAIT THE ASYNC FUNCTION CALL
|
||||
let qualified_table = qualify_table_name_for_data(db_pool, profile_name, &request.table_name)
|
||||
.await
|
||||
.map_err(|e| format!("Invalid table name: {}", e))?;
|
||||
|
||||
let mut conditions = vec!["deleted = FALSE".to_string()];
|
||||
let mut bind_values = Vec::new();
|
||||
|
||||
// Build WHERE conditions from column filters
|
||||
for filter in &request.column_filters {
|
||||
let condition = build_filter_condition(filter, &mut bind_values)?;
|
||||
conditions.push(condition);
|
||||
}
|
||||
|
||||
// Add text search fallback if provided
|
||||
if let Some(text_query) = &request.text_query {
|
||||
if !text_query.trim().is_empty() {
|
||||
bind_values.push(format!("%{}%", text_query.to_lowercase()));
|
||||
conditions.push(format!(
|
||||
"EXISTS (SELECT 1 FROM jsonb_each_text(to_jsonb(t)) as kv(key, value) WHERE LOWER(value) LIKE ${})",
|
||||
bind_values.len()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let where_clause = conditions.join(" AND ");
|
||||
|
||||
// Build ORDER BY clause
|
||||
let order_clause = if let Some(order_by) = &request.order_by {
|
||||
let direction = if request.order_desc.unwrap_or(false) { "DESC" } else { "ASC" };
|
||||
format!("ORDER BY \"{}\" {}", order_by, direction)
|
||||
} else {
|
||||
"ORDER BY id DESC".to_string()
|
||||
};
|
||||
|
||||
let limit_clause = format!("LIMIT {}", request.limit.unwrap_or(100));
|
||||
|
||||
// Main query
|
||||
let sql = format!(
|
||||
"SELECT id, to_jsonb(t) AS data FROM {} t WHERE {} {} {}",
|
||||
qualified_table, where_clause, order_clause, limit_clause
|
||||
);
|
||||
|
||||
// Count query (for pagination)
|
||||
let count_sql = format!(
|
||||
"SELECT COUNT(*) FROM {} t WHERE {}",
|
||||
qualified_table, where_clause
|
||||
);
|
||||
|
||||
Ok((sql, bind_values, count_sql))
|
||||
}
|
||||
|
||||
fn build_filter_condition(filter: &ColumnFilter, bind_values: &mut Vec<String>) -> Result<String, String> {
|
||||
// FIX DEPRECATED WARNING - USE TryFrom INSTEAD
|
||||
let filter_type = FilterType::try_from(filter.filter_type)
|
||||
.map_err(|_| "Invalid filter type".to_string())?;
|
||||
|
||||
let param_idx = bind_values.len() + 1;
|
||||
|
||||
let condition = match filter_type {
|
||||
FilterType::Equals => {
|
||||
bind_values.push(filter.value.clone());
|
||||
format!("\"{}\" = ${}", filter.column_name, param_idx)
|
||||
},
|
||||
FilterType::Contains => {
|
||||
bind_values.push(format!("%{}%", filter.value));
|
||||
format!("\"{}\" ILIKE ${}", filter.column_name, param_idx)
|
||||
},
|
||||
FilterType::StartsWith => {
|
||||
bind_values.push(format!("{}%", filter.value));
|
||||
format!("\"{}\" ILIKE ${}", filter.column_name, param_idx)
|
||||
},
|
||||
FilterType::EndsWith => {
|
||||
bind_values.push(format!("%{}", filter.value));
|
||||
format!("\"{}\" ILIKE ${}", filter.column_name, param_idx)
|
||||
},
|
||||
FilterType::Range => {
|
||||
bind_values.push(filter.value.clone());
|
||||
bind_values.push(filter.value2.as_ref().unwrap_or(&"".to_string()).clone());
|
||||
format!("\"{}\" BETWEEN ${} AND ${}", filter.column_name, param_idx, param_idx + 1)
|
||||
},
|
||||
FilterType::GreaterThan => {
|
||||
bind_values.push(filter.value.clone());
|
||||
format!("\"{}\" > ${}", filter.column_name, param_idx)
|
||||
},
|
||||
FilterType::LessThan => {
|
||||
bind_values.push(filter.value.clone());
|
||||
format!("\"{}\" < ${}", filter.column_name, param_idx)
|
||||
},
|
||||
FilterType::IsNull => {
|
||||
format!("\"{}\" IS NULL", filter.column_name)
|
||||
},
|
||||
FilterType::IsNotNull => {
|
||||
format!("\"{}\" IS NOT NULL", filter.column_name)
|
||||
},
|
||||
};
|
||||
|
||||
Ok(condition)
|
||||
}
|
||||
|
||||
async fn execute_query(pool: &PgPool, sql: &str, bind_values: &[String]) -> Result<Vec<QueryRow>, sqlx::Error> {
|
||||
let mut query = sqlx::query(sql);
|
||||
|
||||
// Bind all parameters
|
||||
for value in bind_values {
|
||||
query = query.bind(value);
|
||||
}
|
||||
|
||||
let rows = query.fetch_all(pool).await?;
|
||||
|
||||
let results = rows.into_iter().map(|row| {
|
||||
QueryRow {
|
||||
id: row.try_get("id").unwrap_or(0),
|
||||
content_json: row.try_get::<serde_json::Value, _>("data")
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
.to_string(),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
async fn execute_count_query(pool: &PgPool, sql: &str, bind_values: &[String]) -> Result<i32, sqlx::Error> {
|
||||
let mut query = sqlx::query(sql);
|
||||
|
||||
// Bind all parameters
|
||||
for value in bind_values {
|
||||
query = query.bind(value);
|
||||
}
|
||||
|
||||
let row = query.fetch_one(pool).await?;
|
||||
let count: i64 = row.try_get(0).unwrap_or(0);
|
||||
|
||||
Ok(count as i32)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use sqlx::{PgPool, Transaction, Postgres};
|
||||
use serde_json::json;
|
||||
use common::proto::komp_ac::table_definition::{PostTableDefinitionRequest, TableDefinitionResponse};
|
||||
|
||||
// TODO CRITICAL add decimal with optional precision"
|
||||
const PREDEFINED_FIELD_TYPES: &[(&str, &str)] = &[
|
||||
("text", "TEXT"),
|
||||
("string", "TEXT"),
|
||||
|
||||
Reference in New Issue
Block a user