Compare commits
521 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8788323c62 | ||
|
|
5b64996462 | ||
|
|
3f4380ff48 | ||
|
|
59a29aa54b | ||
|
|
5d084bf822 | ||
|
|
ebe4adaa5d | ||
|
|
c3441647e0 | ||
|
|
574803988d | ||
|
|
9ff3c59961 | ||
|
|
c5f22d7da1 | ||
|
|
3c62877757 | ||
|
|
cc19c61f37 | ||
|
|
ad82bd4302 | ||
|
|
d584a25fdb | ||
|
|
baa4295059 | ||
|
|
6cbfac9d6e | ||
|
|
13d28f19ea | ||
|
|
8fa86965b8 | ||
|
|
72c38f613f | ||
|
|
e4982f871f | ||
|
|
4e0338276f | ||
|
|
fe193f4f91 | ||
|
|
0011ba0c04 | ||
|
|
3c2eef9596 | ||
|
|
dac788351f | ||
|
|
8d5bc1296e | ||
|
|
969ad229e4 | ||
|
|
0d291fcf57 | ||
|
|
d711f4c491 | ||
|
|
9369626e21 | ||
|
|
f84bb0dc9e | ||
|
|
20b428264e | ||
|
|
05bb84fc98 | ||
|
|
46a85e4b4a | ||
|
|
b4d1572c79 | ||
|
|
b8e1b77222 | ||
|
|
1a451a576f | ||
|
|
074b2914d8 | ||
|
|
aec5f80879 | ||
|
|
a1fa42e204 | ||
|
|
306cb956a0 | ||
|
|
d837acde63 | ||
|
|
db938a2c8d | ||
|
|
f24156775a | ||
|
|
2a7f94cf17 | ||
|
|
15922ed953 | ||
|
|
7129ec97fd | ||
|
|
a921806e62 | ||
|
|
d1b28b4fdd | ||
|
|
64fd7e4af2 | ||
|
|
7b52a739c2 | ||
|
|
8127c7bb1b | ||
|
|
7437908baf | ||
|
|
9eb46cb5d3 | ||
|
|
38a70128b0 | ||
|
|
c58ce52b33 | ||
|
|
c82813185f | ||
|
|
a96681e9d6 | ||
|
|
4df6c40034 | ||
|
|
089d728cc7 | ||
|
|
aca3d718b5 | ||
|
|
8a6a584cf3 | ||
|
|
00ed0cf796 | ||
|
|
7e54b2fe43 | ||
|
|
84871faad4 | ||
|
|
bcb433d7b2 | ||
|
|
7d1b130b68 | ||
|
|
24c2376ea1 | ||
|
|
810ef5fc10 | ||
|
|
fe246b1fe6 | ||
|
|
de42bb48aa | ||
|
|
17495c49ac | ||
|
|
0e3a7a06a3 | ||
|
|
e0ee48eb9c | ||
|
|
d2053b1d5a | ||
|
|
fbe8e53858 | ||
|
|
8fe2581b3f | ||
|
|
60cc0e562e | ||
|
|
26898d474f | ||
|
|
2311fbaa3b | ||
|
|
be99cd9423 | ||
|
|
a3dd6fa95b | ||
|
|
433d87c96d | ||
|
|
aff4383671 | ||
|
|
b7c8f6b1a2 | ||
|
|
3443839ba4 | ||
|
|
6c31d48f3b | ||
|
|
1770292fd8 | ||
|
|
afdd5c5740 | ||
|
|
11487f0833 | ||
|
|
4d5d22d0c2 | ||
|
|
314a957922 | ||
|
|
4c57b562e6 | ||
|
|
a757acf51c | ||
|
|
f4a23be1a2 | ||
|
|
93c67ffa14 | ||
|
|
d1ebe4732f | ||
|
|
7b7f3ca05a | ||
|
|
234613f831 | ||
|
|
f6d84e70cc | ||
|
|
5cd324b6ae | ||
|
|
a7457f5749 | ||
|
|
a5afc75099 | ||
|
|
625c9b3e09 | ||
|
|
e20623ed53 | ||
|
|
aa9adf7348 | ||
|
|
2e82aba0d1 | ||
|
|
b7a3f0f8d9 | ||
|
|
38c82389f7 | ||
|
|
cb0a2bee17 | ||
|
|
dc99131794 | ||
|
|
5c23f61a10 | ||
|
|
f87e3c03cb | ||
|
|
d346670839 | ||
|
|
560d8b7234 | ||
|
|
b297c2b311 | ||
|
|
d390c567d5 | ||
|
|
029e614b9c | ||
|
|
f9a78e4eec | ||
|
|
d8758f7531 | ||
|
|
4e86ecff84 | ||
|
|
070d091e07 | ||
|
|
7403b3c3f8 | ||
|
|
1b1e7b7205 | ||
|
|
1b8f19f1ce | ||
|
|
2a14eadf34 | ||
|
|
fd36cd5795 | ||
|
|
f4286ac3c9 | ||
|
|
92d5eb4844 | ||
|
|
87b9f6ab87 | ||
|
|
06d98aab5c | ||
|
|
298f56a53c | ||
|
|
714a5f2f1c | ||
|
|
4e29d0084f | ||
|
|
63f1b4da2e | ||
|
|
9477f53432 | ||
|
|
ed786f087c | ||
|
|
8e22ea05ff | ||
|
|
8414657224 | ||
|
|
e25213ed1b | ||
|
|
4843b0778c | ||
|
|
f5fae98c69 | ||
|
|
6faf0a4a31 | ||
|
|
011fafc0ff | ||
|
|
8ebe74484c | ||
|
|
3eb9523103 | ||
|
|
3dfa922b9e | ||
|
|
248d54a30f | ||
|
|
b30fef4ccd | ||
|
|
a9c4527318 | ||
|
|
c31f08d5b8 | ||
|
|
9e0fa9ddb1 | ||
|
|
8fcd28832d | ||
|
|
cccf029464 | ||
|
|
512e7fb9e7 | ||
|
|
0e69df8282 | ||
|
|
eb5532c200 | ||
|
|
49ed1dfe33 | ||
|
|
62d1c3f7f5 | ||
|
|
b49dce3334 | ||
|
|
8ace9bc4d1 | ||
|
|
ce490007ed | ||
|
|
eb96c64e26 | ||
|
|
2ac96a8486 | ||
|
|
b8e6cc22af | ||
|
|
634a01f618 | ||
|
|
6abea062ba | ||
|
|
f50887a326 | ||
|
|
3c0af05a3c | ||
|
|
c9131d4457 | ||
|
|
2af79a3ef2 | ||
|
|
afd9228efa | ||
|
|
495d77fda5 | ||
|
|
679bb3b6ab | ||
|
|
350c522d19 | ||
|
|
4760f42589 | ||
|
|
50d15e321f | ||
|
|
a3e7fd8f0a | ||
|
|
645172747a | ||
|
|
7c4ac1eebc | ||
|
|
4b4301ad49 | ||
|
|
b60e03eb70 | ||
|
|
2c7bda3ff1 | ||
|
|
eeaaa3635b | ||
|
|
e61cbb3956 | ||
|
|
f9841f2ef3 | ||
|
|
dc232b2523 | ||
|
|
b086b3e236 | ||
|
|
387e1a0fe0 | ||
|
|
08e01d41f2 | ||
|
|
f5edf52571 | ||
|
|
02c62213c3 | ||
|
|
d0722fbbbe | ||
|
|
4ec569342d | ||
|
|
9540d9ccb9 | ||
|
|
6b5cbe854b | ||
|
|
59ed52814e | ||
|
|
3488ab4f6b | ||
|
|
6e2fc5349b | ||
|
|
ea88c2686d | ||
|
|
3df4baec92 | ||
|
|
ff74e1aaa1 | ||
|
|
b0c865ab76 | ||
|
|
3dbc086f10 | ||
|
|
e9b4b34fb4 | ||
|
|
668eeee197 | ||
|
|
799d8471c9 | ||
|
|
f77c16dec9 | ||
|
|
45026cac6a | ||
|
|
edf6ab5bca | ||
|
|
462b1f14e2 | ||
|
|
7a8f18b116 | ||
|
|
d255e4abb6 | ||
|
|
b770240f0d | ||
|
|
43b064673b | ||
|
|
bf2726c151 | ||
|
|
f3cd921c76 | ||
|
|
913f6b6b64 | ||
|
|
3463a52960 | ||
|
|
116db3566f | ||
|
|
32210a5f7c | ||
|
|
d8f9372bbd | ||
|
|
6e1997fd9d | ||
|
|
4e7213d1aa | ||
|
|
5afb427bb4 | ||
|
|
685361a11a | ||
|
|
bd7c97ca91 | ||
|
|
81235c67dc | ||
|
|
65e8e03224 | ||
|
|
85eb3adec7 | ||
|
|
5d0f958a68 | ||
|
|
b82f50b76b | ||
|
|
0ab11a9bf9 | ||
|
|
d28c310704 | ||
|
|
2e1d7fdf2b | ||
|
|
82e96f7b86 | ||
|
|
7229e2abbd | ||
|
|
4e35043da0 | ||
|
|
56fe1c2ccc | ||
|
|
a874edf2a1 | ||
|
|
9d55ec3e43 | ||
|
|
05580ac978 | ||
|
|
667eb4809d | ||
|
|
58fdaa8298 | ||
|
|
5478a2ac27 | ||
|
|
ad37990da9 | ||
|
|
66824030f2 | ||
|
|
90ca8cf97c | ||
|
|
3c8ea28da1 | ||
|
|
5c352eb863 | ||
|
|
8c312bc163 | ||
|
|
6fa8b06063 | ||
|
|
2992f122bc | ||
|
|
e507993065 | ||
|
|
6f22aad6f4 | ||
|
|
097264040f | ||
|
|
2c03ee6af0 | ||
|
|
ec596b2ada | ||
|
|
b01ba0b2d9 | ||
|
|
f74a6ef093 | ||
|
|
ee687fafbe | ||
|
|
60ba17cfea | ||
|
|
8b3aa5891e | ||
|
|
3ff9399b81 | ||
|
|
d18f7862ab | ||
|
|
dc6c1ce43c | ||
|
|
8d1adccec6 | ||
|
|
420ce71fb2 | ||
|
|
8e5a269ff0 | ||
|
|
f357d6f0ee | ||
|
|
a0467d17a8 | ||
|
|
ef3ecfc73f | ||
|
|
d3fcb23e22 | ||
|
|
5a029283a1 | ||
|
|
09ccad2bd4 | ||
|
|
bdcc10bd40 | ||
|
|
2a1fafc3f9 | ||
|
|
6010b9a0af | ||
|
|
11e8f87fe6 | ||
|
|
14b81cba19 | ||
|
|
2b37de3b4d | ||
|
|
73d9a6367c | ||
|
|
c90233b56f | ||
|
|
39dcf38462 | ||
|
|
efa27cd2dd | ||
|
|
ca231964f2 | ||
|
|
2bb83cb990 | ||
|
|
305bcfcf62 | ||
|
|
92a9011f27 | ||
|
|
e64cebdfc2 | ||
|
|
0db426d278 | ||
|
|
6bfef1c7a0 | ||
|
|
f50fe788cb | ||
|
|
4db78ecf1b | ||
|
|
f22dd7749f | ||
|
|
75af0c3be1 | ||
|
|
6f36b84f85 | ||
|
|
e723198b72 | ||
|
|
8f74febff1 | ||
|
|
d9bd6f8e1d | ||
|
|
bf55417901 | ||
|
|
9511970a1a | ||
|
|
5c8557b369 | ||
|
|
5e47c53fcf | ||
|
|
f7493a8bc4 | ||
|
|
4f39b93edd | ||
|
|
ff8b4eb0f6 | ||
|
|
e921862a7f | ||
|
|
4d7177f15a | ||
|
|
c5d7f56399 | ||
|
|
57f789290d | ||
|
|
f34317e504 | ||
|
|
8159a84447 | ||
|
|
f4db0e384c | ||
|
|
69953401b1 | ||
|
|
93a3c246c6 | ||
|
|
6505e18b0b | ||
|
|
51ab73014f | ||
|
|
05d9e6e46b | ||
|
|
8ea9965ee3 | ||
|
|
486df65aa3 | ||
|
|
8044696d7c | ||
|
|
04a7d86636 | ||
|
|
d0e2f31ce8 | ||
|
|
50fcb09758 | ||
|
|
6d3c09d57a | ||
|
|
cc994fb940 | ||
|
|
eee12513dd | ||
|
|
055b6a0a43 | ||
|
|
26b899df16 | ||
|
|
afc8e1a1e5 | ||
|
|
b6c4d3308d | ||
|
|
af4567aa3d | ||
|
|
415bc2044d | ||
|
|
91ad2b0caf | ||
|
|
bc6471fa54 | ||
|
|
0704668d8d | ||
|
|
2e9f8815d2 | ||
|
|
f4689125e0 | ||
|
|
bbba67a253 | ||
|
|
800b857e53 | ||
|
|
921059bed8 | ||
|
|
e8b585dc07 | ||
|
|
afce27184a | ||
|
|
8d41beef0b | ||
|
|
5e482cd77b | ||
|
|
09068fd4e5 | ||
|
|
d8c2b9089b | ||
|
|
bb577bc276 | ||
|
|
6267e3d593 | ||
|
|
c091a39802 | ||
|
|
f94006dd08 | ||
|
|
f42790980d | ||
|
|
779683de4b | ||
|
|
aa8887318f | ||
|
|
3ad8dc6490 | ||
|
|
20f9fae141 | ||
|
|
ec062bbf24 | ||
|
|
8745c9ea2f | ||
|
|
0917654361 | ||
|
|
d892d1cfa0 | ||
|
|
e31138c250 | ||
|
|
7cbe5ce8be | ||
|
|
1c31d4cd1c | ||
|
|
990ec9317f | ||
|
|
718ceac17e | ||
|
|
d154ba6b89 | ||
|
|
f2a63476b3 | ||
|
|
adcd3b37fa | ||
|
|
71dabc1e37 | ||
|
|
2d724876eb | ||
|
|
1927d1fa4d | ||
|
|
d995fab0e4 | ||
|
|
b4135c1626 | ||
|
|
3d0a9f2082 | ||
|
|
1dd5f685a6 | ||
|
|
c1c4394f94 | ||
|
|
16d9fcdadc | ||
|
|
5b1db01fe6 | ||
|
|
e856e9d6c7 | ||
|
|
ad2c783870 | ||
|
|
5e101bef14 | ||
|
|
149949ad99 | ||
|
|
741cc952fa | ||
|
|
d2bb7a0bf4 | ||
|
|
d4d5bcec9e | ||
|
|
b4053a7b94 | ||
|
|
7b27d00972 | ||
|
|
f4d234089f | ||
|
|
50a329fc0d | ||
|
|
6d6df7ca5c | ||
|
|
6b16068706 | ||
|
|
024a0de4af | ||
|
|
e145d63ba6 | ||
|
|
e7e8b0b3f6 | ||
|
|
a7389db674 | ||
|
|
cf1aa4fd2a | ||
|
|
0fd2a589eb | ||
|
|
944131d5a6 | ||
|
|
eff46ac9bf | ||
|
|
14f9b254b2 | ||
|
|
337d6050ff | ||
|
|
d36348b84f | ||
|
|
dfb6f5b375 | ||
|
|
a9089bc2ff | ||
|
|
5f5d690ff3 | ||
|
|
431882ece9 | ||
|
|
b51e76e366 | ||
|
|
4e01740a61 | ||
|
|
e729ed9df3 | ||
|
|
6b241304fb | ||
|
|
3ed8764087 | ||
|
|
df61b245ab | ||
|
|
7a364d654c | ||
|
|
0d1a0be1a0 | ||
|
|
5da9f5aaf4 | ||
|
|
2bec0f5850 | ||
|
|
5bc28ec38b | ||
|
|
8c7a0a1ec0 | ||
|
|
a8eef8107b | ||
|
|
75cd942f39 | ||
|
|
fc04af148d | ||
|
|
d7d7fd614b | ||
|
|
10e9c3ead0 | ||
|
|
70678432c6 | ||
|
|
bb103fac6c | ||
|
|
0e1fc3f5fa | ||
|
|
7830ebdb3b | ||
|
|
b061dd3395 | ||
|
|
a6f2fa8a88 | ||
|
|
37f12ea6f0 | ||
|
|
e29b576102 | ||
|
|
b1b3cf6136 | ||
|
|
173c4c98b8 | ||
|
|
5a3067c8e5 | ||
|
|
803c748738 | ||
|
|
5879b40e8c | ||
|
|
c3decdac13 | ||
|
|
1baf89dde6 | ||
|
|
0219dc0ede | ||
|
|
1c662888c6 | ||
|
|
7fd1c1e41d | ||
|
|
1eaa716f42 | ||
|
|
704bb7401a | ||
|
|
b0c3fdbdc6 | ||
|
|
1d0ceb7045 | ||
|
|
ce85a050d3 | ||
|
|
94ecbcd639 | ||
|
|
ef1d8bcc9c | ||
|
|
1a7bbdc541 | ||
|
|
3dc5a89459 | ||
|
|
9f6268dbc9 | ||
|
|
2e912dd261 | ||
|
|
6d6fff474f | ||
|
|
e725c70570 | ||
|
|
07aeecbbfc | ||
|
|
9bfac04c44 | ||
|
|
6acf0d6378 | ||
|
|
1a09624242 | ||
|
|
9e36385e63 | ||
|
|
43edadde0c | ||
|
|
d577ff6715 | ||
|
|
ca75c90ee9 | ||
|
|
8e0fae26ee | ||
|
|
bee95c3755 | ||
|
|
306f4de14f | ||
|
|
b2fc681e73 | ||
|
|
060cff3f0f | ||
|
|
07c48985e4 | ||
|
|
3d266c2ad0 | ||
|
|
e2c326bf1e | ||
|
|
7f5b671084 | ||
|
|
3ccf71ed0f | ||
|
|
efb59c5571 | ||
|
|
26cf06df14 | ||
|
|
c82b62f72b | ||
|
|
87cdb8048d | ||
|
|
f71498703a | ||
|
|
94eea47b76 | ||
|
|
ac833294c4 | ||
|
|
81f0527085 | ||
|
|
dd4d9e88c6 | ||
|
|
f66d67c238 | ||
|
|
9cf25afa52 | ||
|
|
2ed2419f9e | ||
|
|
e19b30f4f4 | ||
|
|
f6e21b6a61 | ||
|
|
2180d8decf | ||
|
|
12aec72141 | ||
|
|
0568441e46 | ||
|
|
301189bd85 | ||
|
|
81767f376f | ||
|
|
e36b1817bc | ||
|
|
13d4db6bdc | ||
|
|
b5a5ebd7c0 | ||
|
|
6e04c1f267 | ||
|
|
a0d96cb87a | ||
|
|
4052a5b81b | ||
|
|
ed99ebf541 | ||
|
|
0a4f59cf8e | ||
|
|
fd6a9b73be | ||
|
|
dc994f6ee1 | ||
|
|
e234dd1785 | ||
|
|
6e0943f0cc | ||
|
|
9622e0bd3c | ||
|
|
ee666e91ed | ||
|
|
4f8f2f4a40 | ||
|
|
f21953147b | ||
|
|
48b2658b55 | ||
|
|
37b08fdd10 | ||
|
|
d4efdf4833 | ||
|
|
b408abe8c6 | ||
|
|
722c63af54 | ||
|
|
4601ba4094 | ||
|
|
3e2b8a36df | ||
|
|
11214734ae | ||
|
|
92045a4e67 | ||
|
|
9564bd8524 | ||
|
|
ad64df2ec3 | ||
|
|
aab11c1cba | ||
|
|
1fe139e0c5 |
@@ -4,3 +4,5 @@ RUST_DB_USER=multi_psql_dev
|
||||
RUST_DB_PASSWORD=3
|
||||
RUST_DB_HOST=localhost
|
||||
RUST_DB_PORT=5432
|
||||
|
||||
JWT_SECRET=<YOUR-JWT-HERE>
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,2 +1,7 @@
|
||||
/target
|
||||
.env
|
||||
/tantivy_indexes
|
||||
server/tantivy_indexes
|
||||
steel_decimal/tests/property_tests.proptest-regressions
|
||||
.direnv/
|
||||
canvas/*.toml
|
||||
|
||||
1889
Cargo.lock
generated
1889
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
46
Cargo.toml
46
Cargo.toml
@@ -1,19 +1,55 @@
|
||||
[workspace]
|
||||
members = ["client", "server", "common"]
|
||||
members = ["client", "server", "common", "search", "canvas"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
# TODO: idk how to do the name, fix later
|
||||
# name = "Multieko2"
|
||||
version = "0.2.0"
|
||||
# name = "komp_ac"
|
||||
version = "0.4.2"
|
||||
edition = "2021"
|
||||
license = "GPL-3.0-or-later"
|
||||
authors = ["Filip Priečinský <filippriec@gmail.com>"]
|
||||
description = "Poriadny uctovnicky software."
|
||||
readme = "README.md"
|
||||
repository = "https://gitlab.com/filipriec/multieko2"
|
||||
repository = "https://gitlab.com/filipriec/komp_ac"
|
||||
categories = ["command-line-interface"]
|
||||
|
||||
# [workspace.metadata]
|
||||
# TODO:
|
||||
# documentation = "https://docs.rs/accounting-client"`
|
||||
# documentation = "https://docs.rs/accounting-client"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Async and gRPC
|
||||
tokio = { version = "1.44.2", features = ["full"] }
|
||||
tonic = "0.13.0"
|
||||
prost = "0.13.5"
|
||||
async-trait = "0.1.88"
|
||||
prost-types = "0.13.0"
|
||||
|
||||
# Data Handling & Serialization
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.140"
|
||||
time = "0.3.41"
|
||||
|
||||
# Utilities & Error Handling
|
||||
anyhow = "1.0.98"
|
||||
dotenvy = "0.15.7"
|
||||
lazy_static = "1.5.0"
|
||||
tracing = "0.1.41"
|
||||
|
||||
# Search crate
|
||||
tantivy = "0.24.1"
|
||||
|
||||
# Steel_decimal crate
|
||||
rust_decimal = { version = "1.37.2", features = ["maths", "serde"] }
|
||||
rust_decimal_macros = "1.37.1"
|
||||
thiserror = "2.0.12"
|
||||
regex = "1.11.1"
|
||||
|
||||
# Canvas crate
|
||||
ratatui = { version = "0.29.0", features = ["crossterm"] }
|
||||
crossterm = "0.28.1"
|
||||
toml = "0.8.20"
|
||||
unicode-width = "0.2.0"
|
||||
|
||||
common = { path = "./common" }
|
||||
|
||||
11
README.md
11
README.md
@@ -1,5 +1,7 @@
|
||||
# Hey
|
||||
|
||||
This is only work in progress, until release 1.0.0 this is for development use cases only.
|
||||
|
||||
I run development like this:
|
||||
|
||||
Server:
|
||||
@@ -12,3 +14,12 @@ Client:
|
||||
cargo watch -x 'run --package client -- client'
|
||||
```
|
||||
|
||||
Client with tracing:
|
||||
```
|
||||
ENABLE_TRACING=1 RUST_LOG=client=debug cargo watch -x 'run --package client -- client'
|
||||
```
|
||||
|
||||
Client with debug that cant be traced
|
||||
```
|
||||
cargo run --package client --features ui-debug -- client
|
||||
```
|
||||
|
||||
334
canvas/CANVAS_MIGRATION.md
Normal file
334
canvas/CANVAS_MIGRATION.md
Normal file
@@ -0,0 +1,334 @@
|
||||
# Canvas Library Migration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide covers the migration from the legacy canvas library structure to the new clean, modular architecture. The new design separates core canvas functionality from autocomplete features, providing better type safety and maintainability.
|
||||
|
||||
## Key Changes
|
||||
|
||||
### 1. **Modular Architecture**
|
||||
```
|
||||
# Old Structure (LEGACY)
|
||||
src/
|
||||
├── state.rs # Mixed canvas + autocomplete
|
||||
├── actions/edit.rs # Mixed concerns
|
||||
├── gui/render.rs # Everything together
|
||||
└── suggestions.rs # Legacy file
|
||||
|
||||
# New Structure (CLEAN)
|
||||
src/
|
||||
├── canvas/ # Core canvas functionality
|
||||
│ ├── state.rs # CanvasState trait only
|
||||
│ ├── actions/edit.rs # Canvas actions only
|
||||
│ └── gui.rs # Canvas rendering
|
||||
├── autocomplete/ # Rich autocomplete features
|
||||
│ ├── state.rs # AutocompleteCanvasState trait
|
||||
│ ├── types.rs # SuggestionItem, AutocompleteState
|
||||
│ ├── actions.rs # Autocomplete actions
|
||||
│ └── gui.rs # Autocomplete dropdown rendering
|
||||
└── dispatcher.rs # Action routing
|
||||
```
|
||||
|
||||
### 2. **Trait Separation**
|
||||
- **CanvasState**: Core form functionality (navigation, input, validation)
|
||||
- **AutocompleteCanvasState**: Optional rich autocomplete features
|
||||
|
||||
### 3. **Rich Suggestions**
|
||||
Replaced simple string suggestions with typed, rich suggestion objects.
|
||||
|
||||
## Migration Steps
|
||||
|
||||
### Step 1: Update Import Paths
|
||||
|
||||
**Find and Replace these imports:**
|
||||
|
||||
```rust
|
||||
# OLD IMPORTS
|
||||
use canvas::CanvasState;
|
||||
use canvas::CanvasAction;
|
||||
use canvas::ActionContext;
|
||||
use canvas::HighlightState;
|
||||
use canvas::CanvasTheme;
|
||||
use canvas::ActionDispatcher;
|
||||
use canvas::ActionResult;
|
||||
|
||||
# NEW IMPORTS
|
||||
use canvas::canvas::CanvasState;
|
||||
use canvas::canvas::CanvasAction;
|
||||
use canvas::canvas::ActionContext;
|
||||
use canvas::canvas::HighlightState;
|
||||
use canvas::canvas::CanvasTheme;
|
||||
use canvas::dispatcher::ActionDispatcher;
|
||||
use canvas::canvas::ActionResult;
|
||||
```
|
||||
|
||||
**Complex imports:**
|
||||
```rust
|
||||
# OLD
|
||||
use canvas::{CanvasAction, ActionDispatcher, ActionResult};
|
||||
|
||||
# NEW
|
||||
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher, canvas::ActionResult};
|
||||
```
|
||||
|
||||
### Step 2: Clean Up State Implementation
|
||||
|
||||
**Remove legacy methods from your CanvasState implementation:**
|
||||
|
||||
```rust
|
||||
impl CanvasState for YourFormState {
|
||||
// Keep all the core methods:
|
||||
fn current_field(&self) -> usize { /* ... */ }
|
||||
fn get_current_input(&self) -> &str { /* ... */ }
|
||||
// ... etc
|
||||
|
||||
// ❌ REMOVE these legacy methods:
|
||||
// fn get_suggestions(&self) -> Option<&[String]>
|
||||
// fn get_selected_suggestion_index(&self) -> Option<usize>
|
||||
// fn set_selected_suggestion_index(&mut self, index: Option<usize>)
|
||||
// fn activate_suggestions(&mut self, suggestions: Vec<String>)
|
||||
// fn deactivate_suggestions(&mut self)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Implement Rich Autocomplete (Optional)
|
||||
|
||||
**If you want rich autocomplete features:**
|
||||
|
||||
```rust
|
||||
use canvas::autocomplete::{AutocompleteCanvasState, SuggestionItem, AutocompleteState};
|
||||
|
||||
impl AutocompleteCanvasState for YourFormState {
|
||||
type SuggestionData = YourDataType; // e.g., Hit, CustomRecord, etc.
|
||||
|
||||
fn supports_autocomplete(&self, field_index: usize) -> bool {
|
||||
// Define which fields support autocomplete
|
||||
matches!(field_index, 2 | 3 | 5) // Example: only certain fields
|
||||
}
|
||||
|
||||
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> {
|
||||
Some(&self.autocomplete)
|
||||
}
|
||||
|
||||
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> {
|
||||
Some(&mut self.autocomplete)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Add autocomplete field to your state:**
|
||||
```rust
|
||||
pub struct YourFormState {
|
||||
// ... existing fields
|
||||
pub autocomplete: AutocompleteState<YourDataType>,
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Migrate Suggestions
|
||||
|
||||
**Old way (simple strings):**
|
||||
```rust
|
||||
let suggestions = vec!["John".to_string(), "Jane".to_string()];
|
||||
form_state.activate_suggestions(suggestions);
|
||||
```
|
||||
|
||||
**New way (rich objects):**
|
||||
```rust
|
||||
let suggestions = vec![
|
||||
SuggestionItem::new(
|
||||
hit1, // Your data object
|
||||
"John Doe (Manager) | ID: 123".to_string(), // What user sees
|
||||
"123".to_string(), // What gets stored
|
||||
),
|
||||
SuggestionItem::simple(hit2, "Jane".to_string()), // Simple version
|
||||
];
|
||||
form_state.set_autocomplete_suggestions(suggestions);
|
||||
```
|
||||
|
||||
### Step 5: Update Rendering
|
||||
|
||||
**Old rendering:**
|
||||
```rust
|
||||
// Manual autocomplete rendering
|
||||
if form_state.autocomplete_active {
|
||||
render_autocomplete_dropdown(/* ... */);
|
||||
}
|
||||
```
|
||||
|
||||
**New rendering:**
|
||||
```rust
|
||||
// Canvas handles everything
|
||||
use canvas::canvas::render_canvas;
|
||||
|
||||
let active_field_rect = render_canvas(f, area, form_state, theme, edit_mode, highlight_state);
|
||||
|
||||
// Optional: Rich autocomplete (if implementing AutocompleteCanvasState)
|
||||
if form_state.is_autocomplete_active() {
|
||||
if let Some(autocomplete_state) = form_state.autocomplete_state() {
|
||||
canvas::autocomplete::render_autocomplete_dropdown(
|
||||
f, f.area(), active_field_rect.unwrap(), theme, autocomplete_state
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Update Method Calls
|
||||
|
||||
**Replace legacy method calls:**
|
||||
```rust
|
||||
# OLD
|
||||
form_state.deactivate_suggestions();
|
||||
|
||||
# NEW - Option A: Add your own method
|
||||
impl YourFormState {
|
||||
pub fn deactivate_autocomplete(&mut self) {
|
||||
self.autocomplete_active = false;
|
||||
self.autocomplete_suggestions.clear();
|
||||
self.selected_suggestion_index = None;
|
||||
}
|
||||
}
|
||||
form_state.deactivate_autocomplete();
|
||||
|
||||
# NEW - Option B: Use rich autocomplete trait
|
||||
form_state.deactivate_autocomplete(); // If implementing AutocompleteCanvasState
|
||||
```
|
||||
|
||||
## Benefits of New Architecture
|
||||
|
||||
### 1. **Clean Separation of Concerns**
|
||||
- Canvas: Form rendering, navigation, input handling
|
||||
- Autocomplete: Rich suggestions, dropdown management, async loading
|
||||
|
||||
### 2. **Type Safety**
|
||||
```rust
|
||||
// Old: Stringly typed
|
||||
let suggestions: Vec<String> = vec!["user1".to_string()];
|
||||
|
||||
// New: Fully typed with your domain objects
|
||||
let suggestions: Vec<SuggestionItem<UserRecord>> = vec![
|
||||
SuggestionItem::new(user_record, display_text, stored_value)
|
||||
];
|
||||
```
|
||||
|
||||
### 3. **Rich UX Capabilities**
|
||||
- **Display vs Storage**: Show "John Doe (Manager)" but store user ID
|
||||
- **Loading States**: Built-in spinner/loading indicators
|
||||
- **Async Support**: Designed for async suggestion fetching
|
||||
- **Display Overrides**: Show friendly text while storing normalized data
|
||||
|
||||
### 4. **Future-Proof**
|
||||
- Easy to add new autocomplete features
|
||||
- Canvas features don't interfere with autocomplete
|
||||
- Modular: Use only what you need
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Display Overrides
|
||||
Perfect for foreign key relationships:
|
||||
|
||||
```rust
|
||||
// User selects "John Doe (Manager) | ID: 123"
|
||||
// Field stores: "123" (for database)
|
||||
// User sees: "John Doe" (friendly display)
|
||||
|
||||
impl CanvasState for FormState {
|
||||
fn get_display_value_for_field(&self, index: usize) -> &str {
|
||||
if let Some(display_text) = self.link_display_map.get(&index) {
|
||||
return display_text.as_str(); // Shows "John Doe"
|
||||
}
|
||||
self.inputs().get(index).map(|s| s.as_str()).unwrap_or("") // Shows "123"
|
||||
}
|
||||
|
||||
fn has_display_override(&self, index: usize) -> bool {
|
||||
self.link_display_map.contains_key(&index)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Progressive Enhancement
|
||||
Start simple, add features when needed:
|
||||
|
||||
```rust
|
||||
// Week 1: Basic usage
|
||||
SuggestionItem::simple(data, "John".to_string());
|
||||
|
||||
// Week 5: Rich display
|
||||
SuggestionItem::new(data, "John Doe (Manager)".to_string(), "John".to_string());
|
||||
|
||||
// Week 10: Store IDs, show names
|
||||
SuggestionItem::new(user, "John Doe (Manager)".to_string(), "123".to_string());
|
||||
```
|
||||
|
||||
## Breaking Changes Summary
|
||||
|
||||
1. **Import paths changed**: Add `canvas::` or `dispatcher::` prefixes
|
||||
2. **Legacy suggestion methods removed**: Replace with rich autocomplete or custom methods
|
||||
3. **No more simple suggestions**: Use `SuggestionItem` for typed suggestions
|
||||
4. **Trait split**: `AutocompleteCanvasState` is now separate and optional
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Compilation Errors
|
||||
|
||||
**Error**: `no method named 'get_suggestions' found`
|
||||
**Fix**: Remove legacy method from `CanvasState` implementation
|
||||
|
||||
**Error**: `no 'CanvasState' in the root`
|
||||
**Fix**: Change `use canvas::CanvasState` to `use canvas::canvas::CanvasState`
|
||||
|
||||
**Error**: `trait bound 'FormState: CanvasState' is not satisfied`
|
||||
**Fix**: Make sure your state properly implements the new `CanvasState` trait
|
||||
|
||||
### Migration Checklist
|
||||
|
||||
- [ ] Updated all import paths
|
||||
- [ ] Removed legacy methods from CanvasState implementation
|
||||
- [ ] Added custom autocomplete methods if needed
|
||||
- [ ] Updated suggestion usage to SuggestionItem
|
||||
- [ ] Updated rendering calls
|
||||
- [ ] Tested form functionality
|
||||
- [ ] Tested autocomplete functionality (if using)
|
||||
|
||||
## Example: Complete Migration
|
||||
|
||||
**Before:**
|
||||
```rust
|
||||
use canvas::{CanvasState, CanvasAction};
|
||||
|
||||
impl CanvasState for FormState {
|
||||
fn get_suggestions(&self) -> Option<&[String]> { /* ... */ }
|
||||
fn deactivate_suggestions(&mut self) { /* ... */ }
|
||||
// ... other methods
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```rust
|
||||
use canvas::canvas::{CanvasState, CanvasAction};
|
||||
use canvas::autocomplete::{AutocompleteCanvasState, SuggestionItem};
|
||||
|
||||
impl CanvasState for FormState {
|
||||
// Only core canvas methods, no suggestion methods
|
||||
fn current_field(&self) -> usize { /* ... */ }
|
||||
fn get_current_input(&self) -> &str { /* ... */ }
|
||||
// ... other core methods only
|
||||
}
|
||||
|
||||
impl AutocompleteCanvasState for FormState {
|
||||
type SuggestionData = Hit;
|
||||
|
||||
fn supports_autocomplete(&self, field_index: usize) -> bool {
|
||||
self.fields[field_index].is_link
|
||||
}
|
||||
|
||||
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> {
|
||||
Some(&self.autocomplete)
|
||||
}
|
||||
|
||||
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> {
|
||||
Some(&mut self.autocomplete)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This migration results in cleaner, more maintainable, and more powerful code!
|
||||
35
canvas/Cargo.toml
Normal file
35
canvas/Cargo.toml
Normal file
@@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "canvas"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description.workspace = true
|
||||
readme.workspace = true
|
||||
repository.workspace = true
|
||||
categories.workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
ratatui = { workspace = true, optional = true }
|
||||
crossterm = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
unicode-width.workspace = true
|
||||
thiserror = { workspace = true }
|
||||
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.19"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4.4"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
gui = ["ratatui"]
|
||||
|
||||
[[example]]
|
||||
name = "ratatui_demo"
|
||||
path = "examples/ratatui_demo.rs"
|
||||
337
canvas/README.md
Normal file
337
canvas/README.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# Canvas 🎨
|
||||
|
||||
A reusable, type-safe canvas system for building form-based TUI applications with vim-like modal editing.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Type-Safe Actions**: No more string-based action names - everything is compile-time checked
|
||||
- **Generic Design**: Implement `CanvasState` once, get navigation, editing, and suggestions for free
|
||||
- **Vim-Like Experience**: Modal editing with familiar keybindings
|
||||
- **Suggestion System**: Built-in autocomplete and suggestions support
|
||||
- **Framework Agnostic**: Works with any TUI framework or raw terminal handling
|
||||
- **Async Ready**: Full async/await support for modern Rust applications
|
||||
- **Batch Operations**: Execute multiple actions atomically
|
||||
- **Extensible**: Custom actions and feature-specific handling
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
Add to your `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
cargo add canvas
|
||||
```
|
||||
|
||||
Implement the `CanvasState` trait:
|
||||
|
||||
```rust
|
||||
use canvas::prelude::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct LoginForm {
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
username: String,
|
||||
password: String,
|
||||
has_changes: bool,
|
||||
}
|
||||
|
||||
impl CanvasState for LoginForm {
|
||||
fn current_field(&self) -> usize { self.current_field }
|
||||
fn current_cursor_pos(&self) -> usize { self.cursor_pos }
|
||||
fn set_current_field(&mut self, index: usize) { self.current_field = index; }
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
|
||||
|
||||
fn get_current_input(&self) -> &str {
|
||||
match self.current_field {
|
||||
0 => &self.username,
|
||||
1 => &self.password,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_input_mut(&mut self) -> &mut String {
|
||||
match self.current_field {
|
||||
0 => &mut self.username,
|
||||
1 => &mut self.password,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> { vec![&self.username, &self.password] }
|
||||
fn fields(&self) -> Vec<&str> { vec!["Username", "Password"] }
|
||||
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
|
||||
}
|
||||
```
|
||||
|
||||
Use the type-safe action dispatcher:
|
||||
|
||||
```rust
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut form = LoginForm::new();
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
// Type a character - compile-time safe!
|
||||
ActionDispatcher::dispatch(
|
||||
CanvasAction::InsertChar('h'),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await?;
|
||||
|
||||
// Move to next field
|
||||
ActionDispatcher::dispatch(
|
||||
CanvasAction::NextField,
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await?;
|
||||
|
||||
// Batch operations
|
||||
let actions = vec![
|
||||
CanvasAction::InsertChar('p'),
|
||||
CanvasAction::InsertChar('a'),
|
||||
CanvasAction::InsertChar('s'),
|
||||
CanvasAction::InsertChar('s'),
|
||||
];
|
||||
|
||||
ActionDispatcher::dispatch_batch(actions, &mut form, &mut ideal_cursor).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 Type-Safe Actions
|
||||
|
||||
The Canvas system uses strongly-typed actions instead of error-prone strings:
|
||||
|
||||
```rust
|
||||
// ✅ Type-safe - impossible to make typos
|
||||
ActionDispatcher::dispatch(CanvasAction::MoveLeft, &mut form, &mut cursor).await?;
|
||||
|
||||
// ❌ Old way - runtime errors waiting to happen
|
||||
execute_edit_action("move_left", key, &mut form, &mut cursor).await?;
|
||||
execute_edit_action("move_leftt", key, &mut form, &mut cursor).await?; // Oops!
|
||||
```
|
||||
|
||||
### Available Actions
|
||||
|
||||
```rust
|
||||
pub enum CanvasAction {
|
||||
// Character input
|
||||
InsertChar(char),
|
||||
|
||||
// Deletion
|
||||
DeleteBackward,
|
||||
DeleteForward,
|
||||
|
||||
// Movement
|
||||
MoveLeft, MoveRight, MoveUp, MoveDown,
|
||||
MoveLineStart, MoveLineEnd,
|
||||
MoveWordNext, MoveWordPrev,
|
||||
|
||||
// Navigation
|
||||
NextField, PrevField,
|
||||
MoveFirstLine, MoveLastLine,
|
||||
|
||||
// Suggestions
|
||||
SuggestionUp, SuggestionDown,
|
||||
SelectSuggestion, ExitSuggestions,
|
||||
|
||||
// Extensibility
|
||||
Custom(String),
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 Advanced Features
|
||||
|
||||
### Suggestions and Autocomplete
|
||||
|
||||
```rust
|
||||
impl CanvasState for MyForm {
|
||||
fn get_suggestions(&self) -> Option<&[String]> {
|
||||
if self.suggestions.is_active {
|
||||
Some(&self.suggestions.suggestions)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::InsertChar('@') => {
|
||||
// Trigger email suggestions
|
||||
let suggestions = vec![
|
||||
format!("{}@gmail.com", self.username),
|
||||
format!("{}@company.com", self.username),
|
||||
];
|
||||
self.activate_suggestions(suggestions);
|
||||
None // Let generic handler insert the '@'
|
||||
}
|
||||
CanvasAction::SelectSuggestion => {
|
||||
if let Some(suggestion) = self.suggestions.get_selected() {
|
||||
*self.get_current_input_mut() = suggestion.clone();
|
||||
self.deactivate_autocomplete();
|
||||
Some("Applied suggestion".to_string())
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Actions
|
||||
|
||||
```rust
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::Custom(cmd) => match cmd.as_str() {
|
||||
"uppercase" => {
|
||||
*self.get_current_input_mut() = self.get_current_input().to_uppercase();
|
||||
Some("Converted to uppercase".to_string())
|
||||
}
|
||||
"validate_email" => {
|
||||
if self.get_current_input().contains('@') {
|
||||
Some("Email is valid".to_string())
|
||||
} else {
|
||||
Some("Invalid email format".to_string())
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration with TUI Frameworks
|
||||
|
||||
Canvas is framework-agnostic and works with any TUI library:
|
||||
|
||||
```rust
|
||||
// Works with crossterm (see examples)
|
||||
// Works with termion
|
||||
// Works with ratatui/tui-rs
|
||||
// Works with cursive
|
||||
// Works with raw terminal I/O
|
||||
```
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
Canvas follows a clean, layered architecture:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Your Application │
|
||||
├─────────────────────────────────────┤
|
||||
│ ActionDispatcher │ ← High-level API
|
||||
├─────────────────────────────────────┤
|
||||
│ CanvasAction (Type-Safe) │ ← Type safety layer
|
||||
├─────────────────────────────────────┤
|
||||
│ Action Handlers │ ← Core logic
|
||||
├─────────────────────────────────────┤
|
||||
│ CanvasState Trait │ ← Your implementation
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 🤝 Why Canvas?
|
||||
|
||||
### Before Canvas
|
||||
```rust
|
||||
// ❌ Error-prone string actions
|
||||
execute_action("move_left", key, state)?;
|
||||
execute_action("move_leftt", key, state)?; // Runtime error!
|
||||
|
||||
// ❌ Duplicate navigation logic everywhere
|
||||
impl MyLoginForm { /* navigation code */ }
|
||||
impl MyConfigForm { /* same navigation code */ }
|
||||
impl MyDataForm { /* same navigation code again */ }
|
||||
|
||||
// ❌ Manual cursor and field management
|
||||
if key == Key::Tab {
|
||||
current_field = (current_field + 1) % fields.len();
|
||||
cursor_pos = cursor_pos.min(current_input.len());
|
||||
}
|
||||
```
|
||||
|
||||
### With Canvas
|
||||
```rust
|
||||
// ✅ Type-safe actions
|
||||
ActionDispatcher::dispatch(CanvasAction::MoveLeft, state, cursor)?;
|
||||
// Typos are impossible - won't compile!
|
||||
|
||||
// ✅ Implement once, use everywhere
|
||||
impl CanvasState for MyForm { /* minimal implementation */ }
|
||||
// All navigation, editing, suggestions work automatically!
|
||||
|
||||
// ✅ High-level operations
|
||||
ActionDispatcher::dispatch_batch(actions, state, cursor)?;
|
||||
```
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- **API Docs**: `cargo doc --open`
|
||||
- **Examples**: See `examples/` directory
|
||||
- **Migration Guide**: See `CANVAS_MIGRATION.md`
|
||||
|
||||
## 🔄 Migration from String-Based Actions
|
||||
|
||||
Canvas provides backwards compatibility during migration:
|
||||
|
||||
```rust
|
||||
// Legacy support (deprecated)
|
||||
execute_edit_action("move_left", key, state, cursor).await?;
|
||||
|
||||
// New type-safe way
|
||||
ActionDispatcher::dispatch(CanvasAction::MoveLeft, state, cursor).await?;
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
cargo test
|
||||
|
||||
# Run specific example
|
||||
cargo run --example simple_login
|
||||
|
||||
# Check type safety
|
||||
cargo check
|
||||
```
|
||||
|
||||
## 📋 Requirements
|
||||
|
||||
- Rust 1.70+
|
||||
- Terminal with cursor support
|
||||
- Optional: async runtime (tokio) for examples
|
||||
|
||||
## 🤔 FAQ
|
||||
|
||||
**Q: Does Canvas work with [my TUI framework]?**
|
||||
A: Yes! Canvas is framework-agnostic. Just implement `CanvasState` and handle the key events.
|
||||
|
||||
**Q: Can I extend Canvas with custom actions?**
|
||||
A: Absolutely! Use `CanvasAction::Custom("my_action")` or implement `handle_feature_action`.
|
||||
|
||||
**Q: Is Canvas suitable for complex forms?**
|
||||
A: Yes! See the `config_screen` example for validation, suggestions, and multi-field forms.
|
||||
|
||||
**Q: How do I migrate from string-based actions?**
|
||||
A: Canvas provides backwards compatibility. Migrate incrementally using the type-safe APIs.
|
||||
|
||||
## 📄 License
|
||||
|
||||
Licensed under either of:
|
||||
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
|
||||
- MIT License ([LICENSE-MIT](LICENSE-MIT))
|
||||
|
||||
at your option.
|
||||
|
||||
## 🙏 Contributing
|
||||
|
||||
Will write here something later on, too busy rn
|
||||
|
||||
---
|
||||
|
||||
Built with ❤️ for the Rust TUI community
|
||||
77
canvas/docs/new_function_to_config.txt
Normal file
77
canvas/docs/new_function_to_config.txt
Normal file
@@ -0,0 +1,77 @@
|
||||
❯ git status
|
||||
On branch main
|
||||
Your branch is ahead of 'origin/main' by 1 commit.
|
||||
(use "git push" to publish your local commits)
|
||||
|
||||
Changes not staged for commit:
|
||||
(use "git add <file>..." to update what will be committed)
|
||||
(use "git restore <file>..." to discard changes in working directory)
|
||||
modified: src/canvas/actions/handlers/edit.rs
|
||||
modified: src/canvas/actions/types.rs
|
||||
|
||||
no changes added to commit (use "git add" and/or "git commit -a")
|
||||
❯ git --no-pager diff
|
||||
diff --git a/canvas/src/canvas/actions/handlers/edit.rs b/canvas/src/canvas/actions/handlers/edit.rs
|
||||
index a26fe6f..fa1becb 100644
|
||||
--- a/canvas/src/canvas/actions/handlers/edit.rs
|
||||
+++ b/canvas/src/canvas/actions/handlers/edit.rs
|
||||
@@ -29,6 +29,21 @@ pub async fn handle_edit_action<S: CanvasState>(
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
+ CanvasAction::SelectAll => {
|
||||
+ // Select all text in current field
|
||||
+ let current_input = state.get_current_input();
|
||||
+ let text_length = current_input.len();
|
||||
+
|
||||
+ // Set cursor to start and select all
|
||||
+ state.set_current_cursor_pos(0);
|
||||
+ // TODO: You'd need to add selection state to CanvasState trait
|
||||
+ // For now, just move cursor to end to "select" all
|
||||
+ state.set_current_cursor_pos(text_length);
|
||||
+ *ideal_cursor_column = text_length;
|
||||
+
|
||||
+ Ok(ActionResult::success_with_message(&format!("Selected all {} characters", text_length)))
|
||||
+ }
|
||||
+
|
||||
CanvasAction::DeleteBackward => {
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
if cursor_pos > 0 {
|
||||
@@ -323,6 +338,13 @@ impl ActionHandlerIntrospection for EditHandler {
|
||||
is_required: false,
|
||||
});
|
||||
|
||||
+ actions.push(ActionSpec {
|
||||
+ name: "select_all".to_string(),
|
||||
+ description: "Select all text in current field".to_string(),
|
||||
+ examples: vec!["Ctrl+a".to_string()],
|
||||
+ is_required: false, // Optional action
|
||||
+ });
|
||||
+
|
||||
HandlerCapabilities {
|
||||
mode_name: "edit".to_string(),
|
||||
actions,
|
||||
diff --git a/canvas/src/canvas/actions/types.rs b/canvas/src/canvas/actions/types.rs
|
||||
index 433a4d5..3794596 100644
|
||||
--- a/canvas/src/canvas/actions/types.rs
|
||||
+++ b/canvas/src/canvas/actions/types.rs
|
||||
@@ -31,6 +31,8 @@ pub enum CanvasAction {
|
||||
NextField,
|
||||
PrevField,
|
||||
|
||||
+ SelectAll,
|
||||
+
|
||||
// Autocomplete actions
|
||||
TriggerAutocomplete,
|
||||
SuggestionUp,
|
||||
@@ -62,6 +64,7 @@ impl CanvasAction {
|
||||
"move_word_end_prev" => Self::MoveWordEndPrev,
|
||||
"next_field" => Self::NextField,
|
||||
"prev_field" => Self::PrevField,
|
||||
+ "select_all" => Self::SelectAll,
|
||||
"trigger_autocomplete" => Self::TriggerAutocomplete,
|
||||
"suggestion_up" => Self::SuggestionUp,
|
||||
"suggestion_down" => Self::SuggestionDown,
|
||||
╭─ ~/Doc/p/komp_ac/canvas on main ⇡1 !2
|
||||
╰─
|
||||
|
||||
367
canvas/examples/canvas_gui_demo.rs
Normal file
367
canvas/examples/canvas_gui_demo.rs
Normal file
@@ -0,0 +1,367 @@
|
||||
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(())
|
||||
}
|
||||
21
canvas/examples/generate_template.rs
Normal file
21
canvas/examples/generate_template.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
// 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
|
||||
620
canvas/integration_patterns.rs
Normal file
620
canvas/integration_patterns.rs
Normal file
@@ -0,0 +1,620 @@
|
||||
// examples/integration_patterns.rs
|
||||
//! Advanced integration patterns showing how Canvas works with:
|
||||
//! - State management patterns
|
||||
//! - Event-driven architectures
|
||||
//! - Validation systems
|
||||
//! - Custom rendering
|
||||
//!
|
||||
//! Run with: cargo run --example integration_patterns
|
||||
|
||||
use canvas::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
println!("🔧 Canvas Integration Patterns");
|
||||
println!("==============================\n");
|
||||
|
||||
// Pattern 1: State machine integration
|
||||
state_machine_example().await;
|
||||
|
||||
// Pattern 2: Event-driven architecture
|
||||
event_driven_example().await;
|
||||
|
||||
// Pattern 3: Validation pipeline
|
||||
validation_pipeline_example().await;
|
||||
|
||||
// Pattern 4: Multi-form orchestration
|
||||
multi_form_example().await;
|
||||
}
|
||||
|
||||
// Pattern 1: Canvas with state machine
|
||||
async fn state_machine_example() {
|
||||
println!("🔄 Pattern 1: State Machine Integration");
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum FormState {
|
||||
Initial,
|
||||
Editing,
|
||||
Validating,
|
||||
Submitting,
|
||||
Success,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct StateMachineForm {
|
||||
// Canvas state
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
username: String,
|
||||
password: String,
|
||||
has_changes: bool,
|
||||
|
||||
// State machine
|
||||
state: FormState,
|
||||
}
|
||||
|
||||
impl StateMachineForm {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
current_field: 0,
|
||||
cursor_pos: 0,
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
has_changes: false,
|
||||
state: FormState::Initial,
|
||||
}
|
||||
}
|
||||
|
||||
fn transition_to(&mut self, new_state: FormState) -> String {
|
||||
let old_state = self.state.clone();
|
||||
self.state = new_state;
|
||||
format!("State transition: {:?} -> {:?}", old_state, self.state)
|
||||
}
|
||||
|
||||
fn can_submit(&self) -> bool {
|
||||
matches!(self.state, FormState::Editing) &&
|
||||
!self.username.trim().is_empty() &&
|
||||
!self.password.trim().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasState for StateMachineForm {
|
||||
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(1); }
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
|
||||
|
||||
fn get_current_input(&self) -> &str {
|
||||
match self.current_field {
|
||||
0 => &self.username,
|
||||
1 => &self.password,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_input_mut(&mut self) -> &mut String {
|
||||
match self.current_field {
|
||||
0 => &mut self.username,
|
||||
1 => &mut self.password,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> { vec![&self.username, &self.password] }
|
||||
fn fields(&self) -> Vec<&str> { vec!["Username", "Password"] }
|
||||
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
||||
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
||||
self.has_changes = changed;
|
||||
// Transition to editing state when user starts typing
|
||||
if changed && self.state == FormState::Initial {
|
||||
self.state = FormState::Editing;
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::Custom(cmd) => match cmd.as_str() {
|
||||
"submit" => {
|
||||
if self.can_submit() {
|
||||
let msg = self.transition_to(FormState::Submitting);
|
||||
// Simulate submission
|
||||
self.state = FormState::Success;
|
||||
Some(format!("{} -> Form submitted successfully", msg))
|
||||
} else {
|
||||
let msg = self.transition_to(FormState::Error("Invalid form data".to_string()));
|
||||
Some(msg)
|
||||
}
|
||||
}
|
||||
"reset" => {
|
||||
self.username.clear();
|
||||
self.password.clear();
|
||||
self.has_changes = false;
|
||||
Some(self.transition_to(FormState::Initial))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut form = StateMachineForm::new();
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
println!(" Initial state: {:?}", form.state);
|
||||
|
||||
// Type some text to trigger state change
|
||||
let _result = ActionDispatcher::dispatch(
|
||||
CanvasAction::InsertChar('u'),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
println!(" After typing: {:?}", form.state);
|
||||
|
||||
// Try to submit (should fail)
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::Custom("submit".to_string()),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
println!(" Submit result: {}", result.message().unwrap_or(""));
|
||||
println!(" ✅ State machine integration works!\n");
|
||||
}
|
||||
|
||||
// Pattern 2: Event-driven architecture
|
||||
async fn event_driven_example() {
|
||||
println!("📡 Pattern 2: Event-Driven Architecture");
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum FormEvent {
|
||||
FieldChanged { field: usize, old_value: String, new_value: String },
|
||||
ValidationTriggered { field: usize, is_valid: bool },
|
||||
ActionExecuted { action: String, success: bool },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct EventDrivenForm {
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
email: String,
|
||||
has_changes: bool,
|
||||
events: Vec<FormEvent>,
|
||||
}
|
||||
|
||||
impl EventDrivenForm {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
current_field: 0,
|
||||
cursor_pos: 0,
|
||||
email: String::new(),
|
||||
has_changes: false,
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_event(&mut self, event: FormEvent) {
|
||||
println!(" 📡 Event: {:?}", event);
|
||||
self.events.push(event);
|
||||
}
|
||||
|
||||
fn validate_email(&self) -> bool {
|
||||
self.email.contains('@') && self.email.contains('.')
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasState for EventDrivenForm {
|
||||
fn current_field(&self) -> usize { self.current_field }
|
||||
fn current_cursor_pos(&self) -> usize { self.cursor_pos }
|
||||
fn set_current_field(&mut self, index: usize) { self.current_field = index; }
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
|
||||
|
||||
fn get_current_input(&self) -> &str { &self.email }
|
||||
fn get_current_input_mut(&mut self) -> &mut String { &mut self.email }
|
||||
fn inputs(&self) -> Vec<&String> { vec![&self.email] }
|
||||
fn fields(&self) -> Vec<&str> { vec!["Email"] }
|
||||
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
||||
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
||||
if changed != self.has_changes {
|
||||
let old_value = if self.has_changes { "modified" } else { "unmodified" };
|
||||
let new_value = if changed { "modified" } else { "unmodified" };
|
||||
|
||||
self.emit_event(FormEvent::FieldChanged {
|
||||
field: self.current_field,
|
||||
old_value: old_value.to_string(),
|
||||
new_value: new_value.to_string(),
|
||||
});
|
||||
}
|
||||
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() {
|
||||
"validate" => {
|
||||
let is_valid = self.validate_email();
|
||||
self.emit_event(FormEvent::ValidationTriggered {
|
||||
field: self.current_field,
|
||||
is_valid,
|
||||
});
|
||||
|
||||
self.emit_event(FormEvent::ActionExecuted {
|
||||
action: "validate".to_string(),
|
||||
success: true,
|
||||
});
|
||||
|
||||
if is_valid {
|
||||
Some("Email is valid!".to_string())
|
||||
} else {
|
||||
Some("Email is invalid".to_string())
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut form = EventDrivenForm::new();
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
// Type an email address
|
||||
let email = "user@example.com";
|
||||
for c in email.chars() {
|
||||
ActionDispatcher::dispatch(
|
||||
CanvasAction::InsertChar(c),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
}
|
||||
|
||||
// Validate the email
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::Custom("validate".to_string()),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
|
||||
println!(" Final email: {}", form.email);
|
||||
println!(" Validation result: {}", result.message().unwrap_or(""));
|
||||
println!(" Total events captured: {}", form.events.len());
|
||||
println!(" ✅ Event-driven architecture works!\n");
|
||||
}
|
||||
|
||||
// Pattern 3: Validation pipeline
|
||||
async fn validation_pipeline_example() {
|
||||
println!("✅ Pattern 3: Validation Pipeline");
|
||||
|
||||
type ValidationRule = Box<dyn Fn(&str) -> Result<(), String>>;
|
||||
|
||||
// Custom Debug implementation since function pointers don't implement Debug
|
||||
struct ValidatedForm {
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
password: String,
|
||||
has_changes: bool,
|
||||
validators: HashMap<usize, Vec<ValidationRule>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ValidatedForm {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ValidatedForm")
|
||||
.field("current_field", &self.current_field)
|
||||
.field("cursor_pos", &self.cursor_pos)
|
||||
.field("password", &self.password)
|
||||
.field("has_changes", &self.has_changes)
|
||||
.field("validators", &format!("HashMap with {} entries", self.validators.len()))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl ValidatedForm {
|
||||
fn new() -> Self {
|
||||
let mut validators: HashMap<usize, Vec<ValidationRule>> = HashMap::new();
|
||||
|
||||
// Password validators
|
||||
let mut password_validators: Vec<ValidationRule> = Vec::new();
|
||||
password_validators.push(Box::new(|value| {
|
||||
if value.len() < 8 {
|
||||
Err("Password must be at least 8 characters".to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}));
|
||||
password_validators.push(Box::new(|value| {
|
||||
if !value.chars().any(|c| c.is_uppercase()) {
|
||||
Err("Password must contain at least one uppercase letter".to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}));
|
||||
password_validators.push(Box::new(|value| {
|
||||
if !value.chars().any(|c| c.is_numeric()) {
|
||||
Err("Password must contain at least one number".to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}));
|
||||
|
||||
validators.insert(0, password_validators);
|
||||
|
||||
Self {
|
||||
current_field: 0,
|
||||
cursor_pos: 0,
|
||||
password: String::new(),
|
||||
has_changes: false,
|
||||
validators,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_field(&self, field_index: usize) -> Vec<String> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if let Some(validators) = self.validators.get(&field_index) {
|
||||
let value = match field_index {
|
||||
0 => &self.password,
|
||||
_ => return errors,
|
||||
};
|
||||
|
||||
for validator in validators {
|
||||
if let Err(error) = validator(value) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
errors
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasState for ValidatedForm {
|
||||
fn current_field(&self) -> usize { self.current_field }
|
||||
fn current_cursor_pos(&self) -> usize { self.cursor_pos }
|
||||
fn set_current_field(&mut self, index: usize) { self.current_field = index; }
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
|
||||
|
||||
fn get_current_input(&self) -> &str { &self.password }
|
||||
fn get_current_input_mut(&mut self) -> &mut String { &mut self.password }
|
||||
fn inputs(&self) -> Vec<&String> { vec![&self.password] }
|
||||
fn fields(&self) -> Vec<&str> { vec!["Password"] }
|
||||
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() {
|
||||
"validate" => {
|
||||
let errors = self.validate_field(self.current_field);
|
||||
if errors.is_empty() {
|
||||
Some("Password meets all requirements!".to_string())
|
||||
} else {
|
||||
Some(format!("Validation errors: {}", errors.join(", ")))
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut form = ValidatedForm::new();
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
// Test with weak password
|
||||
let weak_password = "abc";
|
||||
for c in weak_password.chars() {
|
||||
ActionDispatcher::dispatch(
|
||||
CanvasAction::InsertChar(c),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
}
|
||||
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::Custom("validate".to_string()),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
println!(" Weak password '{}': {}", form.password, result.message().unwrap_or(""));
|
||||
|
||||
// Clear and test with strong password
|
||||
form.password.clear();
|
||||
form.cursor_pos = 0;
|
||||
|
||||
let strong_password = "StrongPass123";
|
||||
for c in strong_password.chars() {
|
||||
ActionDispatcher::dispatch(
|
||||
CanvasAction::InsertChar(c),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
}
|
||||
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::Custom("validate".to_string()),
|
||||
&mut form,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
println!(" Strong password '{}': {}", form.password, result.message().unwrap_or(""));
|
||||
println!(" ✅ Validation pipeline works!\n");
|
||||
}
|
||||
|
||||
// Pattern 4: Multi-form orchestration
|
||||
async fn multi_form_example() {
|
||||
println!("🎭 Pattern 4: Multi-Form Orchestration");
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PersonalInfoForm {
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
name: String,
|
||||
age: String,
|
||||
has_changes: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ContactInfoForm {
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
email: String,
|
||||
phone: String,
|
||||
has_changes: bool,
|
||||
}
|
||||
|
||||
// Implement CanvasState for both forms
|
||||
impl CanvasState for PersonalInfoForm {
|
||||
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(1); }
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
|
||||
|
||||
fn get_current_input(&self) -> &str {
|
||||
match self.current_field {
|
||||
0 => &self.name,
|
||||
1 => &self.age,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_input_mut(&mut self) -> &mut String {
|
||||
match self.current_field {
|
||||
0 => &mut self.name,
|
||||
1 => &mut self.age,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> { vec![&self.name, &self.age] }
|
||||
fn fields(&self) -> Vec<&str> { vec!["Name", "Age"] }
|
||||
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
|
||||
}
|
||||
|
||||
impl CanvasState for ContactInfoForm {
|
||||
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(1); }
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
|
||||
|
||||
fn get_current_input(&self) -> &str {
|
||||
match self.current_field {
|
||||
0 => &self.email,
|
||||
1 => &self.phone,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_input_mut(&mut self) -> &mut String {
|
||||
match self.current_field {
|
||||
0 => &mut self.email,
|
||||
1 => &mut self.phone,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> { vec![&self.email, &self.phone] }
|
||||
fn fields(&self) -> Vec<&str> { vec!["Email", "Phone"] }
|
||||
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
|
||||
}
|
||||
|
||||
// Form orchestrator
|
||||
#[derive(Debug)]
|
||||
struct FormOrchestrator {
|
||||
personal_form: PersonalInfoForm,
|
||||
contact_form: ContactInfoForm,
|
||||
current_form: usize, // 0 = personal, 1 = contact
|
||||
}
|
||||
|
||||
impl FormOrchestrator {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
personal_form: PersonalInfoForm {
|
||||
current_field: 0,
|
||||
cursor_pos: 0,
|
||||
name: String::new(),
|
||||
age: String::new(),
|
||||
has_changes: false,
|
||||
},
|
||||
contact_form: ContactInfoForm {
|
||||
current_field: 0,
|
||||
cursor_pos: 0,
|
||||
email: String::new(),
|
||||
phone: String::new(),
|
||||
has_changes: false,
|
||||
},
|
||||
current_form: 0,
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_action(&mut self, action: CanvasAction) -> ActionResult {
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
match self.current_form {
|
||||
0 => ActionDispatcher::dispatch(action, &mut self.personal_form, &mut ideal_cursor).await.unwrap(),
|
||||
1 => ActionDispatcher::dispatch(action, &mut self.contact_form, &mut ideal_cursor).await.unwrap(),
|
||||
_ => ActionResult::error("Invalid form index"),
|
||||
}
|
||||
}
|
||||
|
||||
fn switch_form(&mut self) -> String {
|
||||
self.current_form = (self.current_form + 1) % 2;
|
||||
match self.current_form {
|
||||
0 => "Switched to Personal Info form".to_string(),
|
||||
1 => "Switched to Contact Info form".to_string(),
|
||||
_ => "Unknown form".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_form_name(&self) -> &str {
|
||||
match self.current_form {
|
||||
0 => "Personal Info",
|
||||
1 => "Contact Info",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut orchestrator = FormOrchestrator::new();
|
||||
|
||||
println!(" Current form: {}", orchestrator.current_form_name());
|
||||
|
||||
// Fill personal info
|
||||
for &c in &['J', 'o', 'h', 'n'] {
|
||||
orchestrator.execute_action(CanvasAction::InsertChar(c)).await;
|
||||
}
|
||||
|
||||
orchestrator.execute_action(CanvasAction::NextField).await;
|
||||
|
||||
for &c in &['2', '5'] {
|
||||
orchestrator.execute_action(CanvasAction::InsertChar(c)).await;
|
||||
}
|
||||
|
||||
println!(" Personal form - Name: '{}', Age: '{}'",
|
||||
orchestrator.personal_form.name,
|
||||
orchestrator.personal_form.age);
|
||||
|
||||
// Switch to contact form
|
||||
let switch_msg = orchestrator.switch_form();
|
||||
println!(" {}", switch_msg);
|
||||
|
||||
// Fill contact info
|
||||
for &c in &['j', 'o', 'h', 'n', '@', 'e', 'x', 'a', 'm', 'p', 'l', 'e', '.', 'c', 'o', 'm'] {
|
||||
orchestrator.execute_action(CanvasAction::InsertChar(c)).await;
|
||||
}
|
||||
|
||||
orchestrator.execute_action(CanvasAction::NextField).await;
|
||||
|
||||
for &c in &['5', '5', '5', '-', '1', '2', '3', '4'] {
|
||||
orchestrator.execute_action(CanvasAction::InsertChar(c)).await;
|
||||
}
|
||||
|
||||
println!(" Contact form - Email: '{}', Phone: '{}'",
|
||||
orchestrator.contact_form.email,
|
||||
orchestrator.contact_form.phone);
|
||||
}
|
||||
128
canvas/src/autocomplete/actions.rs
Normal file
128
canvas/src/autocomplete/actions.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
191
canvas/src/autocomplete/gui.rs
Normal file
191
canvas/src/autocomplete/gui.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
// canvas/src/autocomplete/gui.rs
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use ratatui::{
|
||||
layout::{Alignment, Rect},
|
||||
style::{Modifier, Style},
|
||||
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::autocomplete::types::AutocompleteState;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use crate::canvas::theme::CanvasTheme;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// Render autocomplete dropdown - call this AFTER rendering canvas
|
||||
#[cfg(feature = "gui")]
|
||||
pub fn render_autocomplete_dropdown<T: CanvasTheme>(
|
||||
f: &mut Frame,
|
||||
frame_area: Rect,
|
||||
input_rect: Rect,
|
||||
theme: &T,
|
||||
autocomplete_state: &AutocompleteState<impl Clone + Send + 'static>,
|
||||
) {
|
||||
if !autocomplete_state.is_active {
|
||||
return;
|
||||
}
|
||||
|
||||
if autocomplete_state.is_loading {
|
||||
render_loading_indicator(f, frame_area, input_rect, theme);
|
||||
} else if !autocomplete_state.suggestions.is_empty() {
|
||||
render_suggestions_dropdown(f, frame_area, input_rect, theme, autocomplete_state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show loading spinner/text
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_loading_indicator<T: CanvasTheme>(
|
||||
f: &mut Frame,
|
||||
frame_area: Rect,
|
||||
input_rect: Rect,
|
||||
theme: &T,
|
||||
) {
|
||||
let loading_text = "Loading suggestions...";
|
||||
let loading_width = loading_text.width() as u16 + 4; // +4 for borders and padding
|
||||
let loading_height = 3;
|
||||
|
||||
let dropdown_area = calculate_dropdown_position(
|
||||
input_rect,
|
||||
frame_area,
|
||||
loading_width,
|
||||
loading_height,
|
||||
);
|
||||
|
||||
let loading_block = Block::default()
|
||||
.style(Style::default().bg(theme.bg()));
|
||||
|
||||
let loading_paragraph = Paragraph::new(loading_text)
|
||||
.block(loading_block)
|
||||
.style(Style::default().fg(theme.fg()))
|
||||
.alignment(Alignment::Center);
|
||||
|
||||
f.render_widget(loading_paragraph, dropdown_area);
|
||||
}
|
||||
|
||||
/// Show actual suggestions list
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_suggestions_dropdown<T: CanvasTheme>(
|
||||
f: &mut Frame,
|
||||
frame_area: Rect,
|
||||
input_rect: Rect,
|
||||
theme: &T,
|
||||
autocomplete_state: &AutocompleteState<impl Clone + Send + 'static>,
|
||||
) {
|
||||
let display_texts: Vec<&str> = autocomplete_state.suggestions
|
||||
.iter()
|
||||
.map(|item| item.display_text.as_str())
|
||||
.collect();
|
||||
|
||||
let dropdown_dimensions = calculate_dropdown_dimensions(&display_texts);
|
||||
let dropdown_area = calculate_dropdown_position(
|
||||
input_rect,
|
||||
frame_area,
|
||||
dropdown_dimensions.width,
|
||||
dropdown_dimensions.height,
|
||||
);
|
||||
|
||||
// Background
|
||||
let dropdown_block = Block::default()
|
||||
.style(Style::default().bg(theme.bg()));
|
||||
|
||||
// List items
|
||||
let items = create_suggestion_list_items(
|
||||
&display_texts,
|
||||
autocomplete_state.selected_index,
|
||||
dropdown_dimensions.width,
|
||||
theme,
|
||||
);
|
||||
|
||||
let list = List::new(items).block(dropdown_block);
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(autocomplete_state.selected_index);
|
||||
|
||||
f.render_stateful_widget(list, dropdown_area, &mut list_state);
|
||||
}
|
||||
|
||||
/// Calculate dropdown size based on suggestions - updated to match client dimensions
|
||||
#[cfg(feature = "gui")]
|
||||
fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions {
|
||||
let max_width = display_texts
|
||||
.iter()
|
||||
.map(|text| text.width())
|
||||
.max()
|
||||
.unwrap_or(0) as u16;
|
||||
|
||||
let horizontal_padding = 2; // Changed from 4 to 2 to match client
|
||||
let width = (max_width + horizontal_padding).max(10); // Changed from 12 to 10 to match client
|
||||
let height = (display_texts.len() as u16).min(5); // Removed +2 since no borders
|
||||
|
||||
DropdownDimensions { width, height }
|
||||
}
|
||||
|
||||
/// Position dropdown to stay in bounds
|
||||
#[cfg(feature = "gui")]
|
||||
fn calculate_dropdown_position(
|
||||
input_rect: Rect,
|
||||
frame_area: Rect,
|
||||
dropdown_width: u16,
|
||||
dropdown_height: u16,
|
||||
) -> Rect {
|
||||
let mut dropdown_area = Rect {
|
||||
x: input_rect.x,
|
||||
y: input_rect.y + 1, // below input field
|
||||
width: dropdown_width,
|
||||
height: dropdown_height,
|
||||
};
|
||||
|
||||
// Keep in bounds
|
||||
if dropdown_area.bottom() > frame_area.height {
|
||||
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height);
|
||||
}
|
||||
if dropdown_area.right() > frame_area.width {
|
||||
dropdown_area.x = frame_area.width.saturating_sub(dropdown_width);
|
||||
}
|
||||
dropdown_area.x = dropdown_area.x.max(0);
|
||||
dropdown_area.y = dropdown_area.y.max(0);
|
||||
|
||||
dropdown_area
|
||||
}
|
||||
|
||||
/// Create styled list items - updated to match client spacing
|
||||
#[cfg(feature = "gui")]
|
||||
fn create_suggestion_list_items<'a, T: CanvasTheme>(
|
||||
display_texts: &'a [&'a str],
|
||||
selected_index: Option<usize>,
|
||||
dropdown_width: u16,
|
||||
theme: &T,
|
||||
) -> Vec<ListItem<'a>> {
|
||||
let horizontal_padding = 2; // Changed from 4 to 2 to match client
|
||||
let available_width = dropdown_width; // No border padding needed
|
||||
|
||||
display_texts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, text)| {
|
||||
let is_selected = selected_index == Some(i);
|
||||
let text_width = text.width() as u16;
|
||||
let padding_needed = available_width.saturating_sub(text_width);
|
||||
let padded_text = format!("{}{}", text, " ".repeat(padding_needed as usize));
|
||||
|
||||
ListItem::new(padded_text).style(if is_selected {
|
||||
Style::default()
|
||||
.fg(theme.bg())
|
||||
.bg(theme.highlight())
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.fg()).bg(theme.bg())
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Helper struct for dropdown dimensions
|
||||
#[cfg(feature = "gui")]
|
||||
struct DropdownDimensions {
|
||||
width: u16,
|
||||
height: u16,
|
||||
}
|
||||
10
canvas/src/autocomplete/mod.rs
Normal file
10
canvas/src/autocomplete/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
// src/autocomplete/mod.rs
|
||||
pub mod types;
|
||||
pub mod gui;
|
||||
pub mod state;
|
||||
pub mod actions;
|
||||
|
||||
// Re-export autocomplete types
|
||||
pub use types::{SuggestionItem, AutocompleteState};
|
||||
pub use state::AutocompleteCanvasState;
|
||||
pub use actions::execute_canvas_action_with_autocomplete;
|
||||
96
canvas/src/autocomplete/state.rs
Normal file
96
canvas/src/autocomplete/state.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
// canvas/src/state.rs
|
||||
|
||||
use crate::canvas::state::CanvasState;
|
||||
|
||||
/// OPTIONAL extension trait for states that want rich autocomplete functionality.
|
||||
/// Only implement this if you need the new autocomplete features.
|
||||
pub trait AutocompleteCanvasState: CanvasState {
|
||||
/// Associated type for suggestion data (e.g., Hit, String, CustomType)
|
||||
type SuggestionData: Clone + Send + 'static;
|
||||
|
||||
/// Check if a field supports autocomplete
|
||||
fn supports_autocomplete(&self, _field_index: usize) -> bool {
|
||||
false // Default: no autocomplete support
|
||||
}
|
||||
|
||||
/// Get autocomplete state (read-only)
|
||||
fn autocomplete_state(&self) -> Option<&crate::autocomplete::AutocompleteState<Self::SuggestionData>> {
|
||||
None // Default: no autocomplete state
|
||||
}
|
||||
|
||||
/// Get autocomplete state (mutable)
|
||||
fn autocomplete_state_mut(&mut self) -> Option<&mut crate::autocomplete::AutocompleteState<Self::SuggestionData>> {
|
||||
None // Default: no autocomplete state
|
||||
}
|
||||
|
||||
/// CLIENT API: Activate autocomplete for current field
|
||||
fn activate_autocomplete(&mut self) {
|
||||
let current_field = self.current_field(); // Get field first
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.activate(current_field); // Then use it
|
||||
}
|
||||
}
|
||||
|
||||
/// CLIENT API: Deactivate autocomplete
|
||||
fn deactivate_autocomplete(&mut self) {
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.deactivate();
|
||||
}
|
||||
}
|
||||
|
||||
/// CLIENT API: Set suggestions (called after async fetch completes)
|
||||
fn set_autocomplete_suggestions(&mut self, suggestions: Vec<crate::autocomplete::SuggestionItem<Self::SuggestionData>>) {
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.set_suggestions(suggestions);
|
||||
}
|
||||
}
|
||||
|
||||
/// CLIENT API: Set loading state
|
||||
fn set_autocomplete_loading(&mut self, loading: bool) {
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.is_loading = loading;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if autocomplete is currently active
|
||||
fn is_autocomplete_active(&self) -> bool {
|
||||
self.autocomplete_state()
|
||||
.map(|state| state.is_active)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check if autocomplete is ready for interaction
|
||||
fn is_autocomplete_ready(&self) -> bool {
|
||||
self.autocomplete_state()
|
||||
.map(|state| state.is_ready())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// INTERNAL: Apply selected autocomplete value to current field
|
||||
fn apply_autocomplete_selection(&mut self) -> Option<String> {
|
||||
// First, get the selected value and display text (if any)
|
||||
let selection_info = if let Some(state) = self.autocomplete_state() {
|
||||
state.get_selected().map(|selected| {
|
||||
(selected.value_to_store.clone(), selected.display_text.clone())
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Apply the selection if we have one
|
||||
if let Some((value, display)) = selection_info {
|
||||
// Apply the value to current field
|
||||
*self.get_current_input_mut() = value;
|
||||
self.set_has_unsaved_changes(true);
|
||||
|
||||
// Deactivate autocomplete
|
||||
if let Some(state_mut) = self.autocomplete_state_mut() {
|
||||
state_mut.deactivate();
|
||||
}
|
||||
|
||||
Some(format!("Selected: {}", display))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
126
canvas/src/autocomplete/types.rs
Normal file
126
canvas/src/autocomplete/types.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
376
canvas/src/canvas/actions/handlers/edit.rs
Normal file
376
canvas/src/canvas/actions/handlers/edit.rs
Normal file
@@ -0,0 +1,376 @@
|
||||
// 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(())
|
||||
}
|
||||
}
|
||||
205
canvas/src/canvas/actions/handlers/highlight.rs
Normal file
205
canvas/src/canvas/actions/handlers/highlight.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
// 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(())
|
||||
}
|
||||
}
|
||||
|
||||
10
canvas/src/canvas/actions/handlers/mod.rs
Normal file
10
canvas/src/canvas/actions/handlers/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
// 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;
|
||||
322
canvas/src/canvas/actions/handlers/readonly.rs
Normal file
322
canvas/src/canvas/actions/handlers/readonly.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
// 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(())
|
||||
}
|
||||
}
|
||||
8
canvas/src/canvas/actions/mod.rs
Normal file
8
canvas/src/canvas/actions/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
// src/canvas/actions/mod.rs
|
||||
|
||||
pub mod types;
|
||||
pub mod movement;
|
||||
pub mod handlers;
|
||||
|
||||
// Re-export the main types
|
||||
pub use types::{CanvasAction, ActionResult};
|
||||
49
canvas/src/canvas/actions/movement/char.rs
Normal file
49
canvas/src/canvas/actions/movement/char.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
// src/canvas/actions/movement/char.rs
|
||||
|
||||
/// Calculate new position when moving left
|
||||
pub fn move_left(current_pos: usize) -> usize {
|
||||
current_pos.saturating_sub(1)
|
||||
}
|
||||
|
||||
/// Calculate new position when moving right
|
||||
pub fn move_right(current_pos: usize, text: &str, for_edit_mode: bool) -> usize {
|
||||
if text.is_empty() {
|
||||
return current_pos;
|
||||
}
|
||||
|
||||
if for_edit_mode {
|
||||
// Edit mode: can move past end of text
|
||||
(current_pos + 1).min(text.len())
|
||||
} else {
|
||||
// Read-only/highlight mode: stays within text bounds
|
||||
if current_pos < text.len().saturating_sub(1) {
|
||||
current_pos + 1
|
||||
} else {
|
||||
current_pos
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if cursor position is valid for the given mode
|
||||
pub fn is_valid_cursor_position(pos: usize, text: &str, for_edit_mode: bool) -> bool {
|
||||
if text.is_empty() {
|
||||
return pos == 0;
|
||||
}
|
||||
|
||||
if for_edit_mode {
|
||||
pos <= text.len()
|
||||
} else {
|
||||
pos < text.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp cursor position to valid bounds for the given mode
|
||||
pub fn clamp_cursor_position(pos: usize, text: &str, for_edit_mode: bool) -> usize {
|
||||
if text.is_empty() {
|
||||
0
|
||||
} else if for_edit_mode {
|
||||
pos.min(text.len())
|
||||
} else {
|
||||
pos.min(text.len().saturating_sub(1))
|
||||
}
|
||||
}
|
||||
32
canvas/src/canvas/actions/movement/line.rs
Normal file
32
canvas/src/canvas/actions/movement/line.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
// src/canvas/actions/movement/line.rs
|
||||
|
||||
/// Calculate cursor position for line start
|
||||
pub fn line_start_position() -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
/// Calculate cursor position for line end
|
||||
pub fn line_end_position(text: &str, for_edit_mode: bool) -> usize {
|
||||
if text.is_empty() {
|
||||
0
|
||||
} else if for_edit_mode {
|
||||
// Edit mode: cursor can go past end of text
|
||||
text.len()
|
||||
} else {
|
||||
// Read-only/highlight mode: cursor stays on last character
|
||||
text.len().saturating_sub(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate safe cursor position when switching fields
|
||||
pub fn safe_cursor_position(text: &str, ideal_column: usize, for_edit_mode: bool) -> usize {
|
||||
if text.is_empty() {
|
||||
0
|
||||
} else if for_edit_mode {
|
||||
// Edit mode: cursor can go past end
|
||||
ideal_column.min(text.len())
|
||||
} else {
|
||||
// Read-only/highlight mode: cursor stays within text
|
||||
ideal_column.min(text.len().saturating_sub(1))
|
||||
}
|
||||
}
|
||||
10
canvas/src/canvas/actions/movement/mod.rs
Normal file
10
canvas/src/canvas/actions/movement/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
// src/canvas/actions/movement/mod.rs
|
||||
|
||||
pub mod word;
|
||||
pub mod line;
|
||||
pub mod char;
|
||||
|
||||
// Re-export commonly used functions
|
||||
pub use word::{find_next_word_start, find_word_end, find_prev_word_start, find_prev_word_end};
|
||||
pub use line::{line_start_position, line_end_position, safe_cursor_position};
|
||||
pub use char::{move_left, move_right, is_valid_cursor_position, clamp_cursor_position};
|
||||
146
canvas/src/canvas/actions/movement/word.rs
Normal file
146
canvas/src/canvas/actions/movement/word.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
// src/canvas/actions/movement/word.rs
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum CharType {
|
||||
Whitespace,
|
||||
Alphanumeric,
|
||||
Punctuation,
|
||||
}
|
||||
|
||||
fn get_char_type(c: char) -> CharType {
|
||||
if c.is_whitespace() {
|
||||
CharType::Whitespace
|
||||
} else if c.is_alphanumeric() {
|
||||
CharType::Alphanumeric
|
||||
} else {
|
||||
CharType::Punctuation
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the start of the next word from the current position
|
||||
pub fn find_next_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let current_pos = current_pos.min(chars.len());
|
||||
|
||||
if current_pos == chars.len() {
|
||||
return current_pos;
|
||||
}
|
||||
|
||||
let mut pos = current_pos;
|
||||
let initial_type = get_char_type(chars[pos]);
|
||||
|
||||
// Skip current word/token
|
||||
while pos < chars.len() && get_char_type(chars[pos]) == initial_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
// Skip whitespace
|
||||
while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
/// Find the end of the current or next word
|
||||
pub fn find_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
if len == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.min(len - 1);
|
||||
let current_type = get_char_type(chars[pos]);
|
||||
|
||||
// If we're not on whitespace, move to end of current word
|
||||
if current_type != CharType::Whitespace {
|
||||
while pos < len && get_char_type(chars[pos]) == current_type {
|
||||
pos += 1;
|
||||
}
|
||||
return pos.saturating_sub(1);
|
||||
}
|
||||
|
||||
// If we're on whitespace, find next word and go to its end
|
||||
pos = find_next_word_start(text, pos);
|
||||
if pos >= len {
|
||||
return len.saturating_sub(1);
|
||||
}
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos < len && get_char_type(chars[pos]) == word_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos.saturating_sub(1).min(len.saturating_sub(1))
|
||||
}
|
||||
|
||||
/// Find the start of the previous word
|
||||
pub fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
|
||||
// Skip whitespace backwards
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
// Move to start of word
|
||||
if get_char_type(chars[pos]) != CharType::Whitespace {
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
|
||||
0
|
||||
} else {
|
||||
pos
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the end of the previous word
|
||||
pub fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
|
||||
// Skip whitespace backwards
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
|
||||
return 0;
|
||||
}
|
||||
if pos == 0 && get_char_type(chars[0]) != CharType::Whitespace {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
// Skip whitespace before this word
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos > 0 {
|
||||
pos - 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
108
canvas/src/canvas/actions/types.rs
Normal file
108
canvas/src/canvas/actions/types.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
// src/canvas/actions/types.rs
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum CanvasAction {
|
||||
// Character input
|
||||
InsertChar(char),
|
||||
|
||||
// Deletion
|
||||
DeleteBackward,
|
||||
DeleteForward,
|
||||
|
||||
// Basic cursor movement
|
||||
MoveLeft,
|
||||
MoveRight,
|
||||
MoveUp,
|
||||
MoveDown,
|
||||
|
||||
// Line movement
|
||||
MoveLineStart,
|
||||
MoveLineEnd,
|
||||
MoveFirstLine,
|
||||
MoveLastLine,
|
||||
|
||||
// Word movement
|
||||
MoveWordNext,
|
||||
MoveWordEnd,
|
||||
MoveWordPrev,
|
||||
MoveWordEndPrev,
|
||||
|
||||
// Field navigation
|
||||
NextField,
|
||||
PrevField,
|
||||
|
||||
// Autocomplete actions
|
||||
TriggerAutocomplete,
|
||||
SuggestionUp,
|
||||
SuggestionDown,
|
||||
SelectSuggestion,
|
||||
ExitSuggestions,
|
||||
|
||||
// Custom actions
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl CanvasAction {
|
||||
/// Convert string action name to CanvasAction enum (config-driven)
|
||||
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 {
|
||||
Success(Option<String>),
|
||||
HandledByFeature(String),
|
||||
RequiresContext(String),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
impl ActionResult {
|
||||
pub fn success() -> Self {
|
||||
Self::Success(None)
|
||||
}
|
||||
|
||||
pub fn success_with_message(msg: &str) -> Self {
|
||||
Self::Success(Some(msg.to_string()))
|
||||
}
|
||||
|
||||
pub fn error(msg: &str) -> Self {
|
||||
Self::Error(msg.into())
|
||||
}
|
||||
|
||||
pub fn is_success(&self) -> bool {
|
||||
matches!(self, Self::Success(_) | Self::HandledByFeature(_))
|
||||
}
|
||||
|
||||
pub fn message(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Success(msg) => msg.as_deref(),
|
||||
Self::HandledByFeature(msg) => Some(msg),
|
||||
Self::RequiresContext(msg) => Some(msg),
|
||||
Self::Error(msg) => Some(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
338
canvas/src/canvas/gui.rs
Normal file
338
canvas/src/canvas/gui.rs
Normal file
@@ -0,0 +1,338 @@
|
||||
// canvas/src/canvas/gui.rs
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, BorderType, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::canvas::state::CanvasState;
|
||||
use crate::canvas::modes::HighlightState;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use crate::canvas::theme::CanvasTheme;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use std::cmp::{max, min};
|
||||
|
||||
/// Render ONLY the canvas form fields - no autocomplete
|
||||
#[cfg(feature = "gui")]
|
||||
pub fn render_canvas<T: CanvasTheme>(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
form_state: &impl CanvasState,
|
||||
theme: &T,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
) -> Option<Rect> {
|
||||
let fields: Vec<&str> = form_state.fields();
|
||||
let current_field_idx = form_state.current_field();
|
||||
let inputs: Vec<&String> = form_state.inputs();
|
||||
|
||||
render_canvas_fields(
|
||||
f,
|
||||
area,
|
||||
&fields,
|
||||
¤t_field_idx,
|
||||
&inputs,
|
||||
theme,
|
||||
is_edit_mode,
|
||||
highlight_state,
|
||||
form_state.current_cursor_pos(),
|
||||
form_state.has_unsaved_changes(),
|
||||
|i| form_state.get_display_value_for_field(i).to_string(),
|
||||
|i| form_state.has_display_override(i),
|
||||
)
|
||||
}
|
||||
|
||||
/// Core canvas field rendering
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_canvas_fields<T: CanvasTheme, F1, F2>(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
fields: &[&str],
|
||||
current_field_idx: &usize,
|
||||
inputs: &[&String],
|
||||
theme: &T,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
current_cursor_pos: usize,
|
||||
has_unsaved_changes: bool,
|
||||
get_display_value: F1,
|
||||
has_display_override: F2,
|
||||
) -> Option<Rect>
|
||||
where
|
||||
F1: Fn(usize) -> String,
|
||||
F2: Fn(usize) -> bool,
|
||||
{
|
||||
// Create layout
|
||||
let columns = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
|
||||
.split(area);
|
||||
|
||||
// Border style based on state
|
||||
let border_style = if has_unsaved_changes {
|
||||
Style::default().fg(theme.warning())
|
||||
} else if is_edit_mode {
|
||||
Style::default().fg(theme.accent())
|
||||
} else {
|
||||
Style::default().fg(theme.secondary())
|
||||
};
|
||||
|
||||
// Input container
|
||||
let input_container = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(border_style)
|
||||
.style(Style::default().bg(theme.bg()));
|
||||
|
||||
let input_block = Rect {
|
||||
x: columns[1].x,
|
||||
y: columns[1].y,
|
||||
width: columns[1].width,
|
||||
height: fields.len() as u16 + 2,
|
||||
};
|
||||
|
||||
f.render_widget(&input_container, input_block);
|
||||
|
||||
// Input area layout
|
||||
let input_area = input_container.inner(input_block);
|
||||
let input_rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(vec![Constraint::Length(1); fields.len()])
|
||||
.split(input_area);
|
||||
|
||||
// Render field labels
|
||||
render_field_labels(f, columns[0], input_block, fields, theme);
|
||||
|
||||
// Render field values and return active field rect
|
||||
render_field_values(
|
||||
f,
|
||||
input_rows.to_vec(), // Fix: Convert Rc<[Rect]> to Vec<Rect>
|
||||
inputs,
|
||||
current_field_idx,
|
||||
theme,
|
||||
highlight_state,
|
||||
current_cursor_pos,
|
||||
get_display_value,
|
||||
has_display_override,
|
||||
)
|
||||
}
|
||||
|
||||
/// Render field labels
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_field_labels<T: CanvasTheme>(
|
||||
f: &mut Frame,
|
||||
label_area: Rect,
|
||||
input_block: Rect,
|
||||
fields: &[&str],
|
||||
theme: &T,
|
||||
) {
|
||||
for (i, field) in fields.iter().enumerate() {
|
||||
let label = Paragraph::new(Line::from(Span::styled(
|
||||
format!("{}:", field),
|
||||
Style::default().fg(theme.fg()),
|
||||
)));
|
||||
f.render_widget(
|
||||
label,
|
||||
Rect {
|
||||
x: label_area.x,
|
||||
y: input_block.y + 1 + i as u16,
|
||||
width: label_area.width,
|
||||
height: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render field values with highlighting
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_field_values<T: CanvasTheme, F1, F2>(
|
||||
f: &mut Frame,
|
||||
input_rows: Vec<Rect>,
|
||||
inputs: &[&String],
|
||||
current_field_idx: &usize,
|
||||
theme: &T,
|
||||
highlight_state: &HighlightState,
|
||||
current_cursor_pos: usize,
|
||||
get_display_value: F1,
|
||||
has_display_override: F2,
|
||||
) -> Option<Rect>
|
||||
where
|
||||
F1: Fn(usize) -> String,
|
||||
F2: Fn(usize) -> bool,
|
||||
{
|
||||
let mut active_field_input_rect = None;
|
||||
|
||||
for (i, _input) in inputs.iter().enumerate() {
|
||||
let is_active = i == *current_field_idx;
|
||||
let text = get_display_value(i);
|
||||
|
||||
// Apply highlighting
|
||||
let line = apply_highlighting(
|
||||
&text,
|
||||
i,
|
||||
current_field_idx,
|
||||
current_cursor_pos,
|
||||
highlight_state,
|
||||
theme,
|
||||
is_active,
|
||||
);
|
||||
|
||||
let input_display = Paragraph::new(line).alignment(Alignment::Left);
|
||||
f.render_widget(input_display, input_rows[i]);
|
||||
|
||||
// Set cursor for active field
|
||||
if is_active {
|
||||
active_field_input_rect = Some(input_rows[i]);
|
||||
set_cursor_position(f, input_rows[i], &text, current_cursor_pos, has_display_override(i));
|
||||
}
|
||||
}
|
||||
|
||||
active_field_input_rect
|
||||
}
|
||||
|
||||
/// Apply highlighting based on highlight state
|
||||
#[cfg(feature = "gui")]
|
||||
fn apply_highlighting<'a, T: CanvasTheme>(
|
||||
text: &'a str,
|
||||
field_index: usize,
|
||||
current_field_idx: &usize,
|
||||
current_cursor_pos: usize,
|
||||
highlight_state: &HighlightState,
|
||||
theme: &T,
|
||||
is_active: bool,
|
||||
) -> Line<'a> {
|
||||
let text_len = text.chars().count();
|
||||
|
||||
match highlight_state {
|
||||
HighlightState::Off => {
|
||||
Line::from(Span::styled(
|
||||
text,
|
||||
if is_active {
|
||||
Style::default().fg(theme.highlight())
|
||||
} else {
|
||||
Style::default().fg(theme.fg())
|
||||
},
|
||||
))
|
||||
}
|
||||
HighlightState::Characterwise { anchor } => {
|
||||
apply_characterwise_highlighting(text, text_len, field_index, current_field_idx, current_cursor_pos, anchor, theme, is_active)
|
||||
}
|
||||
HighlightState::Linewise { anchor_line } => {
|
||||
apply_linewise_highlighting(text, field_index, current_field_idx, anchor_line, theme, is_active)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply characterwise highlighting
|
||||
#[cfg(feature = "gui")]
|
||||
fn apply_characterwise_highlighting<'a, T: CanvasTheme>(
|
||||
text: &'a str,
|
||||
text_len: usize,
|
||||
field_index: usize,
|
||||
current_field_idx: &usize,
|
||||
current_cursor_pos: usize,
|
||||
anchor: &(usize, usize),
|
||||
theme: &T,
|
||||
is_active: bool,
|
||||
) -> Line<'a> {
|
||||
let (anchor_field, anchor_char) = *anchor;
|
||||
let start_field = min(anchor_field, *current_field_idx);
|
||||
let end_field = max(anchor_field, *current_field_idx);
|
||||
|
||||
let highlight_style = Style::default()
|
||||
.fg(theme.highlight())
|
||||
.bg(theme.highlight_bg())
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let normal_style_in_highlight = Style::default().fg(theme.highlight());
|
||||
let normal_style_outside = Style::default().fg(theme.fg());
|
||||
|
||||
if field_index >= start_field && field_index <= end_field {
|
||||
if start_field == end_field {
|
||||
let (start_char, end_char) = if anchor_field == *current_field_idx {
|
||||
(min(anchor_char, current_cursor_pos), max(anchor_char, current_cursor_pos))
|
||||
} else if anchor_field < *current_field_idx {
|
||||
(anchor_char, current_cursor_pos)
|
||||
} else {
|
||||
(current_cursor_pos, anchor_char)
|
||||
};
|
||||
|
||||
let clamped_start = start_char.min(text_len);
|
||||
let clamped_end = end_char.min(text_len);
|
||||
|
||||
let before: String = text.chars().take(clamped_start).collect();
|
||||
let highlighted: String = text.chars()
|
||||
.skip(clamped_start)
|
||||
.take(clamped_end.saturating_sub(clamped_start) + 1)
|
||||
.collect();
|
||||
let after: String = text.chars().skip(clamped_end + 1).collect();
|
||||
|
||||
Line::from(vec![
|
||||
Span::styled(before, normal_style_in_highlight),
|
||||
Span::styled(highlighted, highlight_style),
|
||||
Span::styled(after, normal_style_in_highlight),
|
||||
])
|
||||
} else {
|
||||
// Multi-field selection
|
||||
Line::from(Span::styled(text, highlight_style))
|
||||
}
|
||||
} else {
|
||||
Line::from(Span::styled(
|
||||
text,
|
||||
if is_active { normal_style_in_highlight } else { normal_style_outside }
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply linewise highlighting
|
||||
#[cfg(feature = "gui")]
|
||||
fn apply_linewise_highlighting<'a, T: CanvasTheme>(
|
||||
text: &'a str,
|
||||
field_index: usize,
|
||||
current_field_idx: &usize,
|
||||
anchor_line: &usize,
|
||||
theme: &T,
|
||||
is_active: bool,
|
||||
) -> Line<'a> {
|
||||
let start_field = min(*anchor_line, *current_field_idx);
|
||||
let end_field = max(*anchor_line, *current_field_idx);
|
||||
|
||||
let highlight_style = Style::default()
|
||||
.fg(theme.highlight())
|
||||
.bg(theme.highlight_bg())
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let normal_style_in_highlight = Style::default().fg(theme.highlight());
|
||||
let normal_style_outside = Style::default().fg(theme.fg());
|
||||
|
||||
if field_index >= start_field && field_index <= end_field {
|
||||
Line::from(Span::styled(text, highlight_style))
|
||||
} else {
|
||||
Line::from(Span::styled(
|
||||
text,
|
||||
if is_active { normal_style_in_highlight } else { normal_style_outside }
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Set cursor position
|
||||
#[cfg(feature = "gui")]
|
||||
fn set_cursor_position(
|
||||
f: &mut Frame,
|
||||
field_rect: Rect,
|
||||
text: &str,
|
||||
current_cursor_pos: usize,
|
||||
has_display_override: bool,
|
||||
) {
|
||||
let cursor_x = if has_display_override {
|
||||
field_rect.x + text.chars().count() as u16
|
||||
} else {
|
||||
field_rect.x + current_cursor_pos as u16
|
||||
};
|
||||
let cursor_y = field_rect.y;
|
||||
f.set_cursor_position((cursor_x, cursor_y));
|
||||
}
|
||||
20
canvas/src/canvas/mod.rs
Normal file
20
canvas/src/canvas/mod.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
// src/canvas/mod.rs
|
||||
pub mod actions;
|
||||
pub mod gui;
|
||||
pub mod modes;
|
||||
pub mod state;
|
||||
pub mod theme;
|
||||
|
||||
// Re-export commonly used canvas types
|
||||
pub use actions::{CanvasAction, ActionResult};
|
||||
pub use modes::{AppMode, ModeManager, HighlightState};
|
||||
pub use state::{CanvasState, ActionContext};
|
||||
|
||||
// Re-export the main entry point
|
||||
pub use crate::dispatcher::execute_canvas_action;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
pub use theme::CanvasTheme;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
pub use gui::render_canvas;
|
||||
15
canvas/src/canvas/modes/highlight.rs
Normal file
15
canvas/src/canvas/modes/highlight.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
// src/state/app/highlight.rs
|
||||
// canvas/src/modes/highlight.rs
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum HighlightState {
|
||||
Off,
|
||||
Characterwise { anchor: (usize, usize) }, // (field_index, char_position)
|
||||
Linewise { anchor_line: usize }, // field_index
|
||||
}
|
||||
|
||||
impl Default for HighlightState {
|
||||
fn default() -> Self {
|
||||
HighlightState::Off
|
||||
}
|
||||
}
|
||||
33
canvas/src/canvas/modes/manager.rs
Normal file
33
canvas/src/canvas/modes/manager.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
// src/modes/handlers/mode_manager.rs
|
||||
// canvas/src/modes/manager.rs
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AppMode {
|
||||
General, // For intro and admin screens
|
||||
ReadOnly, // Canvas read-only mode
|
||||
Edit, // Canvas edit mode
|
||||
Highlight, // Canvas highlight/visual mode
|
||||
Command, // Command mode overlay
|
||||
}
|
||||
|
||||
pub struct ModeManager;
|
||||
|
||||
impl ModeManager {
|
||||
// Mode transition rules
|
||||
pub fn can_enter_command_mode(current_mode: AppMode) -> bool {
|
||||
!matches!(current_mode, AppMode::Edit)
|
||||
}
|
||||
|
||||
pub fn can_enter_edit_mode(current_mode: AppMode) -> bool {
|
||||
matches!(current_mode, AppMode::ReadOnly)
|
||||
}
|
||||
|
||||
pub fn can_enter_read_only_mode(current_mode: AppMode) -> bool {
|
||||
matches!(current_mode, AppMode::Edit | AppMode::Command | AppMode::Highlight)
|
||||
}
|
||||
|
||||
pub fn can_enter_highlight_mode(current_mode: AppMode) -> bool {
|
||||
matches!(current_mode, AppMode::ReadOnly)
|
||||
}
|
||||
}
|
||||
7
canvas/src/canvas/modes/mod.rs
Normal file
7
canvas/src/canvas/modes/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// canvas/src/modes/mod.rs
|
||||
|
||||
pub mod highlight;
|
||||
pub mod manager;
|
||||
|
||||
pub use highlight::HighlightState;
|
||||
pub use manager::{AppMode, ModeManager};
|
||||
117
canvas/src/canvas/state.rs
Normal file
117
canvas/src/canvas/state.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
// src/canvas/state.rs
|
||||
//! Canvas state trait and related types
|
||||
//!
|
||||
//! 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;
|
||||
|
||||
/// Context information passed to feature-specific action handlers
|
||||
#[derive(Debug)]
|
||||
pub struct ActionContext {
|
||||
/// Original key code that triggered this action (for backwards compatibility)
|
||||
pub key_code: Option<crossterm::event::KeyCode>,
|
||||
/// Current ideal cursor column for vertical movement
|
||||
pub ideal_cursor_column: usize,
|
||||
/// Current input text
|
||||
pub current_input: String,
|
||||
/// Current field index
|
||||
pub current_field: usize,
|
||||
}
|
||||
|
||||
/// Core trait that any form-like state must implement to work with canvas
|
||||
///
|
||||
/// This trait enables the same mode behaviors (edit, read-only, highlight) to work
|
||||
/// across any implementation - login forms, data entry forms, configuration screens, etc.
|
||||
///
|
||||
/// # Required Implementation
|
||||
///
|
||||
/// 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 ---
|
||||
|
||||
/// Get current interaction mode (edit, read-only, highlight, etc.)
|
||||
fn current_mode(&self) -> AppMode;
|
||||
|
||||
// --- Data Access ---
|
||||
|
||||
/// Get immutable reference to current field's text
|
||||
fn get_current_input(&self) -> &str;
|
||||
|
||||
/// Get mutable reference to current field's text
|
||||
fn get_current_input_mut(&mut self) -> &mut String;
|
||||
|
||||
/// Get all input values as immutable references
|
||||
fn inputs(&self) -> Vec<&String>;
|
||||
|
||||
/// 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)
|
||||
/// Used for things like password masking or computed display values
|
||||
fn get_display_value_for_field(&self, index: usize) -> &str {
|
||||
self.inputs()
|
||||
.get(index)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
/// Check if a field has a custom display value
|
||||
/// Return true if get_display_value_for_field returns something different than the actual value
|
||||
fn has_display_override(&self, _index: usize) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
17
canvas/src/canvas/theme.rs
Normal file
17
canvas/src/canvas/theme.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
// canvas/src/gui/theme.rs
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use ratatui::style::Color;
|
||||
|
||||
/// Theme trait that must be implemented by applications using the canvas GUI
|
||||
#[cfg(feature = "gui")]
|
||||
pub trait CanvasTheme {
|
||||
fn bg(&self) -> Color;
|
||||
fn fg(&self) -> Color;
|
||||
fn border(&self) -> Color;
|
||||
fn accent(&self) -> Color;
|
||||
fn secondary(&self) -> Color;
|
||||
fn highlight(&self) -> Color;
|
||||
fn highlight_bg(&self) -> Color;
|
||||
fn warning(&self) -> Color;
|
||||
}
|
||||
665
canvas/src/config/config.rs
Normal file
665
canvas/src/config/config.rs
Normal file
@@ -0,0 +1,665 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
93
canvas/src/config/introspection.rs
Normal file
93
canvas/src/config/introspection.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
12
canvas/src/config/mod.rs
Normal file
12
canvas/src/config/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
// 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::*;
|
||||
135
canvas/src/config/registry.rs
Normal file
135
canvas/src/config/registry.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
278
canvas/src/config/validation.rs
Normal file
278
canvas/src/config/validation.rs
Normal file
@@ -0,0 +1,278 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
110
canvas/src/dispatcher.rs
Normal file
110
canvas/src/dispatcher.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
11
canvas/src/lib.rs
Normal file
11
canvas/src/lib.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
// src/lib.rs
|
||||
pub mod canvas;
|
||||
pub mod autocomplete;
|
||||
pub mod config;
|
||||
pub mod dispatcher;
|
||||
|
||||
// Re-export the main API for easy access
|
||||
pub use dispatcher::{execute_canvas_action, ActionDispatcher};
|
||||
pub use canvas::actions::{CanvasAction, ActionResult};
|
||||
pub use canvas::state::{CanvasState, ActionContext};
|
||||
pub use canvas::modes::{AppMode, HighlightState, ModeManager};
|
||||
55
canvas/view_docs.sh
Executable file
55
canvas/view_docs.sh
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Enhanced documentation viewer for your canvas library
|
||||
echo "=========================================="
|
||||
echo "CANVAS LIBRARY DOCUMENTATION"
|
||||
echo "=========================================="
|
||||
|
||||
# Function to display module docs with colors
|
||||
show_module() {
|
||||
local module=$1
|
||||
local title=$2
|
||||
|
||||
echo -e "\n\033[1;34m=== $title ===\033[0m"
|
||||
echo -e "\033[33mFiles in $module:\033[0m"
|
||||
find src/$module -name "*.rs" 2>/dev/null | sort
|
||||
echo
|
||||
|
||||
# Show doc comments for this module
|
||||
find src/$module -name "*.rs" 2>/dev/null | while read file; do
|
||||
if grep -q "///" "$file"; then
|
||||
echo -e "\033[32m--- $file ---\033[0m"
|
||||
grep -n "^\s*///" "$file" | sed 's/^\([0-9]*:\)\s*\/\/\/ /\1 /' | head -10
|
||||
echo
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Main modules
|
||||
show_module "canvas" "CANVAS SYSTEM"
|
||||
show_module "autocomplete" "AUTOCOMPLETE SYSTEM"
|
||||
show_module "config" "CONFIGURATION SYSTEM"
|
||||
|
||||
# Show lib.rs and other root files
|
||||
echo -e "\n\033[1;34m=== ROOT DOCUMENTATION ===\033[0m"
|
||||
if [ -f "src/lib.rs" ]; then
|
||||
echo -e "\033[32m--- src/lib.rs ---\033[0m"
|
||||
grep -n "^\s*///" src/lib.rs | sed 's/^\([0-9]*:\)\s*\/\/\/ /\1 /' 2>/dev/null
|
||||
fi
|
||||
|
||||
if [ -f "src/dispatcher.rs" ]; then
|
||||
echo -e "\033[32m--- src/dispatcher.rs ---\033[0m"
|
||||
grep -n "^\s*///" src/dispatcher.rs | sed 's/^\([0-9]*:\)\s*\/\/\/ /\1 /' 2>/dev/null
|
||||
fi
|
||||
|
||||
echo -e "\n\033[1;36m=========================================="
|
||||
echo "To view specific module documentation:"
|
||||
echo " ./view_canvas_docs.sh canvas"
|
||||
echo " ./view_canvas_docs.sh autocomplete"
|
||||
echo " ./view_canvas_docs.sh config"
|
||||
echo "==========================================\033[0m"
|
||||
|
||||
# If specific module requested
|
||||
if [ $# -eq 1 ]; then
|
||||
show_module "$1" "$(echo $1 | tr '[:lower:]' '[:upper:]') MODULE DETAILS"
|
||||
fi
|
||||
1
client/.gitignore
vendored
Normal file
1
client/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
canvas_config.toml.txt
|
||||
@@ -5,15 +5,36 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
async-trait = "0.1.88"
|
||||
common = { path = "../common" }
|
||||
canvas = { path = "../canvas", features = ["gui"] }
|
||||
|
||||
crossterm = "0.28.1"
|
||||
ratatui = { workspace = true }
|
||||
crossterm = { workspace = true }
|
||||
prost-types = { workspace = true }
|
||||
dirs = "6.0.0"
|
||||
dotenvy = "0.15.7"
|
||||
lazy_static = "1.5.0"
|
||||
prost = "0.13.5"
|
||||
ratatui = "0.29.0"
|
||||
serde = { version = "1.0.218", features = ["derive"] }
|
||||
tokio = { version = "1.43.0", features = ["full", "macros"] }
|
||||
toml = "0.8.20"
|
||||
tonic = "0.12.3"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.140"
|
||||
time = "0.3.41"
|
||||
tokio = { version = "1.44.2", features = ["full", "macros"] }
|
||||
toml = { workspace = true }
|
||||
tonic = "0.13.0"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.19"
|
||||
tui-textarea = { version = "0.7.0", features = ["crossterm", "ratatui", "search"] }
|
||||
unicode-segmentation = "1.12.0"
|
||||
unicode-width.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
ui-debug = []
|
||||
|
||||
[dev-dependencies]
|
||||
rstest = "0.25.0"
|
||||
tokio-test = "0.4.4"
|
||||
uuid = { version = "1.17.0", features = ["v4"] }
|
||||
futures = "0.3.31"
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
[keybindings]
|
||||
|
||||
enter_command_mode = [":", "ctrl+;"]
|
||||
next_buffer = ["space+b+n"]
|
||||
previous_buffer = ["space+b+p"]
|
||||
close_buffer = ["space+b+d"]
|
||||
|
||||
[keybindings.general]
|
||||
move_up = ["k", "Up"]
|
||||
@@ -10,20 +13,23 @@ next_option = ["l", "Right"]
|
||||
previous_option = ["h", "Left"]
|
||||
select = ["Enter"]
|
||||
toggle_sidebar = ["ctrl+t"]
|
||||
toggle_buffer_list = ["ctrl+b"]
|
||||
next_field = ["Tab"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
exit_table_scroll = ["esc"]
|
||||
open_search = ["ctrl+f"]
|
||||
|
||||
[keybindings.common]
|
||||
save = ["ctrl+s"]
|
||||
quit = ["ctrl+q"]
|
||||
# !!!change to space b r in the future and from edit mode
|
||||
revert = ["ctrl+r"]
|
||||
|
||||
force_quit = ["ctrl+shift+q"]
|
||||
save_and_quit = ["ctrl+shift+s"]
|
||||
move_up = ["Up"]
|
||||
move_down = ["Down"]
|
||||
toggle_sidebar = ["ctrl+t"]
|
||||
toggle_buffer_list = ["ctrl+b"]
|
||||
revert = ["space+b+r"]
|
||||
|
||||
# MODE SPECIFIC
|
||||
# READ ONLY MODE
|
||||
@@ -33,27 +39,75 @@ enter_edit_mode_after = ["a"]
|
||||
previous_entry = ["left","q"]
|
||||
next_entry = ["right","1"]
|
||||
|
||||
move_left = ["h"]
|
||||
move_right = ["l"]
|
||||
move_up = ["k"]
|
||||
move_down = ["j"]
|
||||
enter_highlight_mode = ["v"]
|
||||
enter_highlight_mode_linewise = ["ctrl+v"]
|
||||
|
||||
### AUTOGENERATED CANVAS CONFIG
|
||||
# Required
|
||||
move_up = ["k", "Up"]
|
||||
move_left = ["h", "Left"]
|
||||
move_right = ["l", "Right"]
|
||||
move_down = ["j", "Down"]
|
||||
# Optional
|
||||
move_line_end = ["$"]
|
||||
move_word_next = ["w"]
|
||||
move_word_end = ["e"]
|
||||
next_field = ["Tab"]
|
||||
move_word_prev = ["b"]
|
||||
move_word_end = ["e"]
|
||||
move_last_line = ["shift+g"]
|
||||
move_word_end_prev = ["ge"]
|
||||
move_line_start = ["0"]
|
||||
move_first_line = ["g+g"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
|
||||
[keybindings.highlight]
|
||||
exit_highlight_mode = ["esc"]
|
||||
enter_highlight_mode_linewise = ["ctrl+v"]
|
||||
|
||||
### AUTOGENERATED CANVAS CONFIG
|
||||
# Required
|
||||
move_left = ["h", "Left"]
|
||||
move_right = ["l", "Right"]
|
||||
move_up = ["k", "Up"]
|
||||
move_down = ["j", "Down"]
|
||||
# Optional
|
||||
move_word_next = ["w"]
|
||||
move_line_start = ["0"]
|
||||
move_line_end = ["$"]
|
||||
move_first_line = ["gg"]
|
||||
move_last_line = ["x"]
|
||||
move_word_prev = ["b"]
|
||||
move_word_end = ["e"]
|
||||
|
||||
|
||||
[keybindings.edit]
|
||||
exit_edit_mode = ["esc","ctrl+e"]
|
||||
delete_char_forward = ["delete"]
|
||||
delete_char_backward = ["backspace"]
|
||||
next_field = ["tab", "enter"]
|
||||
prev_field = ["shift+tab", "backtab"]
|
||||
move_left = ["left"]
|
||||
move_right = ["right"]
|
||||
# BIG CHANGES NOW EXIT HANDLES EITHER IF THOSE
|
||||
# exit_edit_mode = ["esc","ctrl+e"]
|
||||
# exit_suggestion_mode = ["esc"]
|
||||
# select_suggestion = ["enter"]
|
||||
# next_field = ["enter"]
|
||||
enter_decider = ["enter"]
|
||||
exit = ["esc", "ctrl+e"]
|
||||
suggestion_down = ["ctrl+n", "tab"]
|
||||
suggestion_up = ["ctrl+p", "shift+tab"]
|
||||
|
||||
### AUTOGENERATED CANVAS CONFIG
|
||||
# Required
|
||||
move_right = ["Right", "l"]
|
||||
delete_char_backward = ["Backspace"]
|
||||
next_field = ["Tab", "Enter"]
|
||||
move_up = ["Up", "k"]
|
||||
move_down = ["Down", "j"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
move_left = ["Left", "h"]
|
||||
# Optional
|
||||
move_last_line = ["Ctrl+End", "G"]
|
||||
delete_char_forward = ["Delete"]
|
||||
move_word_prev = ["Ctrl+Left", "b"]
|
||||
move_word_end = ["e"]
|
||||
move_word_end_prev = ["ge"]
|
||||
move_first_line = ["Ctrl+Home", "gg"]
|
||||
move_word_next = ["Ctrl+Right", "w"]
|
||||
move_line_start = ["Home", "0"]
|
||||
move_line_end = ["End", "$"]
|
||||
|
||||
[keybindings.command]
|
||||
exit_command_mode = ["ctrl+g", "esc"]
|
||||
@@ -64,7 +118,17 @@ quit = ["q"]
|
||||
force_quit = ["q!"]
|
||||
save_and_quit = ["wq"]
|
||||
revert = ["r"]
|
||||
find_file_palette_toggle = ["ff"]
|
||||
|
||||
[editor]
|
||||
keybinding_mode = "vim" # Options: "default", "vim", "emacs"
|
||||
|
||||
[colors]
|
||||
theme = "dark"
|
||||
# Options: "light", "dark", "high_contrast"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
124
client/docs/canvas_add_functionality.md
Normal file
124
client/docs/canvas_add_functionality.md
Normal file
@@ -0,0 +1,124 @@
|
||||
## How Canvas Library Custom Functionality Works
|
||||
|
||||
### 1. **The Canvas Library Calls YOUR Custom Code First**
|
||||
|
||||
When you call `ActionDispatcher::dispatch()`, here's what happens:
|
||||
|
||||
```rust
|
||||
// Inside canvas library (canvas/src/actions/edit.rs):
|
||||
pub async fn execute_canvas_action<S: CanvasState>(
|
||||
action: CanvasAction,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<ActionResult> {
|
||||
// 1. FIRST: Canvas library calls YOUR custom handler
|
||||
if let Some(result) = state.handle_feature_action(&action, &context) {
|
||||
return Ok(ActionResult::HandledByFeature(result)); // YOUR code handled it
|
||||
}
|
||||
|
||||
// 2. ONLY IF your code returns None: Canvas handles generic actions
|
||||
handle_generic_canvas_action(action, state, ideal_cursor_column).await
|
||||
}
|
||||
```
|
||||
|
||||
### 2. **Your Extension Point: `handle_feature_action`**
|
||||
|
||||
You add custom functionality by implementing `handle_feature_action` in your states:
|
||||
|
||||
```rust
|
||||
// In src/state/pages/auth.rs
|
||||
impl CanvasState for LoginState {
|
||||
// ... other methods ...
|
||||
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
// Custom login-specific actions
|
||||
CanvasAction::Custom(action_str) if action_str == "submit_login" => {
|
||||
if self.username.is_empty() || self.password.is_empty() {
|
||||
Some("Please fill in all required fields".to_string())
|
||||
} else {
|
||||
// Trigger login process
|
||||
Some(format!("Logging in user: {}", self.username))
|
||||
}
|
||||
}
|
||||
|
||||
CanvasAction::Custom(action_str) if action_str == "clear_form" => {
|
||||
self.username.clear();
|
||||
self.password.clear();
|
||||
self.set_has_unsaved_changes(false);
|
||||
Some("Login form cleared".to_string())
|
||||
}
|
||||
|
||||
// Custom behavior for standard actions
|
||||
CanvasAction::NextField => {
|
||||
// Custom validation when moving between fields
|
||||
if self.current_field == 0 && self.username.is_empty() {
|
||||
Some("Username cannot be empty".to_string())
|
||||
} else {
|
||||
None // Let canvas library handle the normal field movement
|
||||
}
|
||||
}
|
||||
|
||||
// Let canvas library handle everything else
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. **Multiple Ways to Add Custom Functionality**
|
||||
|
||||
#### A) **Custom Actions via Config**
|
||||
```toml
|
||||
# In config.toml
|
||||
[keybindings.edit]
|
||||
submit_login = ["ctrl+enter"]
|
||||
clear_form = ["ctrl+r"]
|
||||
```
|
||||
|
||||
#### B) **Override Standard Actions**
|
||||
```rust
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::InsertChar('p') if self.current_field == 1 => {
|
||||
// Custom behavior when typing 'p' in password field
|
||||
Some("Password field - use secure input".to_string())
|
||||
}
|
||||
_ => None, // Let canvas handle normally
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### C) **Context-Aware Logic**
|
||||
```rust
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::MoveDown => {
|
||||
// Custom logic based on current state
|
||||
if context.current_field == 1 && context.current_input.len() < 8 {
|
||||
Some("Password should be at least 8 characters".to_string())
|
||||
} else {
|
||||
None // Normal field movement
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## The Canvas Library Philosophy
|
||||
|
||||
**Canvas Library = Generic behavior + Your extension points**
|
||||
|
||||
- ✅ **Canvas handles**: Character insertion, cursor movement, field navigation, etc.
|
||||
- ✅ **You handle**: Validation, submission, clearing, app-specific logic
|
||||
- ✅ **You decide**: Return `Some(message)` to override, `None` to use canvas default
|
||||
|
||||
## Summary
|
||||
|
||||
You **don't communicate with the library elsewhere**. Instead:
|
||||
|
||||
1. **Canvas library calls your code first** via `handle_feature_action`
|
||||
2. **Your code decides** whether to handle the action or let canvas handle it
|
||||
3. **Canvas library handles** generic form behavior when you return `None`
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
// src/components/admin.rs
|
||||
pub mod admin_panel;
|
||||
pub mod admin_panel_admin;
|
||||
pub mod add_table;
|
||||
pub mod add_logic;
|
||||
|
||||
pub use admin_panel::*;
|
||||
pub use admin_panel_admin::*;
|
||||
pub use add_table::*;
|
||||
pub use add_logic::*;
|
||||
|
||||
318
client/src/components/admin/add_logic.rs
Normal file
318
client/src/components/admin/add_logic.rs
Normal file
@@ -0,0 +1,318 @@
|
||||
// src/components/admin/add_logic.rs
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::add_logic::{AddLogicFocus, AddLogicState};
|
||||
use canvas::canvas::{render_canvas, CanvasState, HighlightState as CanvasHighlightState}; // Use canvas library
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, BorderType, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use crate::components::common::{dialog, autocomplete}; // Added autocomplete
|
||||
use crate::config::binds::config::EditorKeybindingMode;
|
||||
|
||||
// Helper function to convert between HighlightState types
|
||||
fn convert_highlight_state(local: &HighlightState) -> CanvasHighlightState {
|
||||
match local {
|
||||
HighlightState::Off => CanvasHighlightState::Off,
|
||||
HighlightState::Characterwise { anchor } => CanvasHighlightState::Characterwise { anchor: *anchor },
|
||||
HighlightState::Linewise { anchor_line } => CanvasHighlightState::Linewise { anchor_line: *anchor_line },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_add_logic(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
app_state: &AppState,
|
||||
add_logic_state: &mut AddLogicState,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
) {
|
||||
let main_block = Block::default()
|
||||
.title(" Add New Logic Script ")
|
||||
.title_alignment(Alignment::Center)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(theme.border))
|
||||
.style(Style::default().bg(theme.bg));
|
||||
let inner_area = main_block.inner(area);
|
||||
f.render_widget(main_block, area);
|
||||
|
||||
// Handle full-screen script editing
|
||||
if add_logic_state.current_focus == AddLogicFocus::InsideScriptContent {
|
||||
let mut editor_ref = add_logic_state.script_content_editor.borrow_mut();
|
||||
let border_style_color = if is_edit_mode { theme.highlight } else { theme.secondary };
|
||||
let border_style = Style::default().fg(border_style_color);
|
||||
|
||||
editor_ref.set_cursor_line_style(Style::default());
|
||||
editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
|
||||
let script_title_hint = match add_logic_state.editor_keybinding_mode {
|
||||
EditorKeybindingMode::Vim => {
|
||||
let vim_mode_status = crate::components::common::text_editor::TextEditor::get_vim_mode_status(&add_logic_state.vim_state);
|
||||
format!("Script {}", vim_mode_status)
|
||||
}
|
||||
EditorKeybindingMode::Emacs | EditorKeybindingMode::Default => {
|
||||
if is_edit_mode {
|
||||
"Script (Editing)".to_string()
|
||||
} else {
|
||||
"Script".to_string()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
editor_ref.set_block(
|
||||
Block::default()
|
||||
.title(Span::styled(script_title_hint, Style::default().fg(theme.fg)))
|
||||
.title_alignment(Alignment::Center)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(border_style),
|
||||
);
|
||||
f.render_widget(&*editor_ref, inner_area);
|
||||
|
||||
// Drop the editor borrow before accessing autocomplete state
|
||||
drop(editor_ref);
|
||||
|
||||
// === SCRIPT EDITOR AUTOCOMPLETE RENDERING ===
|
||||
if add_logic_state.script_editor_autocomplete_active && !add_logic_state.script_editor_suggestions.is_empty() {
|
||||
// Get the current cursor position from textarea
|
||||
let current_cursor = {
|
||||
let editor_borrow = add_logic_state.script_content_editor.borrow();
|
||||
editor_borrow.cursor() // Returns (row, col) as (usize, usize)
|
||||
};
|
||||
|
||||
let (cursor_line, cursor_col) = current_cursor;
|
||||
|
||||
// Account for TextArea's block borders (1 for each side)
|
||||
let block_offset_x = 1;
|
||||
let block_offset_y = 1;
|
||||
|
||||
// Position autocomplete at current cursor position
|
||||
// Add 1 to column to position dropdown right after the cursor
|
||||
let autocomplete_x = cursor_col + 1;
|
||||
let autocomplete_y = cursor_line;
|
||||
|
||||
let input_rect = Rect {
|
||||
x: (inner_area.x + block_offset_x + autocomplete_x as u16).min(inner_area.right().saturating_sub(20)),
|
||||
y: (inner_area.y + block_offset_y + autocomplete_y as u16).min(inner_area.bottom().saturating_sub(5)),
|
||||
width: 1, // Minimum width for positioning
|
||||
height: 1,
|
||||
};
|
||||
|
||||
// Render autocomplete dropdown
|
||||
autocomplete::render_autocomplete_dropdown(
|
||||
f,
|
||||
input_rect,
|
||||
f.area(), // Full frame area for clamping
|
||||
theme,
|
||||
&add_logic_state.script_editor_suggestions,
|
||||
add_logic_state.script_editor_selected_suggestion_index,
|
||||
);
|
||||
}
|
||||
|
||||
return; // Exit early for fullscreen mode
|
||||
}
|
||||
|
||||
// Regular layout with preview
|
||||
let main_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Top info
|
||||
Constraint::Length(9), // Canvas for 3 inputs (each 1 line + 1 padding = 2 lines * 3 + 2 border = 8, +1 for good measure)
|
||||
Constraint::Min(5), // Script preview
|
||||
Constraint::Length(3), // Buttons
|
||||
])
|
||||
.split(inner_area);
|
||||
|
||||
let top_info_area = main_chunks[0];
|
||||
let canvas_area = main_chunks[1];
|
||||
let script_content_area = main_chunks[2];
|
||||
let buttons_area = main_chunks[3];
|
||||
|
||||
// Top info
|
||||
let profile_text = Paragraph::new(vec![
|
||||
Line::from(Span::styled(
|
||||
format!("Profile: {}", add_logic_state.profile_name),
|
||||
Style::default().fg(theme.fg),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(
|
||||
"Table: {}",
|
||||
add_logic_state
|
||||
.selected_table_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| add_logic_state.selected_table_id
|
||||
.map(|id| format!("ID {}", id))
|
||||
.unwrap_or_else(|| "Global (Not Selected)".to_string()))
|
||||
),
|
||||
Style::default().fg(theme.fg),
|
||||
)),
|
||||
])
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::BOTTOM)
|
||||
.border_style(Style::default().fg(theme.secondary)),
|
||||
);
|
||||
f.render_widget(profile_text, top_info_area);
|
||||
|
||||
// Canvas - USING CANVAS LIBRARY
|
||||
let focus_on_canvas_inputs = matches!(
|
||||
add_logic_state.current_focus,
|
||||
AddLogicFocus::InputLogicName
|
||||
| AddLogicFocus::InputTargetColumn
|
||||
| AddLogicFocus::InputDescription
|
||||
);
|
||||
|
||||
let canvas_highlight_state = convert_highlight_state(highlight_state);
|
||||
let active_field_rect = render_canvas(
|
||||
f,
|
||||
canvas_area,
|
||||
add_logic_state, // AddLogicState implements CanvasState
|
||||
theme, // Theme implements CanvasTheme
|
||||
is_edit_mode && focus_on_canvas_inputs,
|
||||
&canvas_highlight_state,
|
||||
);
|
||||
|
||||
// --- Render Autocomplete for Target Column ---
|
||||
// `is_edit_mode` here refers to the general edit mode of the EventHandler
|
||||
if is_edit_mode && add_logic_state.current_field() == 1 { // Target Column field
|
||||
if add_logic_state.in_target_column_suggestion_mode && add_logic_state.show_target_column_suggestions {
|
||||
if !add_logic_state.target_column_suggestions.is_empty() {
|
||||
if let Some(input_rect) = active_field_rect {
|
||||
autocomplete::render_autocomplete_dropdown(
|
||||
f,
|
||||
input_rect,
|
||||
f.area(), // Full frame area for clamping
|
||||
theme,
|
||||
&add_logic_state.target_column_suggestions,
|
||||
add_logic_state.selected_target_column_suggestion_index,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Script content preview
|
||||
{
|
||||
let mut editor_ref = add_logic_state.script_content_editor.borrow_mut();
|
||||
editor_ref.set_cursor_line_style(Style::default());
|
||||
|
||||
let is_script_preview_focused = add_logic_state.current_focus == AddLogicFocus::ScriptContentPreview;
|
||||
|
||||
if is_script_preview_focused {
|
||||
editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
} else {
|
||||
let underscore_cursor_style = Style::default()
|
||||
.add_modifier(Modifier::UNDERLINED)
|
||||
.fg(theme.secondary);
|
||||
editor_ref.set_cursor_style(underscore_cursor_style);
|
||||
}
|
||||
|
||||
let border_style_color = if is_script_preview_focused {
|
||||
theme.highlight
|
||||
} else {
|
||||
theme.secondary
|
||||
};
|
||||
|
||||
let title_text = "Script Preview"; // Title doesn't need to change based on focus here
|
||||
|
||||
let title_style = if is_script_preview_focused {
|
||||
Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.fg)
|
||||
};
|
||||
|
||||
editor_ref.set_block(
|
||||
Block::default()
|
||||
.title(Span::styled(title_text, title_style))
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(border_style_color)),
|
||||
);
|
||||
f.render_widget(&*editor_ref, script_content_area);
|
||||
}
|
||||
|
||||
// Buttons
|
||||
let get_button_style = |button_focus: AddLogicFocus, current_focus_state: AddLogicFocus| {
|
||||
let is_focused = current_focus_state == button_focus;
|
||||
let base_style = Style::default().fg(if is_focused {
|
||||
theme.highlight
|
||||
} else {
|
||||
theme.secondary
|
||||
});
|
||||
if is_focused {
|
||||
base_style.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
base_style
|
||||
}
|
||||
};
|
||||
|
||||
let get_button_border_style = |is_focused: bool, current_theme: &Theme| {
|
||||
if is_focused {
|
||||
Style::default().fg(current_theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(current_theme.secondary)
|
||||
}
|
||||
};
|
||||
|
||||
let button_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(50),
|
||||
Constraint::Percentage(50),
|
||||
])
|
||||
.split(buttons_area);
|
||||
|
||||
let save_button = Paragraph::new(" Save Logic ")
|
||||
.style(get_button_style(
|
||||
AddLogicFocus::SaveButton,
|
||||
add_logic_state.current_focus,
|
||||
))
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(get_button_border_style(
|
||||
add_logic_state.current_focus == AddLogicFocus::SaveButton,
|
||||
theme,
|
||||
)),
|
||||
);
|
||||
f.render_widget(save_button, button_chunks[0]);
|
||||
|
||||
let cancel_button = Paragraph::new(" Cancel ")
|
||||
.style(get_button_style(
|
||||
AddLogicFocus::CancelButton,
|
||||
add_logic_state.current_focus,
|
||||
))
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(get_button_border_style(
|
||||
add_logic_state.current_focus == AddLogicFocus::CancelButton,
|
||||
theme,
|
||||
)),
|
||||
);
|
||||
f.render_widget(cancel_button, button_chunks[1]);
|
||||
|
||||
// Dialog
|
||||
if app_state.ui.dialog.dialog_show {
|
||||
dialog::render_dialog(
|
||||
f,
|
||||
f.area(),
|
||||
theme,
|
||||
&app_state.ui.dialog.dialog_title,
|
||||
&app_state.ui.dialog.dialog_message,
|
||||
&app_state.ui.dialog.dialog_buttons,
|
||||
app_state.ui.dialog.dialog_active_button_index,
|
||||
app_state.ui.dialog.is_loading,
|
||||
);
|
||||
}
|
||||
}
|
||||
577
client/src/components/admin/add_table.rs
Normal file
577
client/src/components/admin/add_table.rs
Normal file
@@ -0,0 +1,577 @@
|
||||
// src/components/admin/add_table.rs
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::add_table::{AddTableFocus, AddTableState};
|
||||
use canvas::canvas::{render_canvas, CanvasState, HighlightState as CanvasHighlightState};
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, BorderType, Borders, Cell, Paragraph, Row, Table},
|
||||
Frame,
|
||||
};
|
||||
use crate::components::common::dialog;
|
||||
|
||||
// Helper function to convert between HighlightState types
|
||||
fn convert_highlight_state(local: &HighlightState) -> CanvasHighlightState {
|
||||
match local {
|
||||
HighlightState::Off => CanvasHighlightState::Off,
|
||||
HighlightState::Characterwise { anchor } => CanvasHighlightState::Characterwise { anchor: *anchor },
|
||||
HighlightState::Linewise { anchor_line } => CanvasHighlightState::Linewise { anchor_line: *anchor_line },
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the Add New Table page layout, structuring the display of table information,
|
||||
/// input fields, and action buttons. Adapts layout based on terminal width.
|
||||
pub fn render_add_table(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
app_state: &AppState,
|
||||
add_table_state: &mut AddTableState,
|
||||
is_edit_mode: bool, // Determines if canvas inputs are in edit mode
|
||||
highlight_state: &HighlightState, // For text highlighting in canvas
|
||||
) {
|
||||
// --- Configuration ---
|
||||
// Threshold width to switch between wide and narrow layouts
|
||||
const NARROW_LAYOUT_THRESHOLD: u16 = 120; // Adjust this value as needed
|
||||
|
||||
// --- State Checks ---
|
||||
let focus_on_canvas_inputs = matches!(
|
||||
add_table_state.current_focus,
|
||||
AddTableFocus::InputTableName
|
||||
| AddTableFocus::InputColumnName
|
||||
| AddTableFocus::InputColumnType
|
||||
);
|
||||
|
||||
// --- Main Page Block ---
|
||||
let main_block = Block::default()
|
||||
.title(" Add New Table ")
|
||||
.title_alignment(Alignment::Center)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(theme.border))
|
||||
.style(Style::default().bg(theme.bg));
|
||||
let inner_area = main_block.inner(area);
|
||||
f.render_widget(main_block, area);
|
||||
|
||||
// --- Fullscreen Columns Table Check (Narrow Screens Only) ---
|
||||
if area.width < NARROW_LAYOUT_THRESHOLD && add_table_state.current_focus == AddTableFocus::InsideColumnsTable {
|
||||
// Render ONLY the columns table taking the full inner area
|
||||
let columns_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen
|
||||
let column_rows: Vec<Row<'_>> = add_table_state
|
||||
.columns
|
||||
.iter()
|
||||
.map(|col_def| {
|
||||
Row::new(vec![
|
||||
Cell::from(if col_def.selected { "[*]" } else { "[ ]" }),
|
||||
Cell::from(col_def.name.clone()),
|
||||
Cell::from(col_def.data_type.clone()),
|
||||
])
|
||||
.style(Style::default().fg(theme.fg))
|
||||
})
|
||||
.collect();
|
||||
let header_cells = ["Sel", "Name", "Type"]
|
||||
.iter()
|
||||
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
|
||||
let header = Row::new(header_cells).height(1).bottom_margin(1);
|
||||
let columns_table = Table::new(column_rows, [Constraint::Length(5), Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.header(header)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(Span::styled(" Columns (Fullscreen) ", theme.fg)) // Indicate fullscreen
|
||||
.title_alignment(Alignment::Center)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(columns_border_style),
|
||||
)
|
||||
.row_highlight_style(
|
||||
Style::default()
|
||||
.add_modifier(Modifier::REVERSED)
|
||||
.fg(theme.highlight),
|
||||
)
|
||||
.highlight_symbol(" > "); // Use the inside symbol
|
||||
f.render_stateful_widget(columns_table, inner_area, &mut add_table_state.column_table_state);
|
||||
return; // IMPORTANT: Stop rendering here for fullscreen mode
|
||||
}
|
||||
|
||||
// --- Fullscreen Indexes Table Check ---
|
||||
if add_table_state.current_focus == AddTableFocus::InsideIndexesTable { // Remove width check
|
||||
// Render ONLY the indexes table taking the full inner area
|
||||
let indexes_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen
|
||||
let index_rows: Vec<Row<'_>> = add_table_state
|
||||
.indexes
|
||||
.iter()
|
||||
.map(|index_def| {
|
||||
Row::new(vec![
|
||||
Cell::from(if index_def.selected { "[*]" } else { "[ ]" }),
|
||||
Cell::from(index_def.name.clone()),
|
||||
])
|
||||
.style(Style::default().fg(theme.fg))
|
||||
})
|
||||
.collect();
|
||||
let index_header_cells = ["Sel", "Column Name"]
|
||||
.iter()
|
||||
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
|
||||
let index_header = Row::new(index_header_cells).height(1).bottom_margin(1);
|
||||
let indexes_table = Table::new(index_rows, [Constraint::Length(5), Constraint::Percentage(95)])
|
||||
.header(index_header)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(Span::styled(" Indexes (Fullscreen) ", theme.fg)) // Indicate fullscreen
|
||||
.title_alignment(Alignment::Center)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(indexes_border_style),
|
||||
)
|
||||
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED).fg(theme.highlight))
|
||||
.highlight_symbol(" > "); // Use the inside symbol
|
||||
f.render_stateful_widget(indexes_table, inner_area, &mut add_table_state.index_table_state);
|
||||
return; // IMPORTANT: Stop rendering here for fullscreen mode
|
||||
}
|
||||
|
||||
// --- Fullscreen Links Table Check ---
|
||||
if add_table_state.current_focus == AddTableFocus::InsideLinksTable {
|
||||
// Render ONLY the links table taking the full inner area
|
||||
let links_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen
|
||||
let link_rows: Vec<Row<'_>> = add_table_state
|
||||
.links
|
||||
.iter()
|
||||
.map(|link_def| {
|
||||
Row::new(vec![
|
||||
Cell::from(if link_def.selected { "[*]" } else { "[ ]" }), // Selection first
|
||||
Cell::from(link_def.linked_table_name.clone()), // Table name second
|
||||
])
|
||||
.style(Style::default().fg(theme.fg))
|
||||
})
|
||||
.collect();
|
||||
let link_header_cells = ["Sel", "Available Table"]
|
||||
|
||||
.iter()
|
||||
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
|
||||
let link_header = Row::new(link_header_cells).height(1).bottom_margin(1);
|
||||
let links_table = Table::new(link_rows, [Constraint::Length(5), Constraint::Percentage(95)])
|
||||
.header(link_header)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(Span::styled(" Links (Fullscreen) ", theme.fg)) // Indicate fullscreen
|
||||
.title_alignment(Alignment::Center)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(links_border_style),
|
||||
)
|
||||
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED).fg(theme.highlight))
|
||||
.highlight_symbol(" > "); // Use the inside symbol
|
||||
f.render_stateful_widget(links_table, inner_area, &mut add_table_state.link_table_state);
|
||||
return; // IMPORTANT: Stop rendering here for fullscreen mode
|
||||
}
|
||||
|
||||
// --- Area Variable Declarations ---
|
||||
let top_info_area: Rect;
|
||||
let columns_area: Rect;
|
||||
let canvas_area: Rect;
|
||||
let add_button_area: Rect;
|
||||
let indexes_area: Rect;
|
||||
let links_area: Rect;
|
||||
let bottom_buttons_area: Rect;
|
||||
|
||||
// --- Layout Decision ---
|
||||
if area.width >= NARROW_LAYOUT_THRESHOLD {
|
||||
// --- WIDE Layout (Based on first screenshot) ---
|
||||
let main_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Top Info (Profile/Table Name) - Increased to 3 lines
|
||||
Constraint::Min(10), // Middle Area (Columns | Right Pane)
|
||||
Constraint::Length(3), // Bottom Buttons
|
||||
])
|
||||
.split(inner_area);
|
||||
|
||||
top_info_area = main_chunks[0];
|
||||
let middle_area = main_chunks[1];
|
||||
bottom_buttons_area = main_chunks[2];
|
||||
|
||||
// Split Middle Horizontally
|
||||
let middle_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(50), // Left: Columns Table
|
||||
Constraint::Percentage(50), // Right: Inputs etc.
|
||||
])
|
||||
.split(middle_area);
|
||||
|
||||
columns_area = middle_chunks[0];
|
||||
let right_pane_area = middle_chunks[1];
|
||||
|
||||
// Split Right Pane Vertically
|
||||
let right_pane_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(5), // Input Canvas Area
|
||||
Constraint::Length(3), // Add Button Area
|
||||
Constraint::Min(5), // Indexes & Links Area
|
||||
])
|
||||
.split(right_pane_area);
|
||||
|
||||
canvas_area = right_pane_chunks[0];
|
||||
add_button_area = right_pane_chunks[1];
|
||||
let indexes_links_area = right_pane_chunks[2];
|
||||
|
||||
// Split Indexes/Links Horizontally
|
||||
let indexes_links_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(50), // Indexes Table
|
||||
Constraint::Percentage(50), // Links Table
|
||||
])
|
||||
.split(indexes_links_area);
|
||||
indexes_area = indexes_links_chunks[0];
|
||||
links_area = indexes_links_chunks[1];
|
||||
|
||||
// --- Top Info Rendering (Wide - 2 lines) ---
|
||||
let profile_text = Paragraph::new(vec![
|
||||
Line::from(Span::styled(
|
||||
format!("Profile: {}", add_table_state.profile_name),
|
||||
theme.fg,
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!("Table name: {}", add_table_state.table_name),
|
||||
theme.fg,
|
||||
)),
|
||||
])
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::BOTTOM)
|
||||
.border_style(Style::default().fg(theme.secondary)),
|
||||
);
|
||||
f.render_widget(profile_text, top_info_area);
|
||||
} else {
|
||||
// --- NARROW Layout (Based on second screenshot) ---
|
||||
let main_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // Top: Profile & Table Name (Single Row)
|
||||
Constraint::Length(5), // Column Definition Input Canvas Area
|
||||
Constraint::Length(3), // Add Button Area
|
||||
Constraint::Min(5), // Columns Table Area
|
||||
Constraint::Min(5), // Indexes & Links Area
|
||||
Constraint::Length(3), // Bottom: Save/Cancel Buttons
|
||||
])
|
||||
.split(inner_area);
|
||||
|
||||
top_info_area = main_chunks[0];
|
||||
canvas_area = main_chunks[1];
|
||||
add_button_area = main_chunks[2];
|
||||
columns_area = main_chunks[3];
|
||||
let indexes_links_area = main_chunks[4];
|
||||
bottom_buttons_area = main_chunks[5];
|
||||
|
||||
// Split Indexes/Links Horizontally
|
||||
let indexes_links_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(50), // Indexes Table
|
||||
Constraint::Percentage(50), // Links Table
|
||||
])
|
||||
.split(indexes_links_area);
|
||||
indexes_area = indexes_links_chunks[0];
|
||||
links_area = indexes_links_chunks[1];
|
||||
|
||||
// --- Top Info Rendering (Narrow - 1 line) ---
|
||||
let top_info_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(50),
|
||||
Constraint::Percentage(50),
|
||||
])
|
||||
.split(top_info_area);
|
||||
|
||||
let profile_text = Paragraph::new(Span::styled(
|
||||
format!("Profile: {}", add_table_state.profile_name),
|
||||
theme.fg,
|
||||
))
|
||||
.alignment(Alignment::Left);
|
||||
f.render_widget(profile_text, top_info_chunks[0]);
|
||||
|
||||
let table_name_text = Paragraph::new(Span::styled(
|
||||
format!("Table: {}", add_table_state.table_name),
|
||||
theme.fg,
|
||||
))
|
||||
.alignment(Alignment::Left);
|
||||
f.render_widget(table_name_text, top_info_chunks[1]);
|
||||
}
|
||||
|
||||
// --- Common Widget Rendering (Uses calculated areas) ---
|
||||
|
||||
// --- Columns Table Rendering ---
|
||||
let columns_focused = matches!(add_table_state.current_focus, AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable);
|
||||
let columns_border_style = if columns_focused {
|
||||
Style::default().fg(theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(theme.secondary)
|
||||
};
|
||||
let column_rows: Vec<Row<'_>> = add_table_state
|
||||
.columns
|
||||
.iter()
|
||||
.map(|col_def| {
|
||||
Row::new(vec![
|
||||
Cell::from(if col_def.selected { "[*]" } else { "[ ]" }),
|
||||
Cell::from(col_def.name.clone()),
|
||||
Cell::from(col_def.data_type.clone()),
|
||||
])
|
||||
.style(Style::default().fg(theme.fg))
|
||||
})
|
||||
.collect();
|
||||
let header_cells = ["Sel", "Name", "Type"]
|
||||
.iter()
|
||||
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
|
||||
let header = Row::new(header_cells).height(1).bottom_margin(1);
|
||||
let columns_table = Table::new(
|
||||
column_rows,
|
||||
[ // Define constraints for 3 columns: Sel, Name, Type
|
||||
Constraint::Length(5),
|
||||
Constraint::Percentage(60),
|
||||
Constraint::Percentage(35),
|
||||
],
|
||||
)
|
||||
.header(header)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(Span::styled(" Columns ", theme.fg))
|
||||
.title_alignment(Alignment::Center)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(columns_border_style),
|
||||
)
|
||||
.row_highlight_style(
|
||||
Style::default()
|
||||
.add_modifier(Modifier::REVERSED)
|
||||
.fg(theme.highlight),
|
||||
)
|
||||
.highlight_symbol(" > ");
|
||||
f.render_stateful_widget(
|
||||
columns_table,
|
||||
columns_area,
|
||||
&mut add_table_state.column_table_state,
|
||||
);
|
||||
|
||||
// --- Canvas Rendering (Column Definition Input) - USING CANVAS LIBRARY ---
|
||||
let canvas_highlight_state = convert_highlight_state(highlight_state);
|
||||
let _active_field_rect = render_canvas(
|
||||
f,
|
||||
canvas_area,
|
||||
add_table_state, // AddTableState implements CanvasState
|
||||
theme, // Theme implements CanvasTheme
|
||||
is_edit_mode && focus_on_canvas_inputs,
|
||||
&canvas_highlight_state,
|
||||
);
|
||||
|
||||
// --- Button Style Helpers ---
|
||||
let get_button_style = |button_focus: AddTableFocus, current_focus| {
|
||||
// Only handles text style (FG + Bold) now, no BG
|
||||
let is_focused = current_focus == button_focus;
|
||||
let base_style = Style::default().fg(if is_focused {
|
||||
theme.highlight // Highlighted text color
|
||||
} else {
|
||||
theme.secondary // Normal text color
|
||||
});
|
||||
if is_focused {
|
||||
base_style.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
base_style
|
||||
}
|
||||
};
|
||||
// Updated signature to accept bool and theme
|
||||
let get_button_border_style = |is_focused: bool, theme: &Theme| {
|
||||
if is_focused {
|
||||
Style::default().fg(theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(theme.secondary)
|
||||
}
|
||||
};
|
||||
|
||||
// --- Add Button Rendering ---
|
||||
// Determine if the add button is focused
|
||||
let is_add_button_focused = add_table_state.current_focus == AddTableFocus::AddColumnButton;
|
||||
|
||||
// Create the Add button Paragraph widget
|
||||
let add_button = Paragraph::new(" Add ")
|
||||
.style(get_button_style(AddTableFocus::AddColumnButton, add_table_state.current_focus)) // Use existing closure
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(get_button_border_style(is_add_button_focused, theme)), // Pass bool and theme
|
||||
);
|
||||
|
||||
// Render the button in its designated area
|
||||
f.render_widget(add_button, add_button_area);
|
||||
|
||||
// --- Indexes Table Rendering ---
|
||||
let indexes_focused = matches!(add_table_state.current_focus, AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable);
|
||||
let indexes_border_style = if indexes_focused {
|
||||
Style::default().fg(theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(theme.secondary)
|
||||
};
|
||||
let index_rows: Vec<Row<'_>> = add_table_state
|
||||
.indexes
|
||||
.iter()
|
||||
.map(|index_def| { // Use index_def now
|
||||
Row::new(vec![
|
||||
Cell::from(if index_def.selected { "[*]" } else { "[ ]" }), // Display selection
|
||||
Cell::from(index_def.name.clone()),
|
||||
])
|
||||
.style(Style::default().fg(theme.fg))
|
||||
})
|
||||
.collect();
|
||||
let index_header_cells = ["Sel", "Column Name"]
|
||||
.iter()
|
||||
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
|
||||
let index_header = Row::new(index_header_cells).height(1).bottom_margin(1);
|
||||
let indexes_table =
|
||||
Table::new(index_rows, [Constraint::Length(5), Constraint::Percentage(95)])
|
||||
.header(index_header)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(Span::styled(" Indexes ", theme.fg))
|
||||
.title_alignment(Alignment::Center)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(indexes_border_style),
|
||||
)
|
||||
.row_highlight_style(
|
||||
Style::default()
|
||||
.add_modifier(Modifier::REVERSED)
|
||||
.fg(theme.highlight),
|
||||
)
|
||||
.highlight_symbol(" > ");
|
||||
f.render_stateful_widget(
|
||||
indexes_table,
|
||||
indexes_area,
|
||||
&mut add_table_state.index_table_state,
|
||||
);
|
||||
|
||||
// --- Links Table Rendering ---
|
||||
let links_focused = matches!(add_table_state.current_focus, AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable);
|
||||
let links_border_style = if links_focused {
|
||||
Style::default().fg(theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(theme.secondary)
|
||||
};
|
||||
let link_rows: Vec<Row<'_>> = add_table_state
|
||||
.links
|
||||
.iter()
|
||||
.map(|link_def| {
|
||||
Row::new(vec![
|
||||
Cell::from(if link_def.selected { "[*]" } else { "[ ]" }),
|
||||
Cell::from(link_def.linked_table_name.clone()),
|
||||
])
|
||||
.style(Style::default().fg(theme.fg))
|
||||
})
|
||||
.collect();
|
||||
let link_header_cells = ["Sel", "Available Table"]
|
||||
.iter()
|
||||
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
|
||||
let link_header = Row::new(link_header_cells).height(1).bottom_margin(1);
|
||||
let links_table =
|
||||
Table::new(link_rows, [Constraint::Length(5), Constraint::Percentage(95)])
|
||||
.header(link_header)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(Span::styled(" Links ", theme.fg))
|
||||
.title_alignment(Alignment::Center)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(links_border_style),
|
||||
)
|
||||
.row_highlight_style(
|
||||
Style::default()
|
||||
.add_modifier(Modifier::REVERSED)
|
||||
.fg(theme.highlight),
|
||||
)
|
||||
.highlight_symbol(" > ");
|
||||
f.render_stateful_widget(
|
||||
links_table,
|
||||
links_area,
|
||||
&mut add_table_state.link_table_state,
|
||||
);
|
||||
|
||||
// --- Save/Cancel Buttons Rendering ---
|
||||
let bottom_button_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(33), // Save Button
|
||||
Constraint::Percentage(34), // Delete Button
|
||||
Constraint::Percentage(33), // Cancel Button
|
||||
])
|
||||
.split(bottom_buttons_area);
|
||||
|
||||
let save_button = Paragraph::new(" Save table ")
|
||||
.style(get_button_style(
|
||||
AddTableFocus::SaveButton,
|
||||
add_table_state.current_focus,
|
||||
))
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(get_button_border_style(
|
||||
add_table_state.current_focus == AddTableFocus::SaveButton, // Pass bool
|
||||
theme,
|
||||
)),
|
||||
);
|
||||
f.render_widget(save_button, bottom_button_chunks[0]);
|
||||
|
||||
let delete_button = Paragraph::new(" Delete Selected ")
|
||||
.style(get_button_style(
|
||||
AddTableFocus::DeleteSelectedButton,
|
||||
add_table_state.current_focus,
|
||||
))
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(get_button_border_style(
|
||||
add_table_state.current_focus == AddTableFocus::DeleteSelectedButton, // Pass bool
|
||||
theme,
|
||||
)),
|
||||
);
|
||||
f.render_widget(delete_button, bottom_button_chunks[1]);
|
||||
|
||||
let cancel_button = Paragraph::new(" Cancel ")
|
||||
.style(get_button_style(
|
||||
AddTableFocus::CancelButton,
|
||||
add_table_state.current_focus,
|
||||
))
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(get_button_border_style(
|
||||
add_table_state.current_focus == AddTableFocus::CancelButton, // Pass bool
|
||||
theme,
|
||||
)),
|
||||
);
|
||||
f.render_widget(cancel_button, bottom_button_chunks[2]);
|
||||
|
||||
// --- DIALOG ---
|
||||
// Render the dialog overlay if it's active
|
||||
if app_state.ui.dialog.dialog_show {
|
||||
dialog::render_dialog(
|
||||
f,
|
||||
f.area(), // Render over the whole frame area
|
||||
theme,
|
||||
&app_state.ui.dialog.dialog_title,
|
||||
&app_state.ui.dialog.dialog_message,
|
||||
&app_state.ui.dialog.dialog_buttons,
|
||||
app_state.ui.dialog.dialog_active_button_index,
|
||||
app_state.ui.dialog.is_loading,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,118 +1,131 @@
|
||||
// src/components/admin/admin_panel.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::pages::auth::AuthState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use common::proto::komp_ac::table_definition::ProfileTreeResponse;
|
||||
use ratatui::{
|
||||
widgets::{Block, BorderType, Borders, List, ListItem, ListState, Paragraph},
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::Style,
|
||||
text::{Line, Span, Text},
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
widgets::{Block, BorderType, Borders, List, ListItem, Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
use common::proto::multieko2::table_definition::ProfileTreeResponse;
|
||||
use crate::config::colors::themes::Theme;
|
||||
use super::admin_panel_admin::render_admin_panel_admin;
|
||||
|
||||
pub struct AdminPanelState {
|
||||
pub list_state: ListState,
|
||||
pub profiles: Vec<String>,
|
||||
pub fn render_admin_panel(
|
||||
f: &mut Frame,
|
||||
app_state: &AppState,
|
||||
auth_state: &AuthState,
|
||||
admin_state: &mut AdminState,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
profile_tree: &ProfileTreeResponse,
|
||||
selected_profile: &Option<String>,
|
||||
) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(theme.accent))
|
||||
.style(Style::default().bg(theme.bg));
|
||||
|
||||
let inner_area = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Min(1)])
|
||||
.split(inner_area);
|
||||
|
||||
// Content
|
||||
let content_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
|
||||
.split(chunks[1]);
|
||||
|
||||
if auth_state.role.as_deref() != Some("admin") {
|
||||
render_admin_panel_non_admin(
|
||||
f,
|
||||
admin_state,
|
||||
&content_chunks,
|
||||
theme,
|
||||
profile_tree,
|
||||
selected_profile,
|
||||
);
|
||||
} else {
|
||||
render_admin_panel_admin(
|
||||
f,
|
||||
chunks[1],
|
||||
app_state,
|
||||
admin_state,
|
||||
theme,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl AdminPanelState {
|
||||
pub fn new(profiles: Vec<String>) -> Self {
|
||||
let mut list_state = ListState::default();
|
||||
if !profiles.is_empty() {
|
||||
list_state.select(Some(0));
|
||||
}
|
||||
Self { list_state, profiles }
|
||||
}
|
||||
|
||||
pub fn next(&mut self) {
|
||||
let i = self.list_state.selected().map_or(0, |i|
|
||||
if i >= self.profiles.len() - 1 { 0 } else { i + 1 });
|
||||
self.list_state.select(Some(i));
|
||||
}
|
||||
|
||||
pub fn previous(&mut self) {
|
||||
let i = self.list_state.selected().map_or(0, |i|
|
||||
if i == 0 { self.profiles.len() - 1 } else { i - 1 });
|
||||
self.list_state.select(Some(i));
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
&mut self,
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
profile_tree: &ProfileTreeResponse,
|
||||
selected_profile: &Option<String>,
|
||||
) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(theme.accent))
|
||||
.style(Style::default().bg(theme.bg));
|
||||
|
||||
let inner_area = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Min(1)])
|
||||
.split(inner_area);
|
||||
|
||||
// Title
|
||||
let title = Line::from(Span::styled("Admin Panel", Style::default().fg(theme.highlight)));
|
||||
let title_widget = Paragraph::new(title).alignment(Alignment::Center);
|
||||
f.render_widget(title_widget, chunks[0]);
|
||||
|
||||
// Content
|
||||
let content_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
|
||||
.split(chunks[1]);
|
||||
|
||||
// Profile list
|
||||
let items: Vec<ListItem> = self.profiles.iter()
|
||||
.map(|p| ListItem::new(Line::from(vec![
|
||||
/// Renders the view for non-admin users (profile list and details).
|
||||
fn render_admin_panel_non_admin(
|
||||
f: &mut Frame,
|
||||
admin_state: &AdminState,
|
||||
content_chunks: &[Rect],
|
||||
theme: &Theme,
|
||||
profile_tree: &ProfileTreeResponse,
|
||||
selected_profile: &Option<String>,
|
||||
) {
|
||||
// Profile list - Use data from admin_state
|
||||
let items: Vec<ListItem> = admin_state
|
||||
.profiles
|
||||
.iter()
|
||||
.map(|p| {
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
if Some(p) == selected_profile.as_ref() { "✓ " } else { " " },
|
||||
Style::default().fg(theme.accent)
|
||||
Style::default().fg(theme.accent),
|
||||
),
|
||||
Span::styled(p, Style::default().fg(theme.fg)),
|
||||
])))
|
||||
.collect();
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.block(Block::default().title("Profiles"))
|
||||
.highlight_style(Style::default().bg(theme.highlight).fg(theme.bg));
|
||||
|
||||
f.render_stateful_widget(list, content_chunks[0], &mut self.list_state);
|
||||
let list = List::new(items)
|
||||
.block(Block::default().title("Profiles"))
|
||||
.highlight_style(Style::default().bg(theme.highlight).fg(theme.bg));
|
||||
|
||||
// Profile details
|
||||
if let Some(profile) = self.list_state.selected()
|
||||
.and_then(|i| profile_tree.profiles.get(i))
|
||||
{
|
||||
let mut text = Text::default();
|
||||
text.lines.push(Line::from(vec![
|
||||
Span::styled("Profile: ", Style::default().fg(theme.accent)),
|
||||
Span::styled(&profile.name, Style::default().fg(theme.highlight)),
|
||||
]));
|
||||
let mut profile_list_state_clone = admin_state.profile_list_state.clone();
|
||||
f.render_stateful_widget(list, content_chunks[0], &mut profile_list_state_clone);
|
||||
|
||||
text.lines.push(Line::from(""));
|
||||
text.lines.push(Line::from(Span::styled("Tables:", Style::default().fg(theme.accent))));
|
||||
|
||||
for table in &profile.tables {
|
||||
let mut line = vec![Span::styled(format!("├─ {}", table.name), theme.fg)];
|
||||
if !table.depends_on.is_empty() {
|
||||
line.push(Span::styled(
|
||||
format!(" → {}", table.depends_on.join(", ")),
|
||||
Style::default().fg(theme.secondary)
|
||||
));
|
||||
}
|
||||
text.lines.push(Line::from(line));
|
||||
// Profile details - Use selection info from admin_state
|
||||
if let Some(profile) = admin_state
|
||||
.get_selected_index()
|
||||
.and_then(|i| profile_tree.profiles.get(i))
|
||||
{
|
||||
let mut text = Text::default();
|
||||
text.lines.push(Line::from(vec![
|
||||
Span::styled("Profile: ", Style::default().fg(theme.accent)),
|
||||
Span::styled(&profile.name, Style::default().fg(theme.highlight)),
|
||||
]));
|
||||
|
||||
text.lines.push(Line::from(""));
|
||||
text.lines.push(Line::from(Span::styled(
|
||||
"Tables:",
|
||||
Style::default().fg(theme.accent),
|
||||
)));
|
||||
|
||||
for table in &profile.tables {
|
||||
let mut line = vec![Span::styled(format!("├─ {}", table.name), theme.fg)];
|
||||
if !table.depends_on.is_empty() {
|
||||
line.push(Span::styled(
|
||||
format!(" → {}", table.depends_on.join(", ")),
|
||||
Style::default().fg(theme.secondary),
|
||||
));
|
||||
}
|
||||
|
||||
let details_widget = Paragraph::new(text)
|
||||
.block(Block::default().title("Details"));
|
||||
f.render_widget(details_widget, content_chunks[1]);
|
||||
text.lines.push(Line::from(line));
|
||||
}
|
||||
|
||||
let details_widget = Paragraph::new(text)
|
||||
.block(Block::default().title("Details"))
|
||||
.wrap(Wrap { trim: true });
|
||||
f.render_widget(details_widget, content_chunks[1]);
|
||||
}
|
||||
}
|
||||
|
||||
180
client/src/components/admin/admin_panel_admin.rs
Normal file
180
client/src/components/admin/admin_panel_admin.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
// src/components/admin/admin_panel_admin.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::pages::admin::{AdminFocus, AdminState};
|
||||
use crate::state::app::state::AppState;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::Style,
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, BorderType, Borders, List, ListItem, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
pub fn render_admin_panel_admin(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
app_state: &AppState,
|
||||
admin_state: &mut AdminState,
|
||||
theme: &Theme,
|
||||
) {
|
||||
let main_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(0), Constraint::Length(1)].as_ref())
|
||||
.split(area);
|
||||
let panes_area = main_chunks[0];
|
||||
let buttons_area = main_chunks[1];
|
||||
|
||||
let pane_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(25), // Profiles
|
||||
Constraint::Percentage(40), // Tables
|
||||
Constraint::Percentage(35), // Dependencies
|
||||
].as_ref())
|
||||
.split(panes_area);
|
||||
|
||||
let profiles_pane = pane_chunks[0];
|
||||
let tables_pane = pane_chunks[1];
|
||||
let deps_pane = pane_chunks[2];
|
||||
|
||||
// --- Profiles Pane (Left) ---
|
||||
let profile_pane_has_focus = matches!(admin_state.current_focus, AdminFocus::ProfilesPane | AdminFocus::InsideProfilesList);
|
||||
let profile_border_style = if profile_pane_has_focus {
|
||||
Style::default().fg(theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(theme.border)
|
||||
};
|
||||
let profiles_block = Block::default().title(" Profiles ").borders(Borders::ALL).border_type(BorderType::Rounded).border_style(profile_border_style);
|
||||
let profiles_inner_area = profiles_block.inner(profiles_pane);
|
||||
f.render_widget(profiles_block, profiles_pane);
|
||||
let profile_list_items: Vec<ListItem> = app_state.profile_tree.profiles.iter().enumerate().map(|(idx, profile)| {
|
||||
let is_persistently_selected = admin_state.selected_profile_index == Some(idx);
|
||||
let is_nav_highlighted = admin_state.profile_list_state.selected() == Some(idx) && admin_state.current_focus == AdminFocus::InsideProfilesList;
|
||||
let prefix = if is_persistently_selected { "[*] " } else { "[ ] " };
|
||||
let item_style = if is_nav_highlighted { Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD) }
|
||||
else if is_persistently_selected { Style::default().fg(theme.accent) }
|
||||
else { Style::default().fg(theme.fg) };
|
||||
ListItem::new(Line::from(vec![Span::styled(prefix, item_style), Span::styled(&profile.name, item_style)]))
|
||||
}).collect();
|
||||
let profile_list = List::new(profile_list_items)
|
||||
.highlight_style(if admin_state.current_focus == AdminFocus::InsideProfilesList { Style::default().add_modifier(ratatui::style::Modifier::REVERSED) } else { Style::default() })
|
||||
.highlight_symbol(if admin_state.current_focus == AdminFocus::InsideProfilesList { "> " } else { " " });
|
||||
f.render_stateful_widget(profile_list, profiles_inner_area, &mut admin_state.profile_list_state);
|
||||
|
||||
|
||||
// --- Tables Pane (Middle) ---
|
||||
let table_pane_has_focus = matches!(admin_state.current_focus, AdminFocus::Tables | AdminFocus::InsideTablesList);
|
||||
let table_border_style = if table_pane_has_focus { Style::default().fg(theme.highlight) } else { Style::default().fg(theme.border) };
|
||||
|
||||
let profile_to_display_tables_for_idx: Option<usize>;
|
||||
if admin_state.current_focus == AdminFocus::InsideProfilesList {
|
||||
profile_to_display_tables_for_idx = admin_state.profile_list_state.selected();
|
||||
} else {
|
||||
profile_to_display_tables_for_idx = admin_state.selected_profile_index
|
||||
.or_else(|| admin_state.profile_list_state.selected());
|
||||
}
|
||||
let tables_pane_title_profile_name = profile_to_display_tables_for_idx
|
||||
.and_then(|idx| app_state.profile_tree.profiles.get(idx))
|
||||
.map_or("None Selected", |p| p.name.as_str());
|
||||
let tables_block = Block::default().title(format!(" Tables (Profile: {}) ", tables_pane_title_profile_name)).borders(Borders::ALL).border_type(BorderType::Rounded).border_style(table_border_style);
|
||||
let tables_inner_area = tables_block.inner(tables_pane);
|
||||
f.render_widget(tables_block, tables_pane);
|
||||
|
||||
let table_list_items_for_display: Vec<ListItem> =
|
||||
if let Some(profile_data_for_tables) = profile_to_display_tables_for_idx
|
||||
.and_then(|idx| app_state.profile_tree.profiles.get(idx)) {
|
||||
profile_data_for_tables.tables.iter().enumerate().map(|(idx, table)| {
|
||||
let is_table_persistently_selected = admin_state.selected_table_index == Some(idx) &&
|
||||
profile_to_display_tables_for_idx == admin_state.selected_profile_index;
|
||||
let is_table_nav_highlighted = admin_state.table_list_state.selected() == Some(idx) &&
|
||||
admin_state.current_focus == AdminFocus::InsideTablesList;
|
||||
let prefix = if is_table_persistently_selected { "[*] " } else { "[ ] " };
|
||||
let style = if is_table_nav_highlighted { Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD) }
|
||||
else if is_table_persistently_selected { Style::default().fg(theme.accent) }
|
||||
else { Style::default().fg(theme.fg) };
|
||||
ListItem::new(Line::from(vec![Span::styled(prefix, style), Span::styled(&table.name, style)]))
|
||||
}).collect()
|
||||
} else {
|
||||
vec![ListItem::new("Select a profile to see tables")]
|
||||
};
|
||||
let table_list = List::new(table_list_items_for_display)
|
||||
.highlight_style(if admin_state.current_focus == AdminFocus::InsideTablesList { Style::default().add_modifier(ratatui::style::Modifier::REVERSED) } else { Style::default() })
|
||||
.highlight_symbol(if admin_state.current_focus == AdminFocus::InsideTablesList { "> " } else { " " });
|
||||
f.render_stateful_widget(table_list, tables_inner_area, &mut admin_state.table_list_state);
|
||||
|
||||
|
||||
// --- Dependencies Pane (Right) ---
|
||||
let mut deps_pane_title_table_name = "N/A".to_string();
|
||||
let dependencies_to_display: Vec<String>;
|
||||
|
||||
if admin_state.current_focus == AdminFocus::InsideTablesList {
|
||||
// If navigating tables, show dependencies for the '>' highlighted table.
|
||||
// The profile context is `profile_to_display_tables_for_idx` (from Tables pane logic).
|
||||
if let Some(p_idx_for_current_tables) = profile_to_display_tables_for_idx {
|
||||
if let Some(current_profile_showing_tables) = app_state.profile_tree.profiles.get(p_idx_for_current_tables) {
|
||||
if let Some(table_nav_idx) = admin_state.table_list_state.selected() { // The '>' highlighted table
|
||||
if let Some(navigated_table) = current_profile_showing_tables.tables.get(table_nav_idx) {
|
||||
deps_pane_title_table_name = navigated_table.name.clone();
|
||||
dependencies_to_display = navigated_table.depends_on.clone();
|
||||
} else {
|
||||
dependencies_to_display = Vec::new(); // Navigated table index out of bounds
|
||||
}
|
||||
} else {
|
||||
dependencies_to_display = Vec::new(); // No table navigated with '>'
|
||||
}
|
||||
} else {
|
||||
dependencies_to_display = Vec::new(); // Profile for tables out of bounds
|
||||
}
|
||||
} else {
|
||||
dependencies_to_display = Vec::new(); // No profile active for table display
|
||||
}
|
||||
} else {
|
||||
// Otherwise, show dependencies for the '[*]' persistently selected table & profile.
|
||||
if let Some(p_idx) = admin_state.selected_profile_index { // Must be a persistently selected profile
|
||||
if let Some(selected_profile) = app_state.profile_tree.profiles.get(p_idx) {
|
||||
if let Some(t_idx) = admin_state.selected_table_index { // Must be a persistently selected table
|
||||
if let Some(selected_table) = selected_profile.tables.get(t_idx) {
|
||||
deps_pane_title_table_name = selected_table.name.clone();
|
||||
dependencies_to_display = selected_table.depends_on.clone();
|
||||
} else { dependencies_to_display = Vec::new(); }
|
||||
} else { dependencies_to_display = Vec::new(); }
|
||||
} else { dependencies_to_display = Vec::new(); }
|
||||
} else { dependencies_to_display = Vec::new(); }
|
||||
}
|
||||
|
||||
let deps_block = Block::default()
|
||||
.title(format!(" Dependencies (Table: {}) ", deps_pane_title_table_name))
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(theme.border));
|
||||
let deps_inner_area = deps_block.inner(deps_pane);
|
||||
f.render_widget(deps_block, deps_pane);
|
||||
|
||||
let mut deps_content = Text::default();
|
||||
deps_content.lines.push(Line::from(Span::styled(
|
||||
"Depends On:",
|
||||
Style::default().fg(theme.accent),
|
||||
)));
|
||||
|
||||
if !dependencies_to_display.is_empty() {
|
||||
for dep in dependencies_to_display {
|
||||
deps_content.lines.push(Line::from(Span::styled(format!("- {}", dep), theme.fg)));
|
||||
}
|
||||
} else {
|
||||
deps_content.lines.push(Line::from(Span::styled(" None", theme.secondary)));
|
||||
}
|
||||
let deps_paragraph = Paragraph::new(deps_content);
|
||||
f.render_widget(deps_paragraph, deps_inner_area);
|
||||
|
||||
// --- Buttons Row ---
|
||||
let button_chunks = Layout::default().direction(Direction::Horizontal).constraints([Constraint::Percentage(33), Constraint::Percentage(34), Constraint::Percentage(33)].as_ref()).split(buttons_area);
|
||||
let btn_base_style = Style::default().fg(theme.secondary);
|
||||
let get_btn_style = |button_focus: AdminFocus| { if admin_state.current_focus == button_focus { btn_base_style.add_modifier(ratatui::style::Modifier::REVERSED) } else { btn_base_style } };
|
||||
let btn1 = Paragraph::new("Add Logic").style(get_btn_style(AdminFocus::Button1)).alignment(Alignment::Center);
|
||||
let btn2 = Paragraph::new("Add Table").style(get_btn_style(AdminFocus::Button2)).alignment(Alignment::Center);
|
||||
let btn3 = Paragraph::new("Change Table").style(get_btn_style(AdminFocus::Button3)).alignment(Alignment::Center);
|
||||
f.render_widget(btn1, button_chunks[0]);
|
||||
f.render_widget(btn2, button_chunks[1]);
|
||||
f.render_widget(btn3, button_chunks[2]);
|
||||
}
|
||||
@@ -1,111 +1,155 @@
|
||||
// src/components/login/login.rs
|
||||
// src/components/auth/login.rs
|
||||
|
||||
use crate::{
|
||||
config::colors::themes::Theme,
|
||||
state::pages::auth::LoginState,
|
||||
components::common::dialog,
|
||||
state::app::state::AppState,
|
||||
};
|
||||
use ratatui::{
|
||||
widgets::{Block, BorderType, Borders, List, ListItem, ListState, Paragraph},
|
||||
style::Style,
|
||||
text::{Line, Span, Text},
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect, Margin},
|
||||
style::{Style, Modifier, Color},
|
||||
widgets::{Block, BorderType, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use canvas::canvas::{render_canvas, HighlightState as CanvasHighlightState}; // Use canvas library's render function
|
||||
|
||||
pub struct LoginState {
|
||||
pub fields: Vec<String>,
|
||||
pub values: Vec<String>,
|
||||
pub selected_field: usize,
|
||||
}
|
||||
|
||||
impl LoginState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
fields: vec!["Username".to_string(), "Password".to_string()],
|
||||
values: vec![String::new(), String::new()],
|
||||
selected_field: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_field(&mut self) {
|
||||
self.selected_field = (self.selected_field + 1) % self.fields.len();
|
||||
}
|
||||
|
||||
pub fn previous_field(&mut self) {
|
||||
self.selected_field = if self.selected_field == 0 {
|
||||
self.fields.len() - 1
|
||||
} else {
|
||||
self.selected_field - 1
|
||||
};
|
||||
}
|
||||
|
||||
pub fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(theme.accent))
|
||||
.style(Style::default().bg(theme.bg));
|
||||
|
||||
let inner_area = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(5),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.split(inner_area);
|
||||
|
||||
// Title
|
||||
let title = Line::from(Span::styled("Login", Style::default().fg(theme.highlight)));
|
||||
let title_widget = Paragraph::new(title).alignment(Alignment::Center);
|
||||
f.render_widget(title_widget, chunks[0]);
|
||||
|
||||
// Login form
|
||||
let form_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.split(chunks[1]);
|
||||
|
||||
// Username field
|
||||
let username_block = Block::default()
|
||||
.title("Username")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(if self.selected_field == 0 {
|
||||
Style::default().fg(theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(theme.border)
|
||||
});
|
||||
let username = Paragraph::new(self.values[0].as_str())
|
||||
.block(username_block);
|
||||
f.render_widget(username, form_chunks[0]);
|
||||
|
||||
// Password field
|
||||
let password_block = Block::default()
|
||||
.title("Password")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(if self.selected_field == 1 {
|
||||
Style::default().fg(theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(theme.border)
|
||||
});
|
||||
let password = Paragraph::new("*".repeat(self.values[1].len()))
|
||||
.block(password_block);
|
||||
f.render_widget(password, form_chunks[1]);
|
||||
|
||||
// Submit button
|
||||
let submit_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(if self.selected_field == 2 {
|
||||
Style::default().fg(theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(theme.border)
|
||||
});
|
||||
let submit = Paragraph::new("Submit")
|
||||
.block(submit_block)
|
||||
.alignment(Alignment::Center);
|
||||
f.render_widget(submit, form_chunks[2]);
|
||||
// Helper function to convert between HighlightState types
|
||||
fn convert_highlight_state(local: &HighlightState) -> CanvasHighlightState {
|
||||
match local {
|
||||
HighlightState::Off => CanvasHighlightState::Off,
|
||||
HighlightState::Characterwise { anchor } => CanvasHighlightState::Characterwise { anchor: *anchor },
|
||||
HighlightState::Linewise { anchor_line } => CanvasHighlightState::Linewise { anchor_line: *anchor_line },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_login(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
login_state: &LoginState,
|
||||
app_state: &AppState,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
) {
|
||||
// Main container
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Plain)
|
||||
.border_style(Style::default().fg(theme.border))
|
||||
.title(" Login ")
|
||||
.style(Style::default().bg(theme.bg));
|
||||
|
||||
f.render_widget(block, area);
|
||||
|
||||
let inner_area = area.inner(Margin {
|
||||
horizontal: 1,
|
||||
vertical: 1,
|
||||
});
|
||||
|
||||
// Layout chunks
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(4), // Form (2 fields + padding)
|
||||
Constraint::Length(1), // Error message
|
||||
Constraint::Length(3), // Buttons
|
||||
])
|
||||
.split(inner_area);
|
||||
|
||||
// --- FORM RENDERING (Using canvas library directly) ---
|
||||
let canvas_highlight_state = convert_highlight_state(highlight_state);
|
||||
render_canvas(
|
||||
f,
|
||||
chunks[0],
|
||||
login_state, // LoginState implements CanvasState
|
||||
theme, // Theme implements CanvasTheme
|
||||
is_edit_mode,
|
||||
&canvas_highlight_state,
|
||||
);
|
||||
|
||||
// --- ERROR MESSAGE ---
|
||||
if let Some(err) = &login_state.error_message {
|
||||
f.render_widget(
|
||||
Paragraph::new(err.as_str())
|
||||
.style(Style::default().fg(Color::Red))
|
||||
.alignment(Alignment::Center),
|
||||
chunks[1],
|
||||
);
|
||||
}
|
||||
|
||||
// --- BUTTONS (unchanged) ---
|
||||
let button_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(chunks[2]);
|
||||
|
||||
// Login Button
|
||||
let login_button_index = 0;
|
||||
let login_active = if app_state.ui.focus_outside_canvas {
|
||||
app_state.focused_button_index== login_button_index
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let mut login_style = Style::default().fg(theme.fg);
|
||||
let mut login_border = Style::default().fg(theme.border);
|
||||
if login_active {
|
||||
login_style = login_style.fg(theme.highlight).add_modifier(Modifier::BOLD);
|
||||
login_border = login_border.fg(theme.accent);
|
||||
}
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new("Login")
|
||||
.style(login_style)
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Plain)
|
||||
.border_style(login_border),
|
||||
),
|
||||
button_chunks[0],
|
||||
);
|
||||
|
||||
// Return Button
|
||||
let return_button_index = 1;
|
||||
let return_active = if app_state.ui.focus_outside_canvas {
|
||||
app_state.focused_button_index== return_button_index
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let mut return_style = Style::default().fg(theme.fg);
|
||||
let mut return_border = Style::default().fg(theme.border);
|
||||
if return_active {
|
||||
return_style = return_style.fg(theme.highlight).add_modifier(Modifier::BOLD);
|
||||
return_border = return_border.fg(theme.accent);
|
||||
}
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new("Return")
|
||||
.style(return_style)
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Plain)
|
||||
.border_style(return_border),
|
||||
),
|
||||
button_chunks[1],
|
||||
);
|
||||
|
||||
// --- DIALOG ---
|
||||
if app_state.ui.dialog.dialog_show {
|
||||
dialog::render_dialog(
|
||||
f,
|
||||
f.area(),
|
||||
theme,
|
||||
&app_state.ui.dialog.dialog_title,
|
||||
&app_state.ui.dialog.dialog_message,
|
||||
&app_state.ui.dialog.dialog_buttons,
|
||||
app_state.ui.dialog.dialog_active_button_index,
|
||||
app_state.ui.dialog.is_loading,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// src/components/auth/register.rs
|
||||
|
||||
use crate::{
|
||||
config::colors::themes::Theme,
|
||||
state::pages::auth::RegisterState,
|
||||
components::common::dialog,
|
||||
state::app::state::AppState,
|
||||
modes::handlers::mode_manager::AppMode,
|
||||
};
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect, Margin},
|
||||
style::{Style, Modifier, Color},
|
||||
widgets::{Block, BorderType, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use canvas::canvas::{render_canvas, HighlightState as CanvasHighlightState}; // Use canvas library's render function
|
||||
use canvas::autocomplete::gui::render_autocomplete_dropdown;
|
||||
use canvas::autocomplete::AutocompleteCanvasState;
|
||||
|
||||
// Helper function to convert between HighlightState types
|
||||
fn convert_highlight_state(local: &HighlightState) -> CanvasHighlightState {
|
||||
match local {
|
||||
HighlightState::Off => CanvasHighlightState::Off,
|
||||
HighlightState::Characterwise { anchor } => CanvasHighlightState::Characterwise { anchor: *anchor },
|
||||
HighlightState::Linewise { anchor_line } => CanvasHighlightState::Linewise { anchor_line: *anchor_line },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_register(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
state: &RegisterState,
|
||||
app_state: &AppState,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Plain)
|
||||
.border_style(Style::default().fg(theme.border))
|
||||
.title(" Register ")
|
||||
.style(Style::default().bg(theme.bg));
|
||||
|
||||
f.render_widget(block, area);
|
||||
|
||||
let inner_area = area.inner(Margin {
|
||||
horizontal: 1,
|
||||
vertical: 1,
|
||||
});
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(7), // Form (5 fields + padding)
|
||||
Constraint::Length(1), // Help text line
|
||||
Constraint::Length(1), // Error message
|
||||
Constraint::Length(3), // Buttons
|
||||
])
|
||||
.split(inner_area);
|
||||
|
||||
// --- FORM RENDERING (Using canvas library directly) ---
|
||||
let canvas_highlight_state = convert_highlight_state(highlight_state);
|
||||
let input_rect = render_canvas(
|
||||
f,
|
||||
chunks[0],
|
||||
state, // RegisterState implements CanvasState
|
||||
theme, // Theme implements CanvasTheme
|
||||
is_edit_mode,
|
||||
&canvas_highlight_state,
|
||||
);
|
||||
|
||||
// --- HELP TEXT ---
|
||||
let help_text = Paragraph::new("* are optional fields")
|
||||
.style(Style::default().fg(theme.fg))
|
||||
.alignment(Alignment::Center);
|
||||
f.render_widget(help_text, chunks[1]);
|
||||
|
||||
// --- ERROR MESSAGE ---
|
||||
if let Some(err) = &state.error_message {
|
||||
f.render_widget(
|
||||
Paragraph::new(err.as_str())
|
||||
.style(Style::default().fg(Color::Red))
|
||||
.alignment(Alignment::Center),
|
||||
chunks[2],
|
||||
);
|
||||
}
|
||||
|
||||
// --- BUTTONS ---
|
||||
let button_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(chunks[3]);
|
||||
|
||||
// Register Button
|
||||
let register_button_index = 0;
|
||||
let register_active = if app_state.ui.focus_outside_canvas {
|
||||
app_state.focused_button_index== register_button_index
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let mut register_style = Style::default().fg(theme.fg);
|
||||
let mut register_border = Style::default().fg(theme.border);
|
||||
if register_active {
|
||||
register_style = register_style.fg(theme.highlight).add_modifier(Modifier::BOLD);
|
||||
register_border = register_border.fg(theme.accent);
|
||||
}
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new("Register")
|
||||
.style(register_style)
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Plain)
|
||||
.border_style(register_border),
|
||||
),
|
||||
button_chunks[0],
|
||||
);
|
||||
|
||||
// Return Button
|
||||
let return_button_index = 1;
|
||||
let return_active = if app_state.ui.focus_outside_canvas {
|
||||
app_state.focused_button_index== return_button_index
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let mut return_style = Style::default().fg(theme.fg);
|
||||
let mut return_border = Style::default().fg(theme.border);
|
||||
if return_active {
|
||||
return_style = return_style.fg(theme.highlight).add_modifier(Modifier::BOLD);
|
||||
return_border = return_border.fg(theme.accent);
|
||||
}
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new("Return")
|
||||
.style(return_style)
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Plain)
|
||||
.border_style(return_border),
|
||||
),
|
||||
button_chunks[1],
|
||||
);
|
||||
|
||||
// --- AUTOCOMPLETE DROPDOWN (Using canvas library directly) ---
|
||||
if app_state.current_mode == AppMode::Edit {
|
||||
if let Some(autocomplete_state) = state.autocomplete_state() {
|
||||
if let Some(input_rect) = input_rect {
|
||||
render_autocomplete_dropdown(
|
||||
f,
|
||||
f.area(), // Frame area
|
||||
input_rect, // Current input field rect
|
||||
theme, // Theme implements CanvasTheme
|
||||
autocomplete_state,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- DIALOG ---
|
||||
if app_state.ui.dialog.dialog_show {
|
||||
dialog::render_dialog(
|
||||
f,
|
||||
f.area(),
|
||||
theme,
|
||||
&app_state.ui.dialog.dialog_title,
|
||||
&app_state.ui.dialog.dialog_message,
|
||||
&app_state.ui.dialog.dialog_buttons,
|
||||
app_state.ui.dialog.dialog_active_button_index,
|
||||
app_state.ui.dialog.is_loading,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
// src/components/common.rs
|
||||
pub mod command_line;
|
||||
pub mod status_line;
|
||||
pub mod text_editor;
|
||||
pub mod background;
|
||||
pub mod dialog;
|
||||
pub mod autocomplete;
|
||||
pub mod search_palette;
|
||||
pub mod find_file_palette;
|
||||
|
||||
pub use command_line::*;
|
||||
pub use status_line::*;
|
||||
pub use text_editor::*;
|
||||
pub use background::*;
|
||||
pub use dialog::*;
|
||||
pub use autocomplete::*;
|
||||
pub use search_palette::*;
|
||||
pub use find_file_palette::*;
|
||||
|
||||
153
client/src/components/common/autocomplete.rs
Normal file
153
client/src/components/common/autocomplete.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
// src/components/common/autocomplete.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::pages::form::FormState;
|
||||
use common::proto::komp_ac::search::search_response::Hit;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
widgets::{Block, List, ListItem, ListState},
|
||||
Frame,
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// Renders an opaque dropdown list for simple string-based suggestions.
|
||||
/// THIS IS THE RESTORED FUNCTION.
|
||||
pub fn render_autocomplete_dropdown(
|
||||
f: &mut Frame,
|
||||
input_rect: Rect,
|
||||
frame_area: Rect,
|
||||
theme: &Theme,
|
||||
suggestions: &[String],
|
||||
selected_index: Option<usize>,
|
||||
) {
|
||||
if suggestions.is_empty() {
|
||||
return;
|
||||
}
|
||||
let max_suggestion_width =
|
||||
suggestions.iter().map(|s| s.width()).max().unwrap_or(0) as u16;
|
||||
let horizontal_padding: u16 = 2;
|
||||
let dropdown_width = (max_suggestion_width + horizontal_padding).max(10);
|
||||
let dropdown_height = (suggestions.len() as u16).min(5);
|
||||
|
||||
let mut dropdown_area = Rect {
|
||||
x: input_rect.x,
|
||||
y: input_rect.y + 1,
|
||||
width: dropdown_width,
|
||||
height: dropdown_height,
|
||||
};
|
||||
|
||||
if dropdown_area.bottom() > frame_area.height {
|
||||
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height);
|
||||
}
|
||||
if dropdown_area.right() > frame_area.width {
|
||||
dropdown_area.x = frame_area.width.saturating_sub(dropdown_width);
|
||||
}
|
||||
dropdown_area.x = dropdown_area.x.max(0);
|
||||
dropdown_area.y = dropdown_area.y.max(0);
|
||||
|
||||
let background_block =
|
||||
Block::default().style(Style::default().bg(Color::DarkGray));
|
||||
f.render_widget(background_block, dropdown_area);
|
||||
|
||||
let items: Vec<ListItem> = suggestions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, s)| {
|
||||
let is_selected = selected_index == Some(i);
|
||||
let s_width = s.width() as u16;
|
||||
let padding_needed = dropdown_width.saturating_sub(s_width);
|
||||
let padded_s =
|
||||
format!("{}{}", s, " ".repeat(padding_needed as usize));
|
||||
|
||||
ListItem::new(padded_s).style(if is_selected {
|
||||
Style::default()
|
||||
.fg(theme.bg)
|
||||
.bg(theme.highlight)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.fg).bg(Color::DarkGray)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items);
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(selected_index);
|
||||
|
||||
f.render_stateful_widget(list, dropdown_area, &mut list_state);
|
||||
}
|
||||
|
||||
/// Renders an opaque dropdown list for rich `Hit`-based suggestions.
|
||||
/// RENAMED from render_rich_autocomplete_dropdown
|
||||
pub fn render_hit_autocomplete_dropdown(
|
||||
f: &mut Frame,
|
||||
input_rect: Rect,
|
||||
frame_area: Rect,
|
||||
theme: &Theme,
|
||||
suggestions: &[Hit],
|
||||
selected_index: Option<usize>,
|
||||
form_state: &FormState,
|
||||
) {
|
||||
if suggestions.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let display_names: Vec<String> = suggestions
|
||||
.iter()
|
||||
.map(|hit| form_state.get_display_name_for_hit(hit))
|
||||
.collect();
|
||||
|
||||
let max_suggestion_width =
|
||||
display_names.iter().map(|s| s.width()).max().unwrap_or(0) as u16;
|
||||
let horizontal_padding: u16 = 2;
|
||||
let dropdown_width = (max_suggestion_width + horizontal_padding).max(10);
|
||||
let dropdown_height = (suggestions.len() as u16).min(5);
|
||||
|
||||
let mut dropdown_area = Rect {
|
||||
x: input_rect.x,
|
||||
y: input_rect.y + 1,
|
||||
width: dropdown_width,
|
||||
height: dropdown_height,
|
||||
};
|
||||
|
||||
if dropdown_area.bottom() > frame_area.height {
|
||||
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height);
|
||||
}
|
||||
if dropdown_area.right() > frame_area.width {
|
||||
dropdown_area.x = frame_area.width.saturating_sub(dropdown_width);
|
||||
}
|
||||
dropdown_area.x = dropdown_area.x.max(0);
|
||||
dropdown_area.y = dropdown_area.y.max(0);
|
||||
|
||||
let background_block =
|
||||
Block::default().style(Style::default().bg(Color::DarkGray));
|
||||
f.render_widget(background_block, dropdown_area);
|
||||
|
||||
let items: Vec<ListItem> = display_names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, s)| {
|
||||
let is_selected = selected_index == Some(i);
|
||||
let s_width = s.width() as u16;
|
||||
let padding_needed = dropdown_width.saturating_sub(s_width);
|
||||
let padded_s =
|
||||
format!("{}{}", s, " ".repeat(padding_needed as usize));
|
||||
|
||||
ListItem::new(padded_s).style(if is_selected {
|
||||
Style::default()
|
||||
.fg(theme.bg)
|
||||
.bg(theme.highlight)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.fg).bg(Color::DarkGray)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items);
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(selected_index);
|
||||
|
||||
f.render_stateful_widget(list, dropdown_area, &mut list_state);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
// src/client/components/command_line.rs
|
||||
// src/components/common/command_line.rs
|
||||
|
||||
use ratatui::{
|
||||
widgets::{Block, Paragraph},
|
||||
style::Style,
|
||||
@@ -6,30 +7,63 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
use crate::config::colors::themes::Theme;
|
||||
use unicode_width::UnicodeWidthStr; // Import for width calculation
|
||||
|
||||
pub fn render_command_line(f: &mut Frame, area: Rect, input: &str, active: bool, theme: &Theme, message: &str) {
|
||||
let prompt = if active {
|
||||
":"
|
||||
} else {
|
||||
""
|
||||
pub fn render_command_line(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
input: &str, // This is event_handler.command_input
|
||||
active: bool, // This is event_handler.command_mode
|
||||
theme: &Theme,
|
||||
message: &str, // This is event_handler.command_message
|
||||
) {
|
||||
// Original logic for determining display_text
|
||||
let display_text = if !active {
|
||||
// If not in normal command mode, but there's a message (e.g. from Find File palette closing)
|
||||
// Or if command mode is off and message is empty (render minimally)
|
||||
if message.is_empty() {
|
||||
"".to_string() // Render an empty string, background will cover
|
||||
} else {
|
||||
message.to_string()
|
||||
}
|
||||
} else { // active is true (normal command mode)
|
||||
let prompt = ":";
|
||||
if message.is_empty() || message == ":" {
|
||||
format!("{}{}", prompt, input)
|
||||
} else {
|
||||
if input.is_empty() { // If command was just executed, input is cleared, show message
|
||||
message.to_string()
|
||||
} else { // Show input and message
|
||||
format!("{}{} | {}", prompt, input, message)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Combine the prompt, input, and message
|
||||
let display_text = if message.is_empty() {
|
||||
format!("{}{}", prompt, input)
|
||||
let content_width = UnicodeWidthStr::width(display_text.as_str());
|
||||
let available_width = area.width as usize;
|
||||
let padding_needed = available_width.saturating_sub(content_width);
|
||||
|
||||
let display_text_padded = if padding_needed > 0 {
|
||||
format!("{}{}", display_text, " ".repeat(padding_needed))
|
||||
} else {
|
||||
format!("{}{} | {}", prompt, input, message)
|
||||
// If text is too long, ratatui's Paragraph will handle truncation.
|
||||
// We could also truncate here if specific behavior is needed:
|
||||
// display_text.chars().take(available_width).collect::<String>()
|
||||
display_text
|
||||
};
|
||||
|
||||
let style = if active {
|
||||
// Determine style based on active state, but apply to the whole paragraph
|
||||
let text_style = if active {
|
||||
Style::default().fg(theme.accent)
|
||||
} else {
|
||||
// If not active, but there's a message, use default foreground.
|
||||
// If message is also empty, this style won't matter much for empty text.
|
||||
Style::default().fg(theme.fg)
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(display_text)
|
||||
.block(Block::default().style(Style::default().bg(theme.bg)))
|
||||
.style(style);
|
||||
let paragraph = Paragraph::new(display_text_padded)
|
||||
.block(Block::default().style(Style::default().bg(theme.bg))) // Block ensures bg for whole area
|
||||
.style(text_style); // Style for the text itself
|
||||
|
||||
f.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
183
client/src/components/common/dialog.rs
Normal file
183
client/src/components/common/dialog.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
use crate::config::colors::themes::Theme;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Margin, Rect},
|
||||
prelude::Alignment,
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, BorderType, Borders, Paragraph, Clear},
|
||||
Frame,
|
||||
};
|
||||
use unicode_segmentation::UnicodeSegmentation; // For grapheme clusters
|
||||
use unicode_width::UnicodeWidthStr; // For accurate width calculation
|
||||
|
||||
pub fn render_dialog(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
dialog_title: &str,
|
||||
dialog_message: &str,
|
||||
dialog_buttons: &[String],
|
||||
dialog_active_button_index: usize,
|
||||
is_loading: bool,
|
||||
) {
|
||||
// Calculate required height based on the actual number of lines in the message
|
||||
let message_lines: Vec<_> = dialog_message.lines().collect();
|
||||
let message_height = message_lines.len() as u16;
|
||||
let button_row_height = if dialog_buttons.is_empty() { 0 } else { 3 };
|
||||
let vertical_padding = 2; // Block borders (top/bottom)
|
||||
let inner_vertical_margin = 2; // Margin inside block (top/bottom)
|
||||
|
||||
// Calculate required height based on actual message lines
|
||||
let required_inner_height =
|
||||
message_height + button_row_height + inner_vertical_margin;
|
||||
let required_total_height = required_inner_height + vertical_padding;
|
||||
|
||||
// Use a fixed percentage width, clamped to min/max
|
||||
let width_percentage: u16 = 60;
|
||||
let dialog_width = (area.width * width_percentage / 100)
|
||||
.max(20) // Minimum width
|
||||
.min(area.width); // Maximum width
|
||||
|
||||
// Ensure height doesn't exceed available area
|
||||
let dialog_height = required_total_height.min(area.height);
|
||||
|
||||
// Calculate centered area manually
|
||||
let dialog_x = area.x + (area.width.saturating_sub(dialog_width)) / 2;
|
||||
let dialog_y = area.y + (area.height.saturating_sub(dialog_height)) / 2;
|
||||
let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
|
||||
|
||||
// Clear the area first before drawing the dialog
|
||||
f.render_widget(Clear, dialog_area);
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(theme.accent))
|
||||
.title(format!(" {} ", dialog_title)) // Add padding to title
|
||||
.style(Style::default().bg(theme.bg));
|
||||
|
||||
f.render_widget(block, dialog_area);
|
||||
|
||||
// Calculate inner area *after* defining the block
|
||||
let inner_area = dialog_area.inner(Margin {
|
||||
horizontal: 2, // Left/Right padding inside border
|
||||
vertical: 1, // Top/Bottom padding inside border
|
||||
});
|
||||
|
||||
if is_loading {
|
||||
// --- Loading State ---
|
||||
let loading_text = Paragraph::new(dialog_message) // Use the message passed for loading
|
||||
.style(Style::default().fg(theme.fg).add_modifier(Modifier::ITALIC))
|
||||
.alignment(Alignment::Center);
|
||||
// Render loading message centered in the inner area
|
||||
f.render_widget(loading_text, inner_area);
|
||||
} else {
|
||||
// --- Normal State (Message + Buttons) ---
|
||||
|
||||
// Layout for Message and Buttons based on actual message height
|
||||
let mut constraints = vec![
|
||||
// Allocate space for message, ensuring at least 1 line height
|
||||
Constraint::Length(message_height.max(1)), // Use actual calculated height
|
||||
];
|
||||
if button_row_height > 0 {
|
||||
constraints.push(Constraint::Length(button_row_height));
|
||||
}
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(constraints)
|
||||
.split(inner_area);
|
||||
|
||||
// Render Message
|
||||
let available_width = inner_area.width as usize;
|
||||
let ellipsis = "...";
|
||||
let ellipsis_width = UnicodeWidthStr::width(ellipsis);
|
||||
|
||||
let processed_lines: Vec<Line> = message_lines
|
||||
.into_iter()
|
||||
.map(|line| {
|
||||
let line_width = UnicodeWidthStr::width(line);
|
||||
if line_width > available_width {
|
||||
// Truncate with ellipsis
|
||||
let mut truncated_len = 0;
|
||||
let mut current_width = 0;
|
||||
for (idx, grapheme) in line.grapheme_indices(true) {
|
||||
let grapheme_width = UnicodeWidthStr::width(grapheme);
|
||||
if current_width + grapheme_width
|
||||
> available_width.saturating_sub(ellipsis_width)
|
||||
{
|
||||
break;
|
||||
}
|
||||
current_width += grapheme_width;
|
||||
truncated_len = idx + grapheme.len();
|
||||
}
|
||||
let truncated_line =
|
||||
format!("{}{}", &line[..truncated_len], ellipsis);
|
||||
Line::from(Span::styled(
|
||||
truncated_line,
|
||||
Style::default().fg(theme.fg),
|
||||
))
|
||||
} else {
|
||||
Line::from(Span::styled(line, Style::default().fg(theme.fg)))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let message_paragraph =
|
||||
Paragraph::new(Text::from(processed_lines)).alignment(Alignment::Center);
|
||||
f.render_widget(message_paragraph, chunks[0]); // Render message in the first chunk
|
||||
|
||||
// Render Buttons if they exist and there's a chunk for them
|
||||
if !dialog_buttons.is_empty() && chunks.len() > 1 {
|
||||
let button_area = chunks[1];
|
||||
let button_count = dialog_buttons.len();
|
||||
|
||||
let button_constraints = std::iter::repeat(Constraint::Ratio(
|
||||
1,
|
||||
button_count as u32,
|
||||
))
|
||||
.take(button_count)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let button_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints(button_constraints)
|
||||
.horizontal_margin(1) // Add space between buttons
|
||||
.split(button_area);
|
||||
|
||||
for (i, button_label) in dialog_buttons.iter().enumerate() {
|
||||
if i >= button_chunks.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let is_active = i == dialog_active_button_index;
|
||||
let (button_style, border_style) = if is_active {
|
||||
(
|
||||
Style::default()
|
||||
.fg(theme.highlight)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(theme.accent),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Style::default().fg(theme.fg),
|
||||
Style::default().fg(theme.border),
|
||||
)
|
||||
};
|
||||
|
||||
let button_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Plain)
|
||||
.border_style(border_style);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(button_label.as_str())
|
||||
.block(button_block)
|
||||
.style(button_style)
|
||||
.alignment(Alignment::Center),
|
||||
button_chunks[i],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
142
client/src/components/common/find_file_palette.rs
Normal file
142
client/src/components/common/find_file_palette.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
// src/components/common/find_file_palette.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::modes::general::command_navigation::NavigationState; // Corrected path
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::Style,
|
||||
widgets::{Block, List, ListItem, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
const PALETTE_MAX_VISIBLE_OPTIONS: usize = 15;
|
||||
const PADDING_CHAR: &str = " ";
|
||||
|
||||
pub fn render_find_file_palette(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
navigation_state: &NavigationState,
|
||||
) {
|
||||
let palette_display_input = navigation_state.get_display_input(); // Use the new method
|
||||
|
||||
let num_total_filtered = navigation_state.filtered_options.len();
|
||||
let current_selected_list_idx = navigation_state.selected_index;
|
||||
|
||||
let mut display_start_offset = 0;
|
||||
if num_total_filtered > PALETTE_MAX_VISIBLE_OPTIONS {
|
||||
if let Some(sel_idx) = current_selected_list_idx {
|
||||
if sel_idx >= display_start_offset + PALETTE_MAX_VISIBLE_OPTIONS {
|
||||
display_start_offset = sel_idx - PALETTE_MAX_VISIBLE_OPTIONS + 1;
|
||||
} else if sel_idx < display_start_offset {
|
||||
display_start_offset = sel_idx;
|
||||
}
|
||||
display_start_offset = display_start_offset
|
||||
.min(num_total_filtered.saturating_sub(PALETTE_MAX_VISIBLE_OPTIONS));
|
||||
}
|
||||
}
|
||||
display_start_offset = display_start_offset.max(0);
|
||||
|
||||
let display_end_offset = (display_start_offset + PALETTE_MAX_VISIBLE_OPTIONS)
|
||||
.min(num_total_filtered);
|
||||
|
||||
// navigation_state.filtered_options is Vec<(usize, String)>
|
||||
// We only need the String part for display.
|
||||
let visible_options_slice: Vec<&String> = if num_total_filtered > 0 {
|
||||
navigation_state.filtered_options
|
||||
[display_start_offset..display_end_offset]
|
||||
.iter()
|
||||
.map(|(_, opt_str)| opt_str)
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // For palette input line
|
||||
Constraint::Min(0), // For options list, take remaining space
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Ensure list_area height does not exceed PALETTE_MAX_VISIBLE_OPTIONS
|
||||
let list_area_height = std::cmp::min(chunks[1].height, PALETTE_MAX_VISIBLE_OPTIONS as u16);
|
||||
let final_list_area = Rect::new(chunks[1].x, chunks[1].y, chunks[1].width, list_area_height);
|
||||
|
||||
|
||||
let input_area = chunks[0];
|
||||
// let list_area = chunks[1]; // Use final_list_area
|
||||
|
||||
let prompt_prefix = match navigation_state.navigation_type {
|
||||
crate::modes::general::command_navigation::NavigationType::FindFile => "Find File: ",
|
||||
crate::modes::general::command_navigation::NavigationType::TableTree => "Table Path: ",
|
||||
};
|
||||
let base_prompt_text = format!("{}{}", prompt_prefix, palette_display_input);
|
||||
let prompt_text_width = UnicodeWidthStr::width(base_prompt_text.as_str());
|
||||
let input_area_width = input_area.width as usize;
|
||||
let input_padding_needed =
|
||||
input_area_width.saturating_sub(prompt_text_width);
|
||||
|
||||
let padded_prompt_text = if input_padding_needed > 0 {
|
||||
format!(
|
||||
"{}{}",
|
||||
base_prompt_text,
|
||||
PADDING_CHAR.repeat(input_padding_needed)
|
||||
)
|
||||
} else {
|
||||
base_prompt_text
|
||||
};
|
||||
|
||||
let input_paragraph = Paragraph::new(padded_prompt_text)
|
||||
.style(Style::default().fg(theme.accent).bg(theme.bg));
|
||||
f.render_widget(input_paragraph, input_area);
|
||||
|
||||
let mut display_list_items: Vec<ListItem> =
|
||||
Vec::with_capacity(PALETTE_MAX_VISIBLE_OPTIONS);
|
||||
|
||||
for (idx_in_visible_slice, opt_str) in
|
||||
visible_options_slice.iter().enumerate()
|
||||
{
|
||||
// The selected_index in navigation_state is relative to the full filtered_options list.
|
||||
// We need to check if the current item (from the visible slice) corresponds to the selected_index.
|
||||
let original_filtered_idx = display_start_offset + idx_in_visible_slice;
|
||||
let is_selected =
|
||||
current_selected_list_idx == Some(original_filtered_idx);
|
||||
|
||||
let style = if is_selected {
|
||||
Style::default().fg(theme.bg).bg(theme.accent)
|
||||
} else {
|
||||
Style::default().fg(theme.fg).bg(theme.bg)
|
||||
};
|
||||
|
||||
let opt_width = opt_str.width() as u16;
|
||||
let list_item_width = final_list_area.width;
|
||||
let padding_amount = list_item_width.saturating_sub(opt_width);
|
||||
let padded_opt_str = format!(
|
||||
"{}{}",
|
||||
opt_str,
|
||||
PADDING_CHAR.repeat(padding_amount as usize)
|
||||
);
|
||||
display_list_items.push(ListItem::new(padded_opt_str).style(style));
|
||||
}
|
||||
|
||||
// Fill remaining lines in the list area to maintain fixed height appearance
|
||||
let num_rendered_options = display_list_items.len();
|
||||
if num_rendered_options < PALETTE_MAX_VISIBLE_OPTIONS && (final_list_area.height as usize) > num_rendered_options {
|
||||
for _ in num_rendered_options..(final_list_area.height as usize) {
|
||||
let empty_padded_str =
|
||||
PADDING_CHAR.repeat(final_list_area.width as usize);
|
||||
display_list_items.push(
|
||||
ListItem::new(empty_padded_str)
|
||||
.style(Style::default().fg(theme.bg).bg(theme.bg)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let options_list_widget = List::new(display_list_items)
|
||||
.block(Block::default().style(Style::default().bg(theme.bg)));
|
||||
f.render_widget(options_list_widget, final_list_area);
|
||||
}
|
||||
121
client/src/components/common/search_palette.rs
Normal file
121
client/src/components/common/search_palette.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
// src/components/common/search_palette.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::search::SearchState;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Renders the search palette dialog over the main UI.
|
||||
pub fn render_search_palette(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
state: &SearchState,
|
||||
) {
|
||||
// --- Dialog Area Calculation ---
|
||||
let height = (area.height as f32 * 0.7).min(30.0) as u16;
|
||||
let width = (area.width as f32 * 0.6).min(100.0) as u16;
|
||||
let dialog_area = Rect {
|
||||
x: area.x + (area.width - width) / 2,
|
||||
y: area.y + (area.height - height) / 4,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
|
||||
f.render_widget(Clear, dialog_area); // Clear background
|
||||
|
||||
let block = Block::default()
|
||||
.title(format!(" Search in '{}' ", state.table_name))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme.accent));
|
||||
f.render_widget(block.clone(), dialog_area);
|
||||
|
||||
// --- Inner Layout (Input + Results) ---
|
||||
let inner_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.margin(1)
|
||||
.constraints([
|
||||
Constraint::Length(3), // For input box
|
||||
Constraint::Min(0), // For results list
|
||||
])
|
||||
.split(dialog_area);
|
||||
|
||||
// --- Render Input Box ---
|
||||
let input_block = Block::default()
|
||||
.title("Query")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme.border));
|
||||
let input_text = Paragraph::new(state.input.as_str())
|
||||
.block(input_block)
|
||||
.style(Style::default().fg(theme.fg));
|
||||
f.render_widget(input_text, inner_chunks[0]);
|
||||
// Set cursor position
|
||||
f.set_cursor_position((
|
||||
inner_chunks[0].x + state.cursor_position as u16 + 1,
|
||||
inner_chunks[0].y + 1,
|
||||
));
|
||||
|
||||
// --- Render Results List ---
|
||||
if state.is_loading {
|
||||
let loading_p = Paragraph::new("Searching...")
|
||||
.style(Style::default().fg(theme.fg).add_modifier(Modifier::ITALIC));
|
||||
f.render_widget(loading_p, inner_chunks[1]);
|
||||
} else {
|
||||
let list_items: Vec<ListItem> = state
|
||||
.results
|
||||
.iter()
|
||||
.map(|hit| {
|
||||
// Parse the JSON string to make it readable
|
||||
let content_summary = match serde_json::from_str::<
|
||||
serde_json::Value,
|
||||
>(&hit.content_json)
|
||||
{
|
||||
Ok(json) => {
|
||||
if let Some(obj) = json.as_object() {
|
||||
// Create a summary from the first few non-null string values
|
||||
obj.values()
|
||||
.filter_map(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.take(3)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" | ")
|
||||
} else {
|
||||
"Non-object JSON".to_string()
|
||||
}
|
||||
}
|
||||
Err(_) => "Invalid JSON content".to_string(),
|
||||
};
|
||||
|
||||
let line = Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{:<4.2} ", hit.score),
|
||||
Style::default().fg(theme.accent),
|
||||
),
|
||||
Span::raw(content_summary),
|
||||
]);
|
||||
ListItem::new(line)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results_list = List::new(list_items)
|
||||
.block(Block::default().title("Results"))
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.bg(theme.highlight)
|
||||
.fg(theme.bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol(">> ");
|
||||
|
||||
// We need a mutable ListState to render the selection
|
||||
let mut list_state =
|
||||
ratatui::widgets::ListState::default().with_selected(Some(state.selected_index));
|
||||
|
||||
f.render_stateful_widget(results_list, inner_chunks[1], &mut list_state);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
// src/client/components/handlers/status_line.rs
|
||||
use ratatui::{
|
||||
widgets::Paragraph,
|
||||
style::Style,
|
||||
layout::Rect,
|
||||
Frame,
|
||||
text::{Line, Span},
|
||||
};
|
||||
// client/src/components/common/status_line.rs
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::state::AppState;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span, Text},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
use ratatui::widgets::Wrap;
|
||||
use std::path::Path;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
pub fn render_status_line(
|
||||
f: &mut Frame,
|
||||
@@ -15,66 +18,138 @@ pub fn render_status_line(
|
||||
current_dir: &str,
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool,
|
||||
current_fps: f64,
|
||||
app_state: &AppState,
|
||||
) {
|
||||
// Program name and version
|
||||
let program_info = format!("multieko2 v{}", env!("CARGO_PKG_VERSION"));
|
||||
#[cfg(feature = "ui-debug")]
|
||||
{
|
||||
if let Some(debug_state) = &app_state.debug_state {
|
||||
let paragraph = if debug_state.is_error {
|
||||
// --- THIS IS THE CRITICAL LOGIC FOR ERRORS ---
|
||||
// 1. Create a `Text` object, which can contain multiple lines.
|
||||
let error_text = Text::from(debug_state.displayed_message.clone());
|
||||
|
||||
let mode_text = if is_edit_mode {
|
||||
"[EDIT]"
|
||||
} else {
|
||||
"[READ-ONLY]"
|
||||
};
|
||||
// 2. Create a Paragraph from the Text and TELL IT TO WRAP.
|
||||
Paragraph::new(error_text)
|
||||
.wrap(Wrap { trim: true }) // This line makes the text break into new rows.
|
||||
.style(Style::default().bg(theme.highlight).fg(theme.bg))
|
||||
} else {
|
||||
// --- This is for normal, single-line info messages ---
|
||||
Paragraph::new(debug_state.displayed_message.as_str())
|
||||
.style(Style::default().fg(theme.accent).bg(theme.bg))
|
||||
};
|
||||
f.render_widget(paragraph, area);
|
||||
} else {
|
||||
// Fallback for when debug state is None
|
||||
let paragraph = Paragraph::new("").style(Style::default().bg(theme.bg));
|
||||
f.render_widget(paragraph, area);
|
||||
}
|
||||
return; // Stop here and don't render the normal status line.
|
||||
}
|
||||
|
||||
// Shorten the current directory path
|
||||
let home_dir = dirs::home_dir().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
// --- The normal status line rendering logic (unchanged) ---
|
||||
let program_info = format!("komp_ac v{}", env!("CARGO_PKG_VERSION"));
|
||||
let mode_text = if is_edit_mode { "[EDIT]" } else { "[READ-ONLY]" };
|
||||
|
||||
let home_dir = dirs::home_dir()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let display_dir = if current_dir.starts_with(&home_dir) {
|
||||
current_dir.replacen(&home_dir, "~", 1)
|
||||
} else {
|
||||
current_dir.to_string()
|
||||
};
|
||||
|
||||
// Create the full status line text
|
||||
let full_text = format!("{} | {} | {}", mode_text, display_dir, program_info);
|
||||
|
||||
// Check if the full text fits in the available width
|
||||
let available_width = area.width as usize;
|
||||
let mut display_text = if full_text.len() <= available_width {
|
||||
// If it fits, use the full text
|
||||
full_text
|
||||
let mode_width = UnicodeWidthStr::width(mode_text);
|
||||
let program_info_width = UnicodeWidthStr::width(program_info.as_str());
|
||||
let fps_text = format!("{:.0} FPS", current_fps);
|
||||
let fps_width = UnicodeWidthStr::width(fps_text.as_str());
|
||||
let separator = " | ";
|
||||
let separator_width = UnicodeWidthStr::width(separator);
|
||||
|
||||
let fixed_width_with_fps = mode_width
|
||||
+ separator_width
|
||||
+ separator_width
|
||||
+ program_info_width
|
||||
+ separator_width
|
||||
+ fps_width;
|
||||
|
||||
let show_fps = fixed_width_with_fps <= available_width;
|
||||
|
||||
let remaining_width_for_dir = available_width.saturating_sub(
|
||||
mode_width
|
||||
+ separator_width
|
||||
+ separator_width
|
||||
+ program_info_width
|
||||
+ (if show_fps {
|
||||
separator_width + fps_width
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
);
|
||||
|
||||
let dir_display_text_str = if UnicodeWidthStr::width(display_dir.as_str())
|
||||
<= remaining_width_for_dir
|
||||
{
|
||||
display_dir
|
||||
} else {
|
||||
// If it doesn't fit, prioritize mode and program info, and show only the directory name
|
||||
let dir_name = Path::new(current_dir)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(current_dir);
|
||||
format!("{} | {} | {}", mode_text, dir_name, program_info)
|
||||
if UnicodeWidthStr::width(dir_name) <= remaining_width_for_dir {
|
||||
dir_name.to_string()
|
||||
} else {
|
||||
dir_name
|
||||
.chars()
|
||||
.take(remaining_width_for_dir)
|
||||
.collect::<String>()
|
||||
}
|
||||
};
|
||||
|
||||
// If even the shortened version overflows, truncate it
|
||||
if display_text.len() > available_width {
|
||||
display_text = display_text.chars().take(available_width).collect();
|
||||
let mut current_content_width = mode_width
|
||||
+ separator_width
|
||||
+ UnicodeWidthStr::width(dir_display_text_str.as_str())
|
||||
+ separator_width
|
||||
+ program_info_width;
|
||||
if show_fps {
|
||||
current_content_width += separator_width + fps_width;
|
||||
}
|
||||
|
||||
// Create the status line text using Line and Span
|
||||
let status_line = Line::from(vec![
|
||||
let mut line_spans = vec![
|
||||
Span::styled(mode_text, Style::default().fg(theme.accent)),
|
||||
Span::styled(" | ", Style::default().fg(theme.border)),
|
||||
Span::styled(separator, Style::default().fg(theme.border)),
|
||||
Span::styled(
|
||||
display_text.split(" | ").nth(1).unwrap_or(""), // Directory part
|
||||
dir_display_text_str.as_str(),
|
||||
Style::default().fg(theme.fg),
|
||||
),
|
||||
Span::styled(" | ", Style::default().fg(theme.border)),
|
||||
Span::styled(separator, Style::default().fg(theme.border)),
|
||||
Span::styled(
|
||||
program_info,
|
||||
Style::default()
|
||||
.fg(theme.secondary)
|
||||
.add_modifier(ratatui::style::Modifier::BOLD),
|
||||
program_info.as_str(),
|
||||
Style::default().fg(theme.secondary),
|
||||
),
|
||||
]);
|
||||
];
|
||||
|
||||
// Render the status line
|
||||
let paragraph = Paragraph::new(status_line)
|
||||
.style(Style::default().bg(theme.bg));
|
||||
if show_fps {
|
||||
line_spans
|
||||
.push(Span::styled(separator, Style::default().fg(theme.border)));
|
||||
line_spans.push(Span::styled(
|
||||
fps_text.as_str(),
|
||||
Style::default().fg(theme.secondary),
|
||||
));
|
||||
}
|
||||
|
||||
let padding_needed = available_width.saturating_sub(current_content_width);
|
||||
if padding_needed > 0 {
|
||||
line_spans.push(Span::styled(
|
||||
" ".repeat(padding_needed),
|
||||
Style::default().bg(theme.bg),
|
||||
));
|
||||
}
|
||||
|
||||
let paragraph =
|
||||
Paragraph::new(Line::from(line_spans)).style(Style::default().bg(theme.bg));
|
||||
|
||||
f.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
331
client/src/components/common/text_editor.rs
Normal file
331
client/src/components/common/text_editor.rs
Normal file
@@ -0,0 +1,331 @@
|
||||
// src/components/common/text_editor.rs
|
||||
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
|
||||
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
|
||||
use ratatui::style::{Color, Style, Modifier};
|
||||
use tui_textarea::{Input, Key, TextArea, CursorMove};
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VimMode {
|
||||
Normal,
|
||||
Insert,
|
||||
Visual,
|
||||
Operator(char),
|
||||
}
|
||||
|
||||
impl VimMode {
|
||||
pub fn cursor_style(&self) -> Style {
|
||||
let color = match self {
|
||||
Self::Normal => Color::Reset,
|
||||
Self::Insert => Color::LightBlue,
|
||||
Self::Visual => Color::LightYellow,
|
||||
Self::Operator(_) => Color::LightGreen,
|
||||
};
|
||||
Style::default().fg(color).add_modifier(Modifier::REVERSED)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for VimMode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
|
||||
match self {
|
||||
Self::Normal => write!(f, "NORMAL"),
|
||||
Self::Insert => write!(f, "INSERT"),
|
||||
Self::Visual => write!(f, "VISUAL"),
|
||||
Self::Operator(c) => write!(f, "OPERATOR({})", c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Transition {
|
||||
Nop,
|
||||
Mode(VimMode),
|
||||
Pending(Input),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VimState {
|
||||
pub mode: VimMode,
|
||||
pub pending: Input,
|
||||
}
|
||||
|
||||
impl Default for VimState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: VimMode::Normal,
|
||||
pending: Input::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VimState {
|
||||
pub fn new(mode: VimMode) -> Self {
|
||||
Self {
|
||||
mode,
|
||||
pending: Input::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_pending(self, pending: Input) -> Self {
|
||||
Self {
|
||||
mode: self.mode,
|
||||
pending,
|
||||
}
|
||||
}
|
||||
|
||||
fn transition(&self, input: Input, textarea: &mut TextArea<'_>) -> Transition {
|
||||
if input.key == Key::Null {
|
||||
return Transition::Nop;
|
||||
}
|
||||
|
||||
match self.mode {
|
||||
VimMode::Normal | VimMode::Visual | VimMode::Operator(_) => {
|
||||
match input {
|
||||
Input { key: Key::Char('h'), .. } => textarea.move_cursor(CursorMove::Back),
|
||||
Input { key: Key::Char('j'), .. } => textarea.move_cursor(CursorMove::Down),
|
||||
Input { key: Key::Char('k'), .. } => textarea.move_cursor(CursorMove::Up),
|
||||
Input { key: Key::Char('l'), .. } => textarea.move_cursor(CursorMove::Forward),
|
||||
Input { key: Key::Char('w'), .. } => textarea.move_cursor(CursorMove::WordForward),
|
||||
Input { key: Key::Char('e'), ctrl: false, .. } => {
|
||||
textarea.move_cursor(CursorMove::WordEnd);
|
||||
if matches!(self.mode, VimMode::Operator(_)) {
|
||||
textarea.move_cursor(CursorMove::Forward);
|
||||
}
|
||||
}
|
||||
Input { key: Key::Char('b'), ctrl: false, .. } => textarea.move_cursor(CursorMove::WordBack),
|
||||
Input { key: Key::Char('^'), .. } => textarea.move_cursor(CursorMove::Head),
|
||||
Input { key: Key::Char('$'), .. } => textarea.move_cursor(CursorMove::End),
|
||||
Input { key: Key::Char('0'), .. } => textarea.move_cursor(CursorMove::Head),
|
||||
Input { key: Key::Char('D'), .. } => {
|
||||
textarea.delete_line_by_end();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('C'), .. } => {
|
||||
textarea.delete_line_by_end();
|
||||
textarea.cancel_selection();
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
Input { key: Key::Char('p'), .. } => {
|
||||
textarea.paste();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('u'), ctrl: false, .. } => {
|
||||
textarea.undo();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('r'), ctrl: true, .. } => {
|
||||
textarea.redo();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('x'), .. } => {
|
||||
textarea.delete_next_char();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('i'), .. } => {
|
||||
textarea.cancel_selection();
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
Input { key: Key::Char('a'), .. } => {
|
||||
textarea.cancel_selection();
|
||||
textarea.move_cursor(CursorMove::Forward);
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
Input { key: Key::Char('A'), .. } => {
|
||||
textarea.cancel_selection();
|
||||
textarea.move_cursor(CursorMove::End);
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
Input { key: Key::Char('o'), .. } => {
|
||||
textarea.move_cursor(CursorMove::End);
|
||||
textarea.insert_newline();
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
Input { key: Key::Char('O'), .. } => {
|
||||
textarea.move_cursor(CursorMove::Head);
|
||||
textarea.insert_newline();
|
||||
textarea.move_cursor(CursorMove::Up);
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
Input { key: Key::Char('I'), .. } => {
|
||||
textarea.cancel_selection();
|
||||
textarea.move_cursor(CursorMove::Head);
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
Input { key: Key::Char('v'), ctrl: false, .. } if self.mode == VimMode::Normal => {
|
||||
textarea.start_selection();
|
||||
return Transition::Mode(VimMode::Visual);
|
||||
}
|
||||
Input { key: Key::Char('V'), ctrl: false, .. } if self.mode == VimMode::Normal => {
|
||||
textarea.move_cursor(CursorMove::Head);
|
||||
textarea.start_selection();
|
||||
textarea.move_cursor(CursorMove::End);
|
||||
return Transition::Mode(VimMode::Visual);
|
||||
}
|
||||
Input { key: Key::Esc, .. } | Input { key: Key::Char('v'), ctrl: false, .. } if self.mode == VimMode::Visual => {
|
||||
textarea.cancel_selection();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('g'), ctrl: false, .. } if matches!(
|
||||
self.pending,
|
||||
Input { key: Key::Char('g'), ctrl: false, .. }
|
||||
) => {
|
||||
textarea.move_cursor(CursorMove::Top)
|
||||
}
|
||||
Input { key: Key::Char('G'), ctrl: false, .. } => textarea.move_cursor(CursorMove::Bottom),
|
||||
Input { key: Key::Char(c), ctrl: false, .. } if self.mode == VimMode::Operator(c) => {
|
||||
textarea.move_cursor(CursorMove::Head);
|
||||
textarea.start_selection();
|
||||
let cursor = textarea.cursor();
|
||||
textarea.move_cursor(CursorMove::Down);
|
||||
if cursor == textarea.cursor() {
|
||||
textarea.move_cursor(CursorMove::End);
|
||||
}
|
||||
}
|
||||
Input { key: Key::Char(op @ ('y' | 'd' | 'c')), ctrl: false, .. } if self.mode == VimMode::Normal => {
|
||||
textarea.start_selection();
|
||||
return Transition::Mode(VimMode::Operator(op));
|
||||
}
|
||||
Input { key: Key::Char('y'), ctrl: false, .. } if self.mode == VimMode::Visual => {
|
||||
textarea.move_cursor(CursorMove::Forward);
|
||||
textarea.copy();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('d'), ctrl: false, .. } if self.mode == VimMode::Visual => {
|
||||
textarea.move_cursor(CursorMove::Forward);
|
||||
textarea.cut();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('c'), ctrl: false, .. } if self.mode == VimMode::Visual => {
|
||||
textarea.move_cursor(CursorMove::Forward);
|
||||
textarea.cut();
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
// Arrow keys work in normal mode
|
||||
Input { key: Key::Up, .. } => textarea.move_cursor(CursorMove::Up),
|
||||
Input { key: Key::Down, .. } => textarea.move_cursor(CursorMove::Down),
|
||||
Input { key: Key::Left, .. } => textarea.move_cursor(CursorMove::Back),
|
||||
Input { key: Key::Right, .. } => textarea.move_cursor(CursorMove::Forward),
|
||||
input => return Transition::Pending(input),
|
||||
}
|
||||
|
||||
// Handle the pending operator
|
||||
match self.mode {
|
||||
VimMode::Operator('y') => {
|
||||
textarea.copy();
|
||||
Transition::Mode(VimMode::Normal)
|
||||
}
|
||||
VimMode::Operator('d') => {
|
||||
textarea.cut();
|
||||
Transition::Mode(VimMode::Normal)
|
||||
}
|
||||
VimMode::Operator('c') => {
|
||||
textarea.cut();
|
||||
Transition::Mode(VimMode::Insert)
|
||||
}
|
||||
_ => Transition::Nop,
|
||||
}
|
||||
}
|
||||
VimMode::Insert => match input {
|
||||
Input { key: Key::Esc, .. } | Input { key: Key::Char('c'), ctrl: true, .. } => {
|
||||
Transition::Mode(VimMode::Normal)
|
||||
}
|
||||
input => {
|
||||
textarea.input(input);
|
||||
Transition::Mode(VimMode::Insert)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TextEditor;
|
||||
|
||||
impl TextEditor {
|
||||
pub fn new_textarea(editor_config: &EditorConfig) -> TextArea<'static> {
|
||||
let mut textarea = TextArea::default();
|
||||
|
||||
if editor_config.show_line_numbers {
|
||||
textarea.set_line_number_style(Style::default().fg(Color::DarkGray));
|
||||
}
|
||||
|
||||
textarea.set_tab_length(editor_config.tab_width);
|
||||
|
||||
textarea
|
||||
}
|
||||
|
||||
pub fn handle_input(
|
||||
textarea: &mut TextArea<'static>,
|
||||
key_event: KeyEvent,
|
||||
keybinding_mode: &EditorKeybindingMode,
|
||||
vim_state: &mut VimState,
|
||||
) -> bool {
|
||||
match keybinding_mode {
|
||||
EditorKeybindingMode::Vim => {
|
||||
Self::handle_vim_input(textarea, key_event, vim_state)
|
||||
}
|
||||
_ => {
|
||||
let tui_input: Input = key_event.into();
|
||||
textarea.input(tui_input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_vim_input(
|
||||
textarea: &mut TextArea<'static>,
|
||||
key_event: KeyEvent,
|
||||
vim_state: &mut VimState,
|
||||
) -> bool {
|
||||
let input = Self::convert_key_event_to_input(key_event);
|
||||
|
||||
*vim_state = match vim_state.transition(input, textarea) {
|
||||
Transition::Mode(mode) if vim_state.mode != mode => {
|
||||
// Update cursor style based on mode
|
||||
textarea.set_cursor_style(mode.cursor_style());
|
||||
VimState::new(mode)
|
||||
}
|
||||
Transition::Nop | Transition::Mode(_) => vim_state.clone(),
|
||||
Transition::Pending(input) => vim_state.clone().with_pending(input),
|
||||
};
|
||||
|
||||
true // Always consider input as handled in vim mode
|
||||
}
|
||||
|
||||
fn convert_key_event_to_input(key_event: KeyEvent) -> Input {
|
||||
let key = match key_event.code {
|
||||
KeyCode::Char(c) => Key::Char(c),
|
||||
KeyCode::Enter => Key::Enter,
|
||||
KeyCode::Left => Key::Left,
|
||||
KeyCode::Right => Key::Right,
|
||||
KeyCode::Up => Key::Up,
|
||||
KeyCode::Down => Key::Down,
|
||||
KeyCode::Backspace => Key::Backspace,
|
||||
KeyCode::Delete => Key::Delete,
|
||||
KeyCode::Home => Key::Home,
|
||||
KeyCode::End => Key::End,
|
||||
KeyCode::PageUp => Key::PageUp,
|
||||
KeyCode::PageDown => Key::PageDown,
|
||||
KeyCode::Tab => Key::Tab,
|
||||
KeyCode::Esc => Key::Esc,
|
||||
_ => Key::Null,
|
||||
};
|
||||
|
||||
Input {
|
||||
key,
|
||||
ctrl: key_event.modifiers.contains(KeyModifiers::CONTROL),
|
||||
alt: key_event.modifiers.contains(KeyModifiers::ALT),
|
||||
shift: key_event.modifiers.contains(KeyModifiers::SHIFT),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_vim_mode_status(vim_state: &VimState) -> String {
|
||||
vim_state.mode.to_string()
|
||||
}
|
||||
|
||||
pub fn is_vim_insert_mode(vim_state: &VimState) -> bool {
|
||||
matches!(vim_state.mode, VimMode::Insert)
|
||||
}
|
||||
|
||||
pub fn is_vim_normal_mode(vim_state: &VimState) -> bool {
|
||||
matches!(vim_state.mode, VimMode::Normal)
|
||||
}
|
||||
}
|
||||
@@ -1,66 +1,98 @@
|
||||
// src/components/handlers/form.rs
|
||||
// src/components/form/form.rs
|
||||
use crate::components::common::autocomplete;
|
||||
use crate::config::colors::themes::Theme;
|
||||
use canvas::canvas::{CanvasState, render_canvas, HighlightState};
|
||||
use crate::state::pages::form::FormState;
|
||||
use ratatui::{
|
||||
widgets::{Paragraph, Block, Borders},
|
||||
layout::{Layout, Constraint, Direction, Rect, Margin, Alignment},
|
||||
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
|
||||
style::Style,
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::components::handlers::canvas::render_canvas;
|
||||
|
||||
pub fn render_form(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
form_state: &FormState,
|
||||
fields: &[&str],
|
||||
current_field: &usize,
|
||||
current_field_idx: &usize,
|
||||
inputs: &[&String],
|
||||
table_name: &str,
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
total_count: u64,
|
||||
current_position: u64,
|
||||
) {
|
||||
// Create Adresar card
|
||||
let card_title = format!(" {} ", table_name);
|
||||
|
||||
let adresar_card = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme.border))
|
||||
.title(" Adresar ")
|
||||
.title(card_title)
|
||||
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||||
|
||||
f.render_widget(adresar_card, area);
|
||||
|
||||
// Define inner area
|
||||
let inner_area = area.inner(Margin {
|
||||
horizontal: 1,
|
||||
vertical: 1,
|
||||
});
|
||||
|
||||
// Create main layout
|
||||
let main_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.constraints([Constraint::Length(1), Constraint::Min(1)])
|
||||
.split(inner_area);
|
||||
|
||||
// Render count/position
|
||||
let count_position_text = format!("Total: {} | Position: {}", total_count, current_position);
|
||||
let count_position_text = if total_count == 0 && current_position == 1 {
|
||||
"Total: 0 | New Entry".to_string()
|
||||
} else if current_position > total_count && total_count > 0 {
|
||||
format!("Total: {} | New Entry ({})", total_count, current_position)
|
||||
} else if total_count == 0 && current_position > 1 {
|
||||
format!("Total: 0 | New Entry ({})", current_position)
|
||||
} else {
|
||||
format!(
|
||||
"Total: {} | Position: {}/{}",
|
||||
total_count, current_position, total_count
|
||||
)
|
||||
};
|
||||
|
||||
let count_para = Paragraph::new(count_position_text)
|
||||
.style(Style::default().fg(theme.fg))
|
||||
.alignment(Alignment::Left);
|
||||
f.render_widget(count_para, main_layout[0]);
|
||||
|
||||
// Delegate input handling to canvas
|
||||
render_canvas(
|
||||
// Use the canvas library's render_canvas function
|
||||
let active_field_rect = render_canvas(
|
||||
f,
|
||||
main_layout[1],
|
||||
form_state,
|
||||
fields,
|
||||
current_field,
|
||||
inputs,
|
||||
theme,
|
||||
is_edit_mode,
|
||||
highlight_state,
|
||||
);
|
||||
|
||||
// --- RENDER RICH AUTOCOMPLETE ONLY ---
|
||||
if form_state.autocomplete_active {
|
||||
if let Some(active_rect) = active_field_rect {
|
||||
// Get selected index directly from form_state
|
||||
let selected_index = form_state.selected_suggestion_index;
|
||||
|
||||
// Only render rich suggestions (your Hit objects)
|
||||
if let Some(rich_suggestions) = form_state.get_rich_suggestions() {
|
||||
if !rich_suggestions.is_empty() {
|
||||
autocomplete::render_hit_autocomplete_dropdown(
|
||||
f,
|
||||
active_rect,
|
||||
f.area(),
|
||||
theme,
|
||||
rich_suggestions,
|
||||
selected_index,
|
||||
form_state,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Removed simple suggestions - we only use rich ones now!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/components/handlers.rs
|
||||
pub mod canvas;
|
||||
pub mod sidebar;
|
||||
pub mod buffer_list;
|
||||
|
||||
pub use canvas::*;
|
||||
pub use sidebar::*;
|
||||
pub use buffer_list::*;
|
||||
|
||||
80
client/src/components/handlers/buffer_list.rs
Normal file
80
client/src/components/handlers/buffer_list.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
// src/components/handlers/buffer_list.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::buffer::BufferState;
|
||||
use crate::state::app::state::AppState; // Add this import
|
||||
use ratatui::{
|
||||
layout::{Alignment, Rect},
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
use crate::functions::common::buffer::get_view_layer;
|
||||
|
||||
pub fn render_buffer_list(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
buffer_state: &BufferState,
|
||||
app_state: &AppState,
|
||||
) {
|
||||
// --- Style Definitions ---
|
||||
let active_style = Style::default()
|
||||
.fg(theme.bg)
|
||||
.bg(theme.highlight);
|
||||
|
||||
let inactive_style = Style::default()
|
||||
.fg(theme.fg)
|
||||
.bg(theme.bg);
|
||||
|
||||
// --- Determine Active Layer ---
|
||||
let active_layer = match buffer_state.history.get(buffer_state.active_index) {
|
||||
Some(view) => get_view_layer(view),
|
||||
None => 1,
|
||||
};
|
||||
|
||||
// --- Create Spans ---
|
||||
let mut spans = Vec::new();
|
||||
let mut current_width = 0;
|
||||
|
||||
let current_table_name = app_state.current_view_table_name.as_deref();
|
||||
|
||||
for (original_index, view) in buffer_state.history.iter().enumerate() {
|
||||
// Filter: Only process views matching the active layer
|
||||
if get_view_layer(view) != active_layer {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_active = original_index == buffer_state.active_index;
|
||||
let buffer_name = view.display_name_with_context(current_table_name);
|
||||
let buffer_text = format!(" {} ", buffer_name);
|
||||
let text_width = UnicodeWidthStr::width(buffer_text.as_str());
|
||||
|
||||
// Calculate width needed for this buffer (separator + text)
|
||||
let needed_width = text_width;
|
||||
if current_width + needed_width > area.width as usize {
|
||||
break;
|
||||
}
|
||||
|
||||
// Add the buffer text itself
|
||||
let text_style = if is_active { active_style } else { inactive_style };
|
||||
spans.push(Span::styled(buffer_text, text_style));
|
||||
current_width += text_width;
|
||||
}
|
||||
|
||||
// --- Filler Span ---
|
||||
let remaining_width = area.width.saturating_sub(current_width as u16);
|
||||
if !spans.is_empty() || remaining_width > 0 {
|
||||
spans.push(Span::styled(
|
||||
" ".repeat(remaining_width as usize),
|
||||
inactive_style,
|
||||
));
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
let buffer_line = Line::from(spans);
|
||||
let paragraph = Paragraph::new(buffer_line).alignment(Alignment::Left);
|
||||
f.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
// src/components/handlers/canvas.rs
|
||||
use ratatui::{
|
||||
widgets::{Paragraph, Block, Borders},
|
||||
layout::{Layout, Constraint, Direction, Rect},
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
Frame,
|
||||
prelude::Alignment,
|
||||
};
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::pages::form::FormState;
|
||||
|
||||
pub fn render_canvas(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
form_state: &FormState,
|
||||
fields: &[&str],
|
||||
current_field: &usize,
|
||||
inputs: &[&String],
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool,
|
||||
) {
|
||||
// Split area into columns
|
||||
let columns = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
|
||||
.split(area);
|
||||
|
||||
// Input container styling
|
||||
let input_container = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(if is_edit_mode {
|
||||
form_state.has_unsaved_changes.then(|| theme.warning).unwrap_or(theme.accent)
|
||||
} else {
|
||||
theme.secondary
|
||||
})
|
||||
.style(Style::default().bg(theme.bg));
|
||||
|
||||
// Input block dimensions
|
||||
let input_block = Rect {
|
||||
x: columns[1].x,
|
||||
y: columns[1].y,
|
||||
width: columns[1].width,
|
||||
height: fields.len() as u16 + 2,
|
||||
};
|
||||
|
||||
f.render_widget(&input_container, input_block);
|
||||
|
||||
// Input rows layout
|
||||
let input_area = input_container.inner(input_block);
|
||||
let input_rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(vec![Constraint::Length(1); fields.len()])
|
||||
.split(input_area);
|
||||
|
||||
// Render labels
|
||||
for (i, field) in fields.iter().enumerate() {
|
||||
let label = Paragraph::new(Line::from(Span::styled(
|
||||
format!("{}:", field),
|
||||
Style::default().fg(theme.fg)),
|
||||
));
|
||||
f.render_widget(label, Rect {
|
||||
x: columns[0].x,
|
||||
y: input_block.y + 1 + i as u16,
|
||||
width: columns[0].width,
|
||||
height: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// Render inputs and cursor
|
||||
for (i, input) in inputs.iter().enumerate() {
|
||||
let is_active = i == *current_field;
|
||||
let input_display = Paragraph::new(input.as_str())
|
||||
.alignment(Alignment::Left)
|
||||
.style(if is_active {
|
||||
Style::default().fg(theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(theme.fg)
|
||||
});
|
||||
|
||||
f.render_widget(input_display, input_rows[i]);
|
||||
|
||||
if is_active {
|
||||
let cursor_x = input_rows[i].x + form_state.current_cursor_pos as u16;
|
||||
let cursor_y = input_rows[i].y;
|
||||
f.set_cursor_position((cursor_x, cursor_y));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,17 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
use crate::config::colors::themes::Theme;
|
||||
use common::proto::multieko2::table_definition::{ProfileTreeResponse};
|
||||
use common::proto::komp_ac::table_definition::{ProfileTreeResponse};
|
||||
use ratatui::text::{Span, Line};
|
||||
use crate::components::utils::text::truncate_string;
|
||||
|
||||
// Reduced sidebar width
|
||||
const SIDEBAR_WIDTH: u16 = 12;
|
||||
const SIDEBAR_WIDTH: u16 = 20;
|
||||
|
||||
// --- Icons ---
|
||||
const ICON_PROFILE: &str = "📁";
|
||||
const ICON_TABLE: &str = "📄";
|
||||
|
||||
pub fn calculate_sidebar_layout(show_sidebar: bool, main_content_area: Rect) -> (Option<Rect>, Rect) {
|
||||
if show_sidebar {
|
||||
let chunks = Layout::default()
|
||||
@@ -36,18 +41,54 @@ pub fn render_sidebar(
|
||||
) {
|
||||
let sidebar_block = Block::default().style(Style::default().bg(theme.bg));
|
||||
let mut items = Vec::new();
|
||||
let profile_name_available_width = (SIDEBAR_WIDTH as usize).saturating_sub(3);
|
||||
let table_name_available_width = (SIDEBAR_WIDTH as usize).saturating_sub(5);
|
||||
|
||||
if let Some(profile_name) = selected_profile {
|
||||
// Existing code for when a profile is selected...
|
||||
// Find the selected profile in the tree
|
||||
if let Some(profile) = profile_tree
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|p| &p.name == profile_name)
|
||||
{
|
||||
// Add profile name as header
|
||||
items.push(ListItem::new(Line::from(vec![
|
||||
Span::styled(format!("{} ", ICON_PROFILE), Style::default().fg(theme.accent)),
|
||||
Span::styled(
|
||||
truncate_string(&profile.name, profile_name_available_width),
|
||||
Style::default().fg(theme.highlight)
|
||||
),
|
||||
])));
|
||||
|
||||
// List tables for the selected profile
|
||||
for table in &profile.tables {
|
||||
// Get table name without year prefix to save space
|
||||
let display_name = if table.name.starts_with("2025_") {
|
||||
&table.name[5..] // Skip "2025_" prefix
|
||||
} else {
|
||||
&table.name
|
||||
};
|
||||
items.push(ListItem::new(Line::from(vec![
|
||||
Span::raw(" "), // Indentation
|
||||
Span::styled(format!("{} ", ICON_TABLE), Style::default().fg(theme.secondary)),
|
||||
Span::styled(
|
||||
truncate_string(display_name, table_name_available_width),
|
||||
theme.fg
|
||||
),
|
||||
])));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Show full profile tree when no profile is selected (compact version)
|
||||
for (profile_idx, profile) in profile_tree.profiles.iter().enumerate() {
|
||||
// Profile header - more compact
|
||||
items.push(ListItem::new(Line::from(vec![
|
||||
Span::styled("◆", Style::default().fg(theme.accent)),
|
||||
Span::styled(&profile.name, Style::default().fg(theme.highlight)),
|
||||
Span::styled(format!("{} ", ICON_PROFILE), Style::default().fg(theme.accent)),
|
||||
Span::styled(
|
||||
&profile.name,
|
||||
Style::default().fg(theme.highlight)
|
||||
),
|
||||
])));
|
||||
|
||||
// Tables with compact prefixes
|
||||
for (table_idx, table) in profile.tables.iter().enumerate() {
|
||||
let is_last_table = table_idx == profile.tables.len() - 1;
|
||||
@@ -68,18 +109,18 @@ pub fn render_sidebar(
|
||||
&table.name
|
||||
};
|
||||
|
||||
let mut line = vec![
|
||||
Span::styled(prefix, Style::default().fg(theme.fg)),
|
||||
Span::styled(display_name, Style::default().fg(theme.fg)),
|
||||
];
|
||||
// Adjust available width if dependency arrow is shown
|
||||
let current_table_available_width = if !table.depends_on.is_empty() {
|
||||
table_name_available_width.saturating_sub(1)
|
||||
} else {
|
||||
table_name_available_width
|
||||
};
|
||||
|
||||
// Show a simple indicator for dependencies instead of listing them
|
||||
if !table.depends_on.is_empty() {
|
||||
line.push(Span::styled(
|
||||
"→",
|
||||
Style::default().fg(theme.secondary)
|
||||
));
|
||||
}
|
||||
let line = vec![
|
||||
Span::styled(prefix, Style::default().fg(theme.fg)),
|
||||
Span::styled(format!("{} ", ICON_TABLE), Style::default().fg(theme.secondary)),
|
||||
Span::styled(truncate_string(display_name, current_table_available_width), Style::default().fg(theme.fg)),
|
||||
];
|
||||
|
||||
items.push(ListItem::new(Line::from(line)));
|
||||
}
|
||||
|
||||
@@ -8,126 +8,80 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::pages::intro::IntroState;
|
||||
|
||||
pub struct IntroState {
|
||||
pub selected_option: usize,
|
||||
pub fn render_intro(f: &mut Frame, intro_state: &IntroState, area: Rect, theme: &Theme) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(theme.accent))
|
||||
.style(Style::default().bg(theme.bg));
|
||||
|
||||
let inner_area = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
// Center layout
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage(40),
|
||||
Constraint::Length(5),
|
||||
Constraint::Percentage(40),
|
||||
])
|
||||
.split(inner_area);
|
||||
|
||||
// Title
|
||||
let title = Line::from(vec![
|
||||
Span::styled("komp_ac", Style::default().fg(theme.highlight)),
|
||||
Span::styled(" v", Style::default().fg(theme.fg)),
|
||||
Span::styled(env!("CARGO_PKG_VERSION"), Style::default().fg(theme.secondary)),
|
||||
]);
|
||||
let title_para = Paragraph::new(title)
|
||||
.alignment(Alignment::Center);
|
||||
f.render_widget(title_para, chunks[1]);
|
||||
|
||||
// Buttons - now with 4 options
|
||||
let button_area = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(25),
|
||||
])
|
||||
.split(chunks[1].inner(Margin {
|
||||
horizontal: 1,
|
||||
vertical: 1
|
||||
}));
|
||||
|
||||
let buttons = ["Continue", "Admin", "Login", "Register"];
|
||||
for (i, &text) in buttons.iter().enumerate() {
|
||||
render_button(f, button_area[i], text, intro_state.selected_option == i, theme);
|
||||
}
|
||||
}
|
||||
|
||||
impl IntroState {
|
||||
pub fn new() -> Self {
|
||||
Self { selected_option: 0 }
|
||||
}
|
||||
|
||||
pub fn render(&self, f: &mut Frame, area: Rect, theme: &Theme) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(theme.accent))
|
||||
.style(Style::default().bg(theme.bg));
|
||||
|
||||
let inner_area = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
// Center layout
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage(35),
|
||||
Constraint::Length(7), // Increased to accommodate 3 buttons
|
||||
Constraint::Percentage(35),
|
||||
])
|
||||
.split(inner_area);
|
||||
|
||||
// Title
|
||||
let title = Line::from(vec![
|
||||
Span::styled("multieko2", Style::default().fg(theme.highlight)),
|
||||
Span::styled(" v", Style::default().fg(theme.fg)),
|
||||
Span::styled(env!("CARGO_PKG_VERSION"), Style::default().fg(theme.secondary)),
|
||||
]);
|
||||
let title_para = Paragraph::new(title)
|
||||
.alignment(Alignment::Center);
|
||||
f.render_widget(title_para, chunks[1]);
|
||||
|
||||
// Buttons - now with 3 options
|
||||
let button_area = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(33),
|
||||
Constraint::Percentage(33),
|
||||
Constraint::Percentage(33),
|
||||
])
|
||||
.split(chunks[1].inner(Margin {
|
||||
horizontal: 1,
|
||||
vertical: 1
|
||||
}));
|
||||
|
||||
self.render_button(
|
||||
f,
|
||||
button_area[0],
|
||||
"Continue",
|
||||
self.selected_option == 0,
|
||||
theme,
|
||||
);
|
||||
self.render_button(
|
||||
f,
|
||||
button_area[1],
|
||||
"Admin",
|
||||
self.selected_option == 1,
|
||||
theme,
|
||||
);
|
||||
self.render_button(
|
||||
f,
|
||||
button_area[2],
|
||||
"Login",
|
||||
self.selected_option == 2,
|
||||
theme,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_button(&self, f: &mut Frame, area: Rect, text: &str, selected: bool, theme: &Theme) {
|
||||
let button_style = if selected {
|
||||
Style::default()
|
||||
.fg(theme.highlight)
|
||||
.bg(theme.bg)
|
||||
.add_modifier(ratatui::style::Modifier::BOLD)
|
||||
fn render_button(f: &mut Frame, area: Rect, text: &str, selected: bool, theme: &Theme) {
|
||||
let button_style = Style::default()
|
||||
.fg(if selected { theme.highlight } else { theme.fg })
|
||||
.bg(theme.bg)
|
||||
.add_modifier(if selected {
|
||||
ratatui::style::Modifier::BOLD
|
||||
} else {
|
||||
Style::default().fg(theme.fg).bg(theme.bg)
|
||||
};
|
||||
ratatui::style::Modifier::empty()
|
||||
});
|
||||
|
||||
let button = Paragraph::new(text)
|
||||
.style(button_style)
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Double)
|
||||
.border_style(if selected {
|
||||
Style::default().fg(theme.accent)
|
||||
} else {
|
||||
Style::default().fg(theme.border)
|
||||
}),
|
||||
);
|
||||
let border_style = Style::default()
|
||||
.fg(if selected { theme.accent } else { theme.border });
|
||||
|
||||
f.render_widget(button, area);
|
||||
}
|
||||
let button = Paragraph::new(text)
|
||||
.style(button_style)
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Double)
|
||||
.border_style(border_style),
|
||||
);
|
||||
|
||||
pub fn next_option(&mut self) {
|
||||
self.selected_option = (self.selected_option + 1) % 3;
|
||||
}
|
||||
|
||||
pub fn previous_option(&mut self) {
|
||||
self.selected_option = if self.selected_option == 0 { 2 } else { self.selected_option - 1 };
|
||||
}
|
||||
|
||||
pub fn handle_selection(&self, app_state: &mut crate::state::state::AppState) {
|
||||
match self.selected_option {
|
||||
0 => { /* Continue logic */ }
|
||||
1 => { /* Admin logic */ }
|
||||
2 => {
|
||||
app_state.ui.show_intro = false;
|
||||
app_state.ui.show_login = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
f.render_widget(button, area);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod admin;
|
||||
pub mod common;
|
||||
pub mod form;
|
||||
pub mod auth;
|
||||
pub mod utils;
|
||||
|
||||
pub use handlers::*;
|
||||
pub use intro::*;
|
||||
@@ -12,3 +13,4 @@ pub use admin::*;
|
||||
pub use common::*;
|
||||
pub use form::*;
|
||||
pub use auth::*;
|
||||
pub use utils::*;
|
||||
|
||||
4
client/src/components/utils.rs
Normal file
4
client/src/components/utils.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
// src/components/utils.rs
|
||||
pub mod text;
|
||||
|
||||
pub use text::*;
|
||||
29
client/src/components/utils/text.rs
Normal file
29
client/src/components/utils/text.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
// src/components/utils/text.rs
|
||||
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
/// Truncates a string to a maximum width, adding an ellipsis if truncated.
|
||||
/// Considers unicode character widths.
|
||||
pub fn truncate_string(s: &str, max_width: usize) -> String {
|
||||
if UnicodeWidthStr::width(s) <= max_width {
|
||||
s.to_string()
|
||||
} else {
|
||||
let ellipsis = "…";
|
||||
let ellipsis_width = UnicodeWidthStr::width(ellipsis);
|
||||
let mut truncated_width = 0;
|
||||
let mut end_byte_index = 0;
|
||||
|
||||
// Iterate over graphemes to handle multi-byte characters correctly
|
||||
for (i, g) in s.grapheme_indices(true) {
|
||||
let char_width = UnicodeWidthStr::width(g);
|
||||
if truncated_width + char_width + ellipsis_width > max_width {
|
||||
break;
|
||||
}
|
||||
truncated_width += char_width;
|
||||
end_byte_index = i + g.len();
|
||||
}
|
||||
|
||||
format!("{}{}", &s[..end_byte_index], ellipsis)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,57 @@
|
||||
// src/config/binds/config.rs
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize}; // Added Serialize for EditorKeybindingMode if needed elsewhere
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use anyhow::{Context, Result};
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
|
||||
// NEW: Editor Keybinding Mode Enum
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum EditorKeybindingMode {
|
||||
#[serde(rename = "default")]
|
||||
Default,
|
||||
#[serde(rename = "vim")]
|
||||
Vim,
|
||||
#[serde(rename = "emacs")]
|
||||
Emacs,
|
||||
}
|
||||
|
||||
impl Default for EditorKeybindingMode {
|
||||
fn default() -> Self {
|
||||
EditorKeybindingMode::Default
|
||||
}
|
||||
}
|
||||
|
||||
// NEW: Editor Configuration Struct
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EditorConfig {
|
||||
#[serde(default)]
|
||||
pub keybinding_mode: EditorKeybindingMode,
|
||||
#[serde(default = "default_show_line_numbers")]
|
||||
pub show_line_numbers: bool,
|
||||
#[serde(default = "default_tab_width")]
|
||||
pub tab_width: u8,
|
||||
}
|
||||
|
||||
fn default_show_line_numbers() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_tab_width() -> u8 {
|
||||
4
|
||||
}
|
||||
|
||||
impl Default for EditorConfig {
|
||||
fn default() -> Self {
|
||||
EditorConfig {
|
||||
keybinding_mode: EditorKeybindingMode::default(),
|
||||
show_line_numbers: default_show_line_numbers(),
|
||||
tab_width: default_tab_width(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct ColorsConfig {
|
||||
#[serde(default = "default_theme")]
|
||||
@@ -21,9 +68,14 @@ pub struct Config {
|
||||
pub keybindings: ModeKeybindings,
|
||||
#[serde(default)]
|
||||
pub colors: ColorsConfig,
|
||||
// NEW: Add editor configuration
|
||||
#[serde(default)]
|
||||
pub editor: EditorConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
// ... (rest of your Config struct and impl Config remains the same)
|
||||
// Make sure ModeKeybindings is also deserializable if it's not already
|
||||
#[derive(Debug, Deserialize, Default)] // Added Default here if not present
|
||||
pub struct ModeKeybindings {
|
||||
#[serde(default)]
|
||||
pub general: HashMap<String, Vec<String>>,
|
||||
@@ -32,6 +84,8 @@ pub struct ModeKeybindings {
|
||||
#[serde(default)]
|
||||
pub edit: HashMap<String, Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub highlight: HashMap<String, Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub command: HashMap<String, Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub common: HashMap<String, Vec<String>>,
|
||||
@@ -41,16 +95,16 @@ pub struct ModeKeybindings {
|
||||
|
||||
impl Config {
|
||||
/// Loads the configuration from "config.toml" in the client crate directory.
|
||||
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
|
||||
pub fn load() -> Result<Self> {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let config_path = Path::new(manifest_dir).join("config.toml");
|
||||
let config_str = std::fs::read_to_string(&config_path)
|
||||
.map_err(|e| format!("Failed to read config file at {:?}: {}", config_path, e))?;
|
||||
let config: Config = toml::from_str(&config_str)?;
|
||||
.with_context(|| format!("Failed to read config file at {:?}", config_path))?;
|
||||
let config: Config = toml::from_str(&config_str)
|
||||
.with_context(|| format!("Failed to parse config file: {}. Check for syntax errors or missing fields like an empty [editor] section if you added it.", config_str))?; // Enhanced error message
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
|
||||
pub fn get_general_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
||||
self.get_action_for_key_in_mode(&self.keybindings.general, key, modifiers)
|
||||
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers))
|
||||
@@ -75,6 +129,14 @@ impl Config {
|
||||
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers))
|
||||
}
|
||||
|
||||
/// Gets an action for a key in Highlight mode, also checking common/global keybindings.
|
||||
pub fn get_highlight_action_for_key(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
||||
self.get_action_for_key_in_mode(&self.keybindings.highlight, key, modifiers)
|
||||
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.common, key, modifiers))
|
||||
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.read_only, key, modifiers))
|
||||
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers))
|
||||
}
|
||||
|
||||
/// Gets an action for a key in Command mode, also checking common keybindings.
|
||||
pub fn get_command_action_for_key(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
||||
self.get_action_for_key_in_mode(&self.keybindings.command, key, modifiers)
|
||||
@@ -189,47 +251,206 @@ impl Config {
|
||||
key: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
) -> bool {
|
||||
// For multi-character bindings without modifiers, handle them in matches_key_sequence.
|
||||
// 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,
|
||||
"esc" => key == KeyCode::Esc,
|
||||
"enter" => key == KeyCode::Enter,
|
||||
"delete" => key == KeyCode::Delete,
|
||||
"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,
|
||||
_ => false,
|
||||
|
||||
// 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
|
||||
"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
|
||||
"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),
|
||||
|
||||
// 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() {
|
||||
"ctrl" => expected_modifiers |= KeyModifiers::CONTROL,
|
||||
// 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),
|
||||
"esc" => expected_key = Some(KeyCode::Esc),
|
||||
"enter" => expected_key = Some(KeyCode::Enter),
|
||||
"delete" => expected_key = Some(KeyCode::Delete),
|
||||
"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),
|
||||
|
||||
// Special characters and colon (legacy support)
|
||||
":" => expected_key = Some(KeyCode::Char(':')),
|
||||
|
||||
// Single character (letters, numbers, punctuation)
|
||||
part => {
|
||||
if part.len() == 1 {
|
||||
let c = part.chars().next().unwrap();
|
||||
expected_key = Some(KeyCode::Char(c));
|
||||
if let Some(c) = part.chars().next() {
|
||||
expected_key = Some(KeyCode::Char(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,14 +149,17 @@ fn parse_key_part(part: &str) -> Option<ParsedKey> {
|
||||
let mut code = None;
|
||||
|
||||
if part.contains('+') {
|
||||
// This handles modifiers like "ctrl+s"
|
||||
// This handles modifiers like "ctrl+s", "super+shift+f5"
|
||||
let components: Vec<&str> = part.split('+').collect();
|
||||
|
||||
for component in components {
|
||||
match component.to_lowercase().as_str() {
|
||||
"ctrl" => modifiers |= KeyModifiers::CONTROL,
|
||||
"ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
|
||||
"shift" => modifiers |= KeyModifiers::SHIFT,
|
||||
"alt" => modifiers |= KeyModifiers::ALT,
|
||||
"super" | "windows" | "cmd" => modifiers |= KeyModifiers::SUPER,
|
||||
"hyper" => modifiers |= KeyModifiers::HYPER,
|
||||
"meta" => modifiers |= KeyModifiers::META,
|
||||
_ => {
|
||||
// Last component is the key
|
||||
code = string_to_keycode(component);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// src/client/themes/colors.rs
|
||||
// src/config/colors/themes.rs
|
||||
use ratatui::style::Color;
|
||||
use canvas::canvas::CanvasTheme;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Theme {
|
||||
@@ -10,6 +11,8 @@ pub struct Theme {
|
||||
pub highlight: Color,
|
||||
pub warning: Color,
|
||||
pub border: Color,
|
||||
pub highlight_bg: Color,
|
||||
pub inactive_highlight_bg: Color,// admin panel no idea what it really is
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
@@ -31,6 +34,8 @@ impl Theme {
|
||||
highlight: Color::Rgb(152, 251, 152), // Pastel green
|
||||
warning: Color::Rgb(255, 182, 193), // Pastel pink
|
||||
border: Color::Rgb(220, 220, 220), // Light gray border
|
||||
highlight_bg: Color::Rgb(70, 70, 70), // Darker grey for highlight background
|
||||
inactive_highlight_bg: Color::Rgb(50, 50, 50),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +49,8 @@ impl Theme {
|
||||
highlight: Color::Rgb(50, 205, 50), // Bright green
|
||||
warning: Color::Rgb(255, 99, 71), // Bright red
|
||||
border: Color::Rgb(100, 100, 100), // Medium gray border
|
||||
highlight_bg: Color::Rgb(180, 180, 180), // Lighter grey for highlight background
|
||||
inactive_highlight_bg: Color::Rgb(50, 50, 50),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +64,8 @@ impl Theme {
|
||||
highlight: Color::Rgb(0, 128, 0), // Green
|
||||
warning: Color::Rgb(255, 0, 0), // Red
|
||||
border: Color::Rgb(0, 0, 0), // Black border
|
||||
highlight_bg: Color::Rgb(180, 180, 180), // Lighter grey for highlight background
|
||||
inactive_highlight_bg: Color::Rgb(50, 50, 50),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,3 +75,37 @@ impl Default for Theme {
|
||||
Self::light() // Default to light theme
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasTheme for Theme {
|
||||
fn bg(&self) -> Color {
|
||||
self.bg
|
||||
}
|
||||
|
||||
fn fg(&self) -> Color {
|
||||
self.fg
|
||||
}
|
||||
|
||||
fn border(&self) -> Color {
|
||||
self.border
|
||||
}
|
||||
|
||||
fn accent(&self) -> Color {
|
||||
self.accent
|
||||
}
|
||||
|
||||
fn secondary(&self) -> Color {
|
||||
self.secondary
|
||||
}
|
||||
|
||||
fn highlight(&self) -> Color {
|
||||
self.highlight
|
||||
}
|
||||
|
||||
fn highlight_bg(&self) -> Color {
|
||||
self.highlight_bg
|
||||
}
|
||||
|
||||
fn warning(&self) -> Color {
|
||||
self.warning
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
|
||||
pub mod binds;
|
||||
pub mod colors;
|
||||
pub mod storage;
|
||||
|
||||
4
client/src/config/storage.rs
Normal file
4
client/src/config/storage.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
// src/config/storage.rs
|
||||
pub mod storage;
|
||||
|
||||
pub use storage::*;
|
||||
101
client/src/config/storage/storage.rs
Normal file
101
client/src/config/storage/storage.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
// src/config/storage/storage.rs
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{error, info};
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
pub const APP_NAME: &str = "komp_ac_client";
|
||||
pub const TOKEN_FILE_NAME: &str = "auth.token";
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct StoredAuthData {
|
||||
pub access_token: String,
|
||||
pub user_id: String,
|
||||
pub role: String,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
pub fn get_token_storage_path() -> Result<PathBuf> {
|
||||
let state_dir = dirs::state_dir()
|
||||
.or_else(|| dirs::home_dir().map(|home| home.join(".local").join("state")))
|
||||
.ok_or_else(|| anyhow::anyhow!("Could not determine state directory"))?;
|
||||
|
||||
let app_state_dir = state_dir.join(APP_NAME);
|
||||
fs::create_dir_all(&app_state_dir)
|
||||
.with_context(|| format!("Failed to create app state directory at {:?}", app_state_dir))?;
|
||||
|
||||
Ok(app_state_dir.join(TOKEN_FILE_NAME))
|
||||
}
|
||||
|
||||
pub fn save_auth_data(data: &StoredAuthData) -> Result<()> {
|
||||
let path = get_token_storage_path()?;
|
||||
|
||||
let json_data = serde_json::to_string(data)
|
||||
.context("Failed to serialize auth data")?;
|
||||
|
||||
let mut file = File::create(&path)
|
||||
.with_context(|| format!("Failed to create token file at {:?}", path))?;
|
||||
|
||||
file.write_all(json_data.as_bytes())
|
||||
.context("Failed to write token data to file")?;
|
||||
|
||||
// Set file permissions to 600 (owner read/write only) on Unix
|
||||
#[cfg(unix)]
|
||||
{
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))
|
||||
.context("Failed to set token file permissions")?;
|
||||
}
|
||||
|
||||
info!("Auth data saved to {:?}", path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_auth_data() -> Result<Option<StoredAuthData>> {
|
||||
let path = get_token_storage_path()?;
|
||||
|
||||
if !path.exists() {
|
||||
info!("Token file not found at {:?}", path);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let json_data = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read token file at {:?}", path))?;
|
||||
|
||||
if json_data.trim().is_empty() {
|
||||
info!("Token file is empty at {:?}", path);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
match serde_json::from_str::<StoredAuthData>(&json_data) {
|
||||
Ok(data) => {
|
||||
info!("Auth data loaded from {:?}", path);
|
||||
Ok(Some(data))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to deserialize token data from {:?}: {}. Deleting corrupt file.", path, e);
|
||||
if let Err(del_e) = fs::remove_file(&path) {
|
||||
error!("Failed to delete corrupt token file: {}", del_e);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_auth_data() -> Result<()> {
|
||||
let path = get_token_storage_path()?;
|
||||
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)
|
||||
.with_context(|| format!("Failed to delete token file at {:?}", path))?;
|
||||
info!("Token file deleted from {:?}", path);
|
||||
} else {
|
||||
info!("Token file not found for deletion at {:?}", path);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
5
client/src/functions/common.rs
Normal file
5
client/src/functions/common.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
// src/functions/common.rs
|
||||
|
||||
pub mod buffer;
|
||||
|
||||
pub use buffer::*;
|
||||
35
client/src/functions/common/buffer.rs
Normal file
35
client/src/functions/common/buffer.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
// src/functions/common/buffer.rs
|
||||
|
||||
use crate::state::app::buffer::BufferState;
|
||||
use crate::state::app::buffer::AppView;
|
||||
|
||||
pub fn get_view_layer(view: &AppView) -> u8 {
|
||||
match view {
|
||||
AppView::Intro => 1,
|
||||
AppView::Login | AppView::Register | AppView::Admin | AppView::AddTable | AppView::AddLogic => 2,
|
||||
AppView::Form | AppView::Scratch => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Switches the active buffer index.
|
||||
pub fn switch_buffer(buffer_state: &mut BufferState, next: bool) -> bool {
|
||||
if buffer_state.history.len() <= 1 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let len = buffer_state.history.len();
|
||||
let current_index = buffer_state.active_index;
|
||||
let new_index = if next {
|
||||
(current_index + 1) % len
|
||||
} else {
|
||||
(current_index + len - 1) % len
|
||||
};
|
||||
|
||||
if new_index != current_index {
|
||||
buffer_state.active_index = new_index;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
6
client/src/functions/mod.rs
Normal file
6
client/src/functions/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// src/functions/mod.rs
|
||||
|
||||
pub mod common;
|
||||
pub mod modes;
|
||||
|
||||
pub use modes::*;
|
||||
5
client/src/functions/modes.rs
Normal file
5
client/src/functions/modes.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
// src/functions/modes.rs
|
||||
|
||||
pub mod navigation;
|
||||
|
||||
pub use navigation::*;
|
||||
5
client/src/functions/modes/navigation.rs
Normal file
5
client/src/functions/modes/navigation.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
// src/functions/modes/navigation.rs
|
||||
|
||||
pub mod admin_nav;
|
||||
pub mod add_table_nav;
|
||||
pub mod add_logic_nav;
|
||||
440
client/src/functions/modes/navigation/add_logic_nav.rs
Normal file
440
client/src/functions/modes/navigation/add_logic_nav.rs
Normal file
@@ -0,0 +1,440 @@
|
||||
// src/functions/modes/navigation/add_logic_nav.rs
|
||||
use crate::config::binds::config::{Config, EditorKeybindingMode};
|
||||
use crate::state::{
|
||||
app::state::AppState,
|
||||
pages::add_logic::{AddLogicFocus, AddLogicState},
|
||||
app::buffer::AppView,
|
||||
app::buffer::BufferState,
|
||||
};
|
||||
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
|
||||
use crate::services::GrpcClient;
|
||||
use tokio::sync::mpsc;
|
||||
use anyhow::Result;
|
||||
use crate::components::common::text_editor::TextEditor;
|
||||
use crate::services::ui_service::UiService;
|
||||
use tui_textarea::CursorMove; // Ensure this import is present
|
||||
|
||||
pub type SaveLogicResultSender = mpsc::Sender<Result<String>>;
|
||||
|
||||
pub fn handle_add_logic_navigation(
|
||||
key_event: KeyEvent,
|
||||
config: &Config,
|
||||
app_state: &mut AppState,
|
||||
add_logic_state: &mut AddLogicState,
|
||||
is_edit_mode: &mut bool,
|
||||
buffer_state: &mut BufferState,
|
||||
grpc_client: GrpcClient,
|
||||
_save_logic_sender: SaveLogicResultSender, // Marked as unused
|
||||
command_message: &mut String,
|
||||
) -> bool {
|
||||
// === FULLSCREEN SCRIPT EDITING - COMPLETE ISOLATION ===
|
||||
if add_logic_state.current_focus == AddLogicFocus::InsideScriptContent {
|
||||
// === AUTOCOMPLETE HANDLING ===
|
||||
if add_logic_state.script_editor_autocomplete_active {
|
||||
match key_event.code {
|
||||
// ... (Char, Backspace, Tab, Down, Up cases remain the same) ...
|
||||
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => {
|
||||
add_logic_state.script_editor_filter_text.push(c);
|
||||
add_logic_state.update_script_editor_suggestions();
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
*command_message = format!("Filtering: @{}", add_logic_state.script_editor_filter_text);
|
||||
return true;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if !add_logic_state.script_editor_filter_text.is_empty() {
|
||||
add_logic_state.script_editor_filter_text.pop();
|
||||
add_logic_state.update_script_editor_suggestions();
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
*command_message = if add_logic_state.script_editor_filter_text.is_empty() {
|
||||
"Autocomplete: @".to_string()
|
||||
} else {
|
||||
format!("Filtering: @{}", add_logic_state.script_editor_filter_text)
|
||||
};
|
||||
} else {
|
||||
let should_deactivate = if let Some((trigger_line, trigger_col)) = add_logic_state.script_editor_trigger_position {
|
||||
let current_cursor = {
|
||||
let editor_borrow = add_logic_state.script_content_editor.borrow();
|
||||
editor_borrow.cursor()
|
||||
};
|
||||
current_cursor.0 == trigger_line && current_cursor.1 == trigger_col + 1
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if should_deactivate {
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
*command_message = "Autocomplete cancelled".to_string();
|
||||
}
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Down => {
|
||||
if !add_logic_state.script_editor_suggestions.is_empty() {
|
||||
let current = add_logic_state.script_editor_selected_suggestion_index.unwrap_or(0);
|
||||
let next = (current + 1) % add_logic_state.script_editor_suggestions.len();
|
||||
add_logic_state.script_editor_selected_suggestion_index = Some(next);
|
||||
*command_message = format!("Selected: {}", add_logic_state.script_editor_suggestions[next]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Up => {
|
||||
if !add_logic_state.script_editor_suggestions.is_empty() {
|
||||
let current = add_logic_state.script_editor_selected_suggestion_index.unwrap_or(0);
|
||||
let prev = if current == 0 {
|
||||
add_logic_state.script_editor_suggestions.len() - 1
|
||||
} else {
|
||||
current - 1
|
||||
};
|
||||
add_logic_state.script_editor_selected_suggestion_index = Some(prev);
|
||||
*command_message = format!("Selected: {}", add_logic_state.script_editor_suggestions[prev]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(selected_idx) = add_logic_state.script_editor_selected_suggestion_index {
|
||||
if let Some(suggestion) = add_logic_state.script_editor_suggestions.get(selected_idx).cloned() {
|
||||
let trigger_pos = add_logic_state.script_editor_trigger_position;
|
||||
let filter_len = add_logic_state.script_editor_filter_text.len();
|
||||
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
add_logic_state.has_unsaved_changes = true;
|
||||
|
||||
if let Some(pos) = trigger_pos {
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
|
||||
if suggestion == "sql" {
|
||||
replace_autocomplete_text(&mut editor_borrow, pos, filter_len, "sql");
|
||||
editor_borrow.insert_str("('')");
|
||||
// Move cursor back twice to be between the single quotes
|
||||
editor_borrow.move_cursor(CursorMove::Back); // Before ')'
|
||||
editor_borrow.move_cursor(CursorMove::Back); // Before ''' (inside '')
|
||||
*command_message = "Inserted: @sql('')".to_string();
|
||||
} else {
|
||||
let is_table_selection = add_logic_state.is_table_name_suggestion(&suggestion);
|
||||
replace_autocomplete_text(&mut editor_borrow, pos, filter_len, &suggestion);
|
||||
|
||||
if is_table_selection {
|
||||
editor_borrow.insert_str(".");
|
||||
let new_cursor = editor_borrow.cursor();
|
||||
drop(editor_borrow); // Release borrow before calling add_logic_state methods
|
||||
|
||||
add_logic_state.script_editor_trigger_position = Some(new_cursor);
|
||||
add_logic_state.script_editor_autocomplete_active = true;
|
||||
add_logic_state.script_editor_filter_text.clear();
|
||||
add_logic_state.trigger_column_autocomplete_for_table(suggestion.clone());
|
||||
|
||||
let profile_name = add_logic_state.profile_name.clone();
|
||||
let table_name_for_fetch = suggestion.clone();
|
||||
let mut client_clone = grpc_client.clone();
|
||||
tokio::spawn(async move {
|
||||
match UiService::fetch_columns_for_table(&mut client_clone, &profile_name, &table_name_for_fetch).await {
|
||||
Ok(_columns) => {
|
||||
// Result handled by main UI loop
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to fetch columns for {}.{}: {}", profile_name, table_name_for_fetch, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
*command_message = format!("Selected table '{}', fetching columns...", suggestion);
|
||||
} else {
|
||||
*command_message = format!("Inserted: {}", suggestion);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
*command_message = "Autocomplete cancelled".to_string();
|
||||
}
|
||||
_ => {
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
*command_message = "Autocomplete cancelled".to_string();
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if key_event.code == KeyCode::Char('@') && key_event.modifiers == KeyModifiers::NONE {
|
||||
let should_trigger = match add_logic_state.editor_keybinding_mode {
|
||||
EditorKeybindingMode::Vim => *is_edit_mode,
|
||||
_ => true,
|
||||
};
|
||||
if should_trigger {
|
||||
let cursor_before = {
|
||||
let editor_borrow = add_logic_state.script_content_editor.borrow();
|
||||
editor_borrow.cursor()
|
||||
};
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
add_logic_state.script_editor_trigger_position = Some(cursor_before);
|
||||
add_logic_state.script_editor_autocomplete_active = true;
|
||||
add_logic_state.script_editor_filter_text.clear();
|
||||
add_logic_state.update_script_editor_suggestions();
|
||||
add_logic_state.has_unsaved_changes = true;
|
||||
*command_message = "Autocomplete: @ (Tab/↑↓ to navigate, Enter to select, Esc to cancel)".to_string();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if key_event.code == KeyCode::Esc && key_event.modifiers == KeyModifiers::NONE {
|
||||
match add_logic_state.editor_keybinding_mode {
|
||||
EditorKeybindingMode::Vim => {
|
||||
if *is_edit_mode {
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
if TextEditor::is_vim_normal_mode(&add_logic_state.vim_state) {
|
||||
*is_edit_mode = false;
|
||||
*command_message = "VIM: Normal Mode. Esc again to exit script.".to_string();
|
||||
}
|
||||
} else {
|
||||
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
|
||||
app_state.ui.focus_outside_canvas = true;
|
||||
*is_edit_mode = false;
|
||||
*command_message = "Exited script editing.".to_string();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if *is_edit_mode {
|
||||
*is_edit_mode = false;
|
||||
*command_message = "Exited script edit. Esc again to exit script.".to_string();
|
||||
} else {
|
||||
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
|
||||
app_state.ui.focus_outside_canvas = true;
|
||||
*is_edit_mode = false;
|
||||
*command_message = "Exited script editing.".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let changed = {
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
)
|
||||
};
|
||||
if changed {
|
||||
add_logic_state.has_unsaved_changes = true;
|
||||
}
|
||||
if add_logic_state.editor_keybinding_mode == EditorKeybindingMode::Vim {
|
||||
*is_edit_mode = !TextEditor::is_vim_normal_mode(&add_logic_state.vim_state);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let action = config.get_general_action(key_event.code, key_event.modifiers);
|
||||
let current_focus = add_logic_state.current_focus;
|
||||
let mut handled = true;
|
||||
let mut new_focus = current_focus;
|
||||
|
||||
match action.as_deref() {
|
||||
Some("exit_table_scroll") => {
|
||||
handled = false;
|
||||
}
|
||||
Some("move_up") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName => {}
|
||||
AddLogicFocus::InputTargetColumn => new_focus = AddLogicFocus::InputLogicName,
|
||||
AddLogicFocus::InputDescription => new_focus = AddLogicFocus::InputTargetColumn,
|
||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::ScriptContentPreview,
|
||||
AddLogicFocus::CancelButton => new_focus = AddLogicFocus::SaveButton,
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("move_down") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName => new_focus = AddLogicFocus::InputTargetColumn,
|
||||
AddLogicFocus::InputTargetColumn => new_focus = AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::InputDescription => {
|
||||
add_logic_state.last_canvas_field = 2;
|
||||
new_focus = AddLogicFocus::ScriptContentPreview;
|
||||
},
|
||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::SaveButton,
|
||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
|
||||
AddLogicFocus::CancelButton => {}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("next_option") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription =>
|
||||
{ new_focus = AddLogicFocus::ScriptContentPreview; }
|
||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::SaveButton,
|
||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
|
||||
AddLogicFocus::CancelButton => { }
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("previous_option") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription =>
|
||||
{ }
|
||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::ScriptContentPreview,
|
||||
AddLogicFocus::CancelButton => new_focus = AddLogicFocus::SaveButton,
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("next_field") => {
|
||||
new_focus = match current_focus {
|
||||
AddLogicFocus::InputLogicName => AddLogicFocus::InputTargetColumn,
|
||||
AddLogicFocus::InputTargetColumn => AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::InputDescription => AddLogicFocus::ScriptContentPreview,
|
||||
AddLogicFocus::ScriptContentPreview => AddLogicFocus::SaveButton,
|
||||
AddLogicFocus::SaveButton => AddLogicFocus::CancelButton,
|
||||
AddLogicFocus::CancelButton => AddLogicFocus::InputLogicName,
|
||||
_ => current_focus,
|
||||
};
|
||||
}
|
||||
Some("prev_field") => {
|
||||
new_focus = match current_focus {
|
||||
AddLogicFocus::InputLogicName => AddLogicFocus::CancelButton,
|
||||
AddLogicFocus::InputTargetColumn => AddLogicFocus::InputLogicName,
|
||||
AddLogicFocus::InputDescription => AddLogicFocus::InputTargetColumn,
|
||||
AddLogicFocus::ScriptContentPreview => AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::SaveButton => AddLogicFocus::ScriptContentPreview,
|
||||
AddLogicFocus::CancelButton => AddLogicFocus::SaveButton,
|
||||
_ => current_focus,
|
||||
};
|
||||
}
|
||||
Some("select") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::ScriptContentPreview => {
|
||||
new_focus = AddLogicFocus::InsideScriptContent;
|
||||
*is_edit_mode = false;
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
let mode_hint = match add_logic_state.editor_keybinding_mode {
|
||||
EditorKeybindingMode::Vim => "VIM mode - 'i'/'a'/'o' to edit",
|
||||
_ => "Enter/Ctrl+E to edit",
|
||||
};
|
||||
*command_message = format!("Fullscreen script editing. {} or Esc to exit.", mode_hint);
|
||||
}
|
||||
AddLogicFocus::SaveButton => {
|
||||
*command_message = "Save logic action".to_string();
|
||||
}
|
||||
AddLogicFocus::CancelButton => {
|
||||
buffer_state.update_history(AppView::Admin);
|
||||
app_state.ui.show_add_logic = false;
|
||||
*command_message = "Cancelled Add Logic".to_string();
|
||||
*is_edit_mode = false;
|
||||
}
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription => {
|
||||
*is_edit_mode = !*is_edit_mode;
|
||||
*command_message = format!("Field edit mode: {}", if *is_edit_mode { "ON" } else { "OFF" });
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("toggle_edit_mode") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription => {
|
||||
*is_edit_mode = !*is_edit_mode;
|
||||
*command_message = format!("Canvas field edit mode: {}", if *is_edit_mode { "ON" } else { "OFF" });
|
||||
}
|
||||
_ => {
|
||||
*command_message = "Cannot toggle edit mode here.".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
|
||||
if handled && current_focus != new_focus {
|
||||
add_logic_state.current_focus = new_focus;
|
||||
let new_is_canvas_input_focus = matches!(new_focus,
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription
|
||||
);
|
||||
if new_is_canvas_input_focus {
|
||||
*is_edit_mode = false;
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
} else {
|
||||
app_state.ui.focus_outside_canvas = true;
|
||||
if matches!(new_focus, AddLogicFocus::ScriptContentPreview) {
|
||||
*is_edit_mode = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
handled
|
||||
}
|
||||
|
||||
fn replace_autocomplete_text(
|
||||
editor: &mut tui_textarea::TextArea,
|
||||
trigger_pos: (usize, usize),
|
||||
filter_len: usize,
|
||||
replacement: &str,
|
||||
) {
|
||||
// use tui_textarea::CursorMove; // Already imported at the top of the module
|
||||
let filter_start_pos = (trigger_pos.0, trigger_pos.1 + 1);
|
||||
editor.move_cursor(CursorMove::Jump(filter_start_pos.0 as u16, filter_start_pos.1 as u16));
|
||||
for _ in 0..filter_len {
|
||||
editor.delete_next_char();
|
||||
}
|
||||
editor.insert_str(replacement);
|
||||
}
|
||||
205
client/src/functions/modes/navigation/add_table_nav.rs
Normal file
205
client/src/functions/modes/navigation/add_table_nav.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
// src/functions/modes/navigation/add_table_nav.rs
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::state::{
|
||||
app::state::AppState,
|
||||
pages::add_table::{AddTableFocus, AddTableState},
|
||||
};
|
||||
use crossterm::event::{KeyEvent};
|
||||
use ratatui::widgets::TableState;
|
||||
use crate::tui::functions::common::add_table::{handle_add_column_action, handle_save_table_action};
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
use crate::services::GrpcClient;
|
||||
use tokio::sync::mpsc;
|
||||
use anyhow::Result;
|
||||
|
||||
pub type SaveTableResultSender = mpsc::Sender<Result<String>>;
|
||||
|
||||
fn navigate_table_up(table_state: &mut TableState, item_count: usize) -> bool {
|
||||
if item_count == 0 { return false; }
|
||||
let current_selection = table_state.selected();
|
||||
match current_selection {
|
||||
Some(index) => {
|
||||
if index > 0 { table_state.select(Some(index - 1)); true }
|
||||
else { false }
|
||||
}
|
||||
None => { table_state.select(Some(0)); true }
|
||||
}
|
||||
}
|
||||
|
||||
fn navigate_table_down(table_state: &mut TableState, item_count: usize) -> bool {
|
||||
if item_count == 0 { return false; }
|
||||
let current_selection = table_state.selected();
|
||||
match current_selection {
|
||||
Some(index) => {
|
||||
if index < item_count - 1 { table_state.select(Some(index + 1)); true }
|
||||
else { false }
|
||||
}
|
||||
None => { table_state.select(Some(0)); true }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_add_table_navigation(
|
||||
key: KeyEvent,
|
||||
config: &Config,
|
||||
app_state: &mut AppState,
|
||||
add_table_state: &mut AddTableState,
|
||||
grpc_client: GrpcClient,
|
||||
save_result_sender: SaveTableResultSender,
|
||||
command_message: &mut String,
|
||||
) -> bool {
|
||||
let action = config.get_general_action(key.code, key.modifiers);
|
||||
let current_focus = add_table_state.current_focus;
|
||||
let mut handled = true;
|
||||
let mut new_focus = current_focus;
|
||||
|
||||
if matches!(current_focus, AddTableFocus::InsideColumnsTable | AddTableFocus::InsideIndexesTable | AddTableFocus::InsideLinksTable) {
|
||||
if matches!(action.as_deref(), Some("next_option") | Some("previous_option")) {
|
||||
*command_message = "Press Esc to exit table item navigation first.".to_string();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
match action.as_deref() {
|
||||
Some("exit_table_scroll") => {
|
||||
match current_focus {
|
||||
AddTableFocus::InsideColumnsTable => {
|
||||
add_table_state.column_table_state.select(None);
|
||||
new_focus = AddTableFocus::ColumnsTable;
|
||||
// *command_message = "Exited Columns Table".to_string(); // Minimal change: remove message
|
||||
}
|
||||
AddTableFocus::InsideIndexesTable => {
|
||||
add_table_state.index_table_state.select(None);
|
||||
new_focus = AddTableFocus::IndexesTable;
|
||||
// *command_message = "Exited Indexes Table".to_string();
|
||||
}
|
||||
AddTableFocus::InsideLinksTable => {
|
||||
add_table_state.link_table_state.select(None);
|
||||
new_focus = AddTableFocus::LinksTable;
|
||||
// *command_message = "Exited Links Table".to_string();
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("move_up") => {
|
||||
match current_focus {
|
||||
AddTableFocus::InputTableName => {
|
||||
// MINIMAL CHANGE: Do nothing, new_focus remains current_focus
|
||||
// *command_message = "At top of form.".to_string(); // Remove message
|
||||
}
|
||||
AddTableFocus::InputColumnName => new_focus = AddTableFocus::InputTableName,
|
||||
AddTableFocus::InputColumnType => new_focus = AddTableFocus::InputColumnName,
|
||||
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::InputColumnType,
|
||||
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::AddColumnButton,
|
||||
AddTableFocus::IndexesTable => new_focus = AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::LinksTable => new_focus = AddTableFocus::IndexesTable,
|
||||
AddTableFocus::InsideColumnsTable => { navigate_table_up(&mut add_table_state.column_table_state, add_table_state.columns.len()); }
|
||||
AddTableFocus::InsideIndexesTable => { navigate_table_up(&mut add_table_state.index_table_state, add_table_state.indexes.len()); }
|
||||
AddTableFocus::InsideLinksTable => { navigate_table_up(&mut add_table_state.link_table_state, add_table_state.links.len()); }
|
||||
AddTableFocus::SaveButton => new_focus = AddTableFocus::LinksTable,
|
||||
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::SaveButton,
|
||||
AddTableFocus::CancelButton => new_focus = AddTableFocus::DeleteSelectedButton,
|
||||
}
|
||||
}
|
||||
Some("move_down") => {
|
||||
match current_focus {
|
||||
AddTableFocus::InputTableName => new_focus = AddTableFocus::InputColumnName,
|
||||
AddTableFocus::InputColumnName => new_focus = AddTableFocus::InputColumnType,
|
||||
AddTableFocus::InputColumnType => {
|
||||
add_table_state.last_canvas_field = 2;
|
||||
new_focus = AddTableFocus::AddColumnButton;
|
||||
},
|
||||
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::IndexesTable,
|
||||
AddTableFocus::IndexesTable => new_focus = AddTableFocus::LinksTable,
|
||||
AddTableFocus::LinksTable => new_focus = AddTableFocus::SaveButton,
|
||||
AddTableFocus::InsideColumnsTable => { navigate_table_down(&mut add_table_state.column_table_state, add_table_state.columns.len()); }
|
||||
AddTableFocus::InsideIndexesTable => { navigate_table_down(&mut add_table_state.index_table_state, add_table_state.indexes.len()); }
|
||||
AddTableFocus::InsideLinksTable => { navigate_table_down(&mut add_table_state.link_table_state, add_table_state.links.len()); }
|
||||
AddTableFocus::SaveButton => new_focus = AddTableFocus::DeleteSelectedButton,
|
||||
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::CancelButton,
|
||||
AddTableFocus::CancelButton => {
|
||||
// MINIMAL CHANGE: Do nothing, new_focus remains current_focus
|
||||
// *command_message = "At bottom of form.".to_string(); // Remove message
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("next_option") => { // This logic should already be non-wrapping
|
||||
match current_focus {
|
||||
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType =>
|
||||
{ new_focus = AddTableFocus::AddColumnButton; }
|
||||
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::IndexesTable,
|
||||
AddTableFocus::IndexesTable => new_focus = AddTableFocus::LinksTable,
|
||||
AddTableFocus::LinksTable => new_focus = AddTableFocus::SaveButton,
|
||||
AddTableFocus::SaveButton => new_focus = AddTableFocus::DeleteSelectedButton,
|
||||
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::CancelButton,
|
||||
AddTableFocus::CancelButton => { /* *command_message = "At last focusable area.".to_string(); */ } // No change in focus
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("previous_option") => { // This logic should already be non-wrapping
|
||||
match current_focus {
|
||||
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType =>
|
||||
{ /* *command_message = "At first focusable area.".to_string(); */ } // No change in focus
|
||||
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::InputColumnType,
|
||||
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::AddColumnButton,
|
||||
AddTableFocus::IndexesTable => new_focus = AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::LinksTable => new_focus = AddTableFocus::IndexesTable,
|
||||
AddTableFocus::SaveButton => new_focus = AddTableFocus::LinksTable,
|
||||
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::SaveButton,
|
||||
AddTableFocus::CancelButton => new_focus = AddTableFocus::DeleteSelectedButton,
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("next_field") => {
|
||||
new_focus = match current_focus {
|
||||
AddTableFocus::InputTableName => AddTableFocus::InputColumnName, AddTableFocus::InputColumnName => AddTableFocus::InputColumnType, AddTableFocus::InputColumnType => AddTableFocus::AddColumnButton, AddTableFocus::AddColumnButton => AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable => AddTableFocus::IndexesTable, AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable => AddTableFocus::LinksTable, AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable => AddTableFocus::SaveButton,
|
||||
AddTableFocus::SaveButton => AddTableFocus::DeleteSelectedButton, AddTableFocus::DeleteSelectedButton => AddTableFocus::CancelButton, AddTableFocus::CancelButton => AddTableFocus::InputTableName,
|
||||
};
|
||||
}
|
||||
Some("prev_field") => {
|
||||
new_focus = match current_focus {
|
||||
AddTableFocus::InputTableName => AddTableFocus::CancelButton, AddTableFocus::InputColumnName => AddTableFocus::InputTableName, AddTableFocus::InputColumnType => AddTableFocus::InputColumnName, AddTableFocus::AddColumnButton => AddTableFocus::InputColumnType,
|
||||
AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable => AddTableFocus::AddColumnButton, AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable => AddTableFocus::ColumnsTable, AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable => AddTableFocus::IndexesTable,
|
||||
AddTableFocus::SaveButton => AddTableFocus::LinksTable, AddTableFocus::DeleteSelectedButton => AddTableFocus::SaveButton, AddTableFocus::CancelButton => AddTableFocus::DeleteSelectedButton,
|
||||
};
|
||||
}
|
||||
Some("select") => {
|
||||
match current_focus {
|
||||
AddTableFocus::ColumnsTable => { new_focus = AddTableFocus::InsideColumnsTable; if add_table_state.column_table_state.selected().is_none() && !add_table_state.columns.is_empty() { add_table_state.column_table_state.select(Some(0)); } /* Message removed */ }
|
||||
AddTableFocus::IndexesTable => { new_focus = AddTableFocus::InsideIndexesTable; if add_table_state.index_table_state.selected().is_none() && !add_table_state.indexes.is_empty() { add_table_state.index_table_state.select(Some(0)); } /* Message removed */ }
|
||||
AddTableFocus::LinksTable => { new_focus = AddTableFocus::InsideLinksTable; if add_table_state.link_table_state.selected().is_none() && !add_table_state.links.is_empty() { add_table_state.link_table_state.select(Some(0)); } /* Message removed */ }
|
||||
AddTableFocus::InsideColumnsTable => { if let Some(index) = add_table_state.column_table_state.selected() { if let Some(col) = add_table_state.columns.get_mut(index) { col.selected = !col.selected; add_table_state.has_unsaved_changes = true; /* Message removed */ }} /* else { Message removed } */ }
|
||||
AddTableFocus::InsideIndexesTable => { if let Some(index) = add_table_state.index_table_state.selected() { if let Some(idx_def) = add_table_state.indexes.get_mut(index) { idx_def.selected = !idx_def.selected; add_table_state.has_unsaved_changes = true; /* Message removed */ }} /* else { Message removed } */ }
|
||||
AddTableFocus::InsideLinksTable => { if let Some(index) = add_table_state.link_table_state.selected() { if let Some(link) = add_table_state.links.get_mut(index) { link.selected = !link.selected; add_table_state.has_unsaved_changes = true; /* Message removed */ }} /* else { Message removed } */ }
|
||||
AddTableFocus::AddColumnButton => { if let Some(focus_after_add) = handle_add_column_action(add_table_state, command_message) { new_focus = focus_after_add; } else { /* Message already set by handle_add_column_action */ }}
|
||||
AddTableFocus::SaveButton => { if add_table_state.table_name.is_empty() { *command_message = "Cannot save: Table name is empty.".to_string(); } else if add_table_state.columns.is_empty() { *command_message = "Cannot save: No columns defined.".to_string(); } else { *command_message = "Saving table...".to_string(); app_state.show_loading_dialog("Saving", "Please wait..."); let mut client_clone = grpc_client.clone(); let state_clone = add_table_state.clone(); let sender_clone = save_result_sender.clone(); tokio::spawn(async move { let result = handle_save_table_action(&mut client_clone, &state_clone).await; let _ = sender_clone.send(result).await; }); }}
|
||||
AddTableFocus::DeleteSelectedButton => { let columns_to_delete: Vec<(usize, String, String)> = add_table_state.columns.iter().enumerate().filter(|(_, col)| col.selected).map(|(index, col)| (index, col.name.clone(), col.data_type.clone())).collect(); if columns_to_delete.is_empty() { *command_message = "No columns selected for deletion.".to_string(); } else { let column_details: String = columns_to_delete.iter().map(|(index, name, dtype)| format!("{}. {} ({})", index + 1, name, dtype)).collect::<Vec<String>>().join("\n"); let message = format!("Delete the following columns?\n\n{}", column_details); app_state.show_dialog("Confirm Deletion", &message, vec!["Confirm".to_string(), "Cancel".to_string()], DialogPurpose::ConfirmDeleteColumns); }}
|
||||
AddTableFocus::CancelButton => { *command_message = "Action: Cancel Add Table (Not Implemented)".to_string(); }
|
||||
_ => { handled = false; }
|
||||
}
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
|
||||
if handled && current_focus != new_focus {
|
||||
add_table_state.current_focus = new_focus;
|
||||
// Minimal change: Command message update logic can be simplified or removed if not desired
|
||||
// For now, let's keep it minimal and only update if it was truly a focus change,
|
||||
// and not a boundary message.
|
||||
if !command_message.starts_with("At ") && current_focus != new_focus { // Avoid overwriting boundary messages
|
||||
// *command_message = format!("Focus: {:?}", add_table_state.current_focus); // Optional: restore if needed
|
||||
}
|
||||
|
||||
|
||||
let new_is_canvas_input_focus = matches!(new_focus,
|
||||
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType
|
||||
);
|
||||
app_state.ui.focus_outside_canvas = !new_is_canvas_input_focus;
|
||||
}
|
||||
// If not handled, command_message remains as it was (e.g., from a deeper function call or previous event)
|
||||
// or can be cleared if that's the desired default. For minimal change, we leave it.
|
||||
|
||||
handled
|
||||
}
|
||||
351
client/src/functions/modes/navigation/admin_nav.rs
Normal file
351
client/src/functions/modes/navigation/admin_nav.rs
Normal file
@@ -0,0 +1,351 @@
|
||||
// src/functions/modes/navigation/admin_nav.rs
|
||||
use crate::state::pages::admin::{AdminFocus, AdminState};
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::state::app::buffer::{BufferState, AppView};
|
||||
use crate::state::pages::add_table::{AddTableState, LinkDefinition};
|
||||
use ratatui::widgets::ListState;
|
||||
use crate::state::pages::add_logic::{AddLogicState, AddLogicFocus}; // Added AddLogicFocus import
|
||||
|
||||
// Helper functions list_select_next and list_select_previous remain the same
|
||||
fn list_select_next(list_state: &mut ListState, item_count: usize) {
|
||||
if item_count == 0 {
|
||||
list_state.select(None);
|
||||
return;
|
||||
}
|
||||
let i = match list_state.selected() {
|
||||
Some(i) => if i >= item_count - 1 { 0 } else { i + 1 },
|
||||
None => 0,
|
||||
};
|
||||
list_state.select(Some(i));
|
||||
}
|
||||
|
||||
fn list_select_previous(list_state: &mut ListState, item_count: usize) {
|
||||
if item_count == 0 {
|
||||
list_state.select(None);
|
||||
return;
|
||||
}
|
||||
let i = match list_state.selected() {
|
||||
Some(i) => if i == 0 { item_count - 1 } else { i - 1 },
|
||||
None => if item_count > 0 { item_count - 1 } else { 0 },
|
||||
};
|
||||
list_state.select(Some(i));
|
||||
}
|
||||
|
||||
pub fn handle_admin_navigation(
|
||||
key: crossterm::event::KeyEvent,
|
||||
config: &Config,
|
||||
app_state: &mut AppState,
|
||||
admin_state: &mut AdminState,
|
||||
buffer_state: &mut BufferState,
|
||||
command_message: &mut String,
|
||||
) -> bool {
|
||||
let action = config.get_general_action(key.code, key.modifiers).map(String::from);
|
||||
let current_focus = admin_state.current_focus;
|
||||
let profile_count = app_state.profile_tree.profiles.len();
|
||||
let mut handled = false;
|
||||
|
||||
match current_focus {
|
||||
AdminFocus::ProfilesPane => {
|
||||
match action.as_deref() {
|
||||
Some("select") => {
|
||||
admin_state.current_focus = AdminFocus::InsideProfilesList;
|
||||
if !app_state.profile_tree.profiles.is_empty() {
|
||||
if admin_state.profile_list_state.selected().is_none() {
|
||||
admin_state.profile_list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
*command_message = "Navigating profiles. Use Up/Down. Esc to exit.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
Some("next_option") | Some("move_down") => {
|
||||
admin_state.current_focus = AdminFocus::Tables;
|
||||
*command_message = "Focus: Tables Pane".to_string();
|
||||
handled = true;
|
||||
}
|
||||
Some("previous_option") | Some("move_up") => {
|
||||
// No wrap-around: Stay on ProfilesPane if trying to go "before" it
|
||||
*command_message = "At first focusable pane.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
|
||||
AdminFocus::InsideProfilesList => {
|
||||
match action.as_deref() {
|
||||
Some("move_up") => {
|
||||
if profile_count > 0 {
|
||||
list_select_previous(&mut admin_state.profile_list_state, profile_count);
|
||||
*command_message = "".to_string();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
Some("move_down") => {
|
||||
if profile_count > 0 {
|
||||
list_select_next(&mut admin_state.profile_list_state, profile_count);
|
||||
*command_message = "".to_string();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
Some("select") => {
|
||||
admin_state.selected_profile_index = admin_state.profile_list_state.selected();
|
||||
admin_state.selected_table_index = None; // Deselect table when profile changes
|
||||
if let Some(profile_idx) = admin_state.selected_profile_index {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(profile_idx) {
|
||||
if !profile.tables.is_empty() {
|
||||
admin_state.table_list_state.select(Some(0)); // Auto-select first table for nav
|
||||
} else {
|
||||
admin_state.table_list_state.select(None);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
admin_state.table_list_state.select(None);
|
||||
}
|
||||
*command_message = format!(
|
||||
"Profile '{}' set as active.",
|
||||
admin_state.get_selected_profile_name().unwrap_or(&"N/A".to_string())
|
||||
);
|
||||
handled = true;
|
||||
}
|
||||
Some("exit_table_scroll") => {
|
||||
admin_state.current_focus = AdminFocus::ProfilesPane;
|
||||
*command_message = "Focus: Profiles Pane".to_string();
|
||||
handled = true;
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
|
||||
AdminFocus::Tables => {
|
||||
match action.as_deref() {
|
||||
Some("select") => {
|
||||
admin_state.current_focus = AdminFocus::InsideTablesList;
|
||||
let current_profile_idx = admin_state.selected_profile_index
|
||||
.or_else(|| admin_state.profile_list_state.selected());
|
||||
if let Some(profile_idx) = current_profile_idx {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(profile_idx) {
|
||||
if !profile.tables.is_empty() {
|
||||
if admin_state.table_list_state.selected().is_none() {
|
||||
admin_state.table_list_state.select(Some(0));
|
||||
}
|
||||
} else {
|
||||
admin_state.table_list_state.select(None);
|
||||
}
|
||||
} else {
|
||||
admin_state.table_list_state.select(None);
|
||||
}
|
||||
} else {
|
||||
admin_state.table_list_state.select(None);
|
||||
*command_message = "Select a profile first to view its tables.".to_string();
|
||||
}
|
||||
if admin_state.current_focus == AdminFocus::InsideTablesList && !admin_state.table_list_state.selected().is_none() {
|
||||
*command_message = "Navigating tables. Use Up/Down. Esc to exit.".to_string();
|
||||
} else if admin_state.table_list_state.selected().is_none() {
|
||||
if current_profile_idx.is_none() {
|
||||
*command_message = "No profile selected to view tables.".to_string();
|
||||
} else {
|
||||
*command_message = "No tables in selected profile.".to_string();
|
||||
}
|
||||
admin_state.current_focus = AdminFocus::Tables; // Stay in Tables pane if no tables to enter
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
Some("previous_option") | Some("move_up") => {
|
||||
admin_state.current_focus = AdminFocus::ProfilesPane;
|
||||
*command_message = "Focus: Profiles Pane".to_string();
|
||||
handled = true;
|
||||
}
|
||||
Some("next_option") | Some("move_down") => {
|
||||
admin_state.current_focus = AdminFocus::Button1;
|
||||
*command_message = "Focus: Add Logic Button".to_string();
|
||||
handled = true;
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
|
||||
AdminFocus::InsideTablesList => {
|
||||
match action.as_deref() {
|
||||
Some("move_up") => {
|
||||
let current_profile_idx = admin_state.selected_profile_index
|
||||
.or_else(|| admin_state.profile_list_state.selected());
|
||||
if let Some(p_idx) = current_profile_idx {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
|
||||
if !profile.tables.is_empty() {
|
||||
list_select_previous(&mut admin_state.table_list_state, profile.tables.len());
|
||||
*command_message = "".to_string();
|
||||
handled = true;
|
||||
} else {
|
||||
*command_message = "No tables to navigate.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
*command_message = "No active profile for tables.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
Some("move_down") => {
|
||||
let current_profile_idx = admin_state.selected_profile_index
|
||||
.or_else(|| admin_state.profile_list_state.selected());
|
||||
if let Some(p_idx) = current_profile_idx {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
|
||||
if !profile.tables.is_empty() {
|
||||
list_select_next(&mut admin_state.table_list_state, profile.tables.len());
|
||||
*command_message = "".to_string();
|
||||
handled = true;
|
||||
} else {
|
||||
*command_message = "No tables to navigate.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
*command_message = "No active profile for tables.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
Some("select") => { // This is for persistently selecting a table with [*]
|
||||
admin_state.selected_table_index = admin_state.table_list_state.selected();
|
||||
let table_name = admin_state.selected_profile_index
|
||||
.and_then(|p_idx| app_state.profile_tree.profiles.get(p_idx))
|
||||
.and_then(|p| admin_state.selected_table_index.and_then(|t_idx| p.tables.get(t_idx)))
|
||||
.map_or("N/A", |t| t.name.as_str());
|
||||
*command_message = format!("Table '{}' set as active.", table_name);
|
||||
handled = true;
|
||||
}
|
||||
Some("exit_table_scroll") => {
|
||||
admin_state.current_focus = AdminFocus::Tables;
|
||||
*command_message = "Focus: Tables Pane".to_string();
|
||||
handled = true;
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
|
||||
AdminFocus::Button1 => { // Add Logic Button
|
||||
match action.as_deref() {
|
||||
Some("select") => { // Typically "Enter" key
|
||||
if let Some(p_idx) = admin_state.selected_profile_index {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
|
||||
if let Some(t_idx) = admin_state.selected_table_index {
|
||||
if let Some(table) = profile.tables.get(t_idx) {
|
||||
// Both profile and table are selected, proceed
|
||||
admin_state.add_logic_state = AddLogicState {
|
||||
profile_name: profile.name.clone(),
|
||||
selected_table_name: Some(table.name.clone()),
|
||||
selected_table_id: Some(table.id), // If you have table IDs
|
||||
editor_keybinding_mode: config.editor.keybinding_mode.clone(),
|
||||
current_focus: AddLogicFocus::default(),
|
||||
..AddLogicState::default()
|
||||
};
|
||||
|
||||
// Store table info for later fetching
|
||||
app_state.pending_table_structure_fetch = Some((
|
||||
profile.name.clone(),
|
||||
table.name.clone()
|
||||
));
|
||||
|
||||
buffer_state.update_history(AppView::AddLogic);
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
*command_message = format!(
|
||||
"Opening Add Logic for table '{}' in profile '{}'...",
|
||||
table.name, profile.name
|
||||
);
|
||||
} else {
|
||||
*command_message = "Error: Selected table data not found.".to_string();
|
||||
}
|
||||
} else {
|
||||
*command_message = "Select a table first!".to_string();
|
||||
}
|
||||
} else {
|
||||
*command_message = "Error: Selected profile data not found.".to_string();
|
||||
}
|
||||
} else {
|
||||
*command_message = "Select a profile first!".to_string();
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
Some("previous_option") | Some("move_up") => {
|
||||
admin_state.current_focus = AdminFocus::Tables;
|
||||
*command_message = "Focus: Tables Pane".to_string();
|
||||
handled = true;
|
||||
}
|
||||
Some("next_option") | Some("move_down") => {
|
||||
admin_state.current_focus = AdminFocus::Button2;
|
||||
*command_message = "Focus: Add Table Button".to_string();
|
||||
handled = true;
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
|
||||
AdminFocus::Button2 => { // Add Table Button
|
||||
match action.as_deref() {
|
||||
Some("select") => {
|
||||
if let Some(p_idx) = admin_state.selected_profile_index {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
|
||||
let selected_profile_name = profile.name.clone();
|
||||
// Prepare links from the selected profile's existing tables
|
||||
let available_links: Vec<LinkDefinition> = profile.tables.iter()
|
||||
.map(|table| LinkDefinition {
|
||||
linked_table_name: table.name.clone(),
|
||||
is_required: false, // Default, can be changed in AddTable screen
|
||||
selected: false,
|
||||
}).collect();
|
||||
|
||||
admin_state.add_table_state = AddTableState {
|
||||
profile_name: selected_profile_name,
|
||||
links: available_links,
|
||||
..AddTableState::default() // Reset other fields
|
||||
};
|
||||
buffer_state.update_history(AppView::AddTable);
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
*command_message = format!("Opening Add Table for profile '{}'...", admin_state.add_table_state.profile_name);
|
||||
handled = true;
|
||||
} else {
|
||||
*command_message = "Error: Selected profile index out of bounds.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
} else {
|
||||
*command_message = "Please select a profile ([*]) first to add a table.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
Some("previous_option") | Some("move_up") => {
|
||||
admin_state.current_focus = AdminFocus::Button1;
|
||||
*command_message = "Focus: Add Logic Button".to_string();
|
||||
handled = true;
|
||||
}
|
||||
Some("next_option") | Some("move_down") => {
|
||||
admin_state.current_focus = AdminFocus::Button3;
|
||||
*command_message = "Focus: Change Table Button".to_string();
|
||||
handled = true;
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
|
||||
AdminFocus::Button3 => { // Change Table Button
|
||||
match action.as_deref() {
|
||||
Some("select") => {
|
||||
// Future: Logic to load selected table into AddTableState for editing
|
||||
*command_message = "Action: Change Table (Not Implemented)".to_string();
|
||||
handled = true;
|
||||
}
|
||||
Some("previous_option") | Some("move_up") => {
|
||||
admin_state.current_focus = AdminFocus::Button2;
|
||||
*command_message = "Focus: Add Table Button".to_string();
|
||||
handled = true;
|
||||
}
|
||||
Some("next_option") | Some("move_down") => {
|
||||
// No wrap-around: Stay on Button3 if trying to go "after" it
|
||||
*command_message = "At last focusable button.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
}
|
||||
handled
|
||||
}
|
||||
@@ -5,6 +5,9 @@ pub mod config;
|
||||
pub mod state;
|
||||
pub mod components;
|
||||
pub mod modes;
|
||||
pub mod functions;
|
||||
pub mod services;
|
||||
pub mod utils;
|
||||
|
||||
pub use ui::run_ui;
|
||||
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
// client/src/main.rs
|
||||
use client::run_ui;
|
||||
#[cfg(feature = "ui-debug")]
|
||||
use client::utils::debug_logger::UiDebugWriter;
|
||||
use dotenvy::dotenv;
|
||||
use std::error::Error;
|
||||
use anyhow::Result;
|
||||
use tracing_subscriber;
|
||||
use std::env;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn Error>> {
|
||||
async fn main() -> Result<()> {
|
||||
#[cfg(feature = "ui-debug")]
|
||||
{
|
||||
// If ui-debug is on, set up our custom writer.
|
||||
let writer = UiDebugWriter::new();
|
||||
tracing_subscriber::fmt()
|
||||
.with_level(false) // Don't show INFO, ERROR, etc.
|
||||
.with_target(false) // Don't show the module path.
|
||||
.without_time() // This is the correct and simpler method.
|
||||
.with_writer(move || writer.clone())
|
||||
.init();
|
||||
}
|
||||
#[cfg(not(feature = "ui-debug"))]
|
||||
{
|
||||
if env::var("ENABLE_TRACING").is_ok() {
|
||||
tracing_subscriber::fmt::init();
|
||||
}
|
||||
}
|
||||
|
||||
dotenv().ok();
|
||||
run_ui().await
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// src/client/modes/canvas.rs
|
||||
pub mod edit;
|
||||
pub mod common;
|
||||
pub mod common_mode;
|
||||
pub mod read_only;
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
// src/modes/canvas/common.rs
|
||||
|
||||
use crossterm::event::{KeyEvent};
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::tui::terminal::grpc_client::GrpcClient;
|
||||
use crate::tui::terminal::core::TerminalCore;
|
||||
use crate::tui::controls::commands::CommandHandler;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::state::state::AppState;
|
||||
use common::proto::multieko2::adresar::{PostAdresarRequest, PutAdresarRequest};
|
||||
|
||||
/// Main handler for common core actions
|
||||
pub async fn handle_core_action(
|
||||
action: &str,
|
||||
form_state: &mut FormState,
|
||||
grpc_client: &mut GrpcClient,
|
||||
command_handler: &mut CommandHandler,
|
||||
terminal: &mut TerminalCore,
|
||||
app_state: &mut AppState,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<(bool, String), Box<dyn std::error::Error>> {
|
||||
match action {
|
||||
"save" => {
|
||||
let message = save(
|
||||
form_state,
|
||||
grpc_client,
|
||||
&mut app_state.ui.is_saved,
|
||||
current_position,
|
||||
total_count,
|
||||
).await?;
|
||||
Ok((false, message))
|
||||
},
|
||||
"force_quit" => {
|
||||
terminal.cleanup()?;
|
||||
Ok((true, "Force exiting without saving.".to_string()))
|
||||
},
|
||||
"save_and_quit" => {
|
||||
let message = save(
|
||||
form_state,
|
||||
grpc_client,
|
||||
&mut app_state.ui.is_saved,
|
||||
current_position,
|
||||
total_count,
|
||||
).await?;
|
||||
terminal.cleanup()?;
|
||||
Ok((true, format!("{}. Exiting application.", message)))
|
||||
},
|
||||
"revert" => {
|
||||
let message = revert(
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
).await?;
|
||||
Ok((false, message))
|
||||
},
|
||||
// We should never hit this case with proper filtering
|
||||
_ => Ok((false, format!("Core action not handled: {}", action))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to check if a key event should trigger a core action
|
||||
pub fn is_core_action(config: &Config, key_code: crossterm::event::KeyCode, modifiers: crossterm::event::KeyModifiers) -> Option<String> {
|
||||
// Check for core application actions (save, quit, etc.)
|
||||
if let Some(action) = config.get_action_for_key_in_mode(
|
||||
&config.keybindings.common,
|
||||
key_code,
|
||||
modifiers
|
||||
) {
|
||||
match action {
|
||||
"save" | "force_quit" | "save_and_quit" | "revert" => {
|
||||
return Some(action.to_string())
|
||||
},
|
||||
_ => {} // Other actions are handled by their respective mode handlers
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Shared logic for saving the current form state
|
||||
pub async fn save(
|
||||
form_state: &mut FormState,
|
||||
grpc_client: &mut GrpcClient,
|
||||
is_saved: &mut bool,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let is_new = *current_position == total_count + 1;
|
||||
|
||||
let message = if is_new {
|
||||
let post_request = PostAdresarRequest {
|
||||
firma: form_state.values[0].clone(),
|
||||
kz: form_state.values[1].clone(),
|
||||
drc: form_state.values[2].clone(),
|
||||
ulica: form_state.values[3].clone(),
|
||||
psc: form_state.values[4].clone(),
|
||||
mesto: form_state.values[5].clone(),
|
||||
stat: form_state.values[6].clone(),
|
||||
banka: form_state.values[7].clone(),
|
||||
ucet: form_state.values[8].clone(),
|
||||
skladm: form_state.values[9].clone(),
|
||||
ico: form_state.values[10].clone(),
|
||||
kontakt: form_state.values[11].clone(),
|
||||
telefon: form_state.values[12].clone(),
|
||||
skladu: form_state.values[13].clone(),
|
||||
fax: form_state.values[14].clone(),
|
||||
};
|
||||
let response = grpc_client.post_adresar(post_request).await?;
|
||||
let new_total = grpc_client.get_adresar_count().await?;
|
||||
*current_position = new_total;
|
||||
form_state.id = response.into_inner().id;
|
||||
"New entry created".to_string()
|
||||
} else {
|
||||
let put_request = PutAdresarRequest {
|
||||
id: form_state.id,
|
||||
firma: form_state.values[0].clone(),
|
||||
kz: form_state.values[1].clone(),
|
||||
drc: form_state.values[2].clone(),
|
||||
ulica: form_state.values[3].clone(),
|
||||
psc: form_state.values[4].clone(),
|
||||
mesto: form_state.values[5].clone(),
|
||||
stat: form_state.values[6].clone(),
|
||||
banka: form_state.values[7].clone(),
|
||||
ucet: form_state.values[8].clone(),
|
||||
skladm: form_state.values[9].clone(),
|
||||
ico: form_state.values[10].clone(),
|
||||
kontakt: form_state.values[11].clone(),
|
||||
telefon: form_state.values[12].clone(),
|
||||
skladu: form_state.values[13].clone(),
|
||||
fax: form_state.values[14].clone(),
|
||||
};
|
||||
let _ = grpc_client.put_adresar(put_request).await?;
|
||||
"Entry updated".to_string()
|
||||
};
|
||||
|
||||
*is_saved = true;
|
||||
form_state.has_unsaved_changes = false;
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
/// Discard changes since last save
|
||||
pub async fn revert(
|
||||
form_state: &mut FormState,
|
||||
grpc_client: &mut GrpcClient,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let is_new = *current_position == total_count + 1;
|
||||
|
||||
if is_new {
|
||||
// Clear all fields for new entries
|
||||
form_state.values.iter_mut().for_each(|v| *v = String::new());
|
||||
form_state.has_unsaved_changes = false;
|
||||
return Ok("New entry cleared".to_string());
|
||||
}
|
||||
|
||||
let data = grpc_client.get_adresar_by_position(*current_position).await?;
|
||||
|
||||
// Update form fields with saved values
|
||||
form_state.values = vec![
|
||||
data.firma,
|
||||
data.kz,
|
||||
data.drc,
|
||||
data.ulica,
|
||||
data.psc,
|
||||
data.mesto,
|
||||
data.stat,
|
||||
data.banka,
|
||||
data.ucet,
|
||||
data.skladm,
|
||||
data.ico,
|
||||
data.kontakt,
|
||||
data.telefon,
|
||||
data.skladu,
|
||||
data.fax,
|
||||
];
|
||||
|
||||
form_state.has_unsaved_changes = false;
|
||||
Ok("Changes discarded, reloaded last saved version".to_string())
|
||||
}
|
||||
86
client/src/modes/canvas/common_mode.rs
Normal file
86
client/src/modes/canvas/common_mode.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
// src/modes/canvas/common_mode.rs
|
||||
|
||||
use crate::tui::terminal::core::TerminalCore;
|
||||
use crate::state::pages::{form::FormState, auth::LoginState, auth::RegisterState, auth::AuthState};
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::services::auth::AuthClient;
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use crate::tui::functions::common::form::SaveOutcome;
|
||||
use anyhow::{Context, Result};
|
||||
use crate::tui::functions::common::{
|
||||
form::{save as form_save, revert as form_revert},
|
||||
login::{save as login_save, revert as login_revert},
|
||||
register::{revert as register_revert},
|
||||
};
|
||||
|
||||
pub async fn handle_core_action(
|
||||
action: &str,
|
||||
form_state: &mut FormState,
|
||||
auth_state: &mut AuthState,
|
||||
login_state: &mut LoginState,
|
||||
register_state: &mut RegisterState,
|
||||
grpc_client: &mut GrpcClient,
|
||||
auth_client: &mut AuthClient,
|
||||
terminal: &mut TerminalCore,
|
||||
app_state: &mut AppState,
|
||||
) -> Result<EventOutcome> {
|
||||
match action {
|
||||
"save" => {
|
||||
if app_state.ui.show_login {
|
||||
let message = login_save(auth_state, login_state, auth_client, app_state).await.context("Login save action failed")?;
|
||||
Ok(EventOutcome::Ok(message))
|
||||
} else {
|
||||
let save_outcome = form_save(
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
).await.context("Register save action failed")?;
|
||||
let message = match save_outcome {
|
||||
SaveOutcome::NoChange => "No changes to save.".to_string(),
|
||||
SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
|
||||
SaveOutcome::CreatedNew(_) => "New entry created.".to_string(),
|
||||
};
|
||||
Ok(EventOutcome::DataSaved(save_outcome, message))
|
||||
}
|
||||
},
|
||||
"force_quit" => {
|
||||
terminal.cleanup()?;
|
||||
Ok(EventOutcome::Exit("Force exiting without saving.".to_string()))
|
||||
},
|
||||
"save_and_quit" => {
|
||||
let message = if app_state.ui.show_login {
|
||||
login_save(auth_state, login_state, auth_client, app_state).await.context("Login save n quit action failed")?
|
||||
} else {
|
||||
let save_outcome = form_save(
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
).await?;
|
||||
match save_outcome {
|
||||
SaveOutcome::NoChange => "No changes to save.".to_string(),
|
||||
SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
|
||||
SaveOutcome::CreatedNew(_) => "New entry created.".to_string(),
|
||||
}
|
||||
};
|
||||
terminal.cleanup()?;
|
||||
Ok(EventOutcome::Exit(format!("{}. Exiting application.", message)))
|
||||
},
|
||||
"revert" => {
|
||||
if app_state.ui.show_login {
|
||||
let message = login_revert(login_state, app_state).await;
|
||||
Ok(EventOutcome::Ok(message))
|
||||
} else if app_state.ui.show_register {
|
||||
let message = register_revert(register_state, app_state).await;
|
||||
Ok(EventOutcome::Ok(message))
|
||||
} else {
|
||||
let message = form_revert(
|
||||
form_state,
|
||||
grpc_client,
|
||||
).await.context("Form revert x action failed")?;
|
||||
Ok(EventOutcome::Ok(message))
|
||||
}
|
||||
},
|
||||
_ => Ok(EventOutcome::Ok(format!("Core action not handled: {}", action))),
|
||||
}
|
||||
}
|
||||
@@ -1,474 +1,500 @@
|
||||
// src/modes/canvas/edit.rs
|
||||
|
||||
// TODO THIS is freaking bloated with functions it never uses REFACTOR 200 LOC can be gone
|
||||
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
|
||||
use crate::tui::terminal::{
|
||||
grpc_client::GrpcClient,
|
||||
};
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::modes::canvas::common;
|
||||
use crate::modes::handlers::event::EventHandler;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use crate::state::pages::{
|
||||
auth::{LoginState, RegisterState},
|
||||
form::FormState,
|
||||
};
|
||||
use canvas::canvas::CanvasState;
|
||||
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher, canvas::ActionResult};
|
||||
use anyhow::Result;
|
||||
use common::proto::komp_ac::search::search_response::Hit;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::info;
|
||||
|
||||
pub async fn handle_edit_event_internal(
|
||||
key: KeyEvent,
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EditEventOutcome {
|
||||
Message(String),
|
||||
ExitEditMode,
|
||||
}
|
||||
|
||||
/// Helper function to spawn a non-blocking search task for autocomplete.
|
||||
async fn trigger_form_autocomplete_search(
|
||||
form_state: &mut FormState,
|
||||
grpc_client: &mut GrpcClient,
|
||||
sender: mpsc::UnboundedSender<Vec<Hit>>,
|
||||
) {
|
||||
if let Some(field_def) = form_state.fields.get(form_state.current_field) {
|
||||
if field_def.is_link {
|
||||
if let Some(target_table) = &field_def.link_target_table {
|
||||
// 1. Update state for immediate UI feedback
|
||||
form_state.autocomplete_loading = true;
|
||||
form_state.autocomplete_active = true;
|
||||
form_state.autocomplete_suggestions.clear();
|
||||
form_state.selected_suggestion_index = None;
|
||||
|
||||
// 2. Clone everything needed for the background task
|
||||
let query = form_state.get_current_input().to_string();
|
||||
let table_to_search = target_table.clone();
|
||||
let mut grpc_client_clone = grpc_client.clone();
|
||||
|
||||
info!(
|
||||
"[Autocomplete] Spawning search in '{}' for query: '{}'",
|
||||
table_to_search, query
|
||||
);
|
||||
|
||||
// 3. Spawn the non-blocking task
|
||||
tokio::spawn(async move {
|
||||
match grpc_client_clone
|
||||
.search_table(table_to_search, query)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
// Send results back through the channel
|
||||
let _ = sender.send(response.hits);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"[Autocomplete] Search failed: {:?}",
|
||||
e
|
||||
);
|
||||
// Send an empty vec on error so the UI can stop loading
|
||||
let _ = sender.send(vec![]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_form_edit_with_canvas(
|
||||
key_event: KeyEvent,
|
||||
config: &Config,
|
||||
form_state: &mut FormState,
|
||||
ideal_cursor_column: &mut usize,
|
||||
command_message: &mut String,
|
||||
is_saved: &mut bool,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
grpc_client: &mut GrpcClient,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
if let Some("enter_command_mode") = config.get_action_for_key_in_mode(&config.keybindings.global, key.code, key.modifiers) {
|
||||
// Ignore in edit mode and process as normal input
|
||||
handle_edit_specific_input(key, form_state, ideal_cursor_column);
|
||||
return Ok(command_message.clone());
|
||||
) -> Result<String> {
|
||||
// Try canvas action from key first
|
||||
let canvas_config = canvas::config::CanvasConfig::load();
|
||||
if let Some(action_name) = canvas_config.get_edit_action(key_event.code, key_event.modifiers) {
|
||||
let canvas_action = CanvasAction::from_string(action_name);
|
||||
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
}
|
||||
Ok(ActionResult::HandledByFeature(msg)) => {
|
||||
return Ok(msg);
|
||||
}
|
||||
Ok(ActionResult::Error(msg)) => {
|
||||
return Ok(format!("Error: {}", msg));
|
||||
}
|
||||
Ok(ActionResult::RequiresContext(msg)) => {
|
||||
return Ok(format!("Context needed: {}", msg));
|
||||
}
|
||||
Err(_) => {
|
||||
// Fall through to try config mapping
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check common actions first
|
||||
if let Some(action) = config.get_action_for_key_in_mode(&config.keybindings.common, key.code, key.modifiers) {
|
||||
return execute_common_action(
|
||||
action,
|
||||
form_state,
|
||||
grpc_client,
|
||||
is_saved,
|
||||
current_position,
|
||||
total_count,
|
||||
).await;
|
||||
// Try config-mapped action
|
||||
if let Some(action_str) = config.get_edit_action_for_key(key_event.code, key_event.modifiers) {
|
||||
let canvas_action = CanvasAction::from_string(&action_str);
|
||||
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
}
|
||||
Ok(ActionResult::HandledByFeature(msg)) => {
|
||||
return Ok(msg);
|
||||
}
|
||||
Ok(ActionResult::Error(msg)) => {
|
||||
return Ok(format!("Error: {}", msg));
|
||||
}
|
||||
Ok(ActionResult::RequiresContext(msg)) => {
|
||||
return Ok(format!("Context needed: {}", msg));
|
||||
}
|
||||
Err(e) => {
|
||||
return Ok(format!("Action failed: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(action) = config.get_edit_action_for_key(key.code, key.modifiers) {
|
||||
return execute_edit_action(
|
||||
action,
|
||||
form_state,
|
||||
ideal_cursor_column,
|
||||
grpc_client, // Changed from AppTerminal
|
||||
is_saved,
|
||||
current_position,
|
||||
total_count,
|
||||
).await;
|
||||
}
|
||||
|
||||
handle_edit_specific_input(
|
||||
key,
|
||||
form_state,
|
||||
ideal_cursor_column,
|
||||
);
|
||||
|
||||
Ok(command_message.clone())
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
async fn execute_common_action(
|
||||
/// Helper function to execute a specific action using canvas library
|
||||
async fn execute_canvas_action(
|
||||
action: &str,
|
||||
form_state: &mut FormState,
|
||||
grpc_client: &mut GrpcClient,
|
||||
is_saved: &mut bool,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
match action {
|
||||
"save" => {
|
||||
common::save(
|
||||
form_state,
|
||||
grpc_client,
|
||||
is_saved,
|
||||
current_position,
|
||||
total_count,
|
||||
).await
|
||||
},
|
||||
"revert" => {
|
||||
common::revert(
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
).await
|
||||
},
|
||||
"move_up" | "move_down" => {
|
||||
// Reuse edit mode's existing logic
|
||||
execute_edit_action(
|
||||
action,
|
||||
form_state,
|
||||
&mut 0, // Dummy ideal_cursor_column (not used here)
|
||||
grpc_client,
|
||||
is_saved,
|
||||
current_position,
|
||||
total_count,
|
||||
).await
|
||||
},
|
||||
_ => Ok(format!("Common action not handled: {}", action)),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_edit_specific_input(
|
||||
key: KeyEvent,
|
||||
form_state: &mut FormState,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) {
|
||||
match key.code {
|
||||
KeyCode::Char(c) => {
|
||||
let cursor_pos = form_state.current_cursor_pos;
|
||||
let field_value = form_state.get_current_input_mut();
|
||||
let mut chars: Vec<char> = field_value.chars().collect();
|
||||
if cursor_pos <= chars.len() {
|
||||
chars.insert(cursor_pos, c);
|
||||
*field_value = chars.into_iter().collect();
|
||||
form_state.current_cursor_pos = cursor_pos + 1;
|
||||
*ideal_cursor_column = form_state.current_cursor_pos;
|
||||
form_state.has_unsaved_changes = true;
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if form_state.current_cursor_pos > 0 {
|
||||
let cursor_pos = form_state.current_cursor_pos;
|
||||
let field_value = form_state.get_current_input_mut();
|
||||
let mut chars: Vec<char> = field_value.chars().collect();
|
||||
if cursor_pos <= chars.len() && cursor_pos > 0 {
|
||||
chars.remove(cursor_pos - 1);
|
||||
*field_value = chars.into_iter().collect();
|
||||
form_state.current_cursor_pos = cursor_pos - 1;
|
||||
*ideal_cursor_column = form_state.current_cursor_pos;
|
||||
form_state.has_unsaved_changes = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
let cursor_pos = form_state.current_cursor_pos;
|
||||
let field_value = form_state.get_current_input_mut();
|
||||
let chars: Vec<char> = field_value.chars().collect();
|
||||
if cursor_pos < chars.len() {
|
||||
let mut new_chars = chars.clone();
|
||||
new_chars.remove(cursor_pos);
|
||||
*field_value = new_chars.into_iter().collect();
|
||||
form_state.has_unsaved_changes = true;
|
||||
}
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
if key.modifiers.contains(KeyModifiers::SHIFT) {
|
||||
if form_state.current_field == 0 {
|
||||
form_state.current_field = form_state.fields.len() - 1;
|
||||
} else {
|
||||
form_state.current_field = form_state.current_field.saturating_sub(1);
|
||||
}
|
||||
} else {
|
||||
form_state.current_field = (form_state.current_field + 1) % form_state.fields.len();
|
||||
}
|
||||
let current_input = form_state.get_current_input();
|
||||
let max_cursor_pos = current_input.len();
|
||||
form_state.current_cursor_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
form_state.current_field = (form_state.current_field + 1) % form_state.fields.len();
|
||||
let current_input = form_state.get_current_input();
|
||||
let max_cursor_pos = current_input.len();
|
||||
form_state.current_cursor_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
}
|
||||
_ => {}
|
||||
) -> Result<String> {
|
||||
let canvas_action = CanvasAction::from_string(action);
|
||||
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => Ok(msg.unwrap_or_default()),
|
||||
Ok(ActionResult::HandledByFeature(msg)) => Ok(msg),
|
||||
Ok(ActionResult::Error(msg)) => Ok(format!("Error: {}", msg)),
|
||||
Ok(ActionResult::RequiresContext(msg)) => Ok(format!("Context needed: {}", msg)),
|
||||
Err(e) => Ok(format!("Action failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_edit_action(
|
||||
action: &str,
|
||||
form_state: &mut FormState,
|
||||
/// FIXED: Unified canvas action handler with proper priority order for edit mode
|
||||
async fn handle_canvas_state_edit<S: CanvasState>(
|
||||
key: KeyEvent,
|
||||
config: &Config,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
grpc_client: &mut GrpcClient, // Changed from AppTerminal
|
||||
is_saved: &mut bool,
|
||||
) -> Result<String> {
|
||||
// println!("DEBUG: Key pressed: {:?}", key); // DEBUG
|
||||
|
||||
// PRIORITY 1: Character insertion in edit mode comes FIRST
|
||||
if let KeyCode::Char(c) = key.code {
|
||||
// Only insert if no modifiers or just shift (for uppercase)
|
||||
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT {
|
||||
// println!("DEBUG: Using character insertion priority for: {}", c); // DEBUG
|
||||
let canvas_action = CanvasAction::InsertChar(c);
|
||||
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
}
|
||||
Ok(ActionResult::HandledByFeature(msg)) => {
|
||||
return Ok(msg);
|
||||
}
|
||||
Ok(ActionResult::Error(msg)) => {
|
||||
return Ok(format!("Error: {}", msg));
|
||||
}
|
||||
Ok(ActionResult::RequiresContext(msg)) => {
|
||||
return Ok(format!("Context needed: {}", msg));
|
||||
}
|
||||
Err(e) => {
|
||||
// println!("DEBUG: Character insertion failed: {:?}, trying config", e);
|
||||
// Fall through to try config mappings
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PRIORITY 2: Check canvas config for special keys/combinations
|
||||
let canvas_config = canvas::config::CanvasConfig::load();
|
||||
if let Some(action_name) = canvas_config.get_edit_action(key.code, key.modifiers) {
|
||||
// println!("DEBUG: Canvas config mapped to: {}", action_name); // DEBUG
|
||||
let canvas_action = CanvasAction::from_string(action_name);
|
||||
|
||||
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
}
|
||||
Ok(ActionResult::HandledByFeature(msg)) => {
|
||||
return Ok(msg);
|
||||
}
|
||||
Ok(ActionResult::Error(msg)) => {
|
||||
return Ok(format!("Error: {}", msg));
|
||||
}
|
||||
Ok(ActionResult::RequiresContext(msg)) => {
|
||||
return Ok(format!("Context needed: {}", msg));
|
||||
}
|
||||
Err(_) => {
|
||||
// println!("DEBUG: Canvas action failed, trying client config"); // DEBUG
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// println!("DEBUG: No canvas config mapping found"); // DEBUG
|
||||
}
|
||||
|
||||
// PRIORITY 3: Check client config ONLY for non-character keys or modified keys
|
||||
if !matches!(key.code, KeyCode::Char(_)) || !key.modifiers.is_empty() {
|
||||
if let Some(action_str) = config.get_edit_action_for_key(key.code, key.modifiers) {
|
||||
// println!("DEBUG: Client config mapped to: {} (for non-char key)", action_str); // DEBUG
|
||||
let canvas_action = CanvasAction::from_string(&action_str);
|
||||
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
}
|
||||
Ok(ActionResult::HandledByFeature(msg)) => {
|
||||
return Ok(msg);
|
||||
}
|
||||
Ok(ActionResult::Error(msg)) => {
|
||||
return Ok(format!("Error: {}", msg));
|
||||
}
|
||||
Ok(ActionResult::RequiresContext(msg)) => {
|
||||
return Ok(format!("Context needed: {}", msg));
|
||||
}
|
||||
Err(e) => {
|
||||
return Ok(format!("Action failed: {}", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// println!("DEBUG: No client config mapping found for non-char key"); // DEBUG
|
||||
}
|
||||
} else {
|
||||
// println!("DEBUG: Skipping client config for character key in edit mode"); // DEBUG
|
||||
}
|
||||
|
||||
// println!("DEBUG: No action taken for key: {:?}", key); // DEBUG
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn handle_edit_event(
|
||||
key: KeyEvent,
|
||||
config: &Config,
|
||||
form_state: &mut FormState,
|
||||
login_state: &mut LoginState,
|
||||
register_state: &mut RegisterState,
|
||||
admin_state: &mut AdminState,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
match action {
|
||||
"save" => {
|
||||
common::save(
|
||||
form_state,
|
||||
grpc_client, // Changed from AppTerminal
|
||||
is_saved,
|
||||
current_position,
|
||||
total_count,
|
||||
).await
|
||||
},
|
||||
"move_left" => {
|
||||
form_state.current_cursor_pos = form_state.current_cursor_pos.saturating_sub(1);
|
||||
*ideal_cursor_column = form_state.current_cursor_pos;
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_right" => {
|
||||
let current_input = form_state.get_current_input();
|
||||
if form_state.current_cursor_pos < current_input.len() {
|
||||
form_state.current_cursor_pos += 1;
|
||||
*ideal_cursor_column = form_state.current_cursor_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_up" => {
|
||||
if form_state.current_field == 0 {
|
||||
form_state.current_field = form_state.fields.len() - 1;
|
||||
} else {
|
||||
form_state.current_field = form_state.current_field.saturating_sub(1);
|
||||
}
|
||||
let current_input = form_state.get_current_input();
|
||||
let max_cursor_pos = current_input.len();
|
||||
form_state.current_cursor_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_down" => {
|
||||
form_state.current_field = (form_state.current_field + 1) % form_state.fields.len();
|
||||
let current_input = form_state.get_current_input();
|
||||
let max_cursor_pos = current_input.len();
|
||||
form_state.current_cursor_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_line_start" => {
|
||||
form_state.current_cursor_pos = 0;
|
||||
*ideal_cursor_column = form_state.current_cursor_pos;
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_line_end" => {
|
||||
let current_input = form_state.get_current_input();
|
||||
form_state.current_cursor_pos = current_input.len();
|
||||
*ideal_cursor_column = form_state.current_cursor_pos;
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_first_line" => {
|
||||
form_state.current_field = 0;
|
||||
let current_input = form_state.get_current_input();
|
||||
let max_cursor_pos = current_input.len();
|
||||
form_state.current_cursor_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
Ok("Moved to first line".to_string())
|
||||
}
|
||||
"move_last_line" => {
|
||||
form_state.current_field = form_state.fields.len() - 1;
|
||||
let current_input = form_state.get_current_input();
|
||||
let max_cursor_pos = current_input.len();
|
||||
form_state.current_cursor_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
Ok("Moved to last line".to_string())
|
||||
}
|
||||
// Word movement actions
|
||||
"move_word_next" => {
|
||||
let current_input = form_state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_next_word_start(current_input, form_state.current_cursor_pos);
|
||||
form_state.current_cursor_pos = new_pos.min(current_input.len());
|
||||
*ideal_cursor_column = form_state.current_cursor_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_end" => {
|
||||
let current_input = form_state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_word_end(current_input, form_state.current_cursor_pos);
|
||||
form_state.current_cursor_pos = new_pos.min(current_input.len());
|
||||
*ideal_cursor_column = form_state.current_cursor_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_prev" => {
|
||||
let current_input = form_state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_start(current_input, form_state.current_cursor_pos);
|
||||
form_state.current_cursor_pos = new_pos;
|
||||
*ideal_cursor_column = form_state.current_cursor_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_end_prev" => {
|
||||
let current_input = form_state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_end(current_input, form_state.current_cursor_pos);
|
||||
form_state.current_cursor_pos = new_pos;
|
||||
*ideal_cursor_column = form_state.current_cursor_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
// Edit-specific actions that can be bound to keys
|
||||
"delete_char_forward" => {
|
||||
let cursor_pos = form_state.current_cursor_pos;
|
||||
let field_value = form_state.get_current_input_mut();
|
||||
let chars: Vec<char> = field_value.chars().collect();
|
||||
if cursor_pos < chars.len() {
|
||||
let mut new_chars = chars.clone();
|
||||
new_chars.remove(cursor_pos);
|
||||
*field_value = new_chars.into_iter().collect();
|
||||
form_state.has_unsaved_changes = true;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"delete_char_backward" => {
|
||||
if form_state.current_cursor_pos > 0 {
|
||||
let cursor_pos = form_state.current_cursor_pos;
|
||||
let field_value = form_state.get_current_input_mut();
|
||||
let mut chars: Vec<char> = field_value.chars().collect();
|
||||
if cursor_pos <= chars.len() && cursor_pos > 0 {
|
||||
chars.remove(cursor_pos - 1);
|
||||
*field_value = chars.into_iter().collect();
|
||||
form_state.current_cursor_pos = cursor_pos - 1;
|
||||
*ideal_cursor_column = form_state.current_cursor_pos;
|
||||
form_state.has_unsaved_changes = true;
|
||||
event_handler: &mut EventHandler,
|
||||
app_state: &AppState,
|
||||
) -> Result<EditEventOutcome> {
|
||||
// --- AUTOCOMPLETE-SPECIFIC KEY HANDLING ---
|
||||
if app_state.ui.show_form && form_state.autocomplete_active {
|
||||
if let Some(action) =
|
||||
config.get_edit_action_for_key(key.code, key.modifiers)
|
||||
{
|
||||
match action {
|
||||
"suggestion_down" => {
|
||||
if !form_state.autocomplete_suggestions.is_empty() {
|
||||
let current =
|
||||
form_state.selected_suggestion_index.unwrap_or(0);
|
||||
let next = (current + 1)
|
||||
% form_state.autocomplete_suggestions.len();
|
||||
form_state.selected_suggestion_index = Some(next);
|
||||
}
|
||||
return Ok(EditEventOutcome::Message(String::new()));
|
||||
}
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"insert_char" => {
|
||||
// This could be expanded to allow configurable character insertion
|
||||
// For now, it's a placeholder that would need additional parameters
|
||||
Ok("Character insertion requires configuration".to_string())
|
||||
}
|
||||
"next_field" => {
|
||||
form_state.current_field = (form_state.current_field + 1) % form_state.fields.len();
|
||||
let current_input = form_state.get_current_input();
|
||||
let max_cursor_pos = current_input.len();
|
||||
form_state.current_cursor_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
Ok("".to_string())
|
||||
}
|
||||
"prev_field" => {
|
||||
if form_state.current_field == 0 {
|
||||
form_state.current_field = form_state.fields.len() - 1;
|
||||
} else {
|
||||
form_state.current_field = form_state.current_field.saturating_sub(1);
|
||||
}
|
||||
let current_input = form_state.get_current_input();
|
||||
let max_cursor_pos = current_input.len();
|
||||
form_state.current_cursor_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
Ok("".to_string())
|
||||
}
|
||||
// Fallback for unrecognized actions
|
||||
_ => Ok(format!("Unknown action: {}", action)),
|
||||
}
|
||||
}
|
||||
"suggestion_up" => {
|
||||
if !form_state.autocomplete_suggestions.is_empty() {
|
||||
let current =
|
||||
form_state.selected_suggestion_index.unwrap_or(0);
|
||||
let prev = if current == 0 {
|
||||
form_state.autocomplete_suggestions.len() - 1
|
||||
} else {
|
||||
current - 1
|
||||
};
|
||||
form_state.selected_suggestion_index = Some(prev);
|
||||
}
|
||||
return Ok(EditEventOutcome::Message(String::new()));
|
||||
}
|
||||
"exit" => {
|
||||
form_state.deactivate_autocomplete();
|
||||
return Ok(EditEventOutcome::Message(
|
||||
"Autocomplete cancelled".to_string(),
|
||||
));
|
||||
}
|
||||
"enter_decider" => {
|
||||
if let Some(selected_idx) =
|
||||
form_state.selected_suggestion_index
|
||||
{
|
||||
if let Some(selection) = form_state
|
||||
.autocomplete_suggestions
|
||||
.get(selected_idx)
|
||||
.cloned()
|
||||
{
|
||||
// --- THIS IS THE CORE LOGIC CHANGE ---
|
||||
|
||||
// Reuse these character and word navigation helper functions
|
||||
#[derive(PartialEq)]
|
||||
enum CharType {
|
||||
Whitespace,
|
||||
Alphanumeric,
|
||||
Punctuation,
|
||||
}
|
||||
// 1. Get the friendly display name for the UI
|
||||
let display_name =
|
||||
form_state.get_display_name_for_hit(&selection);
|
||||
|
||||
fn get_char_type(c: char) -> CharType {
|
||||
if c.is_whitespace() {
|
||||
CharType::Whitespace
|
||||
} else if c.is_alphanumeric() {
|
||||
CharType::Alphanumeric
|
||||
} else {
|
||||
CharType::Punctuation
|
||||
}
|
||||
}
|
||||
// 2. Store the REAL ID in the form's values
|
||||
let current_input =
|
||||
form_state.get_current_input_mut();
|
||||
*current_input = selection.id.to_string();
|
||||
|
||||
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos >= chars.len() {
|
||||
return current_pos;
|
||||
}
|
||||
// 3. Set the persistent display override in the map
|
||||
form_state.link_display_map.insert(
|
||||
form_state.current_field,
|
||||
display_name,
|
||||
);
|
||||
|
||||
let mut pos = current_pos;
|
||||
let initial_type = get_char_type(chars[pos]);
|
||||
while pos < chars.len() && get_char_type(chars[pos]) == initial_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
fn find_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if current_pos >= chars.len() - 1 {
|
||||
return chars.len() - 1;
|
||||
}
|
||||
|
||||
let mut pos = current_pos;
|
||||
|
||||
if get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos += 1;
|
||||
}
|
||||
} else {
|
||||
let current_type = get_char_type(chars[pos]);
|
||||
if pos + 1 < chars.len() && get_char_type(chars[pos + 1]) != current_type {
|
||||
pos += 1;
|
||||
while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos += 1;
|
||||
// 4. Finalize state
|
||||
form_state.deactivate_autocomplete();
|
||||
form_state.set_has_unsaved_changes(true);
|
||||
return Ok(EditEventOutcome::Message(
|
||||
"Selection made".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
form_state.deactivate_autocomplete();
|
||||
// Fall through to default 'enter' behavior
|
||||
}
|
||||
_ => {} // Let other keys fall through to the live search logic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if pos >= chars.len() {
|
||||
return chars.len() - 1;
|
||||
}
|
||||
// --- LIVE AUTOCOMPLETE TRIGGER LOGIC ---
|
||||
let mut trigger_search = false;
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos + 1 < chars.len() && get_char_type(chars[pos + 1]) == word_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if get_char_type(chars[pos]) != CharType::Whitespace {
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos <= 1 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos > 0 && get_char_type(chars[pos]) != CharType::Whitespace {
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos > 0 {
|
||||
pos -= 1;
|
||||
let prev_word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == prev_word_type {
|
||||
pos -= 1;
|
||||
if app_state.ui.show_form {
|
||||
// Manual trigger
|
||||
if let Some("trigger_autocomplete") =
|
||||
config.get_edit_action_for_key(key.code, key.modifiers)
|
||||
{
|
||||
if !form_state.autocomplete_active {
|
||||
trigger_search = true;
|
||||
}
|
||||
|
||||
while pos < chars.len() - 1 &&
|
||||
get_char_type(chars[pos + 1]) == prev_word_type {
|
||||
pos += 1;
|
||||
}
|
||||
// Live search trigger while typing
|
||||
else if form_state.autocomplete_active {
|
||||
if let KeyCode::Char(_) | KeyCode::Backspace = key.code {
|
||||
let action = if let KeyCode::Backspace = key.code {
|
||||
"delete_char_backward"
|
||||
} else {
|
||||
"insert_char"
|
||||
};
|
||||
// FIXED: Use canvas library instead of form_e::execute_edit_action
|
||||
execute_canvas_action(
|
||||
action,
|
||||
key,
|
||||
form_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?;
|
||||
trigger_search = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pos
|
||||
if trigger_search {
|
||||
trigger_form_autocomplete_search(
|
||||
form_state,
|
||||
&mut event_handler.grpc_client,
|
||||
event_handler.autocomplete_result_sender.clone(),
|
||||
)
|
||||
.await;
|
||||
return Ok(EditEventOutcome::Message("Searching...".to_string()));
|
||||
}
|
||||
|
||||
// --- GENERAL EDIT MODE EVENT HANDLING (IF NOT AUTOCOMPLETE) ---
|
||||
|
||||
if let Some(action_str) =
|
||||
config.get_edit_action_for_key(key.code, key.modifiers)
|
||||
{
|
||||
// Handle Enter key (next field)
|
||||
if action_str == "enter_decider" {
|
||||
// FIXED: Use canvas library instead of form_e::execute_edit_action
|
||||
let msg = execute_canvas_action(
|
||||
"next_field",
|
||||
key,
|
||||
form_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?;
|
||||
return Ok(EditEventOutcome::Message(msg));
|
||||
}
|
||||
|
||||
// Handle exiting edit mode
|
||||
if action_str == "exit" {
|
||||
return Ok(EditEventOutcome::ExitEditMode);
|
||||
}
|
||||
|
||||
// Handle all other edit actions - NOW USING CANVAS LIBRARY
|
||||
let msg = if app_state.ui.show_login {
|
||||
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
|
||||
handle_canvas_state_edit(
|
||||
key,
|
||||
config,
|
||||
login_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_add_table {
|
||||
// NEW: Use unified canvas handler instead of add_table_e::execute_edit_action
|
||||
handle_canvas_state_edit(
|
||||
key,
|
||||
config,
|
||||
&mut admin_state.add_table_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_add_logic {
|
||||
// NEW: Use unified canvas handler instead of add_logic_e::execute_edit_action
|
||||
handle_canvas_state_edit(
|
||||
key,
|
||||
config,
|
||||
&mut admin_state.add_logic_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_register {
|
||||
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
|
||||
handle_canvas_state_edit(
|
||||
key,
|
||||
config,
|
||||
register_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
// FIXED: Use canvas library instead of form_e::execute_edit_action
|
||||
execute_canvas_action(
|
||||
action_str,
|
||||
key,
|
||||
form_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
return Ok(EditEventOutcome::Message(msg));
|
||||
}
|
||||
|
||||
// --- FALLBACK FOR CHARACTER INSERTION (IF NO OTHER BINDING MATCHED) ---
|
||||
if let KeyCode::Char(_) = key.code {
|
||||
let msg = if app_state.ui.show_login {
|
||||
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
|
||||
handle_canvas_state_edit(
|
||||
key,
|
||||
config,
|
||||
login_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_add_table {
|
||||
// NEW: Use unified canvas handler instead of add_table_e::execute_edit_action
|
||||
handle_canvas_state_edit(
|
||||
key,
|
||||
config,
|
||||
&mut admin_state.add_table_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_add_logic {
|
||||
// NEW: Use unified canvas handler instead of add_logic_e::execute_edit_action
|
||||
handle_canvas_state_edit(
|
||||
key,
|
||||
config,
|
||||
&mut admin_state.add_logic_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_register {
|
||||
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
|
||||
handle_canvas_state_edit(
|
||||
key,
|
||||
config,
|
||||
register_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
// FIXED: Use canvas library instead of form_e::execute_edit_action
|
||||
execute_canvas_action(
|
||||
"insert_char",
|
||||
key,
|
||||
form_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
return Ok(EditEventOutcome::Message(msg));
|
||||
}
|
||||
|
||||
Ok(EditEventOutcome::Message(String::new())) // No action taken
|
||||
}
|
||||
|
||||
@@ -1,31 +1,158 @@
|
||||
// src/modes/handlers/read_only.rs
|
||||
// src/modes/canvas/read_only.rs
|
||||
|
||||
use crossterm::event::{KeyEvent};
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::config::binds::key_sequences::KeySequenceTracker;
|
||||
use crate::tui::terminal::grpc_client::GrpcClient;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::pages::auth::LoginState;
|
||||
use crate::state::pages::auth::RegisterState;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::state::pages::add_logic::AddLogicState;
|
||||
use crate::state::pages::add_table::AddTableState;
|
||||
use crate::state::app::state::AppState;
|
||||
use canvas::{canvas::{CanvasAction, CanvasState, ActionResult}, dispatcher::ActionDispatcher};
|
||||
use crossterm::event::KeyEvent;
|
||||
use anyhow::Result;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum CharType {
|
||||
Whitespace,
|
||||
Alphanumeric,
|
||||
Punctuation,
|
||||
/// Helper function to dispatch canvas action for any CanvasState
|
||||
async fn dispatch_canvas_action<S: CanvasState>(
|
||||
action: &str,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> String {
|
||||
let canvas_action = CanvasAction::from_string(action);
|
||||
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => msg.unwrap_or_default(),
|
||||
Ok(ActionResult::HandledByFeature(msg)) => msg,
|
||||
Ok(ActionResult::Error(msg)) => format!("Error: {}", msg),
|
||||
Ok(ActionResult::RequiresContext(msg)) => format!("Context needed: {}", msg),
|
||||
Err(e) => format!("Action failed: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to dispatch canvas action to the appropriate state based on UI
|
||||
async fn dispatch_to_active_state(
|
||||
action: &str,
|
||||
app_state: &AppState,
|
||||
form_state: &mut FormState,
|
||||
login_state: &mut LoginState,
|
||||
register_state: &mut RegisterState,
|
||||
add_table_state: &mut AddTableState,
|
||||
add_logic_state: &mut AddLogicState,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> String {
|
||||
if app_state.ui.show_add_table {
|
||||
dispatch_canvas_action(action, add_table_state, ideal_cursor_column).await
|
||||
} else if app_state.ui.show_add_logic {
|
||||
dispatch_canvas_action(action, add_logic_state, ideal_cursor_column).await
|
||||
} else if app_state.ui.show_register {
|
||||
dispatch_canvas_action(action, register_state, ideal_cursor_column).await
|
||||
} else if app_state.ui.show_login {
|
||||
dispatch_canvas_action(action, login_state, ideal_cursor_column).await
|
||||
} else {
|
||||
dispatch_canvas_action(action, form_state, ideal_cursor_column).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to handle context-specific actions that need special treatment
|
||||
async fn handle_context_action(
|
||||
action: &str,
|
||||
app_state: &AppState,
|
||||
form_state: &mut FormState,
|
||||
grpc_client: &mut GrpcClient,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<Option<String>> {
|
||||
const CONTEXT_ACTIONS_FORM: &[&str] = &[
|
||||
"previous_entry",
|
||||
"next_entry",
|
||||
];
|
||||
const CONTEXT_ACTIONS_LOGIN: &[&str] = &[
|
||||
"previous_entry",
|
||||
"next_entry",
|
||||
];
|
||||
|
||||
if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) {
|
||||
Ok(Some(crate::tui::functions::form::handle_action(
|
||||
action,
|
||||
form_state,
|
||||
grpc_client,
|
||||
ideal_cursor_column,
|
||||
).await?))
|
||||
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
|
||||
Ok(Some(crate::tui::functions::login::handle_action(action).await?))
|
||||
} else {
|
||||
Ok(None) // Not a context action, use regular canvas dispatch
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_form_readonly_with_canvas(
|
||||
key_event: KeyEvent,
|
||||
config: &Config,
|
||||
form_state: &mut FormState,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<String> {
|
||||
// Try canvas action from key first
|
||||
let canvas_config = canvas::config::CanvasConfig::load();
|
||||
if let Some(action_name) = canvas_config.get_read_only_action(key_event.code, key_event.modifiers) {
|
||||
let canvas_action = CanvasAction::from_string(action_name);
|
||||
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
}
|
||||
Ok(ActionResult::HandledByFeature(msg)) => {
|
||||
return Ok(msg);
|
||||
}
|
||||
Ok(ActionResult::Error(msg)) => {
|
||||
return Ok(format!("Error: {}", msg));
|
||||
}
|
||||
Ok(ActionResult::RequiresContext(msg)) => {
|
||||
return Ok(format!("Context needed: {}", msg));
|
||||
}
|
||||
Err(_) => {
|
||||
// Fall through to try config mapping
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try config-mapped action
|
||||
if let Some(action_str) = config.get_read_only_action_for_key(key_event.code, key_event.modifiers) {
|
||||
let canvas_action = CanvasAction::from_string(&action_str);
|
||||
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
}
|
||||
Ok(ActionResult::HandledByFeature(msg)) => {
|
||||
return Ok(msg);
|
||||
}
|
||||
Ok(ActionResult::Error(msg)) => {
|
||||
return Ok(format!("Error: {}", msg));
|
||||
}
|
||||
Ok(ActionResult::RequiresContext(msg)) => {
|
||||
return Ok(format!("Context needed: {}", msg));
|
||||
}
|
||||
Err(e) => {
|
||||
return Ok(format!("Action failed: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
pub async fn handle_read_only_event(
|
||||
app_state: &mut AppState,
|
||||
key: KeyEvent,
|
||||
config: &Config,
|
||||
form_state: &mut FormState,
|
||||
login_state: &mut LoginState,
|
||||
register_state: &mut RegisterState,
|
||||
add_table_state: &mut AddTableState,
|
||||
add_logic_state: &mut AddLogicState,
|
||||
key_sequence_tracker: &mut KeySequenceTracker,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
grpc_client: &mut GrpcClient,
|
||||
command_message: &mut String,
|
||||
edit_mode_cooldown: &mut bool,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<(bool, String), Box<dyn std::error::Error>> {
|
||||
// Check for entering Edit mode from Read-Only mode
|
||||
) -> Result<(bool, String)> {
|
||||
if config.is_enter_edit_mode_before(key.code, key.modifiers) {
|
||||
*edit_mode_cooldown = true;
|
||||
*command_message = "Entering Edit mode".to_string();
|
||||
@@ -33,426 +160,154 @@ pub async fn handle_read_only_event(
|
||||
}
|
||||
|
||||
if config.is_enter_edit_mode_after(key.code, key.modifiers) {
|
||||
let current_input = form_state.get_current_input();
|
||||
if !current_input.is_empty() && form_state.current_cursor_pos < current_input.len() {
|
||||
form_state.current_cursor_pos += 1;
|
||||
*ideal_cursor_column = form_state.current_cursor_pos;
|
||||
// Determine target state to adjust cursor - all states now use CanvasState trait
|
||||
if app_state.ui.show_login {
|
||||
let current_input = login_state.get_current_input();
|
||||
let current_pos = login_state.current_cursor_pos();
|
||||
if !current_input.is_empty() && current_pos < current_input.len() {
|
||||
login_state.set_current_cursor_pos(current_pos + 1);
|
||||
*ideal_cursor_column = login_state.current_cursor_pos();
|
||||
}
|
||||
} else if app_state.ui.show_add_logic {
|
||||
let current_input = add_logic_state.get_current_input();
|
||||
let current_pos = add_logic_state.current_cursor_pos();
|
||||
if !current_input.is_empty() && current_pos < current_input.len() {
|
||||
add_logic_state.set_current_cursor_pos(current_pos + 1);
|
||||
*ideal_cursor_column = add_logic_state.current_cursor_pos();
|
||||
}
|
||||
} else if app_state.ui.show_register {
|
||||
let current_input = register_state.get_current_input();
|
||||
let current_pos = register_state.current_cursor_pos();
|
||||
if !current_input.is_empty() && current_pos < current_input.len() {
|
||||
register_state.set_current_cursor_pos(current_pos + 1);
|
||||
*ideal_cursor_column = register_state.current_cursor_pos();
|
||||
}
|
||||
} else if app_state.ui.show_add_table {
|
||||
let current_input = add_table_state.get_current_input();
|
||||
let current_pos = add_table_state.current_cursor_pos();
|
||||
if !current_input.is_empty() && current_pos < current_input.len() {
|
||||
add_table_state.set_current_cursor_pos(current_pos + 1);
|
||||
*ideal_cursor_column = add_table_state.current_cursor_pos();
|
||||
}
|
||||
} else {
|
||||
// Handle FormState
|
||||
let current_input = form_state.get_current_input();
|
||||
let current_pos = form_state.current_cursor_pos();
|
||||
if !current_input.is_empty() && current_pos < current_input.len() {
|
||||
form_state.set_current_cursor_pos(current_pos + 1);
|
||||
*ideal_cursor_column = form_state.current_cursor_pos();
|
||||
}
|
||||
}
|
||||
|
||||
*edit_mode_cooldown = true;
|
||||
*command_message = "Entering Edit mode (after cursor)".to_string();
|
||||
return Ok((false, command_message.clone()));
|
||||
}
|
||||
|
||||
// Handle Read-Only mode keybindings
|
||||
if key.modifiers.is_empty() {
|
||||
key_sequence_tracker.add_key(key.code);
|
||||
let sequence = key_sequence_tracker.get_sequence();
|
||||
|
||||
// Try to match the current sequence against Read-Only mode bindings
|
||||
if let Some(action) = config.matches_key_sequence_generalized(&sequence) {
|
||||
let result = execute_action(
|
||||
if let Some(action) = config.matches_key_sequence_generalized(&sequence).as_deref() {
|
||||
// Try context-specific actions first, otherwise use canvas dispatch
|
||||
let result = if let Some(context_result) = handle_context_action(
|
||||
action,
|
||||
app_state,
|
||||
form_state,
|
||||
ideal_cursor_column,
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
current_position,
|
||||
total_count,
|
||||
grpc_client,
|
||||
).await?;
|
||||
ideal_cursor_column,
|
||||
).await? {
|
||||
context_result
|
||||
} else {
|
||||
dispatch_to_active_state(
|
||||
action,
|
||||
app_state,
|
||||
form_state,
|
||||
login_state,
|
||||
register_state,
|
||||
add_table_state,
|
||||
add_logic_state,
|
||||
ideal_cursor_column,
|
||||
).await
|
||||
};
|
||||
key_sequence_tracker.reset();
|
||||
return Ok((false, result));
|
||||
}
|
||||
|
||||
// Check if this might be a prefix of a longer sequence
|
||||
if config.is_key_sequence_prefix(&sequence) {
|
||||
return Ok((false, command_message.clone()));
|
||||
}
|
||||
|
||||
// Since it's not part of a multi-key sequence, check for a direct action
|
||||
if sequence.len() == 1 && !config.is_key_sequence_prefix(&sequence) {
|
||||
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers) {
|
||||
let result = execute_action(
|
||||
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers).as_deref() {
|
||||
// Try context-specific actions first, otherwise use canvas dispatch
|
||||
let result = if let Some(context_result) = handle_context_action(
|
||||
action,
|
||||
app_state,
|
||||
form_state,
|
||||
ideal_cursor_column,
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
current_position,
|
||||
total_count,
|
||||
grpc_client,
|
||||
).await?;
|
||||
ideal_cursor_column,
|
||||
).await? {
|
||||
context_result
|
||||
} else {
|
||||
dispatch_to_active_state(
|
||||
action,
|
||||
app_state,
|
||||
form_state,
|
||||
login_state,
|
||||
register_state,
|
||||
add_table_state,
|
||||
add_logic_state,
|
||||
ideal_cursor_column,
|
||||
).await
|
||||
};
|
||||
key_sequence_tracker.reset();
|
||||
return Ok((false, result));
|
||||
}
|
||||
}
|
||||
key_sequence_tracker.reset();
|
||||
} else {
|
||||
// If modifiers are pressed, check for direct key bindings
|
||||
key_sequence_tracker.reset();
|
||||
|
||||
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers) {
|
||||
let result = execute_action(
|
||||
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers).as_deref() {
|
||||
// Try context-specific actions first, otherwise use canvas dispatch
|
||||
let result = if let Some(context_result) = handle_context_action(
|
||||
action,
|
||||
app_state,
|
||||
form_state,
|
||||
ideal_cursor_column,
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
current_position,
|
||||
total_count,
|
||||
grpc_client,
|
||||
).await?;
|
||||
ideal_cursor_column,
|
||||
).await? {
|
||||
context_result
|
||||
} else {
|
||||
dispatch_to_active_state(
|
||||
action,
|
||||
app_state,
|
||||
form_state,
|
||||
login_state,
|
||||
register_state,
|
||||
add_table_state,
|
||||
add_logic_state,
|
||||
ideal_cursor_column,
|
||||
).await
|
||||
};
|
||||
return Ok((false, result));
|
||||
}
|
||||
}
|
||||
|
||||
// Show a helpful message when no binding was found
|
||||
if !*edit_mode_cooldown {
|
||||
let default_key = "i".to_string();
|
||||
let edit_key = config.keybindings.read_only.get("enter_edit_mode_before")
|
||||
let edit_key = config
|
||||
.keybindings
|
||||
.read_only
|
||||
.get("enter_edit_mode_before")
|
||||
.and_then(|keys| keys.first())
|
||||
.unwrap_or(&default_key);
|
||||
.map(|k| k.to_string())
|
||||
.unwrap_or(default_key);
|
||||
*command_message = format!("Read-only mode - press {} to edit", edit_key);
|
||||
}
|
||||
|
||||
*edit_mode_cooldown = false;
|
||||
|
||||
Ok((false, command_message.clone()))
|
||||
}
|
||||
|
||||
async fn execute_action(
|
||||
action: &str,
|
||||
form_state: &mut FormState,
|
||||
ideal_cursor_column: &mut usize,
|
||||
key_sequence_tracker: &mut KeySequenceTracker,
|
||||
command_message: &mut String,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
grpc_client: &mut GrpcClient,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
match action {
|
||||
"previous_entry" => {
|
||||
let new_position = current_position.saturating_sub(1);
|
||||
if new_position >= 1 {
|
||||
*current_position = new_position;
|
||||
match grpc_client.get_adresar_by_position(*current_position).await {
|
||||
Ok(response) => {
|
||||
form_state.id = response.id;
|
||||
form_state.values = vec![
|
||||
response.firma, response.kz, response.drc,
|
||||
response.ulica, response.psc, response.mesto,
|
||||
response.stat, response.banka, response.ucet,
|
||||
response.skladm, response.ico, response.kontakt,
|
||||
response.telefon, response.skladu, response.fax,
|
||||
];
|
||||
let current_input = form_state.get_current_input();
|
||||
let max_cursor_pos = if !current_input.is_empty() {
|
||||
current_input.len() - 1
|
||||
} else { 0 };
|
||||
form_state.current_cursor_pos = std::cmp::min(*ideal_cursor_column, max_cursor_pos);
|
||||
form_state.has_unsaved_changes = false;
|
||||
*command_message = format!("Loaded entry {}", *current_position);
|
||||
}
|
||||
Err(e) => {
|
||||
*command_message = format!("Error loading entry: {}", e);
|
||||
}
|
||||
}
|
||||
key_sequence_tracker.reset();
|
||||
}
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"next_entry" => {
|
||||
if *current_position <= total_count {
|
||||
*current_position += 1;
|
||||
if *current_position <= total_count {
|
||||
match grpc_client.get_adresar_by_position(*current_position).await {
|
||||
Ok(response) => {
|
||||
form_state.id = response.id;
|
||||
form_state.values = vec![
|
||||
response.firma, response.kz, response.drc,
|
||||
response.ulica, response.psc, response.mesto,
|
||||
response.stat, response.banka, response.ucet,
|
||||
response.skladm, response.ico, response.kontakt,
|
||||
response.telefon, response.skladu, response.fax,
|
||||
];
|
||||
let current_input = form_state.get_current_input();
|
||||
let max_cursor_pos = if !current_input.is_empty() {
|
||||
current_input.len() - 1
|
||||
} else { 0 };
|
||||
form_state.current_cursor_pos = std::cmp::min(*ideal_cursor_column, max_cursor_pos);
|
||||
form_state.has_unsaved_changes = false;
|
||||
*command_message = format!("Loaded entry {}", *current_position);
|
||||
}
|
||||
Err(e) => {
|
||||
*command_message = format!("Error loading entry: {}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
form_state.reset_to_empty();
|
||||
form_state.current_field = 0;
|
||||
form_state.current_cursor_pos = 0;
|
||||
*ideal_cursor_column = 0;
|
||||
*command_message = "New entry mode".to_string();
|
||||
}
|
||||
key_sequence_tracker.reset();
|
||||
}
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"exit_edit_mode" => {
|
||||
key_sequence_tracker.reset();
|
||||
command_message.clear();
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_left" => {
|
||||
let current_pos = form_state.current_cursor_pos;
|
||||
form_state.current_cursor_pos = current_pos.saturating_sub(1);
|
||||
*ideal_cursor_column = form_state.current_cursor_pos;
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_right" => {
|
||||
let current_input = form_state.get_current_input();
|
||||
let current_pos = form_state.current_cursor_pos;
|
||||
if !current_input.is_empty() && current_pos < current_input.len() - 1 {
|
||||
form_state.current_cursor_pos += 1;
|
||||
*ideal_cursor_column = form_state.current_cursor_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_up" => {
|
||||
// Change field first
|
||||
if form_state.current_field == 0 {
|
||||
form_state.current_field = form_state.fields.len() - 1;
|
||||
} else {
|
||||
form_state.current_field = form_state.current_field.saturating_sub(1);
|
||||
}
|
||||
|
||||
// Get current input AFTER changing field
|
||||
let current_input = form_state.get_current_input();
|
||||
let max_cursor_pos = if !current_input.is_empty() {
|
||||
current_input.len() - 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
form_state.current_cursor_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_down" => {
|
||||
// Change field first
|
||||
form_state.current_field = (form_state.current_field + 1) % form_state.fields.len();
|
||||
|
||||
// Get current input AFTER changing field
|
||||
let current_input = form_state.get_current_input();
|
||||
let max_cursor_pos = if !current_input.is_empty() {
|
||||
current_input.len() - 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
form_state.current_cursor_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_next" => {
|
||||
let current_input = form_state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_next_word_start(current_input, form_state.current_cursor_pos);
|
||||
form_state.current_cursor_pos = new_pos.min(current_input.len().saturating_sub(1));
|
||||
*ideal_cursor_column = form_state.current_cursor_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_end" => {
|
||||
let current_input = form_state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_word_end(current_input, form_state.current_cursor_pos);
|
||||
form_state.current_cursor_pos = new_pos.min(current_input.len().saturating_sub(1));
|
||||
*ideal_cursor_column = form_state.current_cursor_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_prev" => {
|
||||
let current_input = form_state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_start(current_input, form_state.current_cursor_pos);
|
||||
form_state.current_cursor_pos = new_pos;
|
||||
*ideal_cursor_column = form_state.current_cursor_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_end_prev" => {
|
||||
let current_input = form_state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_end(current_input, form_state.current_cursor_pos);
|
||||
form_state.current_cursor_pos = new_pos;
|
||||
*ideal_cursor_column = form_state.current_cursor_pos;
|
||||
}
|
||||
Ok("Moved to previous word end".to_string())
|
||||
}
|
||||
"move_line_start" => {
|
||||
form_state.current_cursor_pos = 0;
|
||||
*ideal_cursor_column = form_state.current_cursor_pos;
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_line_end" => {
|
||||
let current_input = form_state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
form_state.current_cursor_pos = current_input.len() - 1;
|
||||
*ideal_cursor_column = form_state.current_cursor_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_first_line" => {
|
||||
// Change field first
|
||||
form_state.current_field = 0;
|
||||
|
||||
// Get current input AFTER changing field
|
||||
let current_input = form_state.get_current_input();
|
||||
let max_cursor_pos = if !current_input.is_empty() {
|
||||
current_input.len() - 1
|
||||
} else {
|
||||
current_input.len()
|
||||
};
|
||||
form_state.current_cursor_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
Ok("Moved to first line".to_string())
|
||||
}
|
||||
"move_last_line" => {
|
||||
// Change field first
|
||||
form_state.current_field = form_state.fields.len() - 1;
|
||||
|
||||
// Get current input AFTER changing field
|
||||
let current_input = form_state.get_current_input();
|
||||
let max_cursor_pos = if !current_input.is_empty() {
|
||||
current_input.len() - 1
|
||||
} else {
|
||||
current_input.len()
|
||||
};
|
||||
form_state.current_cursor_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
Ok("Moved to last line".to_string())
|
||||
}
|
||||
_ => Ok(format!("Unknown action: {}", action)),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_char_type(c: char) -> CharType {
|
||||
if c.is_whitespace() {
|
||||
CharType::Whitespace
|
||||
} else if c.is_alphanumeric() {
|
||||
CharType::Alphanumeric
|
||||
} else {
|
||||
CharType::Punctuation
|
||||
}
|
||||
}
|
||||
|
||||
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos >= chars.len() {
|
||||
return current_pos;
|
||||
}
|
||||
|
||||
let mut pos = current_pos;
|
||||
let initial_type = get_char_type(chars[pos]);
|
||||
while pos < chars.len() && get_char_type(chars[pos]) == initial_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
fn find_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if current_pos >= chars.len() - 1 {
|
||||
return chars.len() - 1;
|
||||
}
|
||||
|
||||
let mut pos = current_pos;
|
||||
|
||||
if get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos += 1;
|
||||
}
|
||||
} else {
|
||||
let current_type = get_char_type(chars[pos]);
|
||||
if pos + 1 < chars.len() && get_char_type(chars[pos + 1]) != current_type {
|
||||
pos += 1;
|
||||
while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if pos >= chars.len() {
|
||||
return chars.len() - 1;
|
||||
}
|
||||
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos + 1 < chars.len() && get_char_type(chars[pos + 1]) == word_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if get_char_type(chars[pos]) != CharType::Whitespace {
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos <= 1 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos > 0 && get_char_type(chars[pos]) != CharType::Whitespace {
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos > 0 {
|
||||
pos -= 1;
|
||||
let prev_word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == prev_word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
while pos < chars.len() - 1 &&
|
||||
get_char_type(chars[pos + 1]) == prev_word_type {
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// src/client/modes/common.rs
|
||||
pub mod command_mode;
|
||||
pub mod highlight;
|
||||
pub mod commands;
|
||||
|
||||
pub use commands::*;
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
// src/modes/handlers/command_mode.rs
|
||||
// src/modes/common/command_mode.rs
|
||||
|
||||
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
|
||||
use crate::tui::terminal::grpc_client::GrpcClient;
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::tui::controls::commands::CommandHandler;
|
||||
use crate::state::{app::state::AppState, pages::auth::LoginState, pages::auth::RegisterState};
|
||||
use crate::modes::common::commands::CommandHandler;
|
||||
use crate::tui::terminal::core::TerminalCore;
|
||||
use crate::modes::{
|
||||
canvas::{common},
|
||||
};
|
||||
use crate::tui::functions::common::form::{save, revert};
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use crate::tui::functions::common::form::SaveOutcome;
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn handle_command_event(
|
||||
key: KeyEvent,
|
||||
config: &Config,
|
||||
app_state: &mut AppState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
form_state: &mut FormState,
|
||||
command_input: &mut String,
|
||||
command_message: &mut String,
|
||||
@@ -21,15 +26,12 @@ pub async fn handle_command_event(
|
||||
terminal: &mut TerminalCore,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<(bool, String, bool), Box<dyn std::error::Error>> {
|
||||
|
||||
// Return value: (should_exit, message, should_exit_command_mode)
|
||||
|
||||
) -> Result<EventOutcome> {
|
||||
// Exit command mode (via configurable keybinding)
|
||||
if config.is_exit_command_mode(key.code, key.modifiers) {
|
||||
command_input.clear();
|
||||
*command_message = "".to_string();
|
||||
return Ok((false, "".to_string(), true));
|
||||
return Ok(EventOutcome::Ok("Exited command mode".to_string()));
|
||||
}
|
||||
|
||||
// Execute command (via configurable keybinding, defaults to Enter)
|
||||
@@ -37,6 +39,9 @@ pub async fn handle_command_event(
|
||||
return process_command(
|
||||
config,
|
||||
form_state,
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
command_input,
|
||||
command_message,
|
||||
grpc_client,
|
||||
@@ -50,7 +55,7 @@ pub async fn handle_command_event(
|
||||
// Backspace (via configurable keybinding, defaults to Backspace)
|
||||
if config.is_command_backspace(key.code, key.modifiers) {
|
||||
command_input.pop();
|
||||
return Ok((false, "".to_string(), false));
|
||||
return Ok(EventOutcome::Ok("".to_string()));
|
||||
}
|
||||
|
||||
// Regular character input - accept any character in command mode
|
||||
@@ -58,17 +63,20 @@ pub async fn handle_command_event(
|
||||
// Accept regular or shifted characters (e.g., 'a' or 'A')
|
||||
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT {
|
||||
command_input.push(c);
|
||||
return Ok((false, "".to_string(), false));
|
||||
return Ok(EventOutcome::Ok("".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore all other keys
|
||||
Ok((false, "".to_string(), false))
|
||||
Ok(EventOutcome::Ok("".to_string()))
|
||||
}
|
||||
|
||||
async fn process_command(
|
||||
config: &Config,
|
||||
form_state: &mut FormState,
|
||||
app_state: &mut AppState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
command_input: &mut String,
|
||||
command_message: &mut String,
|
||||
grpc_client: &mut GrpcClient,
|
||||
@@ -76,12 +84,12 @@ async fn process_command(
|
||||
terminal: &mut TerminalCore,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<(bool, String, bool), Box<dyn std::error::Error>> {
|
||||
) -> Result<EventOutcome> {
|
||||
// Clone the trimmed command to avoid borrow issues
|
||||
let command = command_input.trim().to_string();
|
||||
if command.is_empty() {
|
||||
*command_message = "Empty command".to_string();
|
||||
return Ok((false, command_message.clone(), false));
|
||||
return Ok(EventOutcome::Ok(command_message.clone()));
|
||||
}
|
||||
|
||||
// Get the action for the command (now checks global and common bindings too)
|
||||
@@ -91,41 +99,48 @@ async fn process_command(
|
||||
match action {
|
||||
"force_quit" | "save_and_quit" | "quit" => {
|
||||
let (should_exit, message) = command_handler
|
||||
.handle_command(action, terminal)
|
||||
.handle_command(
|
||||
action,
|
||||
terminal,
|
||||
app_state,
|
||||
form_state,
|
||||
login_state,
|
||||
register_state,
|
||||
)
|
||||
.await?;
|
||||
command_input.clear();
|
||||
Ok((should_exit, message, true))
|
||||
if should_exit {
|
||||
Ok(EventOutcome::Exit(message))
|
||||
} else {
|
||||
Ok(EventOutcome::Ok(message))
|
||||
}
|
||||
},
|
||||
"save" => {
|
||||
let message = common::save(
|
||||
let outcome = save(
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
&mut command_handler.is_saved,
|
||||
current_position,
|
||||
total_count,
|
||||
).await?;
|
||||
let message = match outcome {
|
||||
SaveOutcome::CreatedNew(_) => "New entry created".to_string(),
|
||||
SaveOutcome::UpdatedExisting => "Entry updated".to_string(),
|
||||
SaveOutcome::NoChange => "No changes to save".to_string(),
|
||||
};
|
||||
command_input.clear();
|
||||
return Ok((false, message, true));
|
||||
Ok(EventOutcome::DataSaved(outcome, message))
|
||||
},
|
||||
"revert" => {
|
||||
let message = common::revert(
|
||||
let message = revert(
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
).await?;
|
||||
command_input.clear();
|
||||
return Ok((false, message, true));
|
||||
},
|
||||
"unknown" => {
|
||||
let message = format!("Unknown command: {}", command);
|
||||
command_input.clear();
|
||||
return Ok((false, message, true));
|
||||
Ok(EventOutcome::Ok(message))
|
||||
},
|
||||
_ => {
|
||||
let message = format!("Unhandled action: {}", action);
|
||||
command_input.clear();
|
||||
return Ok((false, message, true));
|
||||
Ok(EventOutcome::Ok(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
72
client/src/modes/common/commands.rs
Normal file
72
client/src/modes/common/commands.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
// src/modes/common/commands.rs
|
||||
use crate::tui::terminal::core::TerminalCore;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::{form::FormState, auth::LoginState, auth::RegisterState};
|
||||
use canvas::canvas::CanvasState;
|
||||
use anyhow::Result;
|
||||
|
||||
pub struct CommandHandler;
|
||||
|
||||
impl CommandHandler {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub async fn handle_command(
|
||||
&mut self,
|
||||
action: &str,
|
||||
terminal: &mut TerminalCore,
|
||||
app_state: &AppState,
|
||||
form_state: &FormState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
) -> Result<(bool, String)> {
|
||||
match action {
|
||||
"quit" => self.handle_quit(terminal, app_state, form_state, login_state, register_state).await,
|
||||
"force_quit" => self.handle_force_quit(terminal).await,
|
||||
"save_and_quit" => self.handle_save_quit(terminal).await,
|
||||
_ => Ok((false, format!("Unknown command: {}", action))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_quit(
|
||||
&self,
|
||||
terminal: &mut TerminalCore,
|
||||
app_state: &AppState,
|
||||
form_state: &FormState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
) -> Result<(bool, String)> {
|
||||
// Use actual unsaved changes state instead of is_saved flag
|
||||
let has_unsaved = if app_state.ui.show_login {
|
||||
login_state.has_unsaved_changes()
|
||||
} else if app_state.ui.show_register {
|
||||
register_state.has_unsaved_changes()
|
||||
} else {
|
||||
form_state.has_unsaved_changes
|
||||
};
|
||||
|
||||
if !has_unsaved {
|
||||
terminal.cleanup()?;
|
||||
Ok((true, "Exiting.".into()))
|
||||
} else {
|
||||
Ok((false, "No changes saved. Use :q! to force quit.".into()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_force_quit(
|
||||
&self,
|
||||
terminal: &mut TerminalCore,
|
||||
) -> Result<(bool, String)> {
|
||||
terminal.cleanup()?;
|
||||
Ok((true, "Force exiting without saving.".into()))
|
||||
}
|
||||
|
||||
async fn handle_save_quit(
|
||||
&mut self,
|
||||
terminal: &mut TerminalCore,
|
||||
) -> Result<(bool, String)> {
|
||||
terminal.cleanup()?;
|
||||
Ok((true, "State saved. Exiting.".into()))
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
// src/client/modes/general.rs
|
||||
pub mod navigation;
|
||||
pub mod dialog;
|
||||
pub mod command_navigation;
|
||||
|
||||
396
client/src/modes/general/command_navigation.rs
Normal file
396
client/src/modes/general/command_navigation.rs
Normal file
@@ -0,0 +1,396 @@
|
||||
// src/modes/general/command_navigation.rs
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use anyhow::Result;
|
||||
use common::proto::komp_ac::table_definition::ProfileTreeResponse;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum NavigationType {
|
||||
FindFile,
|
||||
TableTree,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TableDependencyGraph {
|
||||
all_tables: HashSet<String>,
|
||||
dependents_map: HashMap<String, Vec<String>>,
|
||||
root_tables: Vec<String>,
|
||||
}
|
||||
|
||||
impl TableDependencyGraph {
|
||||
pub fn from_profile_tree(profile_tree: &ProfileTreeResponse) -> Self {
|
||||
let mut dependents_map: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let mut all_tables_set: HashSet<String> = HashSet::new();
|
||||
let mut table_dependencies: HashMap<String, Vec<String>> = HashMap::new();
|
||||
|
||||
for profile in &profile_tree.profiles {
|
||||
for table in &profile.tables {
|
||||
all_tables_set.insert(table.name.clone());
|
||||
table_dependencies.insert(table.name.clone(), table.depends_on.clone());
|
||||
|
||||
for dependency_name in &table.depends_on {
|
||||
dependents_map
|
||||
.entry(dependency_name.clone())
|
||||
.or_default()
|
||||
.push(table.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let root_tables: Vec<String> = all_tables_set
|
||||
.iter()
|
||||
.filter(|name| {
|
||||
table_dependencies
|
||||
.get(*name)
|
||||
.map_or(true, |deps| deps.is_empty())
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let mut sorted_root_tables = root_tables;
|
||||
sorted_root_tables.sort();
|
||||
|
||||
for dependents_list in dependents_map.values_mut() {
|
||||
dependents_list.sort();
|
||||
}
|
||||
|
||||
Self {
|
||||
all_tables: all_tables_set,
|
||||
dependents_map,
|
||||
root_tables: sorted_root_tables,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_dependent_children(&self, path: &str) -> Vec<String> {
|
||||
if path.is_empty() {
|
||||
return self.root_tables.clone();
|
||||
}
|
||||
|
||||
let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||
if let Some(last_segment_name) = path_segments.last() {
|
||||
if self.all_tables.contains(*last_segment_name) {
|
||||
return self
|
||||
.dependents_map
|
||||
.get(*last_segment_name)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ... (NavigationState struct and its new(), activate_*, deactivate(), add_char(), remove_char(), move_*, autocomplete_selected(), get_display_input() methods are unchanged) ...
|
||||
pub struct NavigationState {
|
||||
pub active: bool,
|
||||
pub input: String,
|
||||
pub selected_index: Option<usize>,
|
||||
pub filtered_options: Vec<(usize, String)>,
|
||||
pub navigation_type: NavigationType,
|
||||
pub current_path: String,
|
||||
pub graph: Option<TableDependencyGraph>,
|
||||
pub all_options: Vec<String>,
|
||||
}
|
||||
|
||||
impl NavigationState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
active: false,
|
||||
input: String::new(),
|
||||
selected_index: None,
|
||||
filtered_options: Vec::new(),
|
||||
navigation_type: NavigationType::FindFile,
|
||||
current_path: String::new(),
|
||||
graph: None,
|
||||
all_options: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn activate_find_file(&mut self, options: Vec<String>) {
|
||||
self.active = true;
|
||||
self.navigation_type = NavigationType::FindFile;
|
||||
self.all_options = options;
|
||||
self.input.clear();
|
||||
self.current_path.clear();
|
||||
self.graph = None;
|
||||
self.update_filtered_options();
|
||||
}
|
||||
|
||||
pub fn activate_table_tree(&mut self, graph: TableDependencyGraph) {
|
||||
self.active = true;
|
||||
self.navigation_type = NavigationType::TableTree;
|
||||
self.graph = Some(graph);
|
||||
self.input.clear();
|
||||
self.current_path.clear();
|
||||
self.update_options_for_path();
|
||||
}
|
||||
|
||||
pub fn deactivate(&mut self) {
|
||||
self.active = false;
|
||||
self.input.clear();
|
||||
self.all_options.clear();
|
||||
self.filtered_options.clear();
|
||||
self.selected_index = None;
|
||||
self.current_path.clear();
|
||||
self.graph = None;
|
||||
}
|
||||
|
||||
pub fn add_char(&mut self, c: char) {
|
||||
match self.navigation_type {
|
||||
NavigationType::FindFile => {
|
||||
self.input.push(c);
|
||||
self.update_filtered_options();
|
||||
}
|
||||
NavigationType::TableTree => {
|
||||
if c == '/' {
|
||||
if !self.input.is_empty() {
|
||||
if self.current_path.is_empty() {
|
||||
self.current_path = self.input.clone();
|
||||
} else {
|
||||
self.current_path.push('/');
|
||||
self.current_path.push_str(&self.input);
|
||||
}
|
||||
self.input.clear();
|
||||
self.update_options_for_path();
|
||||
}
|
||||
} else {
|
||||
self.input.push(c);
|
||||
self.update_filtered_options();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_char(&mut self) {
|
||||
match self.navigation_type {
|
||||
NavigationType::FindFile => {
|
||||
self.input.pop();
|
||||
self.update_filtered_options();
|
||||
}
|
||||
NavigationType::TableTree => {
|
||||
if self.input.is_empty() {
|
||||
if !self.current_path.is_empty() {
|
||||
if let Some(last_slash_idx) = self.current_path.rfind('/') {
|
||||
self.input = self.current_path[last_slash_idx + 1..].to_string();
|
||||
self.current_path = self.current_path[..last_slash_idx].to_string();
|
||||
} else {
|
||||
self.input = self.current_path.clone();
|
||||
self.current_path.clear();
|
||||
}
|
||||
self.update_options_for_path();
|
||||
self.update_filtered_options();
|
||||
}
|
||||
} else {
|
||||
self.input.pop();
|
||||
self.update_filtered_options();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_up(&mut self) {
|
||||
if self.filtered_options.is_empty() {
|
||||
self.selected_index = None;
|
||||
return;
|
||||
}
|
||||
self.selected_index = match self.selected_index {
|
||||
Some(0) => Some(self.filtered_options.len() - 1),
|
||||
Some(current) => Some(current - 1),
|
||||
None => Some(self.filtered_options.len() - 1),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn move_down(&mut self) {
|
||||
if self.filtered_options.is_empty() {
|
||||
self.selected_index = None;
|
||||
return;
|
||||
}
|
||||
self.selected_index = match self.selected_index {
|
||||
Some(current) if current >= self.filtered_options.len() - 1 => Some(0),
|
||||
Some(current) => Some(current + 1),
|
||||
None => Some(0),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get_selected_option_str(&self) -> Option<&str> {
|
||||
self.selected_index
|
||||
.and_then(|idx| self.filtered_options.get(idx))
|
||||
.map(|(_, option_str)| option_str.as_str())
|
||||
}
|
||||
|
||||
pub fn autocomplete_selected(&mut self) {
|
||||
if let Some(selected_option_str) = self.get_selected_option_str() {
|
||||
self.input = selected_option_str.to_string();
|
||||
self.update_filtered_options();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_display_input(&self) -> String {
|
||||
match self.navigation_type {
|
||||
NavigationType::FindFile => self.input.clone(),
|
||||
NavigationType::TableTree => {
|
||||
if self.current_path.is_empty() {
|
||||
self.input.clone()
|
||||
} else {
|
||||
format!("{}/{}", self.current_path, self.input)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- START FIX ---
|
||||
pub fn get_selected_value(&self) -> Option<String> {
|
||||
match self.navigation_type {
|
||||
NavigationType::FindFile => {
|
||||
// Return the highlighted option, not the raw input buffer.
|
||||
self.get_selected_option_str().map(|s| s.to_string())
|
||||
}
|
||||
NavigationType::TableTree => {
|
||||
self.get_selected_option_str().map(|selected_name| {
|
||||
if self.current_path.is_empty() {
|
||||
selected_name.to_string()
|
||||
} else {
|
||||
format!("{}/{}", self.current_path, selected_name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- END FIX ---
|
||||
|
||||
fn update_options_for_path(&mut self) {
|
||||
if let NavigationType::TableTree = self.navigation_type {
|
||||
if let Some(graph) = &self.graph {
|
||||
self.all_options = graph.get_dependent_children(&self.current_path);
|
||||
} else {
|
||||
self.all_options.clear();
|
||||
}
|
||||
}
|
||||
self.update_filtered_options();
|
||||
}
|
||||
|
||||
fn update_filtered_options(&mut self) {
|
||||
let filter_text = match self.navigation_type {
|
||||
NavigationType::FindFile => &self.input,
|
||||
NavigationType::TableTree => &self.input,
|
||||
}
|
||||
.to_lowercase();
|
||||
|
||||
if filter_text.is_empty() {
|
||||
self.filtered_options = self
|
||||
.all_options
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, opt)| (i, opt.clone()))
|
||||
.collect();
|
||||
} else {
|
||||
self.filtered_options = self
|
||||
.all_options
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, opt)| opt.to_lowercase().contains(&filter_text))
|
||||
.map(|(i, opt)| (i, opt.clone()))
|
||||
.collect();
|
||||
}
|
||||
|
||||
if self.filtered_options.is_empty() {
|
||||
self.selected_index = None;
|
||||
} else {
|
||||
self.selected_index = Some(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub async fn handle_command_navigation_event(
|
||||
navigation_state: &mut NavigationState,
|
||||
key: KeyEvent,
|
||||
config: &Config,
|
||||
) -> Result<EventOutcome> {
|
||||
if !navigation_state.active {
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
navigation_state.deactivate();
|
||||
Ok(EventOutcome::Ok("Navigation cancelled".to_string()))
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
if let Some(selected_opt_str) = navigation_state.get_selected_option_str() {
|
||||
if navigation_state.input == selected_opt_str {
|
||||
if navigation_state.navigation_type == NavigationType::TableTree {
|
||||
let path_before_nav = navigation_state.current_path.clone();
|
||||
let input_before_nav = navigation_state.input.clone();
|
||||
navigation_state.add_char('/');
|
||||
if !(navigation_state.input.is_empty() &&
|
||||
(navigation_state.current_path != path_before_nav || !navigation_state.all_options.is_empty())) {
|
||||
if !navigation_state.input.is_empty() && navigation_state.input != input_before_nav {
|
||||
navigation_state.input = input_before_nav;
|
||||
if navigation_state.current_path != path_before_nav {
|
||||
navigation_state.current_path = path_before_nav;
|
||||
}
|
||||
navigation_state.update_options_for_path();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
navigation_state.autocomplete_selected();
|
||||
}
|
||||
}
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
navigation_state.remove_char();
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
navigation_state.add_char(c);
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
_ => {
|
||||
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
|
||||
match action {
|
||||
"move_up" => {
|
||||
navigation_state.move_up();
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
"move_down" => {
|
||||
navigation_state.move_down();
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
"select" => {
|
||||
if let Some(selected_value) = navigation_state.get_selected_value() {
|
||||
let outcome = match navigation_state.navigation_type {
|
||||
// --- START FIX ---
|
||||
NavigationType::FindFile => {
|
||||
// The purpose of this palette is to select a table.
|
||||
// Emit a TableSelected event instead of a generic Ok message.
|
||||
EventOutcome::TableSelected {
|
||||
path: selected_value,
|
||||
}
|
||||
}
|
||||
// --- END FIX ---
|
||||
NavigationType::TableTree => {
|
||||
EventOutcome::TableSelected {
|
||||
path: selected_value,
|
||||
}
|
||||
}
|
||||
};
|
||||
navigation_state.deactivate();
|
||||
Ok(outcome)
|
||||
} else {
|
||||
Ok(EventOutcome::Ok("No selection".to_string()))
|
||||
}
|
||||
}
|
||||
_ => Ok(EventOutcome::Ok(String::new())),
|
||||
}
|
||||
} else {
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
163
client/src/modes/general/dialog.rs
Normal file
163
client/src/modes/general/dialog.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
// src/modes/general/dialog.rs
|
||||
|
||||
use crossterm::event::{Event, KeyCode};
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
use crate::state::app::{state::AppState, buffer::AppView};
|
||||
use crate::state::app::buffer::BufferState;
|
||||
use crate::state::pages::auth::{LoginState, RegisterState};
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use crate::tui::functions::common::{login, register};
|
||||
use crate::tui::functions::common::add_table::handle_delete_selected_columns;
|
||||
use anyhow::Result;
|
||||
|
||||
/// Handles key events specifically when a dialog is active.
|
||||
/// Returns Some(Result<EventOutcome, Error>) if the event was handled (consumed),
|
||||
/// otherwise returns None.
|
||||
pub async fn handle_dialog_event(
|
||||
event: &Event,
|
||||
config: &Config,
|
||||
app_state: &mut AppState,
|
||||
login_state: &mut LoginState,
|
||||
register_state: &mut RegisterState,
|
||||
buffer_state: &mut BufferState,
|
||||
admin_state: &mut AdminState,
|
||||
) -> Option<Result<EventOutcome>> {
|
||||
if let Event::Key(key) = event {
|
||||
// Always allow Esc to dismiss
|
||||
if key.code == KeyCode::Esc {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Dialog dismissed".to_string())));
|
||||
}
|
||||
|
||||
// Check general bindings for dialog actions
|
||||
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
|
||||
match action {
|
||||
"move_down" | "next_option" => {
|
||||
let current_index = app_state.ui.dialog.dialog_active_button_index;
|
||||
let num_buttons = app_state.ui.dialog.dialog_buttons.len();
|
||||
if num_buttons > 0 && current_index < num_buttons - 1 {
|
||||
app_state.ui.dialog.dialog_active_button_index += 1;
|
||||
}
|
||||
return Some(Ok(EventOutcome::Ok(String::new())));
|
||||
}
|
||||
"move_up" | "previous_option" => {
|
||||
let current_index = app_state.ui.dialog.dialog_active_button_index;
|
||||
if current_index > 0 {
|
||||
app_state.ui.dialog.dialog_active_button_index -= 1;
|
||||
}
|
||||
return Some(Ok(EventOutcome::Ok(String::new())));
|
||||
}
|
||||
"select" => {
|
||||
let selected_index = app_state.ui.dialog.dialog_active_button_index;
|
||||
let purpose = match app_state.ui.dialog.purpose {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Internal Error: Dialog context lost".to_string())));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Dialog Actions Directly Here
|
||||
match purpose {
|
||||
DialogPurpose::LoginSuccess => {
|
||||
match selected_index {
|
||||
0 => { // "Menu" button selected
|
||||
app_state.hide_dialog();
|
||||
let message = login::back_to_main(login_state, app_state, buffer_state).await;
|
||||
return Some(Ok(EventOutcome::Ok(message)));
|
||||
}
|
||||
1 => {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Exiting dialog".to_string())));
|
||||
}
|
||||
_ => {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Unknown dialog button selected".to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
DialogPurpose::LoginFailed => {
|
||||
match selected_index {
|
||||
0 => { // "OK" button selected
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Login failed dialog dismissed".to_string())));
|
||||
}
|
||||
_ => {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Unknown dialog button selected".to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
DialogPurpose::RegisterSuccess => { // Add this arm
|
||||
match selected_index {
|
||||
0 => { // "OK" button for RegisterSuccess
|
||||
app_state.hide_dialog();
|
||||
let message = register::back_to_login(register_state, app_state, buffer_state).await;
|
||||
return Some(Ok(EventOutcome::Ok(message)));
|
||||
}
|
||||
_ => { // Default for RegisterSuccess
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Unknown dialog button selected".to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
DialogPurpose::RegisterFailed => { // Add this arm
|
||||
match selected_index {
|
||||
0 => { // "OK" button for RegisterFailed
|
||||
app_state.hide_dialog(); // Just dismiss
|
||||
return Some(Ok(EventOutcome::Ok("Register failed dialog dismissed".to_string())));
|
||||
}
|
||||
_ => { // Default for RegisterFailed
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Unknown dialog button selected".to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
DialogPurpose::ConfirmDeleteColumns => {
|
||||
match selected_index {
|
||||
0 => { // "Confirm" button selected
|
||||
let outcome_message = handle_delete_selected_columns(&mut admin_state.add_table_state);
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok(outcome_message)));
|
||||
}
|
||||
1 => { // "Cancel" button selected
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Deletion cancelled.".to_string())));
|
||||
}
|
||||
_ => { /* Handle unexpected index */ }
|
||||
}
|
||||
}
|
||||
DialogPurpose::SaveTableSuccess => {
|
||||
match selected_index {
|
||||
0 => { // "OK" button selected
|
||||
app_state.hide_dialog();
|
||||
buffer_state.update_history(AppView::Admin); // Navigate back
|
||||
return Some(Ok(EventOutcome::Ok("Save success dialog dismissed.".to_string())));
|
||||
}
|
||||
_ => { /* Handle unexpected index */ }
|
||||
}
|
||||
}
|
||||
DialogPurpose::SaveLogicSuccess => {
|
||||
match selected_index {
|
||||
0 => { // "OK" button selected
|
||||
app_state.hide_dialog();
|
||||
buffer_state.update_history(AppView::Admin);
|
||||
return Some(Ok(EventOutcome::Ok("Save success dialog dismissed.".to_string())));
|
||||
}
|
||||
_ => { /* Handle unexpected index */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {} // Ignore other general actions when dialog is shown
|
||||
}
|
||||
}
|
||||
// If it was a key event but not handled above, consume it
|
||||
Some(Ok(EventOutcome::Ok(String::new())))
|
||||
} else {
|
||||
// If it wasn't a key event, consume it too while dialog is active
|
||||
Some(Ok(EventOutcome::Ok(String::new())))
|
||||
}
|
||||
}
|
||||
@@ -2,152 +2,146 @@
|
||||
|
||||
use crossterm::event::KeyEvent;
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::state::state::AppState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::state::pages::auth::LoginState;
|
||||
use crate::state::pages::auth::RegisterState;
|
||||
use crate::state::pages::intro::IntroState;
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use crate::ui::handlers::context::UiContext;
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use crate::modes::general::command_navigation::{handle_command_navigation_event, NavigationState};
|
||||
use canvas::canvas::CanvasState;
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn handle_navigation_event(
|
||||
key: KeyEvent,
|
||||
config: &Config,
|
||||
form_state: &mut FormState,
|
||||
app_state: &mut AppState,
|
||||
login_state: &mut LoginState,
|
||||
register_state: &mut RegisterState,
|
||||
intro_state: &mut IntroState,
|
||||
admin_state: &mut AdminState,
|
||||
command_mode: &mut bool,
|
||||
command_input: &mut String,
|
||||
command_message: &mut String,
|
||||
) -> Result<(bool, String), Box<dyn std::error::Error>> {
|
||||
navigation_state: &mut NavigationState,
|
||||
) -> Result<EventOutcome> {
|
||||
// Handle command navigation first if active
|
||||
if navigation_state.active {
|
||||
return handle_command_navigation_event(navigation_state, key, config).await;
|
||||
}
|
||||
|
||||
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
|
||||
match action {
|
||||
"move_up" => {
|
||||
move_up(app_state);
|
||||
return Ok((false, String::new()));
|
||||
move_up(app_state, login_state, register_state, intro_state, admin_state);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
"move_down" => {
|
||||
let item_count = if app_state.ui.show_intro {
|
||||
2 // Intro options count
|
||||
} else {
|
||||
app_state.profile_tree.profiles.len() // Admin panel items
|
||||
};
|
||||
move_down(app_state, item_count);
|
||||
return Ok((false, String::new()));
|
||||
move_down(app_state, intro_state, admin_state);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
"next_option" => {
|
||||
next_option(app_state, 2); // Intro has 2 options
|
||||
return Ok((false, String::new()));
|
||||
next_option(app_state, intro_state);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
"previous_option" => {
|
||||
previous_option(app_state);
|
||||
return Ok((false, String::new()));
|
||||
}
|
||||
"select" => {
|
||||
select(app_state);
|
||||
return Ok((false, "Selected".to_string()));
|
||||
}
|
||||
"toggle_sidebar" => {
|
||||
toggle_sidebar(app_state);
|
||||
return Ok((false, format!("Sidebar {}",
|
||||
if app_state.ui.show_sidebar { "shown" } else { "hidden" }
|
||||
)));
|
||||
previous_option(app_state, intro_state);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
"next_field" => {
|
||||
next_field(form_state);
|
||||
return Ok((false, String::new()));
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
"prev_field" => {
|
||||
prev_field(form_state);
|
||||
return Ok((false, String::new()));
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
"enter_command_mode" => {
|
||||
handle_enter_command_mode(command_mode, command_input, command_message);
|
||||
return Ok((false, String::new()));
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
"select" => {
|
||||
let (context, index) = if app_state.ui.show_intro {
|
||||
(UiContext::Intro, intro_state.selected_option)
|
||||
} else if app_state.ui.show_login && app_state.ui.focus_outside_canvas {
|
||||
(UiContext::Login, app_state.focused_button_index)
|
||||
} else if app_state.ui.show_register && app_state.ui.focus_outside_canvas {
|
||||
(UiContext::Register, app_state.focused_button_index)
|
||||
} else if app_state.ui.show_admin {
|
||||
(UiContext::Admin, admin_state.get_selected_index().unwrap_or(0))
|
||||
} else if app_state.ui.dialog.dialog_show {
|
||||
(UiContext::Dialog, app_state.ui.dialog.dialog_active_button_index)
|
||||
} else {
|
||||
return Ok(EventOutcome::Ok("Select (No Action)".to_string()));
|
||||
};
|
||||
return Ok(EventOutcome::ButtonSelected { context, index });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok((false, String::new()))
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
|
||||
pub fn move_up(app_state: &mut AppState) {
|
||||
if app_state.ui.show_intro {
|
||||
app_state.ui.intro_state.previous_option();
|
||||
} else if app_state.ui.show_admin {
|
||||
// Assuming profile_tree.profiles is the list we're navigating
|
||||
let profile_count = app_state.profile_tree.profiles.len();
|
||||
if profile_count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use general state for tracking selection in admin panel
|
||||
if app_state.general.selected_item == 0 {
|
||||
app_state.general.selected_item = profile_count - 1;
|
||||
pub fn move_up(app_state: &mut AppState, login_state: &mut LoginState, register_state: &mut RegisterState, intro_state: &mut IntroState, admin_state: &mut AdminState) {
|
||||
if app_state.ui.focus_outside_canvas && app_state.ui.show_login || app_state.ui.show_register{
|
||||
if app_state.focused_button_index == 0 {
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
if app_state.ui.show_login {
|
||||
let last_field_index = login_state.fields().len().saturating_sub(1);
|
||||
login_state.set_current_field(last_field_index);
|
||||
} else {
|
||||
let last_field_index = register_state.fields().len().saturating_sub(1);
|
||||
register_state.set_current_field(last_field_index);
|
||||
}
|
||||
} else {
|
||||
app_state.general.selected_item = app_state.general.selected_item.saturating_sub(1);
|
||||
app_state.focused_button_index = app_state.focused_button_index.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_down(app_state: &mut AppState, item_count: usize) {
|
||||
if app_state.ui.show_intro {
|
||||
app_state.ui.intro_state.next_option();
|
||||
} else if app_state.ui.show_intro {
|
||||
intro_state.previous_option();
|
||||
} else if app_state.ui.show_admin {
|
||||
// Assuming profile_tree.profiles is the list we're navigating
|
||||
let profile_count = app_state.profile_tree.profiles.len();
|
||||
if profile_count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
app_state.general.selected_item = (app_state.general.selected_item + 1) % profile_count;
|
||||
admin_state.previous();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_option(app_state: &mut AppState, option_count: usize) {
|
||||
pub fn move_down(app_state: &mut AppState, intro_state: &mut IntroState, admin_state: &mut AdminState) {
|
||||
if app_state.ui.focus_outside_canvas && app_state.ui.show_login || app_state.ui.show_register {
|
||||
let num_general_elements = 2;
|
||||
if app_state.focused_button_index < num_general_elements - 1 {
|
||||
app_state.focused_button_index += 1;
|
||||
}
|
||||
} else if app_state.ui.show_intro {
|
||||
intro_state.next_option();
|
||||
} else if app_state.ui.show_admin {
|
||||
admin_state.next();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_option(app_state: &mut AppState, intro_state: &mut IntroState) {
|
||||
if app_state.ui.show_intro {
|
||||
app_state.ui.intro_state.next_option();
|
||||
intro_state.next_option();
|
||||
} else {
|
||||
// For other screens that might have options
|
||||
app_state.general.current_option = (app_state.general.current_option + 1) % option_count;
|
||||
// Get option count from state instead of parameter
|
||||
let option_count = app_state.profile_tree.profiles.len();
|
||||
app_state.focused_button_index = (app_state.focused_button_index + 1) % option_count;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn previous_option(app_state: &mut AppState) {
|
||||
pub fn previous_option(app_state: &mut AppState, intro_state: &mut IntroState) {
|
||||
if app_state.ui.show_intro {
|
||||
app_state.ui.intro_state.previous_option();
|
||||
intro_state.previous_option();
|
||||
} else {
|
||||
// For other screens that might have options
|
||||
if app_state.general.current_option == 0 {
|
||||
// We'd need the option count here, but since it's not passed we can't wrap around correctly
|
||||
// For now, just stay at 0
|
||||
let option_count = app_state.profile_tree.profiles.len();
|
||||
app_state.focused_button_index = if app_state.focused_button_index == 0 {
|
||||
option_count.saturating_sub(1)
|
||||
} else {
|
||||
app_state.general.current_option -= 1;
|
||||
}
|
||||
app_state.focused_button_index - 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select(app_state: &mut AppState) {
|
||||
if app_state.ui.show_intro {
|
||||
// Handle selection in intro screen
|
||||
if app_state.ui.intro_state.selected_option == 0 {
|
||||
// First option selected - show form
|
||||
app_state.ui.show_form = true;
|
||||
app_state.ui.show_admin = false;
|
||||
} else {
|
||||
// Second option selected - show admin
|
||||
app_state.ui.show_form = false;
|
||||
app_state.ui.show_admin = true;
|
||||
}
|
||||
app_state.ui.show_intro = false;
|
||||
} else if app_state.ui.show_admin {
|
||||
// Handle selection in admin panel
|
||||
let profiles = &app_state.profile_tree.profiles;
|
||||
if !profiles.is_empty() && app_state.general.selected_item < profiles.len() {
|
||||
// Set the selected profile
|
||||
app_state.selected_profile = Some(profiles[app_state.general.selected_item].name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_sidebar(app_state: &mut AppState) {
|
||||
app_state.ui.show_sidebar = !app_state.ui.show_sidebar;
|
||||
}
|
||||
|
||||
pub fn next_field(form_state: &mut FormState) {
|
||||
if !form_state.fields.is_empty() {
|
||||
form_state.current_field = (form_state.current_field + 1) % form_state.fields.len();
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// src/client/modes/handlers.rs
|
||||
// src/modes/handlers.rs
|
||||
pub mod event;
|
||||
pub mod mode_manager;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user