Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
a4e94878e7 | ||
|
|
c7353ac81e | ||
|
|
1fbc720620 | ||
|
|
263ccc3260 | ||
|
|
00c0a399cd |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@
|
|||||||
server/tantivy_indexes
|
server/tantivy_indexes
|
||||||
steel_decimal/tests/property_tests.proptest-regressions
|
steel_decimal/tests/property_tests.proptest-regressions
|
||||||
.direnv/
|
.direnv/
|
||||||
|
canvas/*.toml
|
||||||
|
|||||||
173
Cargo.lock
generated
173
Cargo.lock
generated
@@ -384,9 +384,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bon"
|
name = "bon"
|
||||||
version = "3.6.4"
|
version = "3.6.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f61138465baf186c63e8d9b6b613b508cd832cba4ce93cf37ce5f096f91ac1a6"
|
checksum = "33d9ef19ae5263a138da9a86871eca537478ab0332a7770bac7e3f08b801f89f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bon-macros",
|
"bon-macros",
|
||||||
"rustversion",
|
"rustversion",
|
||||||
@@ -394,11 +394,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bon-macros"
|
name = "bon-macros"
|
||||||
version = "3.6.4"
|
version = "3.6.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40d1dad34aa19bf02295382f08d9bc40651585bd497266831d40ee6296fb49ca"
|
checksum = "577ae008f2ca11ca7641bd44601002ee5ab49ef0af64846ce1ab6057218a5cc1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling",
|
"darling 0.21.0",
|
||||||
"ident_case",
|
"ident_case",
|
||||||
"prettyplease",
|
"prettyplease",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -475,10 +475,13 @@ name = "canvas"
|
|||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"async-trait",
|
||||||
"common",
|
"common",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-test",
|
"tokio-test",
|
||||||
"toml",
|
"toml",
|
||||||
@@ -495,18 +498,18 @@ checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "castaway"
|
name = "castaway"
|
||||||
version = "0.2.3"
|
version = "0.2.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5"
|
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustversion",
|
"rustversion",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.29"
|
version = "1.2.30"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5c1599538de2394445747c8cf7935946e3cc27e9625f889d979bfb2aaf569362"
|
checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"jobserver",
|
"jobserver",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -651,9 +654,9 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const_panic"
|
name = "const_panic"
|
||||||
version = "0.2.12"
|
version = "0.2.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2459fc9262a1aa204eb4b5764ad4f189caec88aea9634389c0a25f8be7f6265e"
|
checksum = "b98d1483e98c9d67f341ab4b3915cfdc54740bd6f5cccc9226ee0535d86aa8fb"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
@@ -712,9 +715,9 @@ checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.4.2"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
|
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
@@ -809,8 +812,18 @@ version = "0.20.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling_core",
|
"darling_core 0.20.11",
|
||||||
"darling_macro",
|
"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]]
|
[[package]]
|
||||||
@@ -827,13 +840,38 @@ dependencies = [
|
|||||||
"syn 2.0.104",
|
"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]]
|
[[package]]
|
||||||
name = "darling_macro"
|
name = "darling_macro"
|
||||||
version = "0.20.11"
|
version = "0.20.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||||
dependencies = [
|
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",
|
"quote",
|
||||||
"syn 2.0.104",
|
"syn 2.0.104",
|
||||||
]
|
]
|
||||||
@@ -957,7 +995,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
|
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1428,9 +1466,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.15"
|
version = "0.1.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df"
|
checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
@@ -1441,7 +1479,7 @@ dependencies = [
|
|||||||
"hyper",
|
"hyper",
|
||||||
"libc",
|
"libc",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2 0.6.0",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -1610,9 +1648,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "im-lists"
|
name = "im-lists"
|
||||||
version = "0.9.0"
|
version = "0.9.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "88485149c4fcec01ebce4e4b8284a3c75b3d8a4749169f5481144e6433e9bcd2"
|
checksum = "8b971d2652e5700514cc92ca020dba64c790352af0ff2b9acb7514868a32d6aa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
@@ -1669,11 +1707,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "instability"
|
name = "instability"
|
||||||
version = "0.3.7"
|
version = "0.3.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d"
|
checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling",
|
"darling 0.20.11",
|
||||||
"indoc",
|
"indoc",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -1682,9 +1720,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "io-uring"
|
name = "io-uring"
|
||||||
version = "0.7.8"
|
version = "0.7.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013"
|
checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
@@ -1801,9 +1839,9 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.4"
|
version = "0.1.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638"
|
checksum = "4488594b9328dee448adb906d8b126d9b7deb7cf5c22161ee591610bb1be83c0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -1901,9 +1939,9 @@ checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memmap2"
|
name = "memmap2"
|
||||||
version = "0.9.5"
|
version = "0.9.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f"
|
checksum = "483758ad303d734cec05e5c12b41d7e93e6a6390c5e9dae6bdeb7c1259012d28"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
@@ -2291,17 +2329,16 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "polling"
|
name = "polling"
|
||||||
version = "3.8.0"
|
version = "3.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b53a684391ad002dd6a596ceb6c74fd004fdce75f4be2e3f615068abbea5fd50"
|
checksum = "8ee9b2fa7a4517d2c91ff5bc6c297a427a96749d15f98fcdbb22c05571a4d4b7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"concurrent-queue",
|
"concurrent-queue",
|
||||||
"hermit-abi",
|
"hermit-abi",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustix 1.0.7",
|
"rustix 1.0.8",
|
||||||
"tracing",
|
"windows-sys 0.60.2",
|
||||||
"windows-sys 0.59.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2341,9 +2378,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prettyplease"
|
name = "prettyplease"
|
||||||
version = "0.2.35"
|
version = "0.2.36"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "061c1221631e079b26479d25bbf2275bfe5917ae8419cd7e34f13bfc2aa7539a"
|
checksum = "ff24dfcda44452b9816fff4cd4227e1bb73ff5a2f1bc1105aa92fb8565ce44d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"syn 2.0.104",
|
"syn 2.0.104",
|
||||||
@@ -2505,9 +2542,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.9.1"
|
version = "0.9.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
|
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand_chacha 0.9.0",
|
"rand_chacha 0.9.0",
|
||||||
"rand_core 0.9.3",
|
"rand_core 0.9.3",
|
||||||
@@ -2613,9 +2650,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.13"
|
version = "0.5.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6"
|
checksum = "7e8af0dde094006011e6a740d4879319439489813bd0bcdc7d821beaeeff48ec"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
]
|
]
|
||||||
@@ -2844,20 +2881,20 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.4.15",
|
"linux-raw-sys 0.4.15",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.0.7"
|
version = "1.0.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266"
|
checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.9.4",
|
"linux-raw-sys 0.9.4",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2961,9 +2998,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.140"
|
version = "1.0.141"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
|
checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"itoa",
|
"itoa",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -3007,7 +3044,7 @@ dependencies = [
|
|||||||
"lazy_static",
|
"lazy_static",
|
||||||
"prost",
|
"prost",
|
||||||
"prost-types",
|
"prost-types",
|
||||||
"rand 0.9.1",
|
"rand 0.9.2",
|
||||||
"regex",
|
"regex",
|
||||||
"rstest",
|
"rstest",
|
||||||
"rust-stemmers",
|
"rust-stemmers",
|
||||||
@@ -3171,6 +3208,16 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"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]]
|
[[package]]
|
||||||
name = "spin"
|
name = "spin"
|
||||||
version = "0.9.8"
|
version = "0.9.8"
|
||||||
@@ -3445,7 +3492,7 @@ dependencies = [
|
|||||||
"parking_lot",
|
"parking_lot",
|
||||||
"polling",
|
"polling",
|
||||||
"quickscope",
|
"quickscope",
|
||||||
"rand 0.9.1",
|
"rand 0.9.2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
@@ -3460,9 +3507,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "steel-decimal"
|
name = "steel-decimal"
|
||||||
version = "1.0.0"
|
version = "1.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c43950a3eed43f3e9765a51f5dc1b0de5e1687ba824b8589990747d9ba241187"
|
checksum = "4cd8a6d1a41d2146705b29292cac75c78a3e32d7b6cabb72d808209546615f37"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"regex",
|
"regex",
|
||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
@@ -3597,9 +3644,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tantivy"
|
name = "tantivy"
|
||||||
version = "0.24.1"
|
version = "0.24.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ca2374a21157427c5faff2d90930f035b6c22a5d7b0e5b0b7f522e988ef33c06"
|
checksum = "64a966cb0e76e311f09cf18507c9af192f15d34886ee43d7ba7c7e3803660c43"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
@@ -3756,8 +3803,8 @@ dependencies = [
|
|||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.3.3",
|
"getrandom 0.3.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.0.7",
|
"rustix 1.0.8",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3871,7 +3918,7 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
"slab",
|
"slab",
|
||||||
"socket2",
|
"socket2 0.5.10",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
@@ -3985,7 +4032,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project",
|
"pin-project",
|
||||||
"prost",
|
"prost",
|
||||||
"socket2",
|
"socket2 0.5.10",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tower",
|
"tower",
|
||||||
@@ -4274,7 +4321,7 @@ version = "0.20.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca"
|
checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling",
|
"darling 0.20.11",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"proc-macro-error2",
|
"proc-macro-error2",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -4402,7 +4449,7 @@ checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"either",
|
"either",
|
||||||
"env_home",
|
"env_home",
|
||||||
"rustix 1.0.7",
|
"rustix 1.0.8",
|
||||||
"winsafe",
|
"winsafe",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4438,7 +4485,7 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4729,9 +4776,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "0.7.11"
|
version = "0.7.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd"
|
checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|||||||
1
canvas/.gitignore
vendored
Normal file
1
canvas/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
docs_prompts/
|
||||||
@@ -12,15 +12,18 @@ categories.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
ratatui = { workspace = true, optional = true }
|
ratatui = { workspace = true, optional = true }
|
||||||
crossterm = { workspace = true }
|
crossterm = { workspace = true, optional = true }
|
||||||
anyhow = { workspace = true }
|
anyhow.workspace = true
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true, optional = true }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde.workspace = true
|
||||||
unicode-width.workspace = true
|
unicode-width.workspace = true
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-subscriber = "0.3.19"
|
tracing-subscriber = "0.3.19"
|
||||||
|
async-trait.workspace = true
|
||||||
|
regex = { workspace = true, optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-test = "0.4.4"
|
tokio-test = "0.4.4"
|
||||||
@@ -28,3 +31,20 @@ tokio-test = "0.4.4"
|
|||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
gui = ["ratatui"]
|
gui = ["ratatui"]
|
||||||
|
autocomplete = ["tokio"]
|
||||||
|
cursor-style = ["crossterm"]
|
||||||
|
validation = ["regex"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "autocomplete"
|
||||||
|
required-features = ["autocomplete", "gui"]
|
||||||
|
path = "examples/autocomplete.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "canvas_gui_demo"
|
||||||
|
required-features = ["gui"]
|
||||||
|
path = "examples/canvas_gui_demo.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "validation_1"
|
||||||
|
required-features = ["gui", "validation"]
|
||||||
|
|||||||
@@ -1,58 +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"]
|
|
||||||
trigger_autocomplete = ["Ctrl+p"]
|
|
||||||
|
|
||||||
|
|
||||||
# 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"]
|
|
||||||
392
canvas/examples/autocomplete.rs
Normal file
392
canvas/examples/autocomplete.rs
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
// examples/autocomplete.rs
|
||||||
|
// Run with: cargo run --example autocomplete --features "autocomplete,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,
|
||||||
|
},
|
||||||
|
autocomplete::gui::render_autocomplete_dropdown,
|
||||||
|
FormEditor, DataProvider, AutocompleteProvider, 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_autocomplete(&self, field_index: usize) -> bool {
|
||||||
|
field_index == 1 // Only email field
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// SIMPLE AUTOCOMPLETE PROVIDER - Only data fetching!
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
struct EmailAutocomplete;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AutocompleteProvider for EmailAutocomplete {
|
||||||
|
type SuggestionData = EmailSuggestion;
|
||||||
|
|
||||||
|
async fn fetch_suggestions(&mut self, _field_index: usize, query: &str)
|
||||||
|
-> Result<Vec<SuggestionItem<Self::SuggestionData>>>
|
||||||
|
{
|
||||||
|
// 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 {
|
||||||
|
data: EmailSuggestion {
|
||||||
|
email: full_email.clone(),
|
||||||
|
provider: provider.to_string(),
|
||||||
|
},
|
||||||
|
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>,
|
||||||
|
autocomplete: 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,
|
||||||
|
autocomplete: EmailAutocomplete,
|
||||||
|
debug_message: "Type in email field, Tab to trigger autocomplete, 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 {
|
||||||
|
// === AUTOCOMPLETE KEYS ===
|
||||||
|
KeyCode::Tab => {
|
||||||
|
if state.editor.is_autocomplete_active() {
|
||||||
|
state.editor.autocomplete_next();
|
||||||
|
Ok("Navigated to next suggestion".to_string())
|
||||||
|
} else if state.editor.data_provider().supports_autocomplete(state.editor.current_field()) {
|
||||||
|
state.editor.trigger_autocomplete(&mut state.autocomplete).await
|
||||||
|
.map(|_| "Triggered autocomplete".to_string())
|
||||||
|
} else {
|
||||||
|
state.editor.move_to_next_field();
|
||||||
|
Ok("Moved to next field".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if state.editor.is_autocomplete_active() {
|
||||||
|
if let Some(applied) = state.editor.apply_autocomplete() {
|
||||||
|
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_autocomplete_active() {
|
||||||
|
// Autocomplete will be cleared automatically by mode change
|
||||||
|
Ok("Cancelled autocomplete".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 autocomplete dropdown if active
|
||||||
|
if let Some(input_rect) = active_field_rect {
|
||||||
|
render_autocomplete_dropdown(
|
||||||
|
f,
|
||||||
|
chunks[0],
|
||||||
|
input_rect,
|
||||||
|
theme,
|
||||||
|
&state.editor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status info
|
||||||
|
let autocomplete_status = if state.editor.is_autocomplete_active() {
|
||||||
|
if state.editor.ui_state().is_autocomplete_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 autocomplete"
|
||||||
|
};
|
||||||
|
|
||||||
|
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!("Autocomplete: {}", autocomplete_status))),
|
||||||
|
Line::from(Span::raw(state.debug_message.clone())),
|
||||||
|
Line::from(Span::raw("F10: Quit | Tab: Trigger/Navigate autocomplete | 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(())
|
||||||
|
}
|
||||||
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_autocomplete(&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_autocomplete(&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(())
|
||||||
|
}
|
||||||
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_autocomplete(&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,133 +0,0 @@
|
|||||||
// canvas/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;
|
|
||||||
use crate::config::CanvasConfig;
|
|
||||||
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,
|
|
||||||
config: Option<&CanvasConfig>,
|
|
||||||
) -> 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) = handle_rich_autocomplete_action(action.clone(), state, &context) {
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Handle generic actions and add auto-trigger logic
|
|
||||||
let result = handle_generic_canvas_action(action.clone(), state, ideal_cursor_column, config).await?;
|
|
||||||
|
|
||||||
// 3. AUTO-TRIGGER LOGIC: Check if we should activate/deactivate autocomplete
|
|
||||||
if let Some(cfg) = config {
|
|
||||||
println!("{:?}, {}", action, cfg.should_auto_trigger_autocomplete());
|
|
||||||
if cfg.should_auto_trigger_autocomplete() {
|
|
||||||
println!("AUTO-TRIGGER");
|
|
||||||
match action {
|
|
||||||
CanvasAction::InsertChar(_) => {
|
|
||||||
println!("AUTO-T on Ins");
|
|
||||||
let current_field = state.current_field();
|
|
||||||
let current_input = state.get_current_input();
|
|
||||||
|
|
||||||
if state.supports_autocomplete(current_field)
|
|
||||||
&& !state.is_autocomplete_active()
|
|
||||||
&& current_input.len() >= 1
|
|
||||||
{
|
|
||||||
println!("ACT AUTOC");
|
|
||||||
state.activate_autocomplete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::NextField | CanvasAction::PrevField => {
|
|
||||||
println!("AUTO-T on nav");
|
|
||||||
let current_field = state.current_field();
|
|
||||||
|
|
||||||
if state.supports_autocomplete(current_field) && !state.is_autocomplete_active() {
|
|
||||||
state.activate_autocomplete();
|
|
||||||
} else if !state.supports_autocomplete(current_field) && state.is_autocomplete_active() {
|
|
||||||
state.deactivate_autocomplete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_ => {} // No auto-trigger for other actions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle rich autocomplete actions for AutocompleteCanvasState
|
|
||||||
fn handle_rich_autocomplete_action<S: CanvasState + AutocompleteCanvasState>(
|
|
||||||
action: CanvasAction,
|
|
||||||
state: &mut S,
|
|
||||||
_context: &ActionContext,
|
|
||||||
) -> Option<ActionResult> {
|
|
||||||
match action {
|
|
||||||
CanvasAction::TriggerAutocomplete => {
|
|
||||||
let current_field = state.current_field();
|
|
||||||
if state.supports_autocomplete(current_field) {
|
|
||||||
state.activate_autocomplete();
|
|
||||||
Some(ActionResult::success_with_message("Autocomplete activated"))
|
|
||||||
} else {
|
|
||||||
Some(ActionResult::success_with_message("Autocomplete not supported for this field"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::SuggestionUp => {
|
|
||||||
if state.is_autocomplete_ready() {
|
|
||||||
if let Some(autocomplete_state) = state.autocomplete_state_mut() {
|
|
||||||
autocomplete_state.select_previous();
|
|
||||||
}
|
|
||||||
Some(ActionResult::success())
|
|
||||||
} else {
|
|
||||||
Some(ActionResult::success_with_message("No suggestions available"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::SuggestionDown => {
|
|
||||||
if state.is_autocomplete_ready() {
|
|
||||||
if let Some(autocomplete_state) = state.autocomplete_state_mut() {
|
|
||||||
autocomplete_state.select_next();
|
|
||||||
}
|
|
||||||
Some(ActionResult::success())
|
|
||||||
} else {
|
|
||||||
Some(ActionResult::success_with_message("No suggestions available"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::SelectSuggestion => {
|
|
||||||
if state.is_autocomplete_ready() {
|
|
||||||
if let Some(msg) = state.apply_autocomplete_selection() {
|
|
||||||
Some(ActionResult::success_with_message(&msg))
|
|
||||||
} else {
|
|
||||||
Some(ActionResult::success_with_message("No suggestion selected"))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Some(ActionResult::success_with_message("No suggestions available"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::ExitSuggestions => {
|
|
||||||
if state.is_autocomplete_active() {
|
|
||||||
state.deactivate_autocomplete();
|
|
||||||
Some(ActionResult::success_with_message("Exited autocomplete"))
|
|
||||||
} else {
|
|
||||||
Some(ActionResult::success())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_ => None, // Not a rich autocomplete action
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +1,41 @@
|
|||||||
// canvas/src/autocomplete/gui.rs
|
// src/autocomplete/gui.rs
|
||||||
|
//! Autocomplete GUI updated to work with FormEditor
|
||||||
|
|
||||||
#[cfg(feature = "gui")]
|
#[cfg(feature = "gui")]
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Alignment, Rect},
|
layout::{Alignment, Rect},
|
||||||
style::{Modifier, Style},
|
style::{Modifier, Style},
|
||||||
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
|
widgets::{Block, List, ListItem, ListState, Paragraph}, // Removed Borders
|
||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::autocomplete::types::AutocompleteState;
|
|
||||||
|
|
||||||
#[cfg(feature = "gui")]
|
#[cfg(feature = "gui")]
|
||||||
use crate::canvas::theme::CanvasTheme;
|
use crate::canvas::theme::CanvasTheme;
|
||||||
|
use crate::data_provider::{DataProvider, SuggestionItem};
|
||||||
|
use crate::editor::FormEditor;
|
||||||
|
|
||||||
#[cfg(feature = "gui")]
|
#[cfg(feature = "gui")]
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
/// Render autocomplete dropdown - call this AFTER rendering canvas
|
/// Render autocomplete dropdown for FormEditor - call this AFTER rendering canvas
|
||||||
#[cfg(feature = "gui")]
|
#[cfg(feature = "gui")]
|
||||||
pub fn render_autocomplete_dropdown<T: CanvasTheme>(
|
pub fn render_autocomplete_dropdown<T: CanvasTheme, D: DataProvider>(
|
||||||
f: &mut Frame,
|
f: &mut Frame,
|
||||||
frame_area: Rect,
|
frame_area: Rect,
|
||||||
input_rect: Rect,
|
input_rect: Rect,
|
||||||
theme: &T,
|
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_autocomplete_active() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if autocomplete_state.is_loading {
|
if ui_state.autocomplete.is_loading {
|
||||||
render_loading_indicator(f, frame_area, input_rect, theme);
|
render_loading_indicator(f, frame_area, input_rect, theme);
|
||||||
} else if !autocomplete_state.suggestions.is_empty() {
|
} else if !editor.suggestions().is_empty() {
|
||||||
render_suggestions_dropdown(f, frame_area, input_rect, theme, autocomplete_state);
|
render_suggestions_dropdown(f, frame_area, input_rect, theme, editor.suggestions(), ui_state.autocomplete.selected_index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,9 +76,10 @@ fn render_suggestions_dropdown<T: CanvasTheme>(
|
|||||||
frame_area: Rect,
|
frame_area: Rect,
|
||||||
input_rect: Rect,
|
input_rect: Rect,
|
||||||
theme: &T,
|
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()
|
.iter()
|
||||||
.map(|item| item.display_text.as_str())
|
.map(|item| item.display_text.as_str())
|
||||||
.collect();
|
.collect();
|
||||||
@@ -95,19 +99,19 @@ fn render_suggestions_dropdown<T: CanvasTheme>(
|
|||||||
// List items
|
// List items
|
||||||
let items = create_suggestion_list_items(
|
let items = create_suggestion_list_items(
|
||||||
&display_texts,
|
&display_texts,
|
||||||
autocomplete_state.selected_index,
|
selected_index,
|
||||||
dropdown_dimensions.width,
|
dropdown_dimensions.width,
|
||||||
theme,
|
theme,
|
||||||
);
|
);
|
||||||
|
|
||||||
let list = List::new(items).block(dropdown_block);
|
let list = List::new(items).block(dropdown_block);
|
||||||
let mut list_state = ListState::default();
|
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);
|
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")]
|
#[cfg(feature = "gui")]
|
||||||
fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions {
|
fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions {
|
||||||
let max_width = display_texts
|
let max_width = display_texts
|
||||||
@@ -116,9 +120,9 @@ fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions {
|
|||||||
.max()
|
.max()
|
||||||
.unwrap_or(0) as u16;
|
.unwrap_or(0) as u16;
|
||||||
|
|
||||||
let horizontal_padding = 2; // Changed from 4 to 2 to match client
|
let horizontal_padding = 2;
|
||||||
let width = (max_width + horizontal_padding).max(10); // Changed from 12 to 10 to match client
|
let width = (max_width + horizontal_padding).max(10);
|
||||||
let height = (display_texts.len() as u16).min(5); // Removed +2 since no borders
|
let height = (display_texts.len() as u16).min(5);
|
||||||
|
|
||||||
DropdownDimensions { width, height }
|
DropdownDimensions { width, height }
|
||||||
}
|
}
|
||||||
@@ -151,7 +155,7 @@ fn calculate_dropdown_position(
|
|||||||
dropdown_area
|
dropdown_area
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create styled list items - updated to match client spacing
|
/// Create styled list items
|
||||||
#[cfg(feature = "gui")]
|
#[cfg(feature = "gui")]
|
||||||
fn create_suggestion_list_items<'a, T: CanvasTheme>(
|
fn create_suggestion_list_items<'a, T: CanvasTheme>(
|
||||||
display_texts: &'a [&'a str],
|
display_texts: &'a [&'a str],
|
||||||
@@ -159,8 +163,7 @@ fn create_suggestion_list_items<'a, T: CanvasTheme>(
|
|||||||
dropdown_width: u16,
|
dropdown_width: u16,
|
||||||
theme: &T,
|
theme: &T,
|
||||||
) -> Vec<ListItem<'a>> {
|
) -> Vec<ListItem<'a>> {
|
||||||
let horizontal_padding = 2; // Changed from 4 to 2 to match client
|
let available_width = dropdown_width;
|
||||||
let available_width = dropdown_width; // No border padding needed
|
|
||||||
|
|
||||||
display_texts
|
display_texts
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
// src/autocomplete/mod.rs
|
// src/autocomplete/mod.rs
|
||||||
pub mod types;
|
|
||||||
pub mod gui;
|
|
||||||
pub mod state;
|
|
||||||
pub mod actions;
|
|
||||||
|
|
||||||
// Re-export autocomplete types
|
pub mod state;
|
||||||
pub use types::{SuggestionItem, AutocompleteState};
|
#[cfg(feature = "gui")]
|
||||||
pub use state::AutocompleteCanvasState;
|
pub mod gui;
|
||||||
pub use actions::execute_canvas_action_with_autocomplete;
|
|
||||||
|
// Re-export the main autocomplete types
|
||||||
|
pub use state::{AutocompleteProvider, SuggestionItem};
|
||||||
|
|
||||||
|
// Re-export GUI functions if available
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub use gui::render_autocomplete_dropdown;
|
||||||
|
|||||||
@@ -1,96 +1,5 @@
|
|||||||
// canvas/src/state.rs
|
// src/autocomplete/state.rs
|
||||||
|
//! Autocomplete provider types
|
||||||
|
|
||||||
use crate::canvas::state::CanvasState;
|
// Re-export the main types from data_provider
|
||||||
|
pub use crate::data_provider::{AutocompleteProvider, SuggestionItem};
|
||||||
/// 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,253 +0,0 @@
|
|||||||
// canvas/src/canvas/actions/edit.rs
|
|
||||||
|
|
||||||
use crate::canvas::state::{CanvasState, ActionContext};
|
|
||||||
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
|
||||||
use crate::config::CanvasConfig;
|
|
||||||
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,
|
|
||||||
config: Option<&CanvasConfig>,
|
|
||||||
) -> 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, config).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,
|
|
||||||
config: Option<&CanvasConfig>,
|
|
||||||
) -> Result<ActionResult> {
|
|
||||||
match action {
|
|
||||||
CanvasAction::InsertChar(c) => {
|
|
||||||
let cursor_pos = state.current_cursor_pos();
|
|
||||||
let input = state.get_current_input_mut();
|
|
||||||
input.insert(cursor_pos, c);
|
|
||||||
state.set_current_cursor_pos(cursor_pos + 1);
|
|
||||||
state.set_has_unsaved_changes(true);
|
|
||||||
*ideal_cursor_column = cursor_pos + 1;
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::NextField | CanvasAction::PrevField => {
|
|
||||||
let old_field = state.current_field();
|
|
||||||
let total_fields = state.fields().len();
|
|
||||||
|
|
||||||
// Perform field navigation
|
|
||||||
let new_field = match action {
|
|
||||||
CanvasAction::NextField => {
|
|
||||||
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
|
|
||||||
(old_field + 1) % total_fields
|
|
||||||
} else {
|
|
||||||
(old_field + 1).min(total_fields - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CanvasAction::PrevField => {
|
|
||||||
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
|
|
||||||
if old_field == 0 { total_fields - 1 } else { old_field - 1 }
|
|
||||||
} else {
|
|
||||||
old_field.saturating_sub(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => unreachable!(),
|
|
||||||
};
|
|
||||||
|
|
||||||
state.set_current_field(new_field);
|
|
||||||
*ideal_cursor_column = state.current_cursor_pos();
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::DeleteBackward => {
|
|
||||||
let cursor_pos = state.current_cursor_pos();
|
|
||||||
if cursor_pos > 0 {
|
|
||||||
let input = state.get_current_input_mut();
|
|
||||||
input.remove(cursor_pos - 1);
|
|
||||||
state.set_current_cursor_pos(cursor_pos - 1);
|
|
||||||
state.set_has_unsaved_changes(true);
|
|
||||||
*ideal_cursor_column = cursor_pos - 1;
|
|
||||||
}
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::DeleteForward => {
|
|
||||||
let cursor_pos = state.current_cursor_pos();
|
|
||||||
let input = state.get_current_input_mut();
|
|
||||||
if cursor_pos < input.len() {
|
|
||||||
input.remove(cursor_pos);
|
|
||||||
state.set_has_unsaved_changes(true);
|
|
||||||
}
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveLeft => {
|
|
||||||
let cursor_pos = state.current_cursor_pos();
|
|
||||||
if cursor_pos > 0 {
|
|
||||||
state.set_current_cursor_pos(cursor_pos - 1);
|
|
||||||
*ideal_cursor_column = cursor_pos - 1;
|
|
||||||
}
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveRight => {
|
|
||||||
let cursor_pos = state.current_cursor_pos();
|
|
||||||
let current_input = state.get_current_input();
|
|
||||||
if cursor_pos < current_input.len() {
|
|
||||||
state.set_current_cursor_pos(cursor_pos + 1);
|
|
||||||
*ideal_cursor_column = cursor_pos + 1;
|
|
||||||
}
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveLineStart => {
|
|
||||||
state.set_current_cursor_pos(0);
|
|
||||||
*ideal_cursor_column = 0;
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveLineEnd => {
|
|
||||||
let end_pos = state.get_current_input().len();
|
|
||||||
state.set_current_cursor_pos(end_pos);
|
|
||||||
*ideal_cursor_column = end_pos;
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveUp => {
|
|
||||||
// For single-line fields, move to previous field
|
|
||||||
let current_field = state.current_field();
|
|
||||||
if current_field > 0 {
|
|
||||||
state.set_current_field(current_field - 1);
|
|
||||||
*ideal_cursor_column = state.current_cursor_pos();
|
|
||||||
}
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveDown => {
|
|
||||||
// For single-line fields, move to next field
|
|
||||||
let current_field = state.current_field();
|
|
||||||
let total_fields = state.fields().len();
|
|
||||||
if current_field < total_fields - 1 {
|
|
||||||
state.set_current_field(current_field + 1);
|
|
||||||
*ideal_cursor_column = state.current_cursor_pos();
|
|
||||||
}
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveFirstLine => {
|
|
||||||
state.set_current_field(0);
|
|
||||||
state.set_current_cursor_pos(0);
|
|
||||||
*ideal_cursor_column = 0;
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveLastLine => {
|
|
||||||
let last_field = state.fields().len() - 1;
|
|
||||||
state.set_current_field(last_field);
|
|
||||||
let end_pos = state.get_current_input().len();
|
|
||||||
state.set_current_cursor_pos(end_pos);
|
|
||||||
*ideal_cursor_column = end_pos;
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
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());
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
*ideal_cursor_column = new_pos;
|
|
||||||
}
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveWordEnd => {
|
|
||||||
let current_input = state.get_current_input();
|
|
||||||
if !current_input.is_empty() {
|
|
||||||
let new_pos = find_word_end(current_input, state.current_cursor_pos());
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
*ideal_cursor_column = new_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::Custom(action_str) => {
|
|
||||||
Ok(ActionResult::success_with_message(&format!("Custom action: {}", action_str)))
|
|
||||||
}
|
|
||||||
|
|
||||||
_ => Ok(ActionResult::success_with_message("Action not implemented")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions for word navigation
|
|
||||||
fn find_next_word_start(text: &str, cursor_pos: usize) -> usize {
|
|
||||||
let chars: Vec<char> = text.chars().collect();
|
|
||||||
let mut pos = cursor_pos;
|
|
||||||
|
|
||||||
// Skip current word
|
|
||||||
while pos < chars.len() && chars[pos].is_alphanumeric() {
|
|
||||||
pos += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip whitespace
|
|
||||||
while pos < chars.len() && chars[pos].is_whitespace() {
|
|
||||||
pos += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
pos
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_word_end(text: &str, cursor_pos: usize) -> usize {
|
|
||||||
let chars: Vec<char> = text.chars().collect();
|
|
||||||
let mut pos = cursor_pos;
|
|
||||||
|
|
||||||
// Move to end of current word
|
|
||||||
while pos < chars.len() && chars[pos].is_alphanumeric() {
|
|
||||||
pos += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
pos
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_prev_word_start(text: &str, cursor_pos: usize) -> usize {
|
|
||||||
if cursor_pos == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let chars: Vec<char> = text.chars().collect();
|
|
||||||
let mut pos = cursor_pos.saturating_sub(1);
|
|
||||||
|
|
||||||
// Skip whitespace
|
|
||||||
while pos > 0 && chars[pos].is_whitespace() {
|
|
||||||
pos -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip to start of word
|
|
||||||
while pos > 0 && chars[pos - 1].is_alphanumeric() {
|
|
||||||
pos -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
pos
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// canvas/src/canvas/actions/mod.rs
|
// src/canvas/actions/mod.rs
|
||||||
pub mod types;
|
|
||||||
pub mod edit;
|
|
||||||
|
|
||||||
// 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 types::{CanvasAction, ActionResult};
|
||||||
pub use edit::execute_canvas_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,34 +1,34 @@
|
|||||||
// src/canvas/actions/types.rs
|
// src/canvas/actions/types.rs
|
||||||
|
|
||||||
|
/// All available canvas actions
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum CanvasAction {
|
pub enum CanvasAction {
|
||||||
// Character input
|
// Movement actions
|
||||||
InsertChar(char),
|
|
||||||
|
|
||||||
// Deletion
|
|
||||||
DeleteBackward,
|
|
||||||
DeleteForward,
|
|
||||||
|
|
||||||
// Basic cursor movement
|
|
||||||
MoveLeft,
|
MoveLeft,
|
||||||
MoveRight,
|
MoveRight,
|
||||||
MoveUp,
|
MoveUp,
|
||||||
MoveDown,
|
MoveDown,
|
||||||
|
|
||||||
|
// Word movement
|
||||||
|
MoveWordNext,
|
||||||
|
MoveWordPrev,
|
||||||
|
MoveWordEnd,
|
||||||
|
MoveWordEndPrev,
|
||||||
|
|
||||||
// Line movement
|
// Line movement
|
||||||
MoveLineStart,
|
MoveLineStart,
|
||||||
MoveLineEnd,
|
MoveLineEnd,
|
||||||
|
|
||||||
|
// Field movement
|
||||||
|
NextField,
|
||||||
|
PrevField,
|
||||||
MoveFirstLine,
|
MoveFirstLine,
|
||||||
MoveLastLine,
|
MoveLastLine,
|
||||||
|
|
||||||
// Word movement
|
// Editing actions
|
||||||
MoveWordNext,
|
InsertChar(char),
|
||||||
MoveWordEnd,
|
DeleteBackward,
|
||||||
MoveWordPrev,
|
DeleteForward,
|
||||||
|
|
||||||
// Field navigation
|
|
||||||
NextField,
|
|
||||||
PrevField,
|
|
||||||
|
|
||||||
// Autocomplete actions
|
// Autocomplete actions
|
||||||
TriggerAutocomplete,
|
TriggerAutocomplete,
|
||||||
@@ -41,83 +41,131 @@ pub enum CanvasAction {
|
|||||||
Custom(String),
|
Custom(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CanvasAction {
|
/// Result type for canvas actions
|
||||||
pub fn from_key(key: crossterm::event::KeyCode) -> Option<Self> {
|
#[derive(Debug, Clone)]
|
||||||
match key {
|
|
||||||
crossterm::event::KeyCode::Char(c) => Some(Self::InsertChar(c)),
|
|
||||||
crossterm::event::KeyCode::Backspace => Some(Self::DeleteBackward),
|
|
||||||
crossterm::event::KeyCode::Delete => Some(Self::DeleteForward),
|
|
||||||
crossterm::event::KeyCode::Left => Some(Self::MoveLeft),
|
|
||||||
crossterm::event::KeyCode::Right => Some(Self::MoveRight),
|
|
||||||
crossterm::event::KeyCode::Up => Some(Self::MoveUp),
|
|
||||||
crossterm::event::KeyCode::Down => Some(Self::MoveDown),
|
|
||||||
crossterm::event::KeyCode::Home => Some(Self::MoveLineStart),
|
|
||||||
crossterm::event::KeyCode::End => Some(Self::MoveLineEnd),
|
|
||||||
crossterm::event::KeyCode::Tab => Some(Self::NextField),
|
|
||||||
crossterm::event::KeyCode::BackTab => Some(Self::PrevField),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backward compatibility method
|
|
||||||
pub fn from_string(action: &str) -> Self {
|
|
||||||
match action {
|
|
||||||
"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,
|
|
||||||
"next_field" => Self::NextField,
|
|
||||||
"prev_field" => Self::PrevField,
|
|
||||||
"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()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub enum ActionResult {
|
pub enum ActionResult {
|
||||||
Success(Option<String>),
|
Success,
|
||||||
HandledByFeature(String),
|
Message(String),
|
||||||
RequiresContext(String),
|
HandledByApp(String),
|
||||||
|
HandledByFeature(String), // Keep for compatibility
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ActionResult {
|
impl ActionResult {
|
||||||
pub fn success() -> Self {
|
pub fn success() -> Self {
|
||||||
Self::Success(None)
|
Self::Success
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn success_with_message(msg: &str) -> Self {
|
pub fn success_with_message(msg: &str) -> Self {
|
||||||
Self::Success(Some(msg.to_string()))
|
Self::Message(msg.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handled_by_app(msg: &str) -> Self {
|
||||||
|
Self::HandledByApp(msg.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn error(msg: &str) -> Self {
|
pub fn error(msg: &str) -> Self {
|
||||||
Self::Error(msg.into())
|
Self::Error(msg.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_success(&self) -> bool {
|
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> {
|
pub fn message(&self) -> Option<&str> {
|
||||||
match self {
|
match self {
|
||||||
Self::Success(msg) => msg.as_deref(),
|
Self::Message(msg) | Self::HandledByApp(msg) | Self::HandledByFeature(msg) | Self::Error(msg) => Some(msg),
|
||||||
Self::HandledByFeature(msg) => Some(msg),
|
Self::Success => None,
|
||||||
Self::RequiresContext(msg) => Some(msg),
|
|
||||||
Self::Error(msg) => Some(msg),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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::TriggerAutocomplete => "trigger autocomplete",
|
||||||
|
Self::SuggestionUp => "suggestion up",
|
||||||
|
Self::SuggestionDown => "suggestion down",
|
||||||
|
Self::SelectSuggestion => "select suggestion",
|
||||||
|
Self::ExitSuggestions => "exit suggestions",
|
||||||
|
Self::Custom(_name) => "custom action",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all editing-related actions
|
||||||
|
pub fn editing_actions() -> Vec<CanvasAction> {
|
||||||
|
vec![
|
||||||
|
Self::InsertChar(' '), // Example char
|
||||||
|
Self::DeleteBackward,
|
||||||
|
Self::DeleteForward,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all autocomplete-related actions
|
||||||
|
pub fn autocomplete_actions() -> Vec<CanvasAction> {
|
||||||
|
vec![
|
||||||
|
Self::TriggerAutocomplete,
|
||||||
|
Self::SuggestionUp,
|
||||||
|
Self::SuggestionDown,
|
||||||
|
Self::SelectSuggestion,
|
||||||
|
Self::ExitSuggestions,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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")]
|
#[cfg(feature = "gui")]
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
@@ -9,28 +10,62 @@ use ratatui::{
|
|||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::canvas::state::CanvasState;
|
|
||||||
use crate::canvas::modes::HighlightState;
|
|
||||||
|
|
||||||
#[cfg(feature = "gui")]
|
#[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")]
|
#[cfg(feature = "gui")]
|
||||||
use std::cmp::{max, min};
|
use std::cmp::{max, min};
|
||||||
|
|
||||||
/// Render ONLY the canvas form fields - no autocomplete
|
/// Render ONLY the canvas form fields - no autocomplete
|
||||||
|
/// Updated to work with FormEditor instead of CanvasState trait
|
||||||
#[cfg(feature = "gui")]
|
#[cfg(feature = "gui")]
|
||||||
pub fn render_canvas<T: CanvasTheme>(
|
pub fn render_canvas<T: CanvasTheme, D: DataProvider>(
|
||||||
f: &mut Frame,
|
f: &mut Frame,
|
||||||
area: Rect,
|
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,
|
theme: &T,
|
||||||
is_edit_mode: bool,
|
|
||||||
highlight_state: &HighlightState,
|
highlight_state: &HighlightState,
|
||||||
) -> Option<Rect> {
|
) -> Option<Rect> {
|
||||||
let fields: Vec<&str> = form_state.fields();
|
let ui_state = editor.ui_state();
|
||||||
let current_field_idx = form_state.current_field();
|
let data_provider = editor.data_provider();
|
||||||
let inputs: Vec<&String> = form_state.inputs();
|
|
||||||
|
// 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(
|
render_canvas_fields(
|
||||||
f,
|
f,
|
||||||
@@ -41,13 +76,56 @@ pub fn render_canvas<T: CanvasTheme>(
|
|||||||
theme,
|
theme,
|
||||||
is_edit_mode,
|
is_edit_mode,
|
||||||
highlight_state,
|
highlight_state,
|
||||||
form_state.current_cursor_pos(),
|
editor.display_cursor_position(), // Use display cursor position for masks
|
||||||
form_state.has_unsaved_changes(),
|
false, // TODO: track unsaved changes in editor
|
||||||
|i| form_state.get_display_value_for_field(i).to_string(),
|
|i| {
|
||||||
|i| form_state.has_display_override(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
|
/// Core canvas field rendering
|
||||||
#[cfg(feature = "gui")]
|
#[cfg(feature = "gui")]
|
||||||
fn render_canvas_fields<T: CanvasTheme, F1, F2>(
|
fn render_canvas_fields<T: CanvasTheme, F1, F2>(
|
||||||
@@ -55,7 +133,7 @@ fn render_canvas_fields<T: CanvasTheme, F1, F2>(
|
|||||||
area: Rect,
|
area: Rect,
|
||||||
fields: &[&str],
|
fields: &[&str],
|
||||||
current_field_idx: &usize,
|
current_field_idx: &usize,
|
||||||
inputs: &[&String],
|
inputs: &[String],
|
||||||
theme: &T,
|
theme: &T,
|
||||||
is_edit_mode: bool,
|
is_edit_mode: bool,
|
||||||
highlight_state: &HighlightState,
|
highlight_state: &HighlightState,
|
||||||
@@ -112,7 +190,7 @@ where
|
|||||||
// Render field values and return active field rect
|
// Render field values and return active field rect
|
||||||
render_field_values(
|
render_field_values(
|
||||||
f,
|
f,
|
||||||
input_rows.to_vec(), // Fix: Convert Rc<[Rect]> to Vec<Rect>
|
input_rows.to_vec(),
|
||||||
inputs,
|
inputs,
|
||||||
current_field_idx,
|
current_field_idx,
|
||||||
theme,
|
theme,
|
||||||
@@ -154,7 +232,7 @@ fn render_field_labels<T: CanvasTheme>(
|
|||||||
fn render_field_values<T: CanvasTheme, F1, F2>(
|
fn render_field_values<T: CanvasTheme, F1, F2>(
|
||||||
f: &mut Frame,
|
f: &mut Frame,
|
||||||
input_rows: Vec<Rect>,
|
input_rows: Vec<Rect>,
|
||||||
inputs: &[&String],
|
inputs: &[String],
|
||||||
current_field_idx: &usize,
|
current_field_idx: &usize,
|
||||||
theme: &T,
|
theme: &T,
|
||||||
highlight_state: &HighlightState,
|
highlight_state: &HighlightState,
|
||||||
@@ -205,7 +283,7 @@ fn apply_highlighting<'a, T: CanvasTheme>(
|
|||||||
current_cursor_pos: usize,
|
current_cursor_pos: usize,
|
||||||
highlight_state: &HighlightState,
|
highlight_state: &HighlightState,
|
||||||
theme: &T,
|
theme: &T,
|
||||||
is_active: bool,
|
_is_active: bool,
|
||||||
) -> Line<'a> {
|
) -> Line<'a> {
|
||||||
let text_len = text.chars().count();
|
let text_len = text.chars().count();
|
||||||
|
|
||||||
@@ -213,23 +291,19 @@ fn apply_highlighting<'a, T: CanvasTheme>(
|
|||||||
HighlightState::Off => {
|
HighlightState::Off => {
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
text,
|
text,
|
||||||
if is_active {
|
|
||||||
Style::default().fg(theme.highlight())
|
|
||||||
} else {
|
|
||||||
Style::default().fg(theme.fg())
|
Style::default().fg(theme.fg())
|
||||||
},
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
HighlightState::Characterwise { anchor } => {
|
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 } => {
|
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")]
|
#[cfg(feature = "gui")]
|
||||||
fn apply_characterwise_highlighting<'a, T: CanvasTheme>(
|
fn apply_characterwise_highlighting<'a, T: CanvasTheme>(
|
||||||
text: &'a str,
|
text: &'a str,
|
||||||
@@ -239,21 +313,25 @@ fn apply_characterwise_highlighting<'a, T: CanvasTheme>(
|
|||||||
current_cursor_pos: usize,
|
current_cursor_pos: usize,
|
||||||
anchor: &(usize, usize),
|
anchor: &(usize, usize),
|
||||||
theme: &T,
|
theme: &T,
|
||||||
is_active: bool,
|
_is_active: bool,
|
||||||
) -> Line<'a> {
|
) -> Line<'a> {
|
||||||
let (anchor_field, anchor_char) = *anchor;
|
let (anchor_field, anchor_char) = *anchor;
|
||||||
let start_field = min(anchor_field, *current_field_idx);
|
let start_field = min(anchor_field, *current_field_idx);
|
||||||
let end_field = max(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()
|
let highlight_style = Style::default()
|
||||||
.fg(theme.highlight())
|
.fg(theme.highlight()) // ✅ Contrasting text color for selected text
|
||||||
.bg(theme.highlight_bg())
|
.bg(theme.highlight_bg()) // ✅ Background for selected text
|
||||||
.add_modifier(Modifier::BOLD);
|
.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 field_index >= start_field && field_index <= end_field {
|
||||||
if start_field == end_field {
|
if start_field == end_field {
|
||||||
|
// Single field selection
|
||||||
let (start_char, end_char) = if 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))
|
(min(anchor_char, current_cursor_pos), max(anchor_char, current_cursor_pos))
|
||||||
} else if anchor_field < *current_field_idx {
|
} 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();
|
let after: String = text.chars().skip(clamped_end + 1).collect();
|
||||||
|
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled(before, normal_style_in_highlight),
|
Span::styled(before, normal_style), // Normal text color
|
||||||
Span::styled(highlighted, highlight_style),
|
Span::styled(highlighted, highlight_style), // Contrasting color + background
|
||||||
Span::styled(after, normal_style_in_highlight),
|
Span::styled(after, normal_style), // Normal text color
|
||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
// Multi-field selection
|
// 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 {
|
} else {
|
||||||
Line::from(Span::styled(
|
// Middle field: highlight entire field
|
||||||
text,
|
Line::from(Span::styled(text, highlight_style))
|
||||||
if is_active { normal_style_in_highlight } else { normal_style_outside }
|
}
|
||||||
))
|
}
|
||||||
|
} else {
|
||||||
|
// 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")]
|
#[cfg(feature = "gui")]
|
||||||
fn apply_linewise_highlighting<'a, T: CanvasTheme>(
|
fn apply_linewise_highlighting<'a, T: CanvasTheme>(
|
||||||
text: &'a str,
|
text: &'a str,
|
||||||
@@ -297,25 +416,27 @@ fn apply_linewise_highlighting<'a, T: CanvasTheme>(
|
|||||||
current_field_idx: &usize,
|
current_field_idx: &usize,
|
||||||
anchor_line: &usize,
|
anchor_line: &usize,
|
||||||
theme: &T,
|
theme: &T,
|
||||||
is_active: bool,
|
_is_active: bool,
|
||||||
) -> Line<'a> {
|
) -> Line<'a> {
|
||||||
let start_field = min(*anchor_line, *current_field_idx);
|
let start_field = min(*anchor_line, *current_field_idx);
|
||||||
let end_field = max(*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()
|
let highlight_style = Style::default()
|
||||||
.fg(theme.highlight())
|
.fg(theme.highlight()) // ✅ Contrasting text color for selected text
|
||||||
.bg(theme.highlight_bg())
|
.bg(theme.highlight_bg()) // ✅ Background for selected text
|
||||||
.add_modifier(Modifier::BOLD);
|
.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 field_index >= start_field && field_index <= end_field {
|
||||||
|
// Selected line: contrasting text color + background
|
||||||
Line::from(Span::styled(text, highlight_style))
|
Line::from(Span::styled(text, highlight_style))
|
||||||
} else {
|
} else {
|
||||||
Line::from(Span::styled(
|
// Normal line: normal text color (no special active field color)
|
||||||
text,
|
Line::from(Span::styled(text, normal_style))
|
||||||
if is_active { normal_style_in_highlight } else { normal_style_outside }
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,11 +449,24 @@ fn set_cursor_position(
|
|||||||
current_cursor_pos: usize,
|
current_cursor_pos: usize,
|
||||||
has_display_override: bool,
|
has_display_override: bool,
|
||||||
) {
|
) {
|
||||||
let cursor_x = if has_display_override {
|
// BUG FIX: Use the correct display cursor position, not end of text
|
||||||
field_rect.x + text.chars().count() as u16
|
let cursor_x = field_rect.x + current_cursor_pos as u16;
|
||||||
} else {
|
|
||||||
field_rect.x + current_cursor_pos as u16
|
|
||||||
};
|
|
||||||
let cursor_y = field_rect.y;
|
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
|
// src/canvas/mod.rs
|
||||||
|
|
||||||
pub mod actions;
|
pub mod actions;
|
||||||
pub mod modes;
|
|
||||||
pub mod gui;
|
|
||||||
pub mod theme;
|
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
pub mod modes;
|
||||||
|
|
||||||
// Re-export commonly used canvas types
|
#[cfg(feature = "gui")]
|
||||||
pub use actions::{CanvasAction, ActionResult};
|
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 modes::{AppMode, ModeManager, HighlightState};
|
||||||
pub use state::{CanvasState, ActionContext};
|
|
||||||
|
|
||||||
#[cfg(feature = "gui")]
|
#[cfg(feature = "cursor-style")]
|
||||||
pub use theme::CanvasTheme;
|
pub use cursor::CursorManager;
|
||||||
|
|
||||||
#[cfg(feature = "gui")]
|
|
||||||
pub use gui::render_canvas;
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// src/modes/handlers/mode_manager.rs
|
// src/modes/handlers/mode_manager.rs
|
||||||
// canvas/src/modes/manager.rs
|
// canvas/src/modes/manager.rs
|
||||||
|
|
||||||
|
#[cfg(feature = "cursor-style")]
|
||||||
|
use crate::canvas::CursorManager;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AppMode {
|
pub enum AppMode {
|
||||||
@@ -30,4 +32,39 @@ impl ModeManager {
|
|||||||
pub fn can_enter_highlight_mode(current_mode: AppMode) -> bool {
|
pub fn can_enter_highlight_mode(current_mode: AppMode) -> bool {
|
||||||
matches!(current_mode, AppMode::ReadOnly)
|
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
|
/// Library-owned UI state - user never directly modifies this
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ActionContext {
|
pub struct EditorState {
|
||||||
pub key_code: Option<crossterm::event::KeyCode>, // Kept for backwards compatibility
|
// Navigation state
|
||||||
pub ideal_cursor_column: usize,
|
pub(crate) current_field: usize,
|
||||||
pub current_input: String,
|
pub(crate) cursor_pos: usize,
|
||||||
pub current_field: usize,
|
pub(crate) ideal_cursor_column: usize,
|
||||||
|
|
||||||
|
// Mode state
|
||||||
|
pub(crate) current_mode: AppMode,
|
||||||
|
|
||||||
|
// Autocomplete state
|
||||||
|
pub(crate) autocomplete: AutocompleteUIState,
|
||||||
|
|
||||||
|
// 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.
|
#[derive(Debug, Clone)]
|
||||||
/// This enables the same mode behaviors (edit, read-only, highlight) to work across
|
pub struct AutocompleteUIState {
|
||||||
/// any implementation - login forms, data entry forms, configuration screens, etc.
|
pub(crate) is_active: bool,
|
||||||
pub trait CanvasState {
|
pub(crate) is_loading: bool,
|
||||||
// --- Core Navigation ---
|
pub(crate) selected_index: Option<usize>,
|
||||||
fn current_field(&self) -> usize;
|
pub(crate) active_field: Option<usize>,
|
||||||
fn current_cursor_pos(&self) -> usize;
|
}
|
||||||
fn set_current_field(&mut self, index: usize);
|
|
||||||
fn set_current_cursor_pos(&mut self, pos: usize);
|
|
||||||
|
|
||||||
// --- Data Access ---
|
#[derive(Debug, Clone)]
|
||||||
fn get_current_input(&self) -> &str;
|
pub enum SelectionState {
|
||||||
fn get_current_input_mut(&mut self) -> &mut String;
|
None,
|
||||||
fn inputs(&self) -> Vec<&String>;
|
Characterwise { anchor: (usize, usize) },
|
||||||
fn fields(&self) -> Vec<&str>;
|
Linewise { anchor_field: usize },
|
||||||
|
}
|
||||||
|
|
||||||
// --- State Management ---
|
impl EditorState {
|
||||||
fn has_unsaved_changes(&self) -> bool;
|
pub fn new() -> Self {
|
||||||
fn set_has_unsaved_changes(&mut self, changed: bool);
|
Self {
|
||||||
|
current_field: 0,
|
||||||
// --- Feature-specific action handling ---
|
cursor_pos: 0,
|
||||||
|
ideal_cursor_column: 0,
|
||||||
/// Feature-specific action handling (NEW: Type-safe)
|
current_mode: AppMode::Edit,
|
||||||
fn handle_feature_action(&mut self, _action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
autocomplete: AutocompleteUIState {
|
||||||
None // Default: no feature-specific handling
|
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 {
|
// READ-ONLY ACCESS: User can fetch UI state for compatibility
|
||||||
self.inputs()
|
// ===================================================================
|
||||||
.get(index)
|
|
||||||
.map(|s| s.as_str())
|
/// Get current field index (for user's business logic)
|
||||||
.unwrap_or("")
|
pub fn current_field(&self) -> usize {
|
||||||
|
self.current_field
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_display_override(&self, _index: usize) -> bool {
|
/// Get current cursor position (for user's business logic)
|
||||||
false
|
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 autocomplete is active (for user's business logic)
|
||||||
|
pub fn is_autocomplete_active(&self) -> bool {
|
||||||
|
self.autocomplete.is_active
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if autocomplete is loading (for user's business logic)
|
||||||
|
pub fn is_autocomplete_loading(&self) -> bool {
|
||||||
|
self.autocomplete.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_autocomplete(&mut self, field_index: usize) {
|
||||||
|
self.autocomplete.is_active = true;
|
||||||
|
self.autocomplete.is_loading = true;
|
||||||
|
self.autocomplete.active_field = Some(field_index);
|
||||||
|
self.autocomplete.selected_index = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn deactivate_autocomplete(&mut self) {
|
||||||
|
self.autocomplete.is_active = false;
|
||||||
|
self.autocomplete.is_loading = false;
|
||||||
|
self.autocomplete.active_field = None;
|
||||||
|
self.autocomplete.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 highlight_bg(&self) -> Color;
|
||||||
fn warning(&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,494 +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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// NEW: Check if autocomplete should auto-trigger (simple logic)
|
|
||||||
pub fn should_auto_trigger_autocomplete(&self) -> bool {
|
|
||||||
// If trigger_autocomplete keybinding exists anywhere, use manual mode only
|
|
||||||
// If no trigger_autocomplete keybinding, use auto-trigger mode
|
|
||||||
!self.has_trigger_autocomplete_keybinding()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// NEW: Check if user has configured manual trigger keybinding
|
|
||||||
pub fn has_trigger_autocomplete_keybinding(&self) -> bool {
|
|
||||||
self.keybindings.edit.contains_key("trigger_autocomplete") ||
|
|
||||||
self.keybindings.read_only.contains_key("trigger_autocomplete") ||
|
|
||||||
self.keybindings.global.contains_key("trigger_autocomplete")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 autocomplete (optional)
|
||||||
|
fn supports_autocomplete(&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 autocomplete data
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AutocompleteProvider {
|
||||||
|
/// Fetch autocomplete 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,183 +0,0 @@
|
|||||||
// canvas/src/dispatcher.rs
|
|
||||||
|
|
||||||
use crate::canvas::state::CanvasState;
|
|
||||||
use crate::canvas::actions::{CanvasAction, ActionResult, execute_canvas_action};
|
|
||||||
use crate::config::CanvasConfig;
|
|
||||||
|
|
||||||
/// 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> {
|
|
||||||
|
|
||||||
// Load config once here instead of threading it everywhere
|
|
||||||
execute_canvas_action(action, state, ideal_cursor_column, Some(&CanvasConfig::load())).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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1202
canvas/src/editor.rs
Normal file
1202
canvas/src/editor.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,55 @@
|
|||||||
// src/lib.rs
|
// src/lib.rs
|
||||||
|
|
||||||
pub mod canvas;
|
pub mod canvas;
|
||||||
|
pub mod editor;
|
||||||
|
pub mod data_provider;
|
||||||
|
|
||||||
|
// Only include autocomplete module if feature is enabled
|
||||||
|
#[cfg(feature = "autocomplete")]
|
||||||
pub mod autocomplete;
|
pub mod autocomplete;
|
||||||
pub mod config;
|
|
||||||
pub mod dispatcher;
|
// 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, AutocompleteProvider, 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 = "autocomplete"))]
|
||||||
|
pub use autocomplete::gui::render_autocomplete_dropdown;
|
||||||
|
|||||||
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 "autocomplete" "AUTOCOMPLETE 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 autocomplete"
|
||||||
|
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,58 +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"]
|
|
||||||
trigger_autocomplete = ["Ctrl+p"]
|
|
||||||
|
|
||||||
# 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"]
|
previous_entry = ["left","q"]
|
||||||
next_entry = ["right","1"]
|
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 = ["v"]
|
||||||
enter_highlight_mode_linewise = ["ctrl+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]
|
[keybindings.highlight]
|
||||||
exit_highlight_mode = ["esc"]
|
exit_highlight_mode = ["esc"]
|
||||||
enter_highlight_mode_linewise = ["ctrl+v"]
|
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]
|
[keybindings.edit]
|
||||||
# BIG CHANGES NOW EXIT HANDLES EITHER IF THOSE
|
# BIG CHANGES NOW EXIT HANDLES EITHER IF THOSE
|
||||||
# exit_edit_mode = ["esc","ctrl+e"]
|
# exit_edit_mode = ["esc","ctrl+e"]
|
||||||
@@ -65,15 +85,30 @@ enter_highlight_mode_linewise = ["ctrl+v"]
|
|||||||
# select_suggestion = ["enter"]
|
# select_suggestion = ["enter"]
|
||||||
# next_field = ["enter"]
|
# next_field = ["enter"]
|
||||||
enter_decider = ["enter"]
|
enter_decider = ["enter"]
|
||||||
prev_field = ["shift+enter"]
|
|
||||||
exit = ["esc", "ctrl+e"]
|
exit = ["esc", "ctrl+e"]
|
||||||
delete_char_forward = ["delete"]
|
|
||||||
delete_char_backward = ["backspace"]
|
|
||||||
move_left = ["left"]
|
|
||||||
move_right = ["right"]
|
|
||||||
suggestion_down = ["ctrl+n", "tab"]
|
suggestion_down = ["ctrl+n", "tab"]
|
||||||
suggestion_up = ["ctrl+p", "shift+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]
|
[keybindings.command]
|
||||||
exit_command_mode = ["ctrl+g", "esc"]
|
exit_command_mode = ["ctrl+g", "esc"]
|
||||||
command_execute = ["enter"]
|
command_execute = ["enter"]
|
||||||
@@ -91,3 +126,9 @@ keybinding_mode = "vim" # Options: "default", "vim", "emacs"
|
|||||||
[colors]
|
[colors]
|
||||||
theme = "dark"
|
theme = "dark"
|
||||||
# Options: "light", "dark", "high_contrast"
|
# 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::highlight::HighlightState;
|
||||||
use crate::state::app::state::AppState;
|
use crate::state::app::state::AppState;
|
||||||
use crate::state::pages::add_table::{AddTableFocus, AddTableState};
|
use crate::state::pages::add_table::{AddTableFocus, AddTableState};
|
||||||
use canvas::canvas::{render_canvas, CanvasState, HighlightState as CanvasHighlightState}; // Use canvas library
|
use canvas::canvas::{render_canvas, CanvasState, HighlightState as CanvasHighlightState};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
style::{Modifier, Style},
|
style::{Modifier, Style},
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
// src/components/handlers.rs
|
// src/components/handlers.rs
|
||||||
pub mod canvas;
|
|
||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
pub mod buffer_list;
|
pub mod buffer_list;
|
||||||
|
|
||||||
pub use canvas::*;
|
|
||||||
pub use sidebar::*;
|
pub use sidebar::*;
|
||||||
pub use buffer_list::*;
|
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
|
// src/functions/modes.rs
|
||||||
|
|
||||||
pub mod read_only;
|
|
||||||
pub mod edit;
|
|
||||||
pub mod navigation;
|
pub mod navigation;
|
||||||
|
|
||||||
pub use read_only::*;
|
|
||||||
pub use edit::*;
|
|
||||||
pub use navigation::*;
|
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 canvas::canvas::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 crossterm::event::{KeyCode, KeyEvent};
|
|
||||||
use canvas::canvas::CanvasState;
|
|
||||||
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::app::state::AppState;
|
|
||||||
use canvas::canvas::CanvasState;
|
|
||||||
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::app::state::AppState;
|
|
||||||
use canvas::canvas::CanvasState;
|
|
||||||
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
|
// src/modes/canvas/edit.rs
|
||||||
use crate::config::binds::config::Config;
|
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::modes::handlers::event::EventHandler;
|
||||||
use crate::services::grpc_client::GrpcClient;
|
use crate::services::grpc_client::GrpcClient;
|
||||||
use crate::state::app::state::AppState;
|
use crate::state::app::state::AppState;
|
||||||
@@ -15,7 +12,7 @@ use canvas::canvas::CanvasState;
|
|||||||
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher, canvas::ActionResult};
|
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher, canvas::ActionResult};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use common::proto::komp_ac::search::search_response::Hit;
|
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 tokio::sync::mpsc;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
@@ -82,7 +79,9 @@ pub async fn handle_form_edit_with_canvas(
|
|||||||
ideal_cursor_column: &mut usize,
|
ideal_cursor_column: &mut usize,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
// Try canvas action from key first
|
// 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 {
|
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
|
||||||
Ok(ActionResult::Success(msg)) => {
|
Ok(ActionResult::Success(msg)) => {
|
||||||
return Ok(msg.unwrap_or_default());
|
return Ok(msg.unwrap_or_default());
|
||||||
@@ -127,16 +126,65 @@ pub async fn handle_form_edit_with_canvas(
|
|||||||
Ok(String::new())
|
Ok(String::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// NEW: Unified canvas action handler for any CanvasState (LoginState, RegisterState, etc.)
|
/// Helper function to execute a specific action using canvas library
|
||||||
/// This replaces the old auth_e::execute_edit_action calls with the new 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>(
|
async fn handle_canvas_state_edit<S: CanvasState>(
|
||||||
key: KeyEvent,
|
key: KeyEvent,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
state: &mut S,
|
state: &mut S,
|
||||||
ideal_cursor_column: &mut usize,
|
ideal_cursor_column: &mut usize,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
// Try direct key mapping first (same pattern as FormState)
|
// println!("DEBUG: Key pressed: {:?}", key); // DEBUG
|
||||||
if let Some(canvas_action) = CanvasAction::from_key(key.code) {
|
|
||||||
|
// 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 {
|
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
|
||||||
Ok(ActionResult::Success(msg)) => {
|
Ok(ActionResult::Success(msg)) => {
|
||||||
return Ok(msg.unwrap_or_default());
|
return Ok(msg.unwrap_or_default());
|
||||||
@@ -151,13 +199,17 @@ async fn handle_canvas_state_edit<S: CanvasState>(
|
|||||||
return Ok(format!("Context needed: {}", msg));
|
return Ok(format!("Context needed: {}", msg));
|
||||||
}
|
}
|
||||||
Err(_) => {
|
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)
|
// 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) {
|
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);
|
let canvas_action = CanvasAction::from_string(&action_str);
|
||||||
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
|
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
|
||||||
Ok(ActionResult::Success(msg)) => {
|
Ok(ActionResult::Success(msg)) => {
|
||||||
@@ -176,8 +228,14 @@ async fn handle_canvas_state_edit<S: CanvasState>(
|
|||||||
return Ok(format!("Action failed: {}", 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())
|
Ok(String::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,8 +349,8 @@ pub async fn handle_edit_event(
|
|||||||
} else {
|
} else {
|
||||||
"insert_char"
|
"insert_char"
|
||||||
};
|
};
|
||||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
// FIXED: Use canvas library instead of form_e::execute_edit_action
|
||||||
form_e::execute_edit_action(
|
execute_canvas_action(
|
||||||
action,
|
action,
|
||||||
key,
|
key,
|
||||||
form_state,
|
form_state,
|
||||||
@@ -321,8 +379,8 @@ pub async fn handle_edit_event(
|
|||||||
{
|
{
|
||||||
// Handle Enter key (next field)
|
// Handle Enter key (next field)
|
||||||
if action_str == "enter_decider" {
|
if action_str == "enter_decider" {
|
||||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
// FIXED: Use canvas library instead of form_e::execute_edit_action
|
||||||
let msg = form_e::execute_edit_action(
|
let msg = execute_canvas_action(
|
||||||
"next_field",
|
"next_field",
|
||||||
key,
|
key,
|
||||||
form_state,
|
form_state,
|
||||||
@@ -375,8 +433,8 @@ pub async fn handle_edit_event(
|
|||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
// FIXED: Use canvas library instead of form_e::execute_edit_action
|
||||||
form_e::execute_edit_action(
|
execute_canvas_action(
|
||||||
action_str,
|
action_str,
|
||||||
key,
|
key,
|
||||||
form_state,
|
form_state,
|
||||||
@@ -426,8 +484,8 @@ pub async fn handle_edit_event(
|
|||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
// FIXED: Use canvas library instead of form_e::execute_edit_action
|
||||||
form_e::execute_edit_action(
|
execute_canvas_action(
|
||||||
"insert_char",
|
"insert_char",
|
||||||
key,
|
key,
|
||||||
form_state,
|
form_state,
|
||||||
|
|||||||
@@ -5,16 +5,85 @@ use crate::config::binds::key_sequences::KeySequenceTracker;
|
|||||||
use crate::services::grpc_client::GrpcClient;
|
use crate::services::grpc_client::GrpcClient;
|
||||||
use crate::state::pages::auth::LoginState;
|
use crate::state::pages::auth::LoginState;
|
||||||
use crate::state::pages::auth::RegisterState;
|
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::form::FormState;
|
||||||
use crate::state::pages::add_logic::AddLogicState;
|
use crate::state::pages::add_logic::AddLogicState;
|
||||||
use crate::state::pages::add_table::AddTableState;
|
use crate::state::pages::add_table::AddTableState;
|
||||||
use crate::state::app::state::AppState;
|
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 canvas::{canvas::{CanvasAction, CanvasState, ActionResult}, dispatcher::ActionDispatcher};
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
use anyhow::Result;
|
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(
|
pub async fn handle_form_readonly_with_canvas(
|
||||||
key_event: KeyEvent,
|
key_event: KeyEvent,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
@@ -22,7 +91,9 @@ pub async fn handle_form_readonly_with_canvas(
|
|||||||
ideal_cursor_column: &mut usize,
|
ideal_cursor_column: &mut usize,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
// Try canvas action from key first
|
// 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 {
|
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
|
||||||
Ok(ActionResult::Success(msg)) => {
|
Ok(ActionResult::Success(msg)) => {
|
||||||
return Ok(msg.unwrap_or_default());
|
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) {
|
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 {
|
if app_state.ui.show_login {
|
||||||
let current_input = login_state.get_current_input();
|
let current_input = login_state.get_current_input();
|
||||||
let current_pos = login_state.current_cursor_pos();
|
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();
|
*ideal_cursor_column = add_table_state.current_cursor_pos();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Handle FormState (uses library CanvasState)
|
// Handle FormState
|
||||||
use canvas::canvas::CanvasState as LibraryCanvasState; // Import at the top of the function
|
|
||||||
let current_input = form_state.get_current_input();
|
let current_input = form_state.get_current_input();
|
||||||
let current_pos = form_state.current_cursor_pos();
|
let current_pos = form_state.current_cursor_pos();
|
||||||
if !current_input.is_empty() && current_pos < current_input.len() {
|
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()));
|
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() {
|
if key.modifiers.is_empty() {
|
||||||
key_sequence_tracker.add_key(key.code);
|
key_sequence_tracker.add_key(key.code);
|
||||||
let sequence = key_sequence_tracker.get_sequence();
|
let sequence = key_sequence_tracker.get_sequence();
|
||||||
|
|
||||||
if let Some(action) = config.matches_key_sequence_generalized(&sequence).as_deref() {
|
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) {
|
// Try context-specific actions first, otherwise use canvas dispatch
|
||||||
crate::tui::functions::form::handle_action(
|
let result = if let Some(context_result) = handle_context_action(
|
||||||
action,
|
action,
|
||||||
|
app_state,
|
||||||
form_state,
|
form_state,
|
||||||
grpc_client,
|
grpc_client,
|
||||||
ideal_cursor_column,
|
ideal_cursor_column,
|
||||||
)
|
).await? {
|
||||||
.await?
|
context_result
|
||||||
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
|
} else {
|
||||||
crate::tui::functions::login::handle_action(action).await?
|
dispatch_to_active_state(
|
||||||
} else if app_state.ui.show_add_table {
|
|
||||||
add_table_ro::execute_action(
|
|
||||||
action,
|
action,
|
||||||
app_state,
|
app_state,
|
||||||
|
form_state,
|
||||||
|
login_state,
|
||||||
|
register_state,
|
||||||
add_table_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,
|
add_logic_state,
|
||||||
ideal_cursor_column,
|
ideal_cursor_column,
|
||||||
key_sequence_tracker,
|
).await
|
||||||
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?
|
|
||||||
};
|
};
|
||||||
key_sequence_tracker.reset();
|
key_sequence_tracker.reset();
|
||||||
return Ok((false, result));
|
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 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() {
|
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) {
|
// Try context-specific actions first, otherwise use canvas dispatch
|
||||||
crate::tui::functions::form::handle_action(
|
let result = if let Some(context_result) = handle_context_action(
|
||||||
action,
|
action,
|
||||||
|
app_state,
|
||||||
form_state,
|
form_state,
|
||||||
grpc_client,
|
grpc_client,
|
||||||
ideal_cursor_column,
|
ideal_cursor_column,
|
||||||
)
|
).await? {
|
||||||
.await?
|
context_result
|
||||||
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
|
} else {
|
||||||
crate::tui::functions::login::handle_action(action).await?
|
dispatch_to_active_state(
|
||||||
} else if app_state.ui.show_add_table {
|
|
||||||
add_table_ro::execute_action(
|
|
||||||
action,
|
action,
|
||||||
app_state,
|
app_state,
|
||||||
|
form_state,
|
||||||
|
login_state,
|
||||||
|
register_state,
|
||||||
add_table_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,
|
add_logic_state,
|
||||||
ideal_cursor_column,
|
ideal_cursor_column,
|
||||||
key_sequence_tracker,
|
).await
|
||||||
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?
|
|
||||||
};
|
};
|
||||||
key_sequence_tracker.reset();
|
key_sequence_tracker.reset();
|
||||||
return Ok((false, result));
|
return Ok((false, result));
|
||||||
@@ -282,62 +270,26 @@ pub async fn handle_read_only_event(
|
|||||||
key_sequence_tracker.reset();
|
key_sequence_tracker.reset();
|
||||||
|
|
||||||
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers).as_deref() {
|
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) {
|
// Try context-specific actions first, otherwise use canvas dispatch
|
||||||
crate::tui::functions::form::handle_action(
|
let result = if let Some(context_result) = handle_context_action(
|
||||||
action,
|
action,
|
||||||
|
app_state,
|
||||||
form_state,
|
form_state,
|
||||||
grpc_client,
|
grpc_client,
|
||||||
ideal_cursor_column,
|
ideal_cursor_column,
|
||||||
)
|
).await? {
|
||||||
.await?
|
context_result
|
||||||
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
|
} else {
|
||||||
crate::tui::functions::login::handle_action(action).await?
|
dispatch_to_active_state(
|
||||||
} else if app_state.ui.show_add_table {
|
|
||||||
add_table_ro::execute_action(
|
|
||||||
action,
|
action,
|
||||||
app_state,
|
app_state,
|
||||||
|
form_state,
|
||||||
|
login_state,
|
||||||
|
register_state,
|
||||||
add_table_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,
|
add_logic_state,
|
||||||
ideal_cursor_column,
|
ideal_cursor_column,
|
||||||
key_sequence_tracker,
|
).await
|
||||||
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?
|
|
||||||
};
|
};
|
||||||
return Ok((false, result));
|
return Ok((false, result));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// src/modes/handlers.rs
|
// src/modes/handlers.rs
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod event_helper;
|
|
||||||
pub mod mode_manager;
|
pub mod mode_manager;
|
||||||
|
|||||||
@@ -15,23 +15,20 @@ use crate::modes::{
|
|||||||
general::{dialog, navigation},
|
general::{dialog, navigation},
|
||||||
handlers::mode_manager::{AppMode, ModeManager},
|
handlers::mode_manager::{AppMode, ModeManager},
|
||||||
};
|
};
|
||||||
use crate::state::pages::canvas_state::CanvasState as LegacyCanvasState;
|
|
||||||
use crate::services::auth::AuthClient;
|
use crate::services::auth::AuthClient;
|
||||||
use crate::services::grpc_client::GrpcClient;
|
use crate::services::grpc_client::GrpcClient;
|
||||||
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher};
|
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher};
|
||||||
use canvas::canvas::CanvasState as LibraryCanvasState;
|
use canvas::canvas::CanvasState; // Only need this import now
|
||||||
use super::event_helper::*;
|
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
app::{
|
app::{
|
||||||
buffer::{AppView, BufferState},
|
buffer::{AppView, BufferState},
|
||||||
highlight::HighlightState,
|
highlight::HighlightState,
|
||||||
search::SearchState, // Correctly imported
|
search::SearchState,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
},
|
},
|
||||||
pages::{
|
pages::{
|
||||||
admin::AdminState,
|
admin::AdminState,
|
||||||
auth::{AuthState, LoginState, RegisterState},
|
auth::{AuthState, LoginState, RegisterState},
|
||||||
canvas_state::CanvasState,
|
|
||||||
form::FormState,
|
form::FormState,
|
||||||
intro::IntroState,
|
intro::IntroState,
|
||||||
},
|
},
|
||||||
@@ -48,6 +45,7 @@ use crate::ui::handlers::rat_state::UiStateHandler;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use common::proto::komp_ac::search::search_response::Hit;
|
use common::proto::komp_ac::search::search_response::Hit;
|
||||||
use crossterm::cursor::SetCursorStyle;
|
use crossterm::cursor::SetCursorStyle;
|
||||||
|
use crossterm::event::KeyModifiers;
|
||||||
use crossterm::event::{Event, KeyCode, KeyEvent};
|
use crossterm::event::{Event, KeyCode, KeyEvent};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::sync::mpsc::unbounded_channel;
|
use tokio::sync::mpsc::unbounded_channel;
|
||||||
@@ -89,7 +87,6 @@ pub struct EventHandler {
|
|||||||
pub navigation_state: NavigationState,
|
pub navigation_state: NavigationState,
|
||||||
pub search_result_sender: mpsc::UnboundedSender<Vec<Hit>>,
|
pub search_result_sender: mpsc::UnboundedSender<Vec<Hit>>,
|
||||||
pub search_result_receiver: mpsc::UnboundedReceiver<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_sender: mpsc::UnboundedSender<Vec<Hit>>,
|
||||||
pub autocomplete_result_receiver: mpsc::UnboundedReceiver<Vec<Hit>>,
|
pub autocomplete_result_receiver: mpsc::UnboundedReceiver<Vec<Hit>>,
|
||||||
}
|
}
|
||||||
@@ -103,7 +100,7 @@ impl EventHandler {
|
|||||||
grpc_client: GrpcClient,
|
grpc_client: GrpcClient,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let (search_tx, search_rx) = unbounded_channel();
|
let (search_tx, search_rx) = unbounded_channel();
|
||||||
let (autocomplete_tx, autocomplete_rx) = unbounded_channel(); // ADDED
|
let (autocomplete_tx, autocomplete_rx) = unbounded_channel();
|
||||||
Ok(EventHandler {
|
Ok(EventHandler {
|
||||||
command_mode: false,
|
command_mode: false,
|
||||||
command_input: String::new(),
|
command_input: String::new(),
|
||||||
@@ -122,7 +119,6 @@ impl EventHandler {
|
|||||||
navigation_state: NavigationState::new(),
|
navigation_state: NavigationState::new(),
|
||||||
search_result_sender: search_tx,
|
search_result_sender: search_tx,
|
||||||
search_result_receiver: search_rx,
|
search_result_receiver: search_rx,
|
||||||
// --- ADDED ---
|
|
||||||
autocomplete_result_sender: autocomplete_tx,
|
autocomplete_result_sender: autocomplete_tx,
|
||||||
autocomplete_result_receiver: autocomplete_rx,
|
autocomplete_result_receiver: autocomplete_rx,
|
||||||
})
|
})
|
||||||
@@ -136,6 +132,95 @@ impl EventHandler {
|
|||||||
self.navigation_state.activate_find_file(options);
|
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.
|
// This function handles state changes.
|
||||||
async fn handle_search_palette_event(
|
async fn handle_search_palette_event(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -199,7 +284,6 @@ impl EventHandler {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- START CORRECTED LOGIC ---
|
|
||||||
if trigger_search {
|
if trigger_search {
|
||||||
search_state.is_loading = true;
|
search_state.is_loading = true;
|
||||||
search_state.results.clear();
|
search_state.results.clear();
|
||||||
@@ -214,7 +298,6 @@ impl EventHandler {
|
|||||||
"--- 1. Spawning search task for query: '{}' ---",
|
"--- 1. Spawning search task for query: '{}' ---",
|
||||||
query
|
query
|
||||||
);
|
);
|
||||||
// We now move the grpc_client into the task, just like with login.
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
info!("--- 2. Background task started. ---");
|
info!("--- 2. Background task started. ---");
|
||||||
match grpc_client.search_table(table_name, query).await {
|
match grpc_client.search_table(table_name, query).await {
|
||||||
@@ -226,7 +309,6 @@ impl EventHandler {
|
|||||||
let _ = sender.send(response.hits);
|
let _ = sender.send(response.hits);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// THE FIX: Use the debug formatter `{:?}` to print the full error chain.
|
|
||||||
error!("--- 3b. gRPC call failed: {:?} ---", e);
|
error!("--- 3b. gRPC call failed: {:?} ---", e);
|
||||||
let _ = sender.send(vec![]);
|
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 {
|
if should_close {
|
||||||
app_state.search_state = None;
|
app_state.search_state = None;
|
||||||
app_state.ui.show_search_palette = false;
|
app_state.ui.show_search_palette = false;
|
||||||
@@ -264,7 +344,6 @@ impl EventHandler {
|
|||||||
) -> Result<EventOutcome> {
|
) -> Result<EventOutcome> {
|
||||||
if app_state.ui.show_search_palette {
|
if app_state.ui.show_search_palette {
|
||||||
if let Event::Key(key_event) = event {
|
if let Event::Key(key_event) = event {
|
||||||
// The call no longer passes grpc_client
|
|
||||||
return self
|
return self
|
||||||
.handle_search_palette_event(
|
.handle_search_palette_event(
|
||||||
key_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")
|
if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise")
|
||||||
&& ModeManager::can_enter_highlight_mode(current_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,
|
app_state,
|
||||||
login_state,
|
login_state,
|
||||||
register_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")
|
else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode")
|
||||||
&& ModeManager::can_enter_highlight_mode(current_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,
|
app_state,
|
||||||
login_state,
|
login_state,
|
||||||
register_state,
|
register_state,
|
||||||
form_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,
|
app_state,
|
||||||
login_state,
|
login_state,
|
||||||
register_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")
|
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)
|
&& 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,
|
app_state,
|
||||||
login_state,
|
login_state,
|
||||||
register_state,
|
register_state,
|
||||||
form_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,
|
app_state,
|
||||||
login_state,
|
login_state,
|
||||||
form_state
|
form_state
|
||||||
@@ -642,14 +721,14 @@ impl EventHandler {
|
|||||||
// Move cursor forward if possible
|
// Move cursor forward if possible
|
||||||
if !current_input.is_empty() && current_cursor_pos < current_input.len() {
|
if !current_input.is_empty() && current_cursor_pos < current_input.len() {
|
||||||
let new_cursor_pos = current_cursor_pos + 1;
|
let new_cursor_pos = current_cursor_pos + 1;
|
||||||
set_current_cursor_pos_for_state(
|
Self::set_current_cursor_pos_for_state(
|
||||||
app_state,
|
app_state,
|
||||||
login_state,
|
login_state,
|
||||||
register_state,
|
register_state,
|
||||||
form_state,
|
form_state,
|
||||||
new_cursor_pos
|
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,
|
app_state,
|
||||||
login_state,
|
login_state,
|
||||||
register_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 app_state.ui.show_form {
|
||||||
if let Ok(Some(canvas_message)) = self.handle_form_canvas_action(
|
if let Ok(Some(canvas_message)) = self.handle_form_canvas_action(
|
||||||
key_event,
|
key_event,
|
||||||
config,
|
|
||||||
form_state,
|
form_state,
|
||||||
false, // not edit mode
|
false,
|
||||||
).await {
|
).await {
|
||||||
return Ok(EventOutcome::Ok(canvas_message));
|
return Ok(EventOutcome::Ok(canvas_message));
|
||||||
}
|
}
|
||||||
@@ -753,7 +831,7 @@ impl EventHandler {
|
|||||||
&mut admin_state.add_table_state,
|
&mut admin_state.add_table_state,
|
||||||
&mut admin_state.add_logic_state,
|
&mut admin_state.add_logic_state,
|
||||||
&mut self.key_sequence_tracker,
|
&mut self.key_sequence_tracker,
|
||||||
&mut self.grpc_client, // <-- FIX 2
|
&mut self.grpc_client,
|
||||||
&mut self.command_message,
|
&mut self.command_message,
|
||||||
&mut self.edit_mode_cooldown,
|
&mut self.edit_mode_cooldown,
|
||||||
&mut self.ideal_cursor_column,
|
&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 app_state.ui.show_form {
|
||||||
if let Ok(Some(canvas_message)) = self.handle_form_canvas_action(
|
if let Ok(Some(canvas_message)) = self.handle_form_canvas_action(
|
||||||
key_event,
|
key_event,
|
||||||
config,
|
|
||||||
form_state,
|
form_state,
|
||||||
true, // edit mode
|
true,
|
||||||
).await {
|
).await {
|
||||||
if !canvas_message.is_empty() {
|
if !canvas_message.is_empty() {
|
||||||
self.command_message = canvas_message.clone();
|
self.command_message = canvas_message.clone();
|
||||||
@@ -823,7 +900,7 @@ impl EventHandler {
|
|||||||
self.edit_mode_cooldown = true;
|
self.edit_mode_cooldown = true;
|
||||||
|
|
||||||
// Check for unsaved changes across all states
|
// 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,
|
app_state,
|
||||||
login_state,
|
login_state,
|
||||||
register_state,
|
register_state,
|
||||||
@@ -840,13 +917,13 @@ impl EventHandler {
|
|||||||
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
|
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
|
||||||
|
|
||||||
// Get current input and cursor position
|
// 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,
|
app_state,
|
||||||
login_state,
|
login_state,
|
||||||
register_state,
|
register_state,
|
||||||
form_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,
|
app_state,
|
||||||
login_state,
|
login_state,
|
||||||
register_state,
|
register_state,
|
||||||
@@ -856,7 +933,7 @@ impl EventHandler {
|
|||||||
// Adjust cursor if it's beyond the input length
|
// Adjust cursor if it's beyond the input length
|
||||||
if !current_input.is_empty() && current_cursor_pos >= current_input.len() {
|
if !current_input.is_empty() && current_cursor_pos >= current_input.len() {
|
||||||
let new_pos = current_input.len() - 1;
|
let new_pos = current_input.len() - 1;
|
||||||
set_current_cursor_pos_for_state(
|
Self::set_current_cursor_pos_for_state(
|
||||||
app_state,
|
app_state,
|
||||||
login_state,
|
login_state,
|
||||||
register_state,
|
register_state,
|
||||||
@@ -906,7 +983,7 @@ impl EventHandler {
|
|||||||
form_state,
|
form_state,
|
||||||
&mut self.command_input,
|
&mut self.command_input,
|
||||||
&mut self.command_message,
|
&mut self.command_message,
|
||||||
&mut self.grpc_client, // <-- FIX 5
|
&mut self.grpc_client,
|
||||||
command_handler,
|
command_handler,
|
||||||
terminal,
|
terminal,
|
||||||
&mut current_position,
|
&mut current_position,
|
||||||
@@ -1024,80 +1101,49 @@ impl EventHandler {
|
|||||||
async fn handle_form_canvas_action(
|
async fn handle_form_canvas_action(
|
||||||
&mut self,
|
&mut self,
|
||||||
key_event: KeyEvent,
|
key_event: KeyEvent,
|
||||||
_config: &Config, // Not used anymore - canvas has its own config
|
|
||||||
form_state: &mut FormState,
|
form_state: &mut FormState,
|
||||||
is_edit_mode: bool,
|
is_edit_mode: bool,
|
||||||
) -> Result<Option<String>> {
|
) -> Result<Option<String>> {
|
||||||
// Load canvas config (canvas_config.toml or vim defaults)
|
|
||||||
let canvas_config = canvas::config::CanvasConfig::load();
|
let canvas_config = canvas::config::CanvasConfig::load();
|
||||||
|
|
||||||
// Handle suggestion actions first if suggestions are active
|
// PRIORITY 1: Handle character insertion in edit mode FIRST
|
||||||
if form_state.autocomplete_active {
|
if is_edit_mode {
|
||||||
if let Some(action_str) = canvas_config.get_suggestion_action(key_event.code, key_event.modifiers) {
|
if let KeyCode::Char(c) = key_event.code {
|
||||||
let canvas_action = CanvasAction::from_string(&action_str);
|
// Only insert if it's not a special modifier combination
|
||||||
match ActionDispatcher::dispatch(canvas_action, form_state, &mut self.ideal_cursor_column).await {
|
if key_event.modifiers.is_empty() || key_event.modifiers == KeyModifiers::SHIFT {
|
||||||
Ok(result) => return Ok(Some(result.message().unwrap_or("").to_string())),
|
let canvas_action = CanvasAction::InsertChar(c);
|
||||||
Err(_) => return Ok(Some("Suggestion action failed".to_string())),
|
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("Character insertion failed".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback hardcoded suggestion handling
|
// PRIORITY 2: Handle config-mapped actions for non-character keys
|
||||||
match key_event.code {
|
|
||||||
KeyCode::Up => {
|
|
||||||
if let Ok(result) = ActionDispatcher::dispatch(
|
|
||||||
CanvasAction::SuggestionUp,
|
|
||||||
form_state,
|
|
||||||
&mut self.ideal_cursor_column,
|
|
||||||
).await {
|
|
||||||
return Ok(Some(result.message().unwrap_or("").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
|
|
||||||
let action_str = canvas_config.get_action_for_key(
|
let action_str = canvas_config.get_action_for_key(
|
||||||
key_event.code,
|
key_event.code,
|
||||||
key_event.modifiers,
|
key_event.modifiers,
|
||||||
is_edit_mode,
|
is_edit_mode,
|
||||||
form_state.autocomplete_active
|
form_state.autocomplete_active,
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(action_str) = action_str {
|
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) {
|
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(
|
match ActionDispatcher::dispatch(
|
||||||
canvas_action,
|
canvas_action,
|
||||||
form_state,
|
form_state,
|
||||||
@@ -1112,59 +1158,10 @@ impl EventHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to automatic key handling for edit mode
|
// No action found
|
||||||
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()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ADDED: Helper function to identify mode transition actions
|
|
||||||
fn is_mode_transition_action(action: &str) -> bool {
|
fn is_mode_transition_action(action: &str) -> bool {
|
||||||
matches!(action,
|
matches!(action,
|
||||||
"exit" |
|
"exit" |
|
||||||
@@ -1181,11 +1178,11 @@ impl EventHandler {
|
|||||||
"force_quit" |
|
"force_quit" |
|
||||||
"save_and_quit" |
|
"save_and_quit" |
|
||||||
"revert" |
|
"revert" |
|
||||||
"enter_decider" | // This is also handled specially by legacy system
|
"enter_decider" |
|
||||||
"trigger_autocomplete" | // This is handled specially by legacy system
|
"trigger_autocomplete" |
|
||||||
"suggestion_up" | // These are handled above in suggestion logic
|
"suggestion_up" |
|
||||||
"suggestion_down" |
|
"suggestion_down" |
|
||||||
"previous_entry" | // Navigation between records
|
"previous_entry" |
|
||||||
"next_entry" |
|
"next_entry" |
|
||||||
"toggle_sidebar" |
|
"toggle_sidebar" |
|
||||||
"toggle_buffer_list" |
|
"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 intro;
|
||||||
pub mod add_table;
|
pub mod add_table;
|
||||||
pub mod add_logic;
|
pub mod add_logic;
|
||||||
pub mod canvas_state;
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// src/state/pages/add_logic.rs
|
// src/state/pages/add_logic.rs
|
||||||
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
|
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
|
||||||
use canvas::canvas::{CanvasState, ActionContext, CanvasAction}; // External library
|
use canvas::canvas::{CanvasState, ActionContext, CanvasAction, AppMode};
|
||||||
use crate::components::common::text_editor::{TextEditor, VimState};
|
use crate::components::common::text_editor::{TextEditor, VimState};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
@@ -54,6 +54,7 @@ pub struct AddLogicState {
|
|||||||
// New fields for same-profile table names and column autocomplete
|
// New fields for same-profile table names and column autocomplete
|
||||||
pub same_profile_table_names: Vec<String>, // Tables from same profile only
|
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 script_editor_awaiting_column_autocomplete: Option<String>, // Table name waiting for column fetch
|
||||||
|
pub app_mode: AppMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AddLogicState {
|
impl AddLogicState {
|
||||||
@@ -91,6 +92,7 @@ impl AddLogicState {
|
|||||||
|
|
||||||
same_profile_table_names: Vec::new(),
|
same_profile_table_names: Vec::new(),
|
||||||
script_editor_awaiting_column_autocomplete: None,
|
script_editor_awaiting_column_autocomplete: None,
|
||||||
|
app_mode: AppMode::Edit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +271,9 @@ impl AddLogicState {
|
|||||||
|
|
||||||
impl Default for AddLogicState {
|
impl Default for AddLogicState {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new(&EditorConfig::default())
|
let mut state = Self::new(&EditorConfig::default());
|
||||||
|
state.app_mode = AppMode::Edit;
|
||||||
|
state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,4 +443,8 @@ impl CanvasState for AddLogicState {
|
|||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn current_mode(&self) -> AppMode {
|
||||||
|
self.app_mode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// src/state/pages/add_table.rs
|
// src/state/pages/add_table.rs
|
||||||
use canvas::canvas::{CanvasState, ActionContext, CanvasAction}; // External library
|
use canvas::canvas::{CanvasState, ActionContext, CanvasAction, AppMode};
|
||||||
use ratatui::widgets::TableState;
|
use ratatui::widgets::TableState;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -63,6 +63,7 @@ pub struct AddTableState {
|
|||||||
pub column_name_cursor_pos: usize,
|
pub column_name_cursor_pos: usize,
|
||||||
pub column_type_cursor_pos: usize,
|
pub column_type_cursor_pos: usize,
|
||||||
pub has_unsaved_changes: bool,
|
pub has_unsaved_changes: bool,
|
||||||
|
pub app_mode: AppMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AddTableState {
|
impl Default for AddTableState {
|
||||||
@@ -85,6 +86,7 @@ impl Default for AddTableState {
|
|||||||
column_name_cursor_pos: 0,
|
column_name_cursor_pos: 0,
|
||||||
column_type_cursor_pos: 0,
|
column_type_cursor_pos: 0,
|
||||||
has_unsaved_changes: false,
|
has_unsaved_changes: false,
|
||||||
|
app_mode: AppMode::Edit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,4 +299,8 @@ impl CanvasState for AddTableState {
|
|||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn current_mode(&self) -> AppMode {
|
||||||
|
self.app_mode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// src/state/pages/auth.rs
|
// 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 canvas::autocomplete::{AutocompleteCanvasState, AutocompleteState, SuggestionItem};
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
|
|
||||||
@@ -22,7 +22,6 @@ pub struct AuthState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Represents the state of the Login form UI
|
/// Represents the state of the Login form UI
|
||||||
#[derive(Default)]
|
|
||||||
pub struct LoginState {
|
pub struct LoginState {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
@@ -31,10 +30,26 @@ pub struct LoginState {
|
|||||||
pub current_cursor_pos: usize,
|
pub current_cursor_pos: usize,
|
||||||
pub has_unsaved_changes: bool,
|
pub has_unsaved_changes: bool,
|
||||||
pub login_request_pending: 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
|
/// Represents the state of the Registration form UI
|
||||||
#[derive(Default, Clone)]
|
#[derive(Clone)]
|
||||||
pub struct RegisterState {
|
pub struct RegisterState {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
@@ -45,8 +60,26 @@ pub struct RegisterState {
|
|||||||
pub current_field: usize,
|
pub current_field: usize,
|
||||||
pub current_cursor_pos: usize,
|
pub current_cursor_pos: usize,
|
||||||
pub has_unsaved_changes: bool,
|
pub has_unsaved_changes: bool,
|
||||||
// NEW: Replace old autocomplete with external library's system
|
|
||||||
pub autocomplete: AutocompleteState<String>,
|
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 {
|
impl AuthState {
|
||||||
@@ -57,7 +90,10 @@ impl AuthState {
|
|||||||
|
|
||||||
impl LoginState {
|
impl LoginState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
Self {
|
||||||
|
app_mode: AppMode::Edit,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +101,7 @@ impl RegisterState {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let mut state = Self {
|
let mut state = Self {
|
||||||
autocomplete: AutocompleteState::new(),
|
autocomplete: AutocompleteState::new(),
|
||||||
|
app_mode: AppMode::Edit,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -146,6 +183,10 @@ impl CanvasState for LoginState {
|
|||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn current_mode(&self) -> AppMode {
|
||||||
|
self.app_mode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implement external library's CanvasState for RegisterState
|
// Implement external library's CanvasState for RegisterState
|
||||||
@@ -237,6 +278,10 @@ impl CanvasState for RegisterState {
|
|||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn current_mode(&self) -> AppMode {
|
||||||
|
self.app_mode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add autocomplete support for RegisterState
|
// 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
|
// src/state/pages/form.rs
|
||||||
|
|
||||||
use crate::config::colors::themes::Theme;
|
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 common::proto::komp_ac::search::search_response::Hit;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
@@ -41,6 +41,7 @@ pub struct FormState {
|
|||||||
pub selected_suggestion_index: Option<usize>,
|
pub selected_suggestion_index: Option<usize>,
|
||||||
pub autocomplete_loading: bool,
|
pub autocomplete_loading: bool,
|
||||||
pub link_display_map: HashMap<usize, String>,
|
pub link_display_map: HashMap<usize, String>,
|
||||||
|
pub app_mode: AppMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FormState {
|
impl FormState {
|
||||||
@@ -74,6 +75,7 @@ impl FormState {
|
|||||||
selected_suggestion_index: None,
|
selected_suggestion_index: None,
|
||||||
autocomplete_loading: false,
|
autocomplete_loading: false,
|
||||||
link_display_map: HashMap::new(),
|
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.selected_suggestion_index = if self.autocomplete_active { Some(0) } else { None };
|
||||||
self.autocomplete_loading = false;
|
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 {
|
impl CanvasState for FormState {
|
||||||
@@ -320,4 +331,8 @@ impl CanvasState for FormState {
|
|||||||
fn has_display_override(&self, index: usize) -> bool {
|
fn has_display_override(&self, index: usize) -> bool {
|
||||||
self.link_display_map.contains_key(&index)
|
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::config::colors::themes::Theme;
|
||||||
use crate::modes::general::command_navigation::NavigationState;
|
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;
|
||||||
use canvas::canvas::CanvasState; // Import external library's CanvasState trait
|
|
||||||
use crate::state::app::buffer::BufferState;
|
use crate::state::app::buffer::BufferState;
|
||||||
use crate::state::app::highlight::HighlightState as LocalHighlightState; // CHANGED: Alias local version
|
use crate::state::app::highlight::HighlightState as LocalHighlightState;
|
||||||
use canvas::canvas::HighlightState as CanvasHighlightState; // CHANGED: Import canvas version with alias
|
use canvas::canvas::HighlightState as CanvasHighlightState;
|
||||||
use crate::state::app::state::AppState;
|
use crate::state::app::state::AppState;
|
||||||
use crate::state::pages::admin::AdminState;
|
use crate::state::pages::admin::AdminState;
|
||||||
use crate::state::pages::auth::AuthState;
|
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::common::commands::CommandHandler;
|
||||||
use crate::modes::handlers::event::{EventHandler, EventOutcome};
|
use crate::modes::handlers::event::{EventHandler, EventOutcome};
|
||||||
use crate::modes::handlers::mode_manager::{AppMode, ModeManager};
|
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; // Only external library import
|
||||||
use canvas::canvas::CanvasState; // Import external library's CanvasState trait
|
use crate::state::pages::form::{FormState, FieldDefinition};
|
||||||
use crate::state::pages::form::{FormState, FieldDefinition}; // Import FieldDefinition
|
|
||||||
use crate::state::pages::auth::AuthState;
|
use crate::state::pages::auth::AuthState;
|
||||||
use crate::state::pages::auth::LoginState;
|
use crate::state::pages::auth::LoginState;
|
||||||
use crate::state::pages::auth::RegisterState;
|
use crate::state::pages::auth::RegisterState;
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
use rstest::{fixture, rstest};
|
use rstest::{fixture, rstest};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use client::state::pages::form::{FormState, FieldDefinition};
|
use client::state::pages::form::{FormState, FieldDefinition};
|
||||||
use canvas::state::CanvasState
|
use canvas::canvas::CanvasState;
|
||||||
use client::state::pages::canvas_state::CanvasState;
|
|
||||||
|
|
||||||
#[fixture]
|
#[fixture]
|
||||||
fn test_form_state() -> FormState {
|
fn test_form_state() -> FormState {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
pub use rstest::{fixture, rstest};
|
pub use rstest::{fixture, rstest};
|
||||||
pub use client::services::grpc_client::GrpcClient;
|
pub use client::services::grpc_client::GrpcClient;
|
||||||
pub use client::state::pages::form::FormState;
|
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;
|
||||||
pub use prost_types::value::Kind;
|
pub use prost_types::value::Kind;
|
||||||
pub use std::collections::HashMap;
|
pub use std::collections::HashMap;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
"proto/tables_data.proto",
|
"proto/tables_data.proto",
|
||||||
"proto/table_script.proto",
|
"proto/table_script.proto",
|
||||||
"proto/search.proto",
|
"proto/search.proto",
|
||||||
|
"proto/search2.proto",
|
||||||
],
|
],
|
||||||
&["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 {
|
pub mod search {
|
||||||
include!("proto/komp_ac.search.rs");
|
include!("proto/komp_ac.search.rs");
|
||||||
}
|
}
|
||||||
|
pub mod search2 {
|
||||||
|
include!("proto/komp_ac.search2.rs");
|
||||||
|
}
|
||||||
pub const FILE_DESCRIPTOR_SET: &[u8] =
|
pub const FILE_DESCRIPTOR_SET: &[u8] =
|
||||||
include_bytes!("proto/descriptor.bin");
|
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,
|
pool: &PgPool,
|
||||||
payload: RegisterRequest,
|
payload: RegisterRequest,
|
||||||
) -> Result<Response<AuthResponse>, Status> {
|
) -> Result<Response<AuthResponse>, Status> {
|
||||||
|
// Validate required fields
|
||||||
|
if payload.email.trim().is_empty() {
|
||||||
|
return Err(Status::invalid_argument("Email is required"));
|
||||||
|
}
|
||||||
|
|
||||||
// Validate passwords match
|
// Validate passwords match
|
||||||
if payload.password != payload.password_confirmation {
|
if payload.password != payload.password_confirmation {
|
||||||
return Err(Status::invalid_argument(AuthError::PasswordMismatch.to_string()));
|
return Err(Status::invalid_argument(AuthError::PasswordMismatch.to_string()));
|
||||||
@@ -41,6 +46,15 @@ pub async fn register(
|
|||||||
if db_err.constraint() == Some("valid_roles") {
|
if db_err.constraint() == Some("valid_roles") {
|
||||||
return Status::invalid_argument(format!("Invalid role specified: '{}'", role_to_insert));
|
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") {
|
if e.to_string().contains("duplicate key") {
|
||||||
Status::already_exists(AuthError::UserExists.to_string())
|
Status::already_exists(AuthError::UserExists.to_string())
|
||||||
|
|||||||
@@ -10,14 +10,16 @@ use crate::server::services::{
|
|||||||
TableDefinitionService,
|
TableDefinitionService,
|
||||||
TablesDataService,
|
TablesDataService,
|
||||||
TableScriptService,
|
TableScriptService,
|
||||||
AuthServiceImpl
|
AuthServiceImpl,
|
||||||
|
Search2Service,
|
||||||
};
|
};
|
||||||
use common::proto::komp_ac::{
|
use common::proto::komp_ac::{
|
||||||
table_structure::table_structure_service_server::TableStructureServiceServer,
|
table_structure::table_structure_service_server::TableStructureServiceServer,
|
||||||
table_definition::table_definition_server::TableDefinitionServer,
|
table_definition::table_definition_server::TableDefinitionServer,
|
||||||
tables_data::tables_data_server::TablesDataServer,
|
tables_data::tables_data_server::TablesDataServer,
|
||||||
table_script::table_script_server::TableScriptServer,
|
table_script::table_script_server::TableScriptServer,
|
||||||
auth::auth_service_server::AuthServiceServer
|
auth::auth_service_server::AuthServiceServer,
|
||||||
|
search2::search2_server::Search2Server,
|
||||||
};
|
};
|
||||||
use search::{SearcherService, SearcherServer};
|
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 table_script_service = TableScriptService { db_pool: db_pool.clone() };
|
||||||
let auth_service = AuthServiceImpl { 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 search_service = SearcherService { pool: db_pool.clone() };
|
||||||
|
let search2_service = Search2Service { db_pool: db_pool.clone() };
|
||||||
|
|
||||||
Server::builder()
|
Server::builder()
|
||||||
.add_service(TableStructureServiceServer::new(TableStructureHandler { db_pool: db_pool.clone() }))
|
.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(TableScriptServer::new(table_script_service))
|
||||||
.add_service(AuthServiceServer::new(auth_service))
|
.add_service(AuthServiceServer::new(auth_service))
|
||||||
.add_service(SearcherServer::new(search_service))
|
.add_service(SearcherServer::new(search_service))
|
||||||
|
.add_service(Search2Server::new(search2_service))
|
||||||
.add_service(reflection_service)
|
.add_service(reflection_service)
|
||||||
.serve(addr)
|
.serve(addr)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ pub mod table_definition_service;
|
|||||||
pub mod tables_data_service;
|
pub mod tables_data_service;
|
||||||
pub mod table_script_service;
|
pub mod table_script_service;
|
||||||
pub mod auth_service;
|
pub mod auth_service;
|
||||||
|
pub mod search2_service;
|
||||||
|
|
||||||
pub use table_structure_service::TableStructureHandler;
|
pub use table_structure_service::TableStructureHandler;
|
||||||
pub use table_definition_service::TableDefinitionService;
|
pub use table_definition_service::TableDefinitionService;
|
||||||
pub use tables_data_service::TablesDataService;
|
pub use tables_data_service::TablesDataService;
|
||||||
pub use table_script_service::TableScriptService;
|
pub use table_script_service::TableScriptService;
|
||||||
pub use auth_service::AuthServiceImpl;
|
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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user