Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
225bdc2bb6 | ||
|
|
8605ed1547 | ||
|
|
91cecabaca | ||
|
|
c00a214a0f | ||
|
|
0baf152c3e | ||
|
|
c92c617314 | ||
|
|
8c8ba53668 | ||
|
|
2b08e64db8 | ||
|
|
643db8e586 | ||
|
|
5c39386a3a | ||
|
|
8f99aa79ec | ||
|
|
c594c35b37 | ||
|
|
828a63c30c | ||
|
|
36690e674a | ||
|
|
a4e94878e7 | ||
|
|
c7353ac81e | ||
|
|
1fbc720620 | ||
|
|
263ccc3260 | ||
|
|
00c0a399cd |
171
Cargo.lock
generated
171
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,6 +475,7 @@ name = "canvas"
|
|||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"async-trait",
|
||||||
"common",
|
"common",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
@@ -496,18 +497,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",
|
||||||
@@ -652,9 +653,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"
|
||||||
@@ -713,9 +714,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",
|
||||||
]
|
]
|
||||||
@@ -810,8 +811,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]]
|
||||||
@@ -828,13 +839,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",
|
||||||
]
|
]
|
||||||
@@ -958,7 +994,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]]
|
||||||
@@ -1429,9 +1465,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",
|
||||||
@@ -1442,7 +1478,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",
|
||||||
@@ -1611,9 +1647,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",
|
||||||
]
|
]
|
||||||
@@ -1670,11 +1706,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",
|
||||||
@@ -1683,9 +1719,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",
|
||||||
@@ -1802,9 +1838,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",
|
||||||
@@ -1902,9 +1938,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",
|
||||||
]
|
]
|
||||||
@@ -2292,17 +2328,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]]
|
||||||
@@ -2342,9 +2377,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",
|
||||||
@@ -2506,9 +2541,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",
|
||||||
@@ -2614,9 +2649,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",
|
||||||
]
|
]
|
||||||
@@ -2845,20 +2880,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]]
|
||||||
@@ -2962,9 +2997,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",
|
||||||
@@ -3008,7 +3043,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",
|
||||||
@@ -3172,6 +3207,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"
|
||||||
@@ -3446,7 +3491,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",
|
||||||
@@ -3461,9 +3506,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",
|
||||||
@@ -3598,9 +3643,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",
|
||||||
@@ -3757,8 +3802,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]]
|
||||||
@@ -3872,7 +3917,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",
|
||||||
]
|
]
|
||||||
@@ -3986,7 +4031,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",
|
||||||
@@ -4275,7 +4320,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",
|
||||||
@@ -4403,7 +4448,7 @@ checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"either",
|
"either",
|
||||||
"env_home",
|
"env_home",
|
||||||
"rustix 1.0.7",
|
"rustix 1.0.8",
|
||||||
"winsafe",
|
"winsafe",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4439,7 +4484,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]]
|
||||||
@@ -4730,9 +4775,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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ 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
|
||||||
@@ -22,6 +22,7 @@ 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
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-test = "0.4.4"
|
tokio-test = "0.4.4"
|
||||||
@@ -29,7 +30,15 @@ tokio-test = "0.4.4"
|
|||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
gui = ["ratatui"]
|
gui = ["ratatui"]
|
||||||
|
autocomplete = ["tokio"]
|
||||||
|
cursor-style = ["crossterm"]
|
||||||
|
|
||||||
[[example]]
|
[[example]]
|
||||||
name = "ratatui_demo"
|
name = "autocomplete"
|
||||||
path = "examples/ratatui_demo.rs"
|
required-features = ["autocomplete", "gui"]
|
||||||
|
path = "examples/autocomplete.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "canvas_gui_demo"
|
||||||
|
required-features = ["gui"]
|
||||||
|
path = "examples/canvas_gui_demo.rs"
|
||||||
|
|||||||
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(())
|
||||||
|
}
|
||||||
751
canvas/examples/canvas_cursor_auto.rs
Normal file
751
canvas/examples/canvas_cursor_auto.rs
Normal file
@@ -0,0 +1,751 @@
|
|||||||
|
// 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>,
|
||||||
|
highlight_state: HighlightState,
|
||||||
|
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),
|
||||||
|
highlight_state: HighlightState::Off,
|
||||||
|
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) {
|
||||||
|
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 MODE - Cursor: Blinking Block █".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 MODE - Cursor: Blinking Block █".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 = "🔒 NORMAL MODE - Cursor: Steady Block █".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 {} - Cursor: Blinking Block █",
|
||||||
|
self.editor.cursor_position(),
|
||||||
|
self.editor.current_field()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
HighlightState::Linewise { anchor_line: _ } => {
|
||||||
|
self.debug_message = format!(
|
||||||
|
"🎯 Visual line selection: field {} - Cursor: Blinking Block █",
|
||||||
|
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 highlight_state(&self) -> &HighlightState {
|
||||||
|
&self.highlight_state
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('v'), _) => {
|
||||||
|
editor.enter_visual_mode(); // 🎯 Automatic: cursor becomes blinking block
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(AppMode::ReadOnly, KeyCode::Char('V'), _) => {
|
||||||
|
editor.enter_visual_line_mode(); // 🎯 Automatic: cursor becomes blinking block
|
||||||
|
editor.clear_command_buffer();
|
||||||
|
}
|
||||||
|
(_, KeyCode::Esc, _) => {
|
||||||
|
editor.exit_edit_mode(); // 🎯 Automatic: cursor becomes steady block
|
||||||
|
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
|
||||||
|
let mode_text = match editor.mode() {
|
||||||
|
AppMode::Edit => "INSERT | (bar cursor)",
|
||||||
|
AppMode::ReadOnly => "NORMAL █ (block cursor)",
|
||||||
|
AppMode::Highlight => match editor.highlight_state() {
|
||||||
|
HighlightState::Characterwise { .. } => "VISUAL █ (blinking block)",
|
||||||
|
HighlightState::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
|
||||||
|
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/V=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(())
|
||||||
|
}
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
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,
|
|
||||||
modes::{AppMode, HighlightState, ModeManager},
|
|
||||||
state::{ActionContext, CanvasState},
|
|
||||||
theme::CanvasTheme,
|
|
||||||
},
|
|
||||||
config::CanvasConfig,
|
|
||||||
dispatcher::ActionDispatcher,
|
|
||||||
CanvasAction,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Demo form state
|
|
||||||
struct DemoFormState {
|
|
||||||
fields: Vec<String>,
|
|
||||||
field_names: Vec<String>,
|
|
||||||
current_field: usize,
|
|
||||||
cursor_pos: usize,
|
|
||||||
mode: AppMode,
|
|
||||||
highlight_state: HighlightState,
|
|
||||||
has_changes: bool,
|
|
||||||
ideal_cursor_column: usize,
|
|
||||||
last_action: Option<String>,
|
|
||||||
debug_message: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DemoFormState {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
fields: vec![
|
|
||||||
"John Doe".to_string(),
|
|
||||||
"john.doe@example.com".to_string(),
|
|
||||||
"+1 234 567 8900".to_string(),
|
|
||||||
"123 Main Street Apt 4B".to_string(),
|
|
||||||
"San Francisco".to_string(),
|
|
||||||
"This is a test comment with multiple words".to_string(),
|
|
||||||
],
|
|
||||||
field_names: vec![
|
|
||||||
"Name".to_string(),
|
|
||||||
"Email".to_string(),
|
|
||||||
"Phone".to_string(),
|
|
||||||
"Address".to_string(),
|
|
||||||
"City".to_string(),
|
|
||||||
"Comments".to_string(),
|
|
||||||
],
|
|
||||||
current_field: 0,
|
|
||||||
cursor_pos: 0,
|
|
||||||
mode: AppMode::ReadOnly,
|
|
||||||
highlight_state: HighlightState::Off,
|
|
||||||
has_changes: false,
|
|
||||||
ideal_cursor_column: 0,
|
|
||||||
last_action: None,
|
|
||||||
debug_message: "Ready".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn enter_edit_mode(&mut self) {
|
|
||||||
if ModeManager::can_enter_edit_mode(self.mode) {
|
|
||||||
self.mode = AppMode::Edit;
|
|
||||||
self.debug_message = "Entered EDIT mode".to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn enter_readonly_mode(&mut self) {
|
|
||||||
if ModeManager::can_enter_read_only_mode(self.mode) {
|
|
||||||
self.mode = AppMode::ReadOnly;
|
|
||||||
self.highlight_state = HighlightState::Off;
|
|
||||||
self.debug_message = "Entered READ-ONLY mode".to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn enter_highlight_mode(&mut self) {
|
|
||||||
if ModeManager::can_enter_highlight_mode(self.mode) {
|
|
||||||
self.mode = AppMode::Highlight;
|
|
||||||
self.highlight_state = HighlightState::Characterwise {
|
|
||||||
anchor: (self.current_field, self.cursor_pos),
|
|
||||||
};
|
|
||||||
self.debug_message = "Entered VISUAL mode".to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CanvasState for DemoFormState {
|
|
||||||
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.min(self.fields.len().saturating_sub(1));
|
|
||||||
self.cursor_pos = self.fields[self.current_field].len();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_current_cursor_pos(&mut self, pos: usize) {
|
|
||||||
let max_pos = self.fields[self.current_field].len();
|
|
||||||
self.cursor_pos = pos.min(max_pos);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_mode(&self) -> AppMode {
|
|
||||||
self.mode
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_current_input(&self) -> &str {
|
|
||||||
&self.fields[self.current_field]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_current_input_mut(&mut self) -> &mut String {
|
|
||||||
&mut self.fields[self.current_field]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn inputs(&self) -> Vec<&String> {
|
|
||||||
self.fields.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
|
||||||
match action {
|
|
||||||
CanvasAction::Custom(cmd) => {
|
|
||||||
match cmd.as_str() {
|
|
||||||
"enter_edit_mode" => {
|
|
||||||
self.enter_edit_mode();
|
|
||||||
Some("Entered edit mode".to_string())
|
|
||||||
}
|
|
||||||
"enter_readonly_mode" => {
|
|
||||||
self.enter_readonly_mode();
|
|
||||||
Some("Entered read-only mode".to_string())
|
|
||||||
}
|
|
||||||
"enter_highlight_mode" => {
|
|
||||||
self.enter_highlight_mode();
|
|
||||||
Some("Entered highlight mode".to_string())
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut state: DemoFormState, config: CanvasConfig) -> io::Result<()> {
|
|
||||||
let theme = DemoTheme;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
terminal.draw(|f| ui(f, &state, &theme))?;
|
|
||||||
|
|
||||||
if let Event::Key(key) = event::read()? {
|
|
||||||
// Handle quit
|
|
||||||
if (key.code == KeyCode::Char('q') && key.modifiers.contains(KeyModifiers::CONTROL)) ||
|
|
||||||
(key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)) ||
|
|
||||||
key.code == KeyCode::F(10) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let is_edit_mode = state.mode == AppMode::Edit;
|
|
||||||
let mut handled = false;
|
|
||||||
|
|
||||||
// First priority: Try to dispatch through config system
|
|
||||||
let mut ideal_cursor = state.ideal_cursor_column;
|
|
||||||
|
|
||||||
if let Ok(Some(result)) = ActionDispatcher::dispatch_key(
|
|
||||||
key.code,
|
|
||||||
key.modifiers,
|
|
||||||
&mut state,
|
|
||||||
&mut ideal_cursor,
|
|
||||||
is_edit_mode,
|
|
||||||
false,
|
|
||||||
).await {
|
|
||||||
state.ideal_cursor_column = ideal_cursor;
|
|
||||||
state.debug_message = format!("Config handled: {:?}", key.code);
|
|
||||||
|
|
||||||
// Mark as changed for text modification keys in edit mode
|
|
||||||
if is_edit_mode {
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Delete => {
|
|
||||||
state.set_has_unsaved_changes(true);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second priority: Handle character input in edit mode
|
|
||||||
if !handled && is_edit_mode {
|
|
||||||
if let KeyCode::Char(c) = key.code {
|
|
||||||
if !key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT) {
|
|
||||||
let action = CanvasAction::InsertChar(c);
|
|
||||||
let mut ideal_cursor = state.ideal_cursor_column;
|
|
||||||
if let Ok(_) = ActionDispatcher::dispatch_with_config(
|
|
||||||
action,
|
|
||||||
&mut state,
|
|
||||||
&mut ideal_cursor,
|
|
||||||
Some(&config),
|
|
||||||
).await {
|
|
||||||
state.ideal_cursor_column = ideal_cursor;
|
|
||||||
state.set_has_unsaved_changes(true);
|
|
||||||
state.debug_message = format!("Inserted char: '{}'", c);
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Third priority: Fallback mode transitions
|
|
||||||
if !handled {
|
|
||||||
match (state.mode, key.code) {
|
|
||||||
(AppMode::ReadOnly, KeyCode::Char('i') | KeyCode::Char('a') | KeyCode::Insert) => {
|
|
||||||
state.enter_edit_mode();
|
|
||||||
if key.code == KeyCode::Char('a') {
|
|
||||||
state.cursor_pos = state.fields[state.current_field].len();
|
|
||||||
}
|
|
||||||
state.debug_message = format!("Entered edit mode via {:?}", key.code);
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
(AppMode::ReadOnly, KeyCode::Char('v')) => {
|
|
||||||
state.enter_highlight_mode();
|
|
||||||
state.debug_message = "Entered visual mode".to_string();
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
(_, KeyCode::Esc) => {
|
|
||||||
state.enter_readonly_mode();
|
|
||||||
state.debug_message = "Entered read-only mode".to_string();
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !handled {
|
|
||||||
state.debug_message = format!("Unhandled key: {:?}", key.code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ui(f: &mut Frame, state: &DemoFormState, theme: &DemoTheme) {
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Min(8),
|
|
||||||
Constraint::Length(4),
|
|
||||||
])
|
|
||||||
.split(f.area());
|
|
||||||
|
|
||||||
// Render the canvas form
|
|
||||||
render_canvas(
|
|
||||||
f,
|
|
||||||
chunks[0],
|
|
||||||
state,
|
|
||||||
theme,
|
|
||||||
state.mode == AppMode::Edit,
|
|
||||||
&state.highlight_state,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Render status bar
|
|
||||||
let mode_text = match state.mode {
|
|
||||||
AppMode::Edit => "EDIT",
|
|
||||||
AppMode::ReadOnly => "NORMAL",
|
|
||||||
AppMode::Highlight => "VISUAL",
|
|
||||||
AppMode::General => "GENERAL",
|
|
||||||
AppMode::Command => "COMMAND",
|
|
||||||
};
|
|
||||||
|
|
||||||
let status_text = if state.has_changes {
|
|
||||||
format!("-- {} -- [Modified]", mode_text)
|
|
||||||
} else {
|
|
||||||
format!("-- {} --", mode_text)
|
|
||||||
};
|
|
||||||
|
|
||||||
let position_text = format!("Field: {}/{} | Cursor: {} | Column: {}",
|
|
||||||
state.current_field + 1,
|
|
||||||
state.fields.len(),
|
|
||||||
state.cursor_pos,
|
|
||||||
state.ideal_cursor_column);
|
|
||||||
|
|
||||||
let help_text = match state.mode {
|
|
||||||
AppMode::ReadOnly => "hjkl/arrows: Move | Tab/Shift+Tab: Fields | w/b/e: Words | 0/$: Line | gg/G: File | i/a: Edit | v: Visual | F10: Quit",
|
|
||||||
AppMode::Edit => "Type to edit | hjkl/arrows: Move | Tab/Enter: Next field | Backspace/Delete: Delete | Home/End: Line | Esc: Normal | F10: Quit",
|
|
||||||
AppMode::Highlight => "hjkl/arrows: Select | w/b/e: Words | 0/$: Line | Esc: Normal | F10: Quit",
|
|
||||||
_ => "Esc: Normal | F10: Quit",
|
|
||||||
};
|
|
||||||
|
|
||||||
let status = Paragraph::new(vec![
|
|
||||||
Line::from(Span::styled(status_text, Style::default().fg(theme.accent()))),
|
|
||||||
Line::from(Span::styled(position_text, Style::default().fg(theme.fg()))),
|
|
||||||
Line::from(Span::styled(state.debug_message.clone(), Style::default().fg(theme.warning()))),
|
|
||||||
Line::from(Span::styled(help_text, Style::default().fg(theme.secondary()))),
|
|
||||||
])
|
|
||||||
.block(Block::default().borders(Borders::ALL).title("Status"));
|
|
||||||
|
|
||||||
f.render_widget(status, chunks[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let config = CanvasConfig::load();
|
|
||||||
|
|
||||||
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 = DemoFormState::new();
|
|
||||||
|
|
||||||
let res = run_app(&mut terminal, state, config).await;
|
|
||||||
|
|
||||||
disable_raw_mode()?;
|
|
||||||
execute!(
|
|
||||||
terminal.backend_mut(),
|
|
||||||
LeaveAlternateScreen,
|
|
||||||
DisableMouseCapture
|
|
||||||
)?;
|
|
||||||
terminal.show_cursor()?;
|
|
||||||
|
|
||||||
if let Err(err) = res {
|
|
||||||
println!("{:?}", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(())
|
||||||
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// examples/generate_template.rs
|
|
||||||
use canvas::config::CanvasConfig;
|
|
||||||
use std::env;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let args: Vec<String> = env::args().collect();
|
|
||||||
|
|
||||||
if args.len() > 1 && args[1] == "clean" {
|
|
||||||
// Generate clean template with 80% active code
|
|
||||||
let template = CanvasConfig::generate_clean_template();
|
|
||||||
println!("{}", template);
|
|
||||||
} else {
|
|
||||||
// Generate verbose template with descriptions (default)
|
|
||||||
let template = CanvasConfig::generate_template();
|
|
||||||
println!("{}", template);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage:
|
|
||||||
// cargo run --example generate_template > canvas_config.toml
|
|
||||||
// cargo run --example generate_template clean > canvas_config_clean.toml
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
// src/autocomplete/actions.rs
|
|
||||||
|
|
||||||
use crate::canvas::state::{CanvasState, ActionContext};
|
|
||||||
use crate::autocomplete::state::AutocompleteCanvasState;
|
|
||||||
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
|
||||||
use crate::dispatcher::ActionDispatcher; // NEW: Use dispatcher directly
|
|
||||||
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 using the new dispatcher directly
|
|
||||||
let result = ActionDispatcher::dispatch_with_config(action.clone(), state, ideal_cursor_column, config).await?;
|
|
||||||
|
|
||||||
// 3. AUTO-TRIGGER LOGIC: Check if we should activate/deactivate autocomplete
|
|
||||||
if let Some(cfg) = config {
|
|
||||||
if cfg.should_auto_trigger_autocomplete() {
|
|
||||||
match action {
|
|
||||||
CanvasAction::InsertChar(_) => {
|
|
||||||
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
|
|
||||||
{
|
|
||||||
state.activate_autocomplete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::NextField | CanvasAction::PrevField => {
|
|
||||||
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,376 +0,0 @@
|
|||||||
// src/canvas/actions/handlers/edit.rs
|
|
||||||
//! Edit mode action handler
|
|
||||||
//!
|
|
||||||
//! Handles user input when in edit mode, supporting text entry, deletion,
|
|
||||||
//! and cursor movement with edit-specific behavior (cursor can go past end of text).
|
|
||||||
|
|
||||||
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
|
||||||
use crate::config::introspection::{ActionHandlerIntrospection, HandlerCapabilities, ActionSpec};
|
|
||||||
use crate::canvas::actions::movement::*;
|
|
||||||
use crate::canvas::state::CanvasState;
|
|
||||||
use crate::config::CanvasConfig;
|
|
||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
/// Edit mode uses cursor-past-end behavior for text insertion
|
|
||||||
const FOR_EDIT_MODE: bool = true;
|
|
||||||
|
|
||||||
/// Empty struct that implements edit mode capabilities
|
|
||||||
pub struct EditHandler;
|
|
||||||
|
|
||||||
/// Handle actions in edit mode with edit-specific cursor behavior
|
|
||||||
///
|
|
||||||
/// Edit mode allows text modification and uses cursor positioning that can
|
|
||||||
/// go past the end of existing text to facilitate insertion.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
/// * `action` - The action to perform
|
|
||||||
/// * `state` - Mutable canvas state
|
|
||||||
/// * `ideal_cursor_column` - Desired column for vertical movement (maintained across line changes)
|
|
||||||
/// * `config` - Optional configuration for behavior customization
|
|
||||||
pub async fn handle_edit_action<S: CanvasState>(
|
|
||||||
action: CanvasAction,
|
|
||||||
state: &mut S,
|
|
||||||
ideal_cursor_column: &mut usize,
|
|
||||||
config: Option<&CanvasConfig>,
|
|
||||||
) -> Result<ActionResult> {
|
|
||||||
match action {
|
|
||||||
CanvasAction::InsertChar(c) => {
|
|
||||||
// Insert character at cursor position and advance cursor
|
|
||||||
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::DeleteBackward => {
|
|
||||||
// Delete character before cursor (Backspace behavior)
|
|
||||||
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 => {
|
|
||||||
// Delete character at cursor position (Delete key behavior)
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cursor movement actions
|
|
||||||
CanvasAction::MoveLeft => {
|
|
||||||
let new_pos = move_left(state.current_cursor_pos());
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
*ideal_cursor_column = new_pos;
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveRight => {
|
|
||||||
let current_input = state.get_current_input();
|
|
||||||
let current_pos = state.current_cursor_pos();
|
|
||||||
let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE);
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
*ideal_cursor_column = new_pos;
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Field navigation (treating single-line fields as "lines")
|
|
||||||
CanvasAction::MoveUp => {
|
|
||||||
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 new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
}
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveDown => {
|
|
||||||
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);
|
|
||||||
let current_input = state.get_current_input();
|
|
||||||
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
}
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Line-based movement
|
|
||||||
CanvasAction::MoveLineStart => {
|
|
||||||
let new_pos = line_start_position();
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
*ideal_cursor_column = new_pos;
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveLineEnd => {
|
|
||||||
let current_input = state.get_current_input();
|
|
||||||
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
*ideal_cursor_column = new_pos;
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Document-level movement (first/last field)
|
|
||||||
CanvasAction::MoveFirstLine => {
|
|
||||||
state.set_current_field(0);
|
|
||||||
let current_input = state.get_current_input();
|
|
||||||
let new_pos = safe_cursor_position(current_input, 0, FOR_EDIT_MODE);
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
*ideal_cursor_column = new_pos;
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveLastLine => {
|
|
||||||
let last_field = state.fields().len() - 1;
|
|
||||||
state.set_current_field(last_field);
|
|
||||||
let current_input = state.get_current_input();
|
|
||||||
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
*ideal_cursor_column = new_pos;
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Word-based movement
|
|
||||||
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::MoveWordEndPrev => {
|
|
||||||
let current_input = state.get_current_input();
|
|
||||||
if !current_input.is_empty() {
|
|
||||||
let new_pos = find_prev_word_end(current_input, state.current_cursor_pos());
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
*ideal_cursor_column = new_pos;
|
|
||||||
}
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Field navigation with wrapping behavior
|
|
||||||
CanvasAction::NextField | CanvasAction::PrevField => {
|
|
||||||
let current_field = state.current_field();
|
|
||||||
let total_fields = state.fields().len();
|
|
||||||
|
|
||||||
let new_field = match action {
|
|
||||||
CanvasAction::NextField => {
|
|
||||||
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
|
|
||||||
(current_field + 1) % total_fields // Wrap to first field
|
|
||||||
} else {
|
|
||||||
(current_field + 1).min(total_fields - 1) // Stop at last field
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CanvasAction::PrevField => {
|
|
||||||
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
|
|
||||||
if current_field == 0 { total_fields - 1 } else { current_field - 1 } // Wrap to last field
|
|
||||||
} else {
|
|
||||||
current_field.saturating_sub(1) // Stop at first field
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => unreachable!(),
|
|
||||||
};
|
|
||||||
|
|
||||||
state.set_current_field(new_field);
|
|
||||||
let current_input = state.get_current_input();
|
|
||||||
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::Custom(action_str) => {
|
|
||||||
Ok(ActionResult::success_with_message(&format!("Custom edit action: {}", action_str)))
|
|
||||||
}
|
|
||||||
|
|
||||||
_ => {
|
|
||||||
Ok(ActionResult::success_with_message("Action not implemented for edit mode"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActionHandlerIntrospection for EditHandler {
|
|
||||||
/// Report all actions this handler supports with examples and requirements
|
|
||||||
/// Used for automatic config generation and validation
|
|
||||||
fn introspect() -> HandlerCapabilities {
|
|
||||||
let mut actions = Vec::new();
|
|
||||||
|
|
||||||
// REQUIRED ACTIONS - These must be configured for edit mode to work properly
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_left".to_string(),
|
|
||||||
description: "Move cursor one position to the left".to_string(),
|
|
||||||
examples: vec!["Left".to_string(), "h".to_string()],
|
|
||||||
is_required: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_right".to_string(),
|
|
||||||
description: "Move cursor one position to the right".to_string(),
|
|
||||||
examples: vec!["Right".to_string(), "l".to_string()],
|
|
||||||
is_required: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_up".to_string(),
|
|
||||||
description: "Move to previous field or line".to_string(),
|
|
||||||
examples: vec!["Up".to_string(), "k".to_string()],
|
|
||||||
is_required: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_down".to_string(),
|
|
||||||
description: "Move to next field or line".to_string(),
|
|
||||||
examples: vec!["Down".to_string(), "j".to_string()],
|
|
||||||
is_required: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "delete_char_backward".to_string(),
|
|
||||||
description: "Delete character before cursor (Backspace)".to_string(),
|
|
||||||
examples: vec!["Backspace".to_string()],
|
|
||||||
is_required: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "next_field".to_string(),
|
|
||||||
description: "Move to next input field".to_string(),
|
|
||||||
examples: vec!["Tab".to_string(), "Enter".to_string()],
|
|
||||||
is_required: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "prev_field".to_string(),
|
|
||||||
description: "Move to previous input field".to_string(),
|
|
||||||
examples: vec!["Shift+Tab".to_string()],
|
|
||||||
is_required: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// OPTIONAL ACTIONS - These enhance functionality but aren't required
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_word_next".to_string(),
|
|
||||||
description: "Move cursor to start of next word".to_string(),
|
|
||||||
examples: vec!["Ctrl+Right".to_string(), "w".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_word_prev".to_string(),
|
|
||||||
description: "Move cursor to start of previous word".to_string(),
|
|
||||||
examples: vec!["Ctrl+Left".to_string(), "b".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_word_end".to_string(),
|
|
||||||
description: "Move cursor to end of current/next word".to_string(),
|
|
||||||
examples: vec!["e".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_word_end_prev".to_string(),
|
|
||||||
description: "Move cursor to end of previous word".to_string(),
|
|
||||||
examples: vec!["ge".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_line_start".to_string(),
|
|
||||||
description: "Move cursor to beginning of line".to_string(),
|
|
||||||
examples: vec!["Home".to_string(), "0".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_line_end".to_string(),
|
|
||||||
description: "Move cursor to end of line".to_string(),
|
|
||||||
examples: vec!["End".to_string(), "$".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_first_line".to_string(),
|
|
||||||
description: "Move to first field".to_string(),
|
|
||||||
examples: vec!["Ctrl+Home".to_string(), "gg".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_last_line".to_string(),
|
|
||||||
description: "Move to last field".to_string(),
|
|
||||||
examples: vec!["Ctrl+End".to_string(), "G".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "delete_char_forward".to_string(),
|
|
||||||
description: "Delete character after cursor (Delete key)".to_string(),
|
|
||||||
examples: vec!["Delete".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
HandlerCapabilities {
|
|
||||||
mode_name: "edit".to_string(),
|
|
||||||
actions,
|
|
||||||
auto_handled: vec![
|
|
||||||
"insert_char".to_string(), // Any printable character is inserted automatically
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_capabilities() -> Result<(), String> {
|
|
||||||
// TODO: Could add runtime validation that the handler actually
|
|
||||||
// implements all the actions it claims to support
|
|
||||||
|
|
||||||
// For now, just validate that we have the essential actions
|
|
||||||
let caps = Self::introspect();
|
|
||||||
let required_count = caps.actions.iter().filter(|a| a.is_required).count();
|
|
||||||
|
|
||||||
if required_count < 7 { // We expect at least 7 required actions
|
|
||||||
return Err(format!(
|
|
||||||
"Edit handler claims only {} required actions, expected at least 7",
|
|
||||||
required_count
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
// src/canvas/actions/handlers/highlight.rs
|
|
||||||
|
|
||||||
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
|
||||||
use crate::config::introspection::{ActionHandlerIntrospection, HandlerCapabilities, ActionSpec};
|
|
||||||
|
|
||||||
use crate::canvas::actions::movement::*;
|
|
||||||
use crate::canvas::state::CanvasState;
|
|
||||||
use crate::config::CanvasConfig;
|
|
||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
const FOR_EDIT_MODE: bool = false; // Highlight mode uses read-only cursor behavior
|
|
||||||
|
|
||||||
pub struct HighlightHandler;
|
|
||||||
|
|
||||||
/// Handle actions in highlight/visual mode
|
|
||||||
/// TODO: Implement selection logic and highlight-specific behaviors
|
|
||||||
pub async fn handle_highlight_action<S: CanvasState>(
|
|
||||||
action: CanvasAction,
|
|
||||||
state: &mut S,
|
|
||||||
ideal_cursor_column: &mut usize,
|
|
||||||
config: Option<&CanvasConfig>,
|
|
||||||
) -> Result<ActionResult> {
|
|
||||||
match action {
|
|
||||||
// Movement actions work similar to read-only mode but with selection
|
|
||||||
CanvasAction::MoveLeft => {
|
|
||||||
let new_pos = move_left(state.current_cursor_pos());
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
*ideal_cursor_column = new_pos;
|
|
||||||
// TODO: Update selection range
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveRight => {
|
|
||||||
let current_input = state.get_current_input();
|
|
||||||
let current_pos = state.current_cursor_pos();
|
|
||||||
let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE);
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
*ideal_cursor_column = new_pos;
|
|
||||||
// TODO: Update selection range
|
|
||||||
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());
|
|
||||||
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
|
|
||||||
state.set_current_cursor_pos(final_pos);
|
|
||||||
*ideal_cursor_column = final_pos;
|
|
||||||
// TODO: Update selection range
|
|
||||||
}
|
|
||||||
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());
|
|
||||||
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
|
|
||||||
state.set_current_cursor_pos(final_pos);
|
|
||||||
*ideal_cursor_column = final_pos;
|
|
||||||
// TODO: Update selection range
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
// TODO: Update selection range
|
|
||||||
}
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveLineStart => {
|
|
||||||
let new_pos = line_start_position();
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
*ideal_cursor_column = new_pos;
|
|
||||||
// TODO: Update selection range
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveLineEnd => {
|
|
||||||
let current_input = state.get_current_input();
|
|
||||||
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
*ideal_cursor_column = new_pos;
|
|
||||||
// TODO: Update selection range
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Highlight mode doesn't handle editing actions
|
|
||||||
CanvasAction::InsertChar(_) |
|
|
||||||
CanvasAction::DeleteBackward |
|
|
||||||
CanvasAction::DeleteForward => {
|
|
||||||
Ok(ActionResult::success_with_message("Action not available in highlight mode"))
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::Custom(action_str) => {
|
|
||||||
Ok(ActionResult::success_with_message(&format!("Custom highlight action: {}", action_str)))
|
|
||||||
}
|
|
||||||
|
|
||||||
_ => {
|
|
||||||
Ok(ActionResult::success_with_message("Action not implemented for highlight mode"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActionHandlerIntrospection for HighlightHandler {
|
|
||||||
fn introspect() -> HandlerCapabilities {
|
|
||||||
let mut actions = Vec::new();
|
|
||||||
|
|
||||||
// For now, highlight mode uses similar movement to readonly
|
|
||||||
// but this will be discovered from actual implementation
|
|
||||||
|
|
||||||
// REQUIRED ACTIONS - Basic movement in highlight mode
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_left".to_string(),
|
|
||||||
description: "Move cursor left and extend selection".to_string(),
|
|
||||||
examples: vec!["h".to_string(), "Left".to_string()],
|
|
||||||
is_required: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_right".to_string(),
|
|
||||||
description: "Move cursor right and extend selection".to_string(),
|
|
||||||
examples: vec!["l".to_string(), "Right".to_string()],
|
|
||||||
is_required: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_up".to_string(),
|
|
||||||
description: "Move up and extend selection".to_string(),
|
|
||||||
examples: vec!["k".to_string(), "Up".to_string()],
|
|
||||||
is_required: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_down".to_string(),
|
|
||||||
description: "Move down and extend selection".to_string(),
|
|
||||||
examples: vec!["j".to_string(), "Down".to_string()],
|
|
||||||
is_required: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// OPTIONAL ACTIONS - Advanced highlight movement
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_word_next".to_string(),
|
|
||||||
description: "Move to next word and extend selection".to_string(),
|
|
||||||
examples: vec!["w".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_word_end".to_string(),
|
|
||||||
description: "Move to word end and extend selection".to_string(),
|
|
||||||
examples: vec!["e".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_word_prev".to_string(),
|
|
||||||
description: "Move to previous word and extend selection".to_string(),
|
|
||||||
examples: vec!["b".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_line_start".to_string(),
|
|
||||||
description: "Move to line start and extend selection".to_string(),
|
|
||||||
examples: vec!["0".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_line_end".to_string(),
|
|
||||||
description: "Move to line end and extend selection".to_string(),
|
|
||||||
examples: vec!["$".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
HandlerCapabilities {
|
|
||||||
mode_name: "highlight".to_string(),
|
|
||||||
actions,
|
|
||||||
auto_handled: vec![], // Highlight mode has no auto-handled actions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_capabilities() -> Result<(), String> {
|
|
||||||
let caps = Self::introspect();
|
|
||||||
let required_count = caps.actions.iter().filter(|a| a.is_required).count();
|
|
||||||
|
|
||||||
if required_count < 4 { // We expect at least 4 required actions (basic movement)
|
|
||||||
return Err(format!(
|
|
||||||
"Highlight handler claims only {} required actions, expected at least 4",
|
|
||||||
required_count
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
// src/canvas/actions/handlers/mod.rs
|
|
||||||
|
|
||||||
pub mod edit;
|
|
||||||
pub mod readonly;
|
|
||||||
pub mod highlight;
|
|
||||||
|
|
||||||
// Re-export handler functions
|
|
||||||
pub use edit::handle_edit_action;
|
|
||||||
pub use readonly::handle_readonly_action;
|
|
||||||
pub use highlight::handle_highlight_action;
|
|
||||||
@@ -1,322 +0,0 @@
|
|||||||
// src/canvas/actions/handlers/readonly.rs
|
|
||||||
|
|
||||||
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
|
||||||
use crate::config::introspection::{ActionHandlerIntrospection, HandlerCapabilities, ActionSpec};
|
|
||||||
use crate::canvas::actions::movement::*;
|
|
||||||
use crate::canvas::state::CanvasState;
|
|
||||||
use crate::config::CanvasConfig;
|
|
||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
const FOR_EDIT_MODE: bool = false; // Read-only mode flag
|
|
||||||
|
|
||||||
/// Handle actions in read-only mode with read-only specific cursor behavior
|
|
||||||
pub async fn handle_readonly_action<S: CanvasState>(
|
|
||||||
action: CanvasAction,
|
|
||||||
state: &mut S,
|
|
||||||
ideal_cursor_column: &mut usize,
|
|
||||||
config: Option<&CanvasConfig>,
|
|
||||||
) -> Result<ActionResult> {
|
|
||||||
match action {
|
|
||||||
CanvasAction::MoveLeft => {
|
|
||||||
let new_pos = move_left(state.current_cursor_pos());
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
*ideal_cursor_column = new_pos;
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveRight => {
|
|
||||||
let current_input = state.get_current_input();
|
|
||||||
let current_pos = state.current_cursor_pos();
|
|
||||||
let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE);
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
*ideal_cursor_column = new_pos;
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveUp => {
|
|
||||||
let current_field = state.current_field();
|
|
||||||
let new_field = current_field.saturating_sub(1);
|
|
||||||
state.set_current_field(new_field);
|
|
||||||
|
|
||||||
// Apply ideal cursor column with read-only bounds
|
|
||||||
let current_input = state.get_current_input();
|
|
||||||
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveDown => {
|
|
||||||
let current_field = state.current_field();
|
|
||||||
let total_fields = state.fields().len();
|
|
||||||
if total_fields == 0 {
|
|
||||||
return Ok(ActionResult::success_with_message("No fields to navigate"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let new_field = (current_field + 1).min(total_fields - 1);
|
|
||||||
state.set_current_field(new_field);
|
|
||||||
|
|
||||||
// Apply ideal cursor column with read-only bounds
|
|
||||||
let current_input = state.get_current_input();
|
|
||||||
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveFirstLine => {
|
|
||||||
let total_fields = state.fields().len();
|
|
||||||
if total_fields == 0 {
|
|
||||||
return Ok(ActionResult::success_with_message("No fields to navigate"));
|
|
||||||
}
|
|
||||||
|
|
||||||
state.set_current_field(0);
|
|
||||||
let current_input = state.get_current_input();
|
|
||||||
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
*ideal_cursor_column = new_pos;
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveLastLine => {
|
|
||||||
let total_fields = state.fields().len();
|
|
||||||
if total_fields == 0 {
|
|
||||||
return Ok(ActionResult::success_with_message("No fields to navigate"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let last_field = total_fields - 1;
|
|
||||||
state.set_current_field(last_field);
|
|
||||||
let current_input = state.get_current_input();
|
|
||||||
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
*ideal_cursor_column = new_pos;
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveLineStart => {
|
|
||||||
let new_pos = line_start_position();
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
*ideal_cursor_column = new_pos;
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveLineEnd => {
|
|
||||||
let current_input = state.get_current_input();
|
|
||||||
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
*ideal_cursor_column = new_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());
|
|
||||||
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
|
|
||||||
state.set_current_cursor_pos(final_pos);
|
|
||||||
*ideal_cursor_column = final_pos;
|
|
||||||
}
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveWordEnd => {
|
|
||||||
let current_input = state.get_current_input();
|
|
||||||
if !current_input.is_empty() {
|
|
||||||
let current_pos = state.current_cursor_pos();
|
|
||||||
let new_pos = find_word_end(current_input, current_pos);
|
|
||||||
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
|
|
||||||
state.set_current_cursor_pos(final_pos);
|
|
||||||
*ideal_cursor_column = final_pos;
|
|
||||||
}
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveWordPrev => {
|
|
||||||
let current_input = state.get_current_input();
|
|
||||||
if !current_input.is_empty() {
|
|
||||||
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
*ideal_cursor_column = new_pos;
|
|
||||||
}
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveWordEndPrev => {
|
|
||||||
let current_input = state.get_current_input();
|
|
||||||
if !current_input.is_empty() {
|
|
||||||
let new_pos = find_prev_word_end(current_input, state.current_cursor_pos());
|
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
*ideal_cursor_column = new_pos;
|
|
||||||
}
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::NextField | CanvasAction::PrevField => {
|
|
||||||
let current_field = state.current_field();
|
|
||||||
let total_fields = state.fields().len();
|
|
||||||
|
|
||||||
let new_field = match action {
|
|
||||||
CanvasAction::NextField => {
|
|
||||||
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
|
|
||||||
(current_field + 1) % total_fields
|
|
||||||
} else {
|
|
||||||
(current_field + 1).min(total_fields - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CanvasAction::PrevField => {
|
|
||||||
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
|
|
||||||
if current_field == 0 { total_fields - 1 } else { current_field - 1 }
|
|
||||||
} else {
|
|
||||||
current_field.saturating_sub(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => unreachable!(),
|
|
||||||
};
|
|
||||||
|
|
||||||
state.set_current_field(new_field);
|
|
||||||
*ideal_cursor_column = state.current_cursor_pos();
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read-only mode doesn't handle editing actions
|
|
||||||
CanvasAction::InsertChar(_) |
|
|
||||||
CanvasAction::DeleteBackward |
|
|
||||||
CanvasAction::DeleteForward => {
|
|
||||||
Ok(ActionResult::success_with_message("Action not available in read-only mode"))
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::Custom(action_str) => {
|
|
||||||
Ok(ActionResult::success_with_message(&format!("Custom readonly action: {}", action_str)))
|
|
||||||
}
|
|
||||||
|
|
||||||
_ => {
|
|
||||||
Ok(ActionResult::success_with_message("Action not implemented for read-only mode"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ReadOnlyHandler;
|
|
||||||
|
|
||||||
impl ActionHandlerIntrospection for ReadOnlyHandler {
|
|
||||||
fn introspect() -> HandlerCapabilities {
|
|
||||||
let mut actions = Vec::new();
|
|
||||||
|
|
||||||
// REQUIRED ACTIONS - Navigation is essential in read-only mode
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_left".to_string(),
|
|
||||||
description: "Move cursor one position to the left".to_string(),
|
|
||||||
examples: vec!["h".to_string(), "Left".to_string()],
|
|
||||||
is_required: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_right".to_string(),
|
|
||||||
description: "Move cursor one position to the right".to_string(),
|
|
||||||
examples: vec!["l".to_string(), "Right".to_string()],
|
|
||||||
is_required: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_up".to_string(),
|
|
||||||
description: "Move to previous field".to_string(),
|
|
||||||
examples: vec!["k".to_string(), "Up".to_string()],
|
|
||||||
is_required: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_down".to_string(),
|
|
||||||
description: "Move to next field".to_string(),
|
|
||||||
examples: vec!["j".to_string(), "Down".to_string()],
|
|
||||||
is_required: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// OPTIONAL ACTIONS - Advanced navigation features
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_word_next".to_string(),
|
|
||||||
description: "Move cursor to start of next word".to_string(),
|
|
||||||
examples: vec!["w".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_word_prev".to_string(),
|
|
||||||
description: "Move cursor to start of previous word".to_string(),
|
|
||||||
examples: vec!["b".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_word_end".to_string(),
|
|
||||||
description: "Move cursor to end of current/next word".to_string(),
|
|
||||||
examples: vec!["e".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_word_end_prev".to_string(),
|
|
||||||
description: "Move cursor to end of previous word".to_string(),
|
|
||||||
examples: vec!["ge".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_line_start".to_string(),
|
|
||||||
description: "Move cursor to beginning of line".to_string(),
|
|
||||||
examples: vec!["0".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_line_end".to_string(),
|
|
||||||
description: "Move cursor to end of line".to_string(),
|
|
||||||
examples: vec!["$".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_first_line".to_string(),
|
|
||||||
description: "Move to first field".to_string(),
|
|
||||||
examples: vec!["gg".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "move_last_line".to_string(),
|
|
||||||
description: "Move to last field".to_string(),
|
|
||||||
examples: vec!["G".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "next_field".to_string(),
|
|
||||||
description: "Move to next input field".to_string(),
|
|
||||||
examples: vec!["Tab".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push(ActionSpec {
|
|
||||||
name: "prev_field".to_string(),
|
|
||||||
description: "Move to previous input field".to_string(),
|
|
||||||
examples: vec!["Shift+Tab".to_string()],
|
|
||||||
is_required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
HandlerCapabilities {
|
|
||||||
mode_name: "read_only".to_string(),
|
|
||||||
actions,
|
|
||||||
auto_handled: vec![], // Read-only mode has no auto-handled actions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_capabilities() -> Result<(), String> {
|
|
||||||
let caps = Self::introspect();
|
|
||||||
let required_count = caps.actions.iter().filter(|a| a.is_required).count();
|
|
||||||
|
|
||||||
if required_count < 4 { // We expect at least 4 required actions (basic movement)
|
|
||||||
return Err(format!(
|
|
||||||
"ReadOnly handler claims only {} required actions, expected at least 4",
|
|
||||||
required_count
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub mod movement;
|
pub mod movement;
|
||||||
pub mod handlers;
|
|
||||||
|
|
||||||
// Re-export the main types
|
// Re-export the main API
|
||||||
pub use types::{CanvasAction, ActionResult};
|
pub use types::{CanvasAction, ActionResult};
|
||||||
|
|||||||
@@ -1,35 +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,
|
||||||
MoveWordEndPrev,
|
|
||||||
|
|
||||||
// Field navigation
|
|
||||||
NextField,
|
|
||||||
PrevField,
|
|
||||||
|
|
||||||
// Autocomplete actions
|
// Autocomplete actions
|
||||||
TriggerAutocomplete,
|
TriggerAutocomplete,
|
||||||
@@ -42,67 +41,131 @@ pub enum CanvasAction {
|
|||||||
Custom(String),
|
Custom(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CanvasAction {
|
/// Result type for canvas actions
|
||||||
/// Convert string action name to CanvasAction enum (config-driven)
|
#[derive(Debug, Clone)]
|
||||||
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,
|
|
||||||
"move_word_end_prev" => Self::MoveWordEndPrev,
|
|
||||||
"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,29 +10,43 @@ 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,
|
theme: &T,
|
||||||
is_edit_mode: bool,
|
|
||||||
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));
|
||||||
|
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);
|
||||||
|
|
||||||
|
// For now, create a default highlight state (TODO: get from editor state)
|
||||||
|
let highlight_state = HighlightState::Off;
|
||||||
|
|
||||||
render_canvas_fields(
|
render_canvas_fields(
|
||||||
f,
|
f,
|
||||||
area,
|
area,
|
||||||
@@ -40,11 +55,13 @@ pub fn render_canvas<T: CanvasTheme>(
|
|||||||
&inputs,
|
&inputs,
|
||||||
theme,
|
theme,
|
||||||
is_edit_mode,
|
is_edit_mode,
|
||||||
highlight_state,
|
&highlight_state,
|
||||||
form_state.current_cursor_pos(),
|
ui_state.cursor_position(),
|
||||||
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),
|
data_provider.display_value(i).unwrap_or(data_provider.field_value(i)).to_string()
|
||||||
|
},
|
||||||
|
|i| data_provider.display_value(i).is_some(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +72,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 +129,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 +171,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,
|
||||||
@@ -171,7 +188,7 @@ where
|
|||||||
for (i, _input) in inputs.iter().enumerate() {
|
for (i, _input) in inputs.iter().enumerate() {
|
||||||
let is_active = i == *current_field_idx;
|
let is_active = i == *current_field_idx;
|
||||||
let text = get_display_value(i);
|
let text = get_display_value(i);
|
||||||
|
|
||||||
// Apply highlighting
|
// Apply highlighting
|
||||||
let line = apply_highlighting(
|
let line = apply_highlighting(
|
||||||
&text,
|
&text,
|
||||||
@@ -301,7 +318,7 @@ fn apply_linewise_highlighting<'a, T: CanvasTheme>(
|
|||||||
) -> 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);
|
||||||
|
|
||||||
let highlight_style = Style::default()
|
let highlight_style = Style::default()
|
||||||
.fg(theme.highlight())
|
.fg(theme.highlight())
|
||||||
.bg(theme.highlight_bg())
|
.bg(theme.highlight_bg())
|
||||||
@@ -336,3 +353,14 @@ fn set_cursor_position(
|
|||||||
let cursor_y = field_rect.y;
|
let cursor_y = field_rect.y;
|
||||||
f.set_cursor_position((cursor_x, cursor_y));
|
f.set_cursor_position((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,20 +1,19 @@
|
|||||||
// src/canvas/mod.rs
|
// src/canvas/mod.rs
|
||||||
|
|
||||||
pub mod actions;
|
pub mod actions;
|
||||||
pub mod gui;
|
|
||||||
pub mod modes;
|
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
pub mod modes;
|
||||||
|
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub mod gui;
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
|
|
||||||
// Re-export commonly used canvas types
|
#[cfg(feature = "cursor-style")]
|
||||||
pub use actions::{CanvasAction, ActionResult};
|
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};
|
|
||||||
|
|
||||||
// Re-export the main entry point
|
#[cfg(feature = "cursor-style")]
|
||||||
pub use crate::dispatcher::execute_canvas_action;
|
pub use cursor::CursorManager;
|
||||||
|
|
||||||
#[cfg(feature = "gui")]
|
|
||||||
pub use theme::CanvasTheme;
|
|
||||||
|
|
||||||
#[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,117 +1,137 @@
|
|||||||
// src/canvas/state.rs
|
// src/canvas/state.rs
|
||||||
//! Canvas state trait and related types
|
//! Library-owned UI state - user never directly modifies this
|
||||||
//!
|
|
||||||
//! This module defines the core trait that any form or input system must implement
|
|
||||||
//! to work with the canvas library.
|
|
||||||
|
|
||||||
use crate::canvas::actions::CanvasAction;
|
|
||||||
use crate::canvas::modes::AppMode;
|
use crate::canvas::modes::AppMode;
|
||||||
|
|
||||||
/// Context information 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 {
|
||||||
/// Original key code that triggered this action (for backwards compatibility)
|
// Navigation state
|
||||||
pub key_code: Option<crossterm::event::KeyCode>,
|
pub(crate) current_field: usize,
|
||||||
/// Current ideal cursor column for vertical movement
|
pub(crate) cursor_pos: usize,
|
||||||
pub ideal_cursor_column: usize,
|
pub(crate) ideal_cursor_column: usize,
|
||||||
/// Current input text
|
|
||||||
pub current_input: String,
|
// Mode state
|
||||||
/// Current field index
|
pub(crate) current_mode: AppMode,
|
||||||
pub current_field: usize,
|
|
||||||
|
// Autocomplete state
|
||||||
|
pub(crate) autocomplete: AutocompleteUIState,
|
||||||
|
|
||||||
|
// Selection state (for vim visual mode)
|
||||||
|
pub(crate) selection: SelectionState,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Core trait that any form-like state must implement to work with canvas
|
#[derive(Debug, Clone)]
|
||||||
///
|
pub struct AutocompleteUIState {
|
||||||
/// This trait enables the same mode behaviors (edit, read-only, highlight) to work
|
pub(crate) is_active: bool,
|
||||||
/// across any implementation - login forms, data entry forms, configuration screens, etc.
|
pub(crate) is_loading: bool,
|
||||||
///
|
pub(crate) selected_index: Option<usize>,
|
||||||
/// # Required Implementation
|
pub(crate) active_field: Option<usize>,
|
||||||
///
|
}
|
||||||
/// Your struct needs to track:
|
|
||||||
/// - Current field index and cursor position
|
|
||||||
/// - All input field values
|
|
||||||
/// - Current interaction mode
|
|
||||||
/// - Whether there are unsaved changes
|
|
||||||
///
|
|
||||||
/// # Example Implementation
|
|
||||||
///
|
|
||||||
/// ```rust
|
|
||||||
/// struct MyForm {
|
|
||||||
/// fields: Vec<String>,
|
|
||||||
/// current_field: usize,
|
|
||||||
/// cursor_pos: usize,
|
|
||||||
/// mode: AppMode,
|
|
||||||
/// dirty: bool,
|
|
||||||
/// }
|
|
||||||
///
|
|
||||||
/// impl CanvasState for MyForm {
|
|
||||||
/// fn current_field(&self) -> usize { self.current_field }
|
|
||||||
/// fn current_cursor_pos(&self) -> usize { self.cursor_pos }
|
|
||||||
/// // ... implement other required methods
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
pub trait CanvasState {
|
|
||||||
// --- Core Navigation ---
|
|
||||||
|
|
||||||
/// Get current field index (0-based)
|
|
||||||
fn current_field(&self) -> usize;
|
|
||||||
|
|
||||||
/// Get current cursor position within the current field
|
|
||||||
fn current_cursor_pos(&self) -> usize;
|
|
||||||
|
|
||||||
/// Set current field index (should clamp to valid range)
|
|
||||||
fn set_current_field(&mut self, index: usize);
|
|
||||||
|
|
||||||
/// Set cursor position within current field (should clamp to valid range)
|
|
||||||
fn set_current_cursor_pos(&mut self, pos: usize);
|
|
||||||
|
|
||||||
// --- Mode Information ---
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum SelectionState {
|
||||||
/// Get current interaction mode (edit, read-only, highlight, etc.)
|
None,
|
||||||
fn current_mode(&self) -> AppMode;
|
Characterwise { anchor: (usize, usize) },
|
||||||
|
Linewise { anchor_field: usize },
|
||||||
|
}
|
||||||
|
|
||||||
// --- Data Access ---
|
impl EditorState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
current_field: 0,
|
||||||
|
cursor_pos: 0,
|
||||||
|
ideal_cursor_column: 0,
|
||||||
|
current_mode: AppMode::Edit,
|
||||||
|
autocomplete: AutocompleteUIState {
|
||||||
|
is_active: false,
|
||||||
|
is_loading: false,
|
||||||
|
selected_index: None,
|
||||||
|
active_field: None,
|
||||||
|
},
|
||||||
|
selection: SelectionState::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get immutable reference to current field's text
|
// ===================================================================
|
||||||
fn get_current_input(&self) -> &str;
|
// READ-ONLY ACCESS: User can fetch UI state for compatibility
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
/// Get mutable reference to current field's text
|
/// Get current field index (for user's business logic)
|
||||||
fn get_current_input_mut(&mut self) -> &mut String;
|
pub fn current_field(&self) -> usize {
|
||||||
|
self.current_field
|
||||||
|
}
|
||||||
|
|
||||||
/// Get all input values as immutable references
|
/// Get current cursor position (for user's business logic)
|
||||||
fn inputs(&self) -> Vec<&String>;
|
pub fn cursor_position(&self) -> usize {
|
||||||
|
self.cursor_pos
|
||||||
/// Get all field names/labels
|
|
||||||
fn fields(&self) -> Vec<&str>;
|
|
||||||
|
|
||||||
// --- State Management ---
|
|
||||||
|
|
||||||
/// Check if there are unsaved changes
|
|
||||||
fn has_unsaved_changes(&self) -> bool;
|
|
||||||
|
|
||||||
/// Mark whether there are unsaved changes
|
|
||||||
fn set_has_unsaved_changes(&mut self, changed: bool);
|
|
||||||
|
|
||||||
// --- Optional Overrides ---
|
|
||||||
|
|
||||||
/// Handle application-specific actions not covered by standard handlers
|
|
||||||
/// Return Some(message) if the action was handled, None to use standard handling
|
|
||||||
fn handle_feature_action(&mut self, _action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
|
||||||
None // Default: no custom handling
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get display value for a field (may differ from actual value)
|
/// Get ideal cursor column (for vim-like behavior)
|
||||||
/// Used for things like password masking or computed display values
|
pub fn ideal_cursor_column(&self) -> usize { // ADD THIS
|
||||||
fn get_display_value_for_field(&self, index: usize) -> &str {
|
self.ideal_cursor_column
|
||||||
self.inputs()
|
|
||||||
.get(index)
|
|
||||||
.map(|s| s.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a field has a custom display value
|
/// Get current mode (for user's business logic)
|
||||||
/// Return true if get_display_value_for_field returns something different than the actual value
|
pub fn mode(&self) -> AppMode {
|
||||||
fn has_display_override(&self, _index: usize) -> bool {
|
self.current_mode
|
||||||
false
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// 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,665 +0,0 @@
|
|||||||
// src/config/config.rs
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use crossterm::event::{KeyCode, KeyModifiers};
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
|
|
||||||
// Import from sibling modules
|
|
||||||
use super::registry::ActionRegistry;
|
|
||||||
use super::validation::{ConfigValidator, ValidationError, ValidationResult, ValidationWarning};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct CanvasKeybindings {
|
|
||||||
pub edit: HashMap<String, Vec<String>>,
|
|
||||||
pub read_only: HashMap<String, Vec<String>>,
|
|
||||||
pub global: HashMap<String, Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for CanvasKeybindings {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
edit: HashMap::new(),
|
|
||||||
read_only: HashMap::new(),
|
|
||||||
global: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct CanvasBehavior {
|
|
||||||
pub confirm_on_save: bool,
|
|
||||||
pub auto_indent: bool,
|
|
||||||
pub wrap_search: bool,
|
|
||||||
pub wrap_around_fields: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for CanvasBehavior {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
confirm_on_save: true,
|
|
||||||
auto_indent: true,
|
|
||||||
wrap_search: true,
|
|
||||||
wrap_around_fields: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct CanvasAppearance {
|
|
||||||
pub line_numbers: bool,
|
|
||||||
pub syntax_highlighting: bool,
|
|
||||||
pub current_line_highlight: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for CanvasAppearance {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
line_numbers: true,
|
|
||||||
syntax_highlighting: true,
|
|
||||||
current_line_highlight: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct CanvasConfig {
|
|
||||||
pub keybindings: CanvasKeybindings,
|
|
||||||
pub behavior: CanvasBehavior,
|
|
||||||
pub appearance: CanvasAppearance,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for CanvasConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
keybindings: CanvasKeybindings::with_vim_defaults(),
|
|
||||||
behavior: CanvasBehavior::default(),
|
|
||||||
appearance: CanvasAppearance::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CanvasKeybindings {
|
|
||||||
/// Generate complete vim defaults from introspection system
|
|
||||||
/// This ensures defaults are always in sync with actual handler capabilities
|
|
||||||
pub fn with_vim_defaults() -> Self {
|
|
||||||
let registry = ActionRegistry::from_handlers();
|
|
||||||
Self::generate_from_registry(®istry)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate keybindings from action registry (used by both defaults and config generation)
|
|
||||||
/// This is the single source of truth for what keybindings should exist
|
|
||||||
fn generate_from_registry(registry: &ActionRegistry) -> Self {
|
|
||||||
let mut keybindings = Self::default();
|
|
||||||
|
|
||||||
// Generate keybindings for each mode discovered by introspection
|
|
||||||
for (mode_name, mode_registry) in ®istry.modes {
|
|
||||||
let mode_bindings = match mode_name.as_str() {
|
|
||||||
"edit" => &mut keybindings.edit,
|
|
||||||
"read_only" => &mut keybindings.read_only,
|
|
||||||
"highlight" => &mut keybindings.global, // Highlight actions go in global
|
|
||||||
_ => {
|
|
||||||
// Handle any future modes discovered by introspection
|
|
||||||
eprintln!("Warning: Unknown mode '{}' discovered by introspection", mode_name);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add ALL required actions
|
|
||||||
for (action_name, action_spec) in &mode_registry.required {
|
|
||||||
if !action_spec.examples.is_empty() {
|
|
||||||
mode_bindings.insert(
|
|
||||||
action_name.clone(),
|
|
||||||
action_spec.examples.clone()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add ALL optional actions
|
|
||||||
for (action_name, action_spec) in &mode_registry.optional {
|
|
||||||
if !action_spec.examples.is_empty() {
|
|
||||||
mode_bindings.insert(
|
|
||||||
action_name.clone(),
|
|
||||||
action_spec.examples.clone()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
keybindings
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate a minimal fallback configuration if introspection fails
|
|
||||||
/// This should rarely be used, but provides safety net
|
|
||||||
fn minimal_fallback() -> Self {
|
|
||||||
let mut keybindings = Self::default();
|
|
||||||
|
|
||||||
// Absolute minimum required for basic functionality
|
|
||||||
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.edit.insert("delete_char_backward".to_string(), vec!["Backspace".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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate that generated keybindings match the current introspection state
|
|
||||||
/// This helps catch when handlers change but defaults become stale
|
|
||||||
pub fn validate_against_introspection(&self) -> Result<(), Vec<String>> {
|
|
||||||
let registry = ActionRegistry::from_handlers();
|
|
||||||
let expected = Self::generate_from_registry(®istry);
|
|
||||||
let mut errors = Vec::new();
|
|
||||||
|
|
||||||
// Check each mode
|
|
||||||
for (mode_name, expected_bindings) in [
|
|
||||||
("edit", &expected.edit),
|
|
||||||
("read_only", &expected.read_only),
|
|
||||||
("global", &expected.global),
|
|
||||||
] {
|
|
||||||
let actual_bindings = match mode_name {
|
|
||||||
"edit" => &self.edit,
|
|
||||||
"read_only" => &self.read_only,
|
|
||||||
"global" => &self.global,
|
|
||||||
_ => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check for missing actions
|
|
||||||
for action_name in expected_bindings.keys() {
|
|
||||||
if !actual_bindings.contains_key(action_name) {
|
|
||||||
errors.push(format!(
|
|
||||||
"Missing action '{}' in {} mode (expected by introspection)",
|
|
||||||
action_name, mode_name
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for unexpected actions
|
|
||||||
for action_name in actual_bindings.keys() {
|
|
||||||
if !expected_bindings.contains_key(action_name) {
|
|
||||||
errors.push(format!(
|
|
||||||
"Unexpected action '{}' in {} mode (not found in introspection)",
|
|
||||||
action_name, mode_name
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if errors.is_empty() {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(errors)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CanvasConfig {
|
|
||||||
/// Enhanced load method with introspection validation
|
|
||||||
pub fn load() -> Self {
|
|
||||||
match Self::load_and_validate() {
|
|
||||||
Ok(config) => config,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to load config file: {}", e);
|
|
||||||
eprintln!("Using auto-generated defaults from introspection");
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load and validate configuration with enhanced introspection checks
|
|
||||||
pub fn load_and_validate() -> Result<Self> {
|
|
||||||
// Try to load canvas_config.toml from current directory
|
|
||||||
let config = if let Ok(config) = Self::from_file(std::path::Path::new("canvas_config.toml")) {
|
|
||||||
config
|
|
||||||
} else {
|
|
||||||
// Use auto-generated defaults if file doesn't exist
|
|
||||||
eprintln!("Config file not found, using auto-generated defaults");
|
|
||||||
Self::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validate the configuration against current introspection state
|
|
||||||
let registry = ActionRegistry::from_handlers();
|
|
||||||
|
|
||||||
// Validate handlers are working correctly
|
|
||||||
if let Err(handler_errors) = registry.validate_against_implementation() {
|
|
||||||
eprintln!("Handler validation warnings:");
|
|
||||||
for error in handler_errors {
|
|
||||||
eprintln!(" - {}", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate the configuration against the dynamic registry
|
|
||||||
let validator = ConfigValidator::new(registry);
|
|
||||||
let validation_result = validator.validate_keybindings(&config.keybindings);
|
|
||||||
|
|
||||||
if !validation_result.is_valid {
|
|
||||||
eprintln!("Configuration validation failed:");
|
|
||||||
validator.print_validation_result(&validation_result);
|
|
||||||
} else if !validation_result.warnings.is_empty() {
|
|
||||||
eprintln!("Configuration validation warnings:");
|
|
||||||
validator.print_validation_result(&validation_result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional: Validate that our defaults match introspection
|
|
||||||
if let Err(sync_errors) = config.keybindings.validate_against_introspection() {
|
|
||||||
eprintln!("Default keybindings out of sync with introspection:");
|
|
||||||
for error in sync_errors {
|
|
||||||
eprintln!(" - {}", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate a complete configuration template that matches current defaults
|
|
||||||
/// This ensures the generated config file has the same content as defaults
|
|
||||||
pub fn generate_complete_template() -> String {
|
|
||||||
let registry = ActionRegistry::from_handlers();
|
|
||||||
let defaults = CanvasKeybindings::generate_from_registry(®istry);
|
|
||||||
|
|
||||||
let mut template = String::new();
|
|
||||||
template.push_str("# Canvas Library Configuration\n");
|
|
||||||
template.push_str("# Auto-generated from handler introspection\n");
|
|
||||||
template.push_str("# This config contains ALL available actions\n\n");
|
|
||||||
|
|
||||||
// Generate sections for each mode
|
|
||||||
for (mode_name, bindings) in [
|
|
||||||
("read_only", &defaults.read_only),
|
|
||||||
("edit", &defaults.edit),
|
|
||||||
("global", &defaults.global),
|
|
||||||
] {
|
|
||||||
if bindings.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
template.push_str(&format!("[keybindings.{}]\n", mode_name));
|
|
||||||
|
|
||||||
// Get mode registry for categorization
|
|
||||||
if let Some(mode_registry) = registry.get_mode_registry(mode_name) {
|
|
||||||
// Required actions first
|
|
||||||
let mut found_required = false;
|
|
||||||
for (action_name, keybindings) in bindings {
|
|
||||||
if mode_registry.required.contains_key(action_name) {
|
|
||||||
if !found_required {
|
|
||||||
template.push_str("# Required\n");
|
|
||||||
found_required = true;
|
|
||||||
}
|
|
||||||
template.push_str(&format!("{} = {:?}\n", action_name, keybindings));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional actions second
|
|
||||||
let mut found_optional = false;
|
|
||||||
for (action_name, keybindings) in bindings {
|
|
||||||
if mode_registry.optional.contains_key(action_name) {
|
|
||||||
if !found_optional {
|
|
||||||
template.push_str("# Optional\n");
|
|
||||||
found_optional = true;
|
|
||||||
}
|
|
||||||
template.push_str(&format!("{} = {:?}\n", action_name, keybindings));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback: just list all actions
|
|
||||||
for (action_name, keybindings) in bindings {
|
|
||||||
template.push_str(&format!("{} = {:?}\n", action_name, keybindings));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
template.push('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
template
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate config that only contains actions different from defaults
|
|
||||||
/// Useful for minimal user configs
|
|
||||||
pub fn generate_minimal_template() -> String {
|
|
||||||
let defaults = CanvasKeybindings::with_vim_defaults();
|
|
||||||
|
|
||||||
let mut template = String::new();
|
|
||||||
template.push_str("# Minimal Canvas Configuration\n");
|
|
||||||
template.push_str("# Only uncomment and modify the keybindings you want to change\n");
|
|
||||||
template.push_str("# All other actions will use their default vim-style keybindings\n\n");
|
|
||||||
|
|
||||||
for (mode_name, bindings) in [
|
|
||||||
("read_only", &defaults.read_only),
|
|
||||||
("edit", &defaults.edit),
|
|
||||||
("global", &defaults.global),
|
|
||||||
] {
|
|
||||||
if bindings.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
template.push_str(&format!("# [keybindings.{}]\n", mode_name));
|
|
||||||
|
|
||||||
for (action_name, keybindings) in bindings {
|
|
||||||
template.push_str(&format!("# {} = {:?}\n", action_name, keybindings));
|
|
||||||
}
|
|
||||||
|
|
||||||
template.push('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
template
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate template from actual handler capabilities (legacy method for compatibility)
|
|
||||||
pub fn generate_template() -> String {
|
|
||||||
Self::generate_complete_template()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate clean template from actual handler capabilities (legacy method for compatibility)
|
|
||||||
pub fn generate_clean_template() -> String {
|
|
||||||
let registry = ActionRegistry::from_handlers();
|
|
||||||
|
|
||||||
// Validate handlers first
|
|
||||||
if let Err(errors) = registry.validate_against_implementation() {
|
|
||||||
for error in errors {
|
|
||||||
eprintln!(" - {}", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registry.generate_clean_template()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate current configuration against actual implementation
|
|
||||||
pub fn validate(&self) -> ValidationResult {
|
|
||||||
let registry = ActionRegistry::from_handlers();
|
|
||||||
let validator = ConfigValidator::new(registry);
|
|
||||||
validator.validate_keybindings(&self.keybindings)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Print validation results for current config
|
|
||||||
pub fn print_validation(&self) {
|
|
||||||
let registry = ActionRegistry::from_handlers();
|
|
||||||
let validator = ConfigValidator::new(registry);
|
|
||||||
let result = validator.validate_keybindings(&self.keybindings);
|
|
||||||
validator.print_validation_result(&result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load from TOML string
|
|
||||||
pub fn from_toml(toml_str: &str) -> Result<Self> {
|
|
||||||
toml::from_str(toml_str)
|
|
||||||
.context("Failed to parse TOML configuration")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load from file
|
|
||||||
pub fn from_file(path: &std::path::Path) -> Result<Self> {
|
|
||||||
let contents = std::fs::read_to_string(path)
|
|
||||||
.context("Failed to read config file")?;
|
|
||||||
Self::from_toml(&contents)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 (mode-aware)
|
|
||||||
pub fn get_action_for_key(&self, key: KeyCode, modifiers: KeyModifiers, is_edit_mode: bool, _has_suggestions: bool) -> Option<&str> {
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
// src/config/introspection.rs
|
|
||||||
//! Handler capability introspection system
|
|
||||||
//!
|
|
||||||
//! This module provides traits and utilities for handlers to report their capabilities,
|
|
||||||
//! enabling automatic configuration generation and validation.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
/// Specification for a single action that a handler can perform
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ActionSpec {
|
|
||||||
/// Action name (e.g., "move_left", "delete_char_backward")
|
|
||||||
pub name: String,
|
|
||||||
/// Human-readable description of what this action does
|
|
||||||
pub description: String,
|
|
||||||
/// Example keybindings for this action (e.g., ["Left", "h"])
|
|
||||||
pub examples: Vec<String>,
|
|
||||||
/// Whether this action is required for the handler to function properly
|
|
||||||
pub is_required: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Complete capability description for a single handler
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct HandlerCapabilities {
|
|
||||||
/// Mode name this handler operates in (e.g., "edit", "read_only")
|
|
||||||
pub mode_name: String,
|
|
||||||
/// All actions this handler can perform
|
|
||||||
pub actions: Vec<ActionSpec>,
|
|
||||||
/// Actions handled automatically without configuration (e.g., "insert_char")
|
|
||||||
pub auto_handled: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trait that handlers implement to report their capabilities
|
|
||||||
///
|
|
||||||
/// This enables the configuration system to automatically discover what actions
|
|
||||||
/// are available and validate user configurations against actual implementations.
|
|
||||||
pub trait ActionHandlerIntrospection {
|
|
||||||
/// Return complete capability information for this handler
|
|
||||||
fn introspect() -> HandlerCapabilities;
|
|
||||||
|
|
||||||
/// Validate that this handler actually supports its claimed actions
|
|
||||||
/// Override this to add custom validation logic
|
|
||||||
fn validate_capabilities() -> Result<(), String> {
|
|
||||||
Ok(()) // Default: assume handler is valid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Discovers capabilities from all registered handlers
|
|
||||||
pub struct HandlerDiscovery;
|
|
||||||
|
|
||||||
impl HandlerDiscovery {
|
|
||||||
/// Discover capabilities from all known handlers
|
|
||||||
/// Add new handlers to this function as they are created
|
|
||||||
pub fn discover_all() -> HashMap<String, HandlerCapabilities> {
|
|
||||||
let mut capabilities = HashMap::new();
|
|
||||||
|
|
||||||
// Register all known handlers here
|
|
||||||
let edit_caps = crate::canvas::actions::handlers::edit::EditHandler::introspect();
|
|
||||||
capabilities.insert("edit".to_string(), edit_caps);
|
|
||||||
|
|
||||||
let readonly_caps = crate::canvas::actions::handlers::readonly::ReadOnlyHandler::introspect();
|
|
||||||
capabilities.insert("read_only".to_string(), readonly_caps);
|
|
||||||
|
|
||||||
let highlight_caps = crate::canvas::actions::handlers::highlight::HighlightHandler::introspect();
|
|
||||||
capabilities.insert("highlight".to_string(), highlight_caps);
|
|
||||||
|
|
||||||
capabilities
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate all handlers support their claimed capabilities
|
|
||||||
pub fn validate_all_handlers() -> Result<(), Vec<String>> {
|
|
||||||
let mut errors = Vec::new();
|
|
||||||
|
|
||||||
// Validate each handler
|
|
||||||
if let Err(e) = crate::canvas::actions::handlers::edit::EditHandler::validate_capabilities() {
|
|
||||||
errors.push(format!("Edit handler: {}", e));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = crate::canvas::actions::handlers::readonly::ReadOnlyHandler::validate_capabilities() {
|
|
||||||
errors.push(format!("ReadOnly handler: {}", e));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = crate::canvas::actions::handlers::highlight::HighlightHandler::validate_capabilities() {
|
|
||||||
errors.push(format!("Highlight handler: {}", e));
|
|
||||||
}
|
|
||||||
|
|
||||||
if errors.is_empty() {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(errors)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
// src/config/mod.rs
|
|
||||||
|
|
||||||
mod registry;
|
|
||||||
mod config;
|
|
||||||
mod validation;
|
|
||||||
pub mod introspection;
|
|
||||||
|
|
||||||
// Re-export everything from the main config module
|
|
||||||
pub use registry::*;
|
|
||||||
pub use validation::*;
|
|
||||||
pub use config::*;
|
|
||||||
pub use introspection::*;
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
// src/config/registry.rs
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use crate::config::introspection::{HandlerDiscovery, ActionSpec, HandlerCapabilities};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ModeRegistry {
|
|
||||||
pub required: HashMap<String, ActionSpec>,
|
|
||||||
pub optional: HashMap<String, ActionSpec>,
|
|
||||||
pub auto_handled: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ActionRegistry {
|
|
||||||
pub modes: HashMap<String, ModeRegistry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActionRegistry {
|
|
||||||
/// NEW: Create registry by discovering actual handler capabilities
|
|
||||||
pub fn from_handlers() -> Self {
|
|
||||||
let handler_capabilities = HandlerDiscovery::discover_all();
|
|
||||||
let mut modes = HashMap::new();
|
|
||||||
|
|
||||||
for (mode_name, capabilities) in handler_capabilities {
|
|
||||||
let mode_registry = Self::build_mode_registry(capabilities);
|
|
||||||
modes.insert(mode_name, mode_registry);
|
|
||||||
}
|
|
||||||
|
|
||||||
Self { modes }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a mode registry from handler capabilities
|
|
||||||
fn build_mode_registry(capabilities: HandlerCapabilities) -> ModeRegistry {
|
|
||||||
let mut required = HashMap::new();
|
|
||||||
let mut optional = HashMap::new();
|
|
||||||
|
|
||||||
for action_spec in capabilities.actions {
|
|
||||||
if action_spec.is_required {
|
|
||||||
required.insert(action_spec.name.clone(), action_spec);
|
|
||||||
} else {
|
|
||||||
optional.insert(action_spec.name.clone(), action_spec);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ModeRegistry {
|
|
||||||
required,
|
|
||||||
optional,
|
|
||||||
auto_handled: capabilities.auto_handled,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate that the registry matches the actual implementation
|
|
||||||
pub fn validate_against_implementation(&self) -> Result<(), Vec<String>> {
|
|
||||||
HandlerDiscovery::validate_all_handlers()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_mode_registry(&self, mode: &str) -> Option<&ModeRegistry> {
|
|
||||||
self.modes.get(mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn all_known_actions(&self) -> Vec<String> {
|
|
||||||
let mut actions = Vec::new();
|
|
||||||
|
|
||||||
for registry in self.modes.values() {
|
|
||||||
actions.extend(registry.required.keys().cloned());
|
|
||||||
actions.extend(registry.optional.keys().cloned());
|
|
||||||
}
|
|
||||||
|
|
||||||
actions.sort();
|
|
||||||
actions.dedup();
|
|
||||||
actions
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn generate_config_template(&self) -> String {
|
|
||||||
let mut template = String::new();
|
|
||||||
template.push_str("# Canvas Library Configuration Template\n");
|
|
||||||
template.push_str("# Generated automatically from actual handler capabilities\n\n");
|
|
||||||
|
|
||||||
for (mode_name, registry) in &self.modes {
|
|
||||||
template.push_str(&format!("[keybindings.{}]\n", mode_name));
|
|
||||||
|
|
||||||
if !registry.required.is_empty() {
|
|
||||||
template.push_str("# REQUIRED ACTIONS - These must be configured\n");
|
|
||||||
for (name, spec) in ®istry.required {
|
|
||||||
template.push_str(&format!("# {}\n", spec.description));
|
|
||||||
template.push_str(&format!("{} = {:?}\n\n", name, spec.examples));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !registry.optional.is_empty() {
|
|
||||||
template.push_str("# OPTIONAL ACTIONS - Configure these if you want them enabled\n");
|
|
||||||
for (name, spec) in ®istry.optional {
|
|
||||||
template.push_str(&format!("# {}\n", spec.description));
|
|
||||||
template.push_str(&format!("# {} = {:?}\n\n", name, spec.examples));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !registry.auto_handled.is_empty() {
|
|
||||||
template.push_str("# AUTO-HANDLED - These are handled automatically, don't configure:\n");
|
|
||||||
for auto_action in ®istry.auto_handled {
|
|
||||||
template.push_str(&format!("# {} (automatic)\n", auto_action));
|
|
||||||
}
|
|
||||||
template.push('\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
template
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn generate_clean_template(&self) -> String {
|
|
||||||
let mut template = String::new();
|
|
||||||
|
|
||||||
for (mode_name, registry) in &self.modes {
|
|
||||||
template.push_str(&format!("[keybindings.{}]\n", mode_name));
|
|
||||||
|
|
||||||
if !registry.required.is_empty() {
|
|
||||||
template.push_str("# Required\n");
|
|
||||||
for (name, spec) in ®istry.required {
|
|
||||||
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !registry.optional.is_empty() {
|
|
||||||
template.push_str("# Optional\n");
|
|
||||||
for (name, spec) in ®istry.optional {
|
|
||||||
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
template.push('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
template
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
// src/config/validation.rs
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use thiserror::Error;
|
|
||||||
use crate::config::registry::{ActionRegistry, ModeRegistry};
|
|
||||||
use crate::config::CanvasKeybindings;
|
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
|
||||||
pub enum ValidationError {
|
|
||||||
#[error("Missing required action '{action}' in {mode} mode")]
|
|
||||||
MissingRequired {
|
|
||||||
action: String,
|
|
||||||
mode: String,
|
|
||||||
suggestion: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
#[error("Unknown action '{action}' in {mode} mode")]
|
|
||||||
UnknownAction {
|
|
||||||
action: String,
|
|
||||||
mode: String,
|
|
||||||
similar: Vec<String>,
|
|
||||||
},
|
|
||||||
|
|
||||||
#[error("Multiple validation errors")]
|
|
||||||
Multiple(Vec<ValidationError>),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct ValidationWarning {
|
|
||||||
pub message: String,
|
|
||||||
pub suggestion: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct ValidationResult {
|
|
||||||
pub errors: Vec<ValidationError>,
|
|
||||||
pub warnings: Vec<ValidationWarning>,
|
|
||||||
pub is_valid: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ValidationResult {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
errors: Vec::new(),
|
|
||||||
warnings: Vec::new(),
|
|
||||||
is_valid: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_error(&mut self, error: ValidationError) {
|
|
||||||
self.errors.push(error);
|
|
||||||
self.is_valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_warning(&mut self, warning: ValidationWarning) {
|
|
||||||
self.warnings.push(warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn merge(&mut self, other: ValidationResult) {
|
|
||||||
self.errors.extend(other.errors);
|
|
||||||
self.warnings.extend(other.warnings);
|
|
||||||
if !other.is_valid {
|
|
||||||
self.is_valid = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ConfigValidator {
|
|
||||||
registry: ActionRegistry,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ConfigValidator {
|
|
||||||
// FIXED: Accept registry parameter to match config.rs calls
|
|
||||||
pub fn new(registry: ActionRegistry) -> Self {
|
|
||||||
Self {
|
|
||||||
registry,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn validate_keybindings(&self, keybindings: &CanvasKeybindings) -> ValidationResult {
|
|
||||||
let mut result = ValidationResult::new();
|
|
||||||
|
|
||||||
// Validate each mode that exists in the registry
|
|
||||||
if let Some(edit_registry) = self.registry.get_mode_registry("edit") {
|
|
||||||
result.merge(self.validate_mode_bindings(
|
|
||||||
"edit",
|
|
||||||
&keybindings.edit,
|
|
||||||
edit_registry
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(readonly_registry) = self.registry.get_mode_registry("read_only") {
|
|
||||||
result.merge(self.validate_mode_bindings(
|
|
||||||
"read_only",
|
|
||||||
&keybindings.read_only,
|
|
||||||
readonly_registry
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip suggestions mode if not discovered by introspection
|
|
||||||
// (autocomplete is separate concern as requested)
|
|
||||||
|
|
||||||
// Skip global mode if not discovered by introspection
|
|
||||||
// (can be added later if needed)
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_mode_bindings(
|
|
||||||
&self,
|
|
||||||
mode_name: &str,
|
|
||||||
bindings: &HashMap<String, Vec<String>>,
|
|
||||||
registry: &ModeRegistry
|
|
||||||
) -> ValidationResult {
|
|
||||||
let mut result = ValidationResult::new();
|
|
||||||
|
|
||||||
// Check for missing required actions
|
|
||||||
for (action_name, spec) in ®istry.required {
|
|
||||||
if !bindings.contains_key(action_name) {
|
|
||||||
result.add_error(ValidationError::MissingRequired {
|
|
||||||
action: action_name.clone(),
|
|
||||||
mode: mode_name.to_string(),
|
|
||||||
suggestion: format!(
|
|
||||||
"Add to config: {} = {:?}",
|
|
||||||
action_name,
|
|
||||||
spec.examples
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for unknown actions
|
|
||||||
let all_known: std::collections::HashSet<_> = registry.required.keys()
|
|
||||||
.chain(registry.optional.keys())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for action_name in bindings.keys() {
|
|
||||||
if !all_known.contains(action_name) {
|
|
||||||
let similar = self.find_similar_actions(action_name, &all_known);
|
|
||||||
result.add_error(ValidationError::UnknownAction {
|
|
||||||
action: action_name.clone(),
|
|
||||||
mode: mode_name.to_string(),
|
|
||||||
similar,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for empty keybinding arrays
|
|
||||||
for (action_name, key_list) in bindings {
|
|
||||||
if key_list.is_empty() {
|
|
||||||
result.add_warning(ValidationWarning {
|
|
||||||
message: format!(
|
|
||||||
"Action '{}' in {} mode has empty keybinding list",
|
|
||||||
action_name, mode_name
|
|
||||||
),
|
|
||||||
suggestion: Some(format!(
|
|
||||||
"Either add keybindings or remove the action from config"
|
|
||||||
)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warn about auto-handled actions that shouldn't be in config
|
|
||||||
for auto_action in ®istry.auto_handled {
|
|
||||||
if bindings.contains_key(auto_action) {
|
|
||||||
result.add_warning(ValidationWarning {
|
|
||||||
message: format!(
|
|
||||||
"Action '{}' in {} mode is auto-handled and shouldn't be in config",
|
|
||||||
auto_action, mode_name
|
|
||||||
),
|
|
||||||
suggestion: Some(format!(
|
|
||||||
"Remove '{}' from config - it's handled automatically",
|
|
||||||
auto_action
|
|
||||||
)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_similar_actions(&self, action: &str, known_actions: &std::collections::HashSet<&String>) -> Vec<String> {
|
|
||||||
let mut similar = Vec::new();
|
|
||||||
|
|
||||||
for known in known_actions {
|
|
||||||
if self.is_similar(action, known) {
|
|
||||||
similar.push(known.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
similar.sort();
|
|
||||||
similar.truncate(3); // Limit to 3 suggestions
|
|
||||||
similar
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_similar(&self, a: &str, b: &str) -> bool {
|
|
||||||
// Simple similarity check - could be improved with proper edit distance
|
|
||||||
let a_lower = a.to_lowercase();
|
|
||||||
let b_lower = b.to_lowercase();
|
|
||||||
|
|
||||||
// Check if one contains the other
|
|
||||||
if a_lower.contains(&b_lower) || b_lower.contains(&a_lower) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for common prefixes
|
|
||||||
let common_prefixes = ["move_", "delete_", "suggestion_"];
|
|
||||||
for prefix in &common_prefixes {
|
|
||||||
if a_lower.starts_with(prefix) && b_lower.starts_with(prefix) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn print_validation_result(&self, result: &ValidationResult) {
|
|
||||||
if result.is_valid && result.warnings.is_empty() {
|
|
||||||
println!("✅ Canvas configuration is valid!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result.errors.is_empty() {
|
|
||||||
println!("❌ Canvas configuration has errors:");
|
|
||||||
for error in &result.errors {
|
|
||||||
match error {
|
|
||||||
ValidationError::MissingRequired { action, mode, suggestion } => {
|
|
||||||
println!(" • Missing required action '{}' in {} mode", action, mode);
|
|
||||||
println!(" 💡 {}", suggestion);
|
|
||||||
}
|
|
||||||
ValidationError::UnknownAction { action, mode, similar } => {
|
|
||||||
println!(" • Unknown action '{}' in {} mode", action, mode);
|
|
||||||
if !similar.is_empty() {
|
|
||||||
println!(" 💡 Did you mean: {}", similar.join(", "));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ValidationError::Multiple(_) => {
|
|
||||||
println!(" • Multiple errors occurred");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result.warnings.is_empty() {
|
|
||||||
println!("⚠️ Canvas configuration has warnings:");
|
|
||||||
for warning in &result.warnings {
|
|
||||||
println!(" • {}", warning.message);
|
|
||||||
if let Some(suggestion) = &warning.suggestion {
|
|
||||||
println!(" 💡 {}", suggestion);
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result.is_valid {
|
|
||||||
println!("🔧 To generate a config template, use:");
|
|
||||||
println!(" CanvasConfig::generate_template()");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn generate_missing_config(&self, keybindings: &CanvasKeybindings) -> String {
|
|
||||||
let mut config = String::new();
|
|
||||||
let validation = self.validate_keybindings(keybindings);
|
|
||||||
|
|
||||||
for error in &validation.errors {
|
|
||||||
if let ValidationError::MissingRequired { action, mode, suggestion } = error {
|
|
||||||
if config.is_empty() {
|
|
||||||
config.push_str(&format!("# Missing required actions for canvas\n\n"));
|
|
||||||
config.push_str(&format!("[keybindings.{}]\n", mode));
|
|
||||||
}
|
|
||||||
config.push_str(&format!("{}\n", suggestion));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
config
|
|
||||||
}
|
|
||||||
}
|
|
||||||
44
canvas/src/data_provider.rs
Normal file
44
canvas/src/data_provider.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,110 +0,0 @@
|
|||||||
// src/dispatcher.rs
|
|
||||||
|
|
||||||
use crate::canvas::state::{CanvasState, ActionContext};
|
|
||||||
use crate::canvas::actions::{CanvasAction, ActionResult};
|
|
||||||
use crate::canvas::actions::handlers::{handle_edit_action, handle_readonly_action, handle_highlight_action};
|
|
||||||
use crate::canvas::modes::AppMode;
|
|
||||||
use crate::config::CanvasConfig;
|
|
||||||
use crossterm::event::{KeyCode, KeyModifiers};
|
|
||||||
|
|
||||||
/// Main entry point for executing canvas actions
|
|
||||||
pub async fn execute_canvas_action<S: CanvasState>(
|
|
||||||
action: CanvasAction,
|
|
||||||
state: &mut S,
|
|
||||||
ideal_cursor_column: &mut usize,
|
|
||||||
config: Option<&CanvasConfig>,
|
|
||||||
) -> anyhow::Result<ActionResult> {
|
|
||||||
ActionDispatcher::dispatch_with_config(action, state, ideal_cursor_column, config).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// High-level action dispatcher that routes actions to mode-specific handlers
|
|
||||||
pub struct ActionDispatcher;
|
|
||||||
|
|
||||||
impl ActionDispatcher {
|
|
||||||
/// Dispatch any action to the appropriate mode handler
|
|
||||||
pub async fn dispatch<S: CanvasState>(
|
|
||||||
action: CanvasAction,
|
|
||||||
state: &mut S,
|
|
||||||
ideal_cursor_column: &mut usize,
|
|
||||||
) -> anyhow::Result<ActionResult> {
|
|
||||||
let config = CanvasConfig::load();
|
|
||||||
Self::dispatch_with_config(action, state, ideal_cursor_column, Some(&config)).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispatch action with provided config
|
|
||||||
pub async fn dispatch_with_config<S: CanvasState>(
|
|
||||||
action: CanvasAction,
|
|
||||||
state: &mut S,
|
|
||||||
ideal_cursor_column: &mut usize,
|
|
||||||
config: Option<&CanvasConfig>,
|
|
||||||
) -> anyhow::Result<ActionResult> {
|
|
||||||
// Check for feature-specific handling first
|
|
||||||
let context = ActionContext {
|
|
||||||
key_code: None,
|
|
||||||
ideal_cursor_column: *ideal_cursor_column,
|
|
||||||
current_input: state.get_current_input().to_string(),
|
|
||||||
current_field: state.current_field(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(result) = state.handle_feature_action(&action, &context) {
|
|
||||||
return Ok(ActionResult::HandledByFeature(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route to mode-specific handler
|
|
||||||
match state.current_mode() {
|
|
||||||
AppMode::Edit => {
|
|
||||||
handle_edit_action(action, state, ideal_cursor_column, config).await
|
|
||||||
}
|
|
||||||
AppMode::ReadOnly => {
|
|
||||||
handle_readonly_action(action, state, ideal_cursor_column, config).await
|
|
||||||
}
|
|
||||||
AppMode::Highlight => {
|
|
||||||
handle_highlight_action(action, state, ideal_cursor_column, config).await
|
|
||||||
}
|
|
||||||
AppMode::General | AppMode::Command => {
|
|
||||||
// These modes might not handle canvas actions directly
|
|
||||||
Ok(ActionResult::success_with_message("Mode does not handle canvas actions"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Quick action dispatch from KeyCode using config
|
|
||||||
pub async fn dispatch_key<S: CanvasState>(
|
|
||||||
key: KeyCode,
|
|
||||||
modifiers: KeyModifiers,
|
|
||||||
state: &mut S,
|
|
||||||
ideal_cursor_column: &mut usize,
|
|
||||||
is_edit_mode: bool,
|
|
||||||
has_suggestions: bool,
|
|
||||||
) -> anyhow::Result<Option<ActionResult>> {
|
|
||||||
let config = CanvasConfig::load();
|
|
||||||
|
|
||||||
if let Some(action_name) = config.get_action_for_key(key, modifiers, is_edit_mode, has_suggestions) {
|
|
||||||
let action = CanvasAction::from_string(action_name);
|
|
||||||
let result = Self::dispatch_with_config(action, state, ideal_cursor_column, Some(&config)).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();
|
|
||||||
results.push(result);
|
|
||||||
|
|
||||||
// Stop on first error
|
|
||||||
if !is_success {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(results)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
608
canvas/src/editor.rs
Normal file
608
canvas/src/editor.rs
Normal file
@@ -0,0 +1,608 @@
|
|||||||
|
// src/editor.rs
|
||||||
|
//! Main API for the canvas library - FormEditor with library-owned state
|
||||||
|
|
||||||
|
#[cfg(feature = "cursor-style")]
|
||||||
|
use crate::canvas::CursorManager;
|
||||||
|
#[cfg(feature = "cursor-style")]
|
||||||
|
use crossterm;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use crate::canvas::state::EditorState;
|
||||||
|
use crate::data_provider::{DataProvider, AutocompleteProvider, SuggestionItem};
|
||||||
|
use crate::canvas::modes::AppMode;
|
||||||
|
|
||||||
|
/// Main editor that manages UI state internally and delegates data to user
|
||||||
|
pub struct FormEditor<D: DataProvider> {
|
||||||
|
// Library owns all UI state
|
||||||
|
ui_state: EditorState,
|
||||||
|
|
||||||
|
// User owns business data
|
||||||
|
data_provider: D,
|
||||||
|
|
||||||
|
// Autocomplete suggestions (library manages UI, user provides data)
|
||||||
|
pub(crate) suggestions: Vec<SuggestionItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<D: DataProvider> FormEditor<D> {
|
||||||
|
pub fn new(data_provider: D) -> Self {
|
||||||
|
Self {
|
||||||
|
ui_state: EditorState::new(),
|
||||||
|
data_provider,
|
||||||
|
suggestions: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// READ-ONLY ACCESS: User can fetch UI state
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
/// Get current field index (for user's compatibility)
|
||||||
|
pub fn current_field(&self) -> usize {
|
||||||
|
self.ui_state.current_field()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current cursor position (for user's compatibility)
|
||||||
|
pub fn cursor_position(&self) -> usize {
|
||||||
|
self.ui_state.cursor_position()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current mode (for user's mode-dependent logic)
|
||||||
|
pub fn mode(&self) -> AppMode {
|
||||||
|
self.ui_state.mode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if autocomplete is active (for user's logic)
|
||||||
|
pub fn is_autocomplete_active(&self) -> bool {
|
||||||
|
self.ui_state.is_autocomplete_active()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current field text (convenience method)
|
||||||
|
pub fn current_text(&self) -> &str {
|
||||||
|
let field_index = self.ui_state.current_field;
|
||||||
|
if field_index < self.data_provider.field_count() {
|
||||||
|
self.data_provider.field_value(field_index)
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get reference to UI state for rendering
|
||||||
|
pub fn ui_state(&self) -> &EditorState {
|
||||||
|
&self.ui_state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get reference to data provider for rendering
|
||||||
|
pub fn data_provider(&self) -> &D {
|
||||||
|
&self.data_provider
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get autocomplete suggestions for rendering (read-only)
|
||||||
|
pub fn suggestions(&self) -> &[SuggestionItem] {
|
||||||
|
&self.suggestions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// SYNC OPERATIONS: No async needed for basic editing
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
/// Handle character insertion
|
||||||
|
pub fn insert_char(&mut self, ch: char) -> Result<()> {
|
||||||
|
if self.ui_state.current_mode != AppMode::Edit {
|
||||||
|
return Ok(()); // Ignore in non-edit modes
|
||||||
|
}
|
||||||
|
|
||||||
|
let field_index = self.ui_state.current_field;
|
||||||
|
let cursor_pos = self.ui_state.cursor_pos;
|
||||||
|
|
||||||
|
// Get current text from user
|
||||||
|
let mut current_text = self.data_provider.field_value(field_index).to_string();
|
||||||
|
|
||||||
|
// Insert character
|
||||||
|
current_text.insert(cursor_pos, ch);
|
||||||
|
|
||||||
|
// Update user's data
|
||||||
|
self.data_provider.set_field_value(field_index, current_text);
|
||||||
|
|
||||||
|
// Update library's UI state
|
||||||
|
self.ui_state.cursor_pos += 1;
|
||||||
|
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle cursor movement
|
||||||
|
pub fn move_left(&mut self) {
|
||||||
|
if self.ui_state.cursor_pos > 0 {
|
||||||
|
self.ui_state.cursor_pos -= 1;
|
||||||
|
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_right(&mut self) {
|
||||||
|
let current_text = self.current_text();
|
||||||
|
let max_pos = if self.ui_state.current_mode == AppMode::Edit {
|
||||||
|
current_text.len() // Edit mode: can go past end
|
||||||
|
} else {
|
||||||
|
current_text.len().saturating_sub(1) // ReadOnly: stay in bounds
|
||||||
|
};
|
||||||
|
|
||||||
|
if self.ui_state.cursor_pos < max_pos {
|
||||||
|
self.ui_state.cursor_pos += 1;
|
||||||
|
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle field navigation
|
||||||
|
pub fn move_to_next_field(&mut self) {
|
||||||
|
let field_count = self.data_provider.field_count();
|
||||||
|
let next_field = (self.ui_state.current_field + 1) % field_count;
|
||||||
|
self.ui_state.move_to_field(next_field, field_count);
|
||||||
|
|
||||||
|
// Clamp cursor to new field
|
||||||
|
let current_text = self.current_text();
|
||||||
|
let max_pos = current_text.len();
|
||||||
|
self.ui_state.set_cursor(
|
||||||
|
self.ui_state.ideal_cursor_column,
|
||||||
|
max_pos,
|
||||||
|
self.ui_state.current_mode == AppMode::Edit
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change mode (for vim compatibility)
|
||||||
|
pub fn set_mode(&mut self, mode: AppMode) {
|
||||||
|
#[cfg(feature = "cursor-style")]
|
||||||
|
let old_mode = self.ui_state.current_mode;
|
||||||
|
|
||||||
|
self.ui_state.current_mode = mode;
|
||||||
|
|
||||||
|
// Clear autocomplete when changing modes
|
||||||
|
if mode != AppMode::Edit {
|
||||||
|
self.ui_state.deactivate_autocomplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cursor style if mode changed and cursor-style feature is enabled
|
||||||
|
#[cfg(feature = "cursor-style")]
|
||||||
|
if old_mode != mode {
|
||||||
|
let _ = crate::canvas::CursorManager::update_for_mode(mode);
|
||||||
|
|
||||||
|
// IMMEDIATELY update terminal cursor position for the new mode
|
||||||
|
// This prevents flicker by ensuring position and style change atomically
|
||||||
|
if let Ok((x, y)) = crossterm::cursor::position() {
|
||||||
|
let display_pos = self.display_cursor_position();
|
||||||
|
let current_text = self.current_text();
|
||||||
|
let adjusted_x = x.saturating_sub(current_text.len() as u16) + display_pos as u16;
|
||||||
|
let _ = crossterm::execute!(
|
||||||
|
std::io::stdout(),
|
||||||
|
crossterm::cursor::MoveTo(adjusted_x, y)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter edit mode with cursor positioned for append (vim 'a' command)
|
||||||
|
pub fn enter_append_mode(&mut self) {
|
||||||
|
let current_text = self.current_text();
|
||||||
|
|
||||||
|
// Calculate append position: always move right, even at line end
|
||||||
|
let append_pos = if current_text.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
(self.ui_state.cursor_pos + 1).min(current_text.len())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set cursor position for append
|
||||||
|
self.ui_state.cursor_pos = append_pos;
|
||||||
|
self.ui_state.ideal_cursor_column = append_pos;
|
||||||
|
|
||||||
|
// Enter edit mode (which will update cursor style)
|
||||||
|
self.set_mode(AppMode::Edit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// ASYNC OPERATIONS: Only autocomplete needs async
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
/// Trigger autocomplete (async because it fetches data)
|
||||||
|
pub async fn trigger_autocomplete<A>(&mut self, provider: &mut A) -> Result<()>
|
||||||
|
where
|
||||||
|
A: AutocompleteProvider,
|
||||||
|
{
|
||||||
|
let field_index = self.ui_state.current_field;
|
||||||
|
|
||||||
|
if !self.data_provider.supports_autocomplete(field_index) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate autocomplete UI
|
||||||
|
self.ui_state.activate_autocomplete(field_index);
|
||||||
|
|
||||||
|
// Fetch suggestions from user (no conversion needed!)
|
||||||
|
let query = self.current_text();
|
||||||
|
self.suggestions = provider.fetch_suggestions(field_index, query).await?;
|
||||||
|
|
||||||
|
// Update UI state
|
||||||
|
self.ui_state.autocomplete.is_loading = false;
|
||||||
|
if !self.suggestions.is_empty() {
|
||||||
|
self.ui_state.autocomplete.selected_index = Some(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigate autocomplete suggestions
|
||||||
|
pub fn autocomplete_next(&mut self) {
|
||||||
|
if !self.ui_state.autocomplete.is_active || self.suggestions.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = self.ui_state.autocomplete.selected_index.unwrap_or(0);
|
||||||
|
let next = (current + 1) % self.suggestions.len();
|
||||||
|
self.ui_state.autocomplete.selected_index = Some(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply selected autocomplete suggestion
|
||||||
|
pub fn apply_autocomplete(&mut self) -> Option<String> {
|
||||||
|
if let Some(selected_index) = self.ui_state.autocomplete.selected_index {
|
||||||
|
if let Some(suggestion) = self.suggestions.get(selected_index).cloned() {
|
||||||
|
let field_index = self.ui_state.current_field;
|
||||||
|
|
||||||
|
// Apply to user's data
|
||||||
|
self.data_provider.set_field_value(
|
||||||
|
field_index,
|
||||||
|
suggestion.value_to_store.clone()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update cursor position
|
||||||
|
self.ui_state.cursor_pos = suggestion.value_to_store.len();
|
||||||
|
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||||
|
|
||||||
|
// Close autocomplete
|
||||||
|
self.ui_state.deactivate_autocomplete();
|
||||||
|
self.suggestions.clear();
|
||||||
|
|
||||||
|
return Some(suggestion.display_text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// ADD THESE MISSING MOVEMENT METHODS
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
/// Move to previous field (vim k / up arrow)
|
||||||
|
pub fn move_up(&mut self) {
|
||||||
|
let field_count = self.data_provider.field_count();
|
||||||
|
if field_count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_field = self.ui_state.current_field;
|
||||||
|
let new_field = current_field.saturating_sub(1);
|
||||||
|
|
||||||
|
self.ui_state.move_to_field(new_field, field_count);
|
||||||
|
self.clamp_cursor_to_current_field();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to next field (vim j / down arrow)
|
||||||
|
pub fn move_down(&mut self) {
|
||||||
|
let field_count = self.data_provider.field_count();
|
||||||
|
if field_count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_field = self.ui_state.current_field;
|
||||||
|
let new_field = (current_field + 1).min(field_count - 1);
|
||||||
|
|
||||||
|
self.ui_state.move_to_field(new_field, field_count);
|
||||||
|
self.clamp_cursor_to_current_field();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to first field (vim gg)
|
||||||
|
pub fn move_first_line(&mut self) {
|
||||||
|
let field_count = self.data_provider.field_count();
|
||||||
|
if field_count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.ui_state.move_to_field(0, field_count);
|
||||||
|
self.clamp_cursor_to_current_field();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to last field (vim G)
|
||||||
|
pub fn move_last_line(&mut self) {
|
||||||
|
let field_count = self.data_provider.field_count();
|
||||||
|
if field_count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let last_field = field_count - 1;
|
||||||
|
self.ui_state.move_to_field(last_field, field_count);
|
||||||
|
self.clamp_cursor_to_current_field();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to previous field (alternative to move_up)
|
||||||
|
pub fn prev_field(&mut self) {
|
||||||
|
self.move_up();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to next field (alternative to move_down)
|
||||||
|
pub fn next_field(&mut self) {
|
||||||
|
self.move_down();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to start of current field (vim 0)
|
||||||
|
pub fn move_line_start(&mut self) {
|
||||||
|
use crate::canvas::actions::movement::line::line_start_position;
|
||||||
|
let new_pos = line_start_position();
|
||||||
|
self.ui_state.cursor_pos = new_pos;
|
||||||
|
self.ui_state.ideal_cursor_column = new_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to end of current field (vim $)
|
||||||
|
pub fn move_line_end(&mut self) {
|
||||||
|
use crate::canvas::actions::movement::line::line_end_position;
|
||||||
|
let current_text = self.current_text();
|
||||||
|
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||||
|
|
||||||
|
let new_pos = line_end_position(current_text, is_edit_mode);
|
||||||
|
self.ui_state.cursor_pos = new_pos;
|
||||||
|
self.ui_state.ideal_cursor_column = new_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to start of next word (vim w)
|
||||||
|
pub fn move_word_next(&mut self) {
|
||||||
|
use crate::canvas::actions::movement::word::find_next_word_start;
|
||||||
|
let current_text = self.current_text();
|
||||||
|
|
||||||
|
if current_text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_pos = find_next_word_start(current_text, self.ui_state.cursor_pos);
|
||||||
|
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||||
|
|
||||||
|
// Clamp to valid bounds for current mode
|
||||||
|
let final_pos = if is_edit_mode {
|
||||||
|
new_pos.min(current_text.len())
|
||||||
|
} else {
|
||||||
|
new_pos.min(current_text.len().saturating_sub(1))
|
||||||
|
};
|
||||||
|
|
||||||
|
self.ui_state.cursor_pos = final_pos;
|
||||||
|
self.ui_state.ideal_cursor_column = final_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to start of previous word (vim b)
|
||||||
|
pub fn move_word_prev(&mut self) {
|
||||||
|
use crate::canvas::actions::movement::word::find_prev_word_start;
|
||||||
|
let current_text = self.current_text();
|
||||||
|
|
||||||
|
if current_text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_pos = find_prev_word_start(current_text, self.ui_state.cursor_pos);
|
||||||
|
self.ui_state.cursor_pos = new_pos;
|
||||||
|
self.ui_state.ideal_cursor_column = new_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to end of current/next word (vim e)
|
||||||
|
pub fn move_word_end(&mut self) {
|
||||||
|
use crate::canvas::actions::movement::word::find_word_end;
|
||||||
|
let current_text = self.current_text();
|
||||||
|
|
||||||
|
if current_text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_pos = self.ui_state.cursor_pos;
|
||||||
|
let new_pos = find_word_end(current_text, current_pos);
|
||||||
|
|
||||||
|
// If we didn't move, try next word
|
||||||
|
let final_pos = if new_pos == current_pos && current_pos + 1 < current_text.len() {
|
||||||
|
find_word_end(current_text, current_pos + 1)
|
||||||
|
} else {
|
||||||
|
new_pos
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clamp for read-only mode
|
||||||
|
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||||
|
let clamped_pos = if is_edit_mode {
|
||||||
|
final_pos.min(current_text.len())
|
||||||
|
} else {
|
||||||
|
final_pos.min(current_text.len().saturating_sub(1))
|
||||||
|
};
|
||||||
|
|
||||||
|
self.ui_state.cursor_pos = clamped_pos;
|
||||||
|
self.ui_state.ideal_cursor_column = clamped_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to end of previous word (vim ge)
|
||||||
|
pub fn move_word_end_prev(&mut self) {
|
||||||
|
use crate::canvas::actions::movement::word::find_prev_word_end;
|
||||||
|
let current_text = self.current_text();
|
||||||
|
|
||||||
|
if current_text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_pos = find_prev_word_end(current_text, self.ui_state.cursor_pos);
|
||||||
|
self.ui_state.cursor_pos = new_pos;
|
||||||
|
self.ui_state.ideal_cursor_column = new_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete character before cursor (vim x in insert mode / backspace)
|
||||||
|
pub fn delete_backward(&mut self) -> Result<()> {
|
||||||
|
if self.ui_state.current_mode != AppMode::Edit {
|
||||||
|
return Ok(()); // Silently ignore in non-edit modes
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.ui_state.cursor_pos == 0 {
|
||||||
|
return Ok(()); // Nothing to delete
|
||||||
|
}
|
||||||
|
|
||||||
|
let field_index = self.ui_state.current_field;
|
||||||
|
let mut current_text = self.data_provider.field_value(field_index).to_string();
|
||||||
|
|
||||||
|
if self.ui_state.cursor_pos <= current_text.len() {
|
||||||
|
current_text.remove(self.ui_state.cursor_pos - 1);
|
||||||
|
self.data_provider.set_field_value(field_index, current_text);
|
||||||
|
self.ui_state.cursor_pos -= 1;
|
||||||
|
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete character under cursor (vim x / delete key)
|
||||||
|
pub fn delete_forward(&mut self) -> Result<()> {
|
||||||
|
if self.ui_state.current_mode != AppMode::Edit {
|
||||||
|
return Ok(()); // Silently ignore in non-edit modes
|
||||||
|
}
|
||||||
|
|
||||||
|
let field_index = self.ui_state.current_field;
|
||||||
|
let mut current_text = self.data_provider.field_value(field_index).to_string();
|
||||||
|
|
||||||
|
if self.ui_state.cursor_pos < current_text.len() {
|
||||||
|
current_text.remove(self.ui_state.cursor_pos);
|
||||||
|
self.data_provider.set_field_value(field_index, current_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exit edit mode to read-only mode (vim Escape)
|
||||||
|
// TODO this is still flickering, I have no clue how to fix it
|
||||||
|
pub fn exit_edit_mode(&mut self) {
|
||||||
|
// Adjust cursor position when transitioning from edit to normal mode
|
||||||
|
let current_text = self.current_text();
|
||||||
|
if !current_text.is_empty() {
|
||||||
|
// In normal mode, cursor must be ON a character, not after the last one
|
||||||
|
let max_normal_pos = current_text.len().saturating_sub(1);
|
||||||
|
if self.ui_state.cursor_pos > max_normal_pos {
|
||||||
|
self.ui_state.cursor_pos = max_normal_pos;
|
||||||
|
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.set_mode(AppMode::ReadOnly);
|
||||||
|
// Deactivate autocomplete when exiting edit mode
|
||||||
|
self.ui_state.deactivate_autocomplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter edit mode from read-only mode (vim i/a/o)
|
||||||
|
pub fn enter_edit_mode(&mut self) {
|
||||||
|
self.set_mode(AppMode::Edit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// HELPER METHODS
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
/// Clamp cursor position to valid bounds for current field and mode
|
||||||
|
fn clamp_cursor_to_current_field(&mut self) {
|
||||||
|
let current_text = self.current_text();
|
||||||
|
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||||
|
|
||||||
|
use crate::canvas::actions::movement::line::safe_cursor_position;
|
||||||
|
let safe_pos = safe_cursor_position(
|
||||||
|
current_text,
|
||||||
|
self.ui_state.ideal_cursor_column,
|
||||||
|
is_edit_mode
|
||||||
|
);
|
||||||
|
|
||||||
|
self.ui_state.cursor_pos = safe_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Set the value of the current field
|
||||||
|
pub fn set_current_field_value(&mut self, value: String) {
|
||||||
|
let field_index = self.ui_state.current_field;
|
||||||
|
self.data_provider.set_field_value(field_index, value);
|
||||||
|
// Reset cursor to start of field
|
||||||
|
self.ui_state.cursor_pos = 0;
|
||||||
|
self.ui_state.ideal_cursor_column = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the value of a specific field by index
|
||||||
|
pub fn set_field_value(&mut self, field_index: usize, value: String) {
|
||||||
|
if field_index < self.data_provider.field_count() {
|
||||||
|
self.data_provider.set_field_value(field_index, value);
|
||||||
|
// If we're modifying the current field, reset cursor
|
||||||
|
if field_index == self.ui_state.current_field {
|
||||||
|
self.ui_state.cursor_pos = 0;
|
||||||
|
self.ui_state.ideal_cursor_column = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the current field (set to empty string)
|
||||||
|
pub fn clear_current_field(&mut self) {
|
||||||
|
self.set_current_field_value(String::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get mutable access to data provider (for advanced operations)
|
||||||
|
pub fn data_provider_mut(&mut self) -> &mut D {
|
||||||
|
&mut self.data_provider
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set cursor to exact position (for vim-style movements like f, F, t, T)
|
||||||
|
pub fn set_cursor_position(&mut self, position: usize) {
|
||||||
|
let current_text = self.current_text();
|
||||||
|
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
|
||||||
|
|
||||||
|
// Clamp to valid bounds for current mode
|
||||||
|
let max_pos = if is_edit_mode {
|
||||||
|
current_text.len() // Edit mode: can go past end
|
||||||
|
} else {
|
||||||
|
current_text.len().saturating_sub(1).max(0) // Read-only: stay within text
|
||||||
|
};
|
||||||
|
|
||||||
|
let clamped_pos = position.min(max_pos);
|
||||||
|
|
||||||
|
// Update cursor position directly
|
||||||
|
self.ui_state.cursor_pos = clamped_pos;
|
||||||
|
self.ui_state.ideal_cursor_column = clamped_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cursor position for display (respects mode-specific positioning rules)
|
||||||
|
pub fn display_cursor_position(&self) -> usize {
|
||||||
|
let current_text = self.current_text();
|
||||||
|
|
||||||
|
match self.ui_state.current_mode {
|
||||||
|
AppMode::Edit => {
|
||||||
|
// Edit mode: cursor can be past end of text
|
||||||
|
self.ui_state.cursor_pos.min(current_text.len())
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Normal/other modes: cursor must be on a character
|
||||||
|
if current_text.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
self.ui_state.cursor_pos.min(current_text.len().saturating_sub(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cleanup cursor style (call this when shutting down)
|
||||||
|
pub fn cleanup_cursor(&self) -> std::io::Result<()> {
|
||||||
|
#[cfg(feature = "cursor-style")]
|
||||||
|
{
|
||||||
|
crate::canvas::CursorManager::reset()
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "cursor-style"))]
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Drop implementation for automatic cleanup
|
||||||
|
impl<D: DataProvider> Drop for FormEditor<D> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Reset cursor to default when FormEditor is dropped
|
||||||
|
let _ = self.cleanup_cursor();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,40 @@
|
|||||||
// src/lib.rs
|
// src/lib.rs
|
||||||
pub mod canvas;
|
|
||||||
pub mod autocomplete;
|
|
||||||
pub mod config;
|
|
||||||
pub mod dispatcher;
|
|
||||||
|
|
||||||
// Re-export the main API for easy access
|
pub mod canvas;
|
||||||
pub use dispatcher::{execute_canvas_action, ActionDispatcher};
|
pub mod editor;
|
||||||
|
pub mod data_provider;
|
||||||
|
|
||||||
|
// Only include autocomplete module if feature is enabled
|
||||||
|
#[cfg(feature = "autocomplete")]
|
||||||
|
pub mod autocomplete;
|
||||||
|
|
||||||
|
#[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};
|
pub use canvas::actions::{CanvasAction, ActionResult};
|
||||||
pub use canvas::state::{CanvasState, ActionContext};
|
|
||||||
pub use canvas::modes::{AppMode, HighlightState, ModeManager};
|
// 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;
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ move_right = ["l", "Right"]
|
|||||||
move_down = ["j", "Down"]
|
move_down = ["j", "Down"]
|
||||||
# Optional
|
# Optional
|
||||||
move_line_end = ["$"]
|
move_line_end = ["$"]
|
||||||
move_word_next = ["w"]
|
# move_word_next = ["w"]
|
||||||
next_field = ["Tab"]
|
next_field = ["Tab"]
|
||||||
move_word_prev = ["b"]
|
move_word_prev = ["b"]
|
||||||
move_word_end = ["e"]
|
move_word_end = ["e"]
|
||||||
|
|||||||
@@ -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