Compare commits

...

132 Commits

Author SHA1 Message Date
Priec
bb75b70341 Update submodule tui-canvas to latest commit 2025-10-26 18:06:09 +01:00
Priec
585891b0f8 Link komp_ac_server as submodule 2025-10-26 18:03:39 +01:00
Priec
c4ed5e71b1 Remove inlined server crate (moved to komp_ac_server repo) 2025-10-26 18:03:06 +01:00
Priec
824eadc363 Link tui-canvas as submodule 2025-10-26 17:54:22 +01:00
Priec
afab4bb521 Remove inlined canvas crate (moved to tui-canvas repo) 2025-10-26 17:53:53 +01:00
Priec
bf29c666a5 Update komp_ac_client submodule after layout fix 2025-10-26 17:49:34 +01:00
Priec
4708f98ee9 Link komp_ac_client as submodule 2025-10-26 17:45:54 +01:00
Priec
0d465e47b3 Remove inlined client crate (moved to separate repository) 2025-10-26 17:44:37 +01:00
Priec
8ec1fa1761 validation2 I dont know if its correct??? 2025-10-26 16:44:15 +01:00
Priec
11185282c4 cargo fix on server 2025-10-26 16:07:19 +01:00
Priec
492f1f1e55 with last commit, we can simplify the logic + remove old 2025_ prefix for search of the tables 2025-10-17 22:55:59 +02:00
Priec
241ab99584 get column gets converted to get column with index automatically now 2025-10-17 22:44:36 +02:00
Priec
8bd5b5c62f marked crucial todo for 2025_ deprecated prefixing 2025-09-21 21:47:32 +02:00
Priec
7e21258d2e all tests passed without any problem 2025-09-21 20:50:14 +02:00
Priec
49277cfdd4 last error remaining 2025-09-18 18:53:55 +02:00
Priec
1f6dc3cd75 failed tests all over the place now 2025-09-18 10:47:27 +02:00
Priec
7350b0985c JSONB on table scripts also now 2025-09-18 10:47:01 +02:00
Priec
73bc6dc99c fixing tests and migration to the serialized deserialized JSONB2 2025-09-17 21:19:41 +02:00
Priec
095645a209 fixing tests and migration to the serialized deserialized JSONB 2025-09-17 21:10:11 +02:00
Priec
532977056d fixing serialization and deserialization in the data insertion 2025-09-17 09:38:05 +02:00
Priec
2435f58256 table definition now has serialization deserialization properly implemented 2025-09-16 22:55:49 +02:00
Priec
ceb560c658 serialization of the gRPC JSONB now fully works for the validation 2025-09-14 11:24:27 +02:00
Priec
d88c239bf6 serde of jsonb in grpc 2025-09-14 10:56:38 +02:00
filipriec
01c4ff2e14 validation backend 2025-09-13 21:15:44 +02:00
Priec
c2890e1f3d tests via script and make test works now 2025-09-13 08:53:26 +02:00
Priec
e1ea44c68c inputing data for the client for the validation 2025-09-12 23:06:42 +02:00
Priec
cec2361b00 validation1 for the form 2025-09-12 21:25:49 +02:00
Priec
9672b9949c finally a working space 2025-09-12 19:14:21 +02:00
Priec
e4e9594a9d minor changes 2025-09-12 19:05:17 +02:00
Priec
6daa5202b1 debug is now running properly in the background without any issues 2025-09-12 18:17:52 +02:00
Priec
cae47da5f2 reused grpc connections, not a constant refreshes anymore, all fixed now, keep on fixing other bugs 2025-09-12 18:15:46 +02:00
filipriec
85c7c89c28 space2 is now debugging better 2025-09-12 15:46:14 +02:00
Priec
0d80266e9b space commands here we go again 2025-09-11 22:36:40 +02:00
Priec
a604d62d44 inputs from keyboard are now decoupled 2025-09-10 22:12:22 +02:00
Priec
2cbbfd21aa revert works on login, now do the same for other pages as well 2025-09-08 22:11:53 +02:00
Priec
1c17d07497 space and revert working properly well, also shift 2025-09-08 20:05:39 +02:00
Priec
ad15becd7a doing key sequencing via space 2025-09-08 12:56:03 +02:00
Priec
c2a6272413 buttons in add_logic and add_table works properly well now 2025-09-04 18:56:21 +02:00
Priec
c51af13fb1 intro buttons fixed 2025-09-04 17:46:32 +02:00
Priec
d9d8562539 moving the state from general to each page owning its own state of button or canvas focus 2025-09-04 17:36:13 +02:00
Priec
6891631b8d validation of exact strings 2025-09-02 13:52:36 +02:00
Priec
738d58b5f1 moving add_table to add_logic modern architecture3 2025-09-02 11:46:35 +02:00
Priec
3081125716 moving add_table to add_logic modern architecture2 2025-09-02 00:36:49 +02:00
Priec
6073c7ab43 moving add_table to add_logic modern architecture 2025-09-02 00:23:50 +02:00
filipriec
8157dc7a60 add_table working properly well 2025-09-01 16:37:43 +02:00
filipriec
3b130e9208 add_table fixing 2025-09-01 16:30:57 +02:00
Priec
ab81434c4e add table3 2025-09-01 07:41:13 +02:00
Priec
62c54dc1eb moving add table to the same way as add logic 2025-08-31 23:07:57 +02:00
Priec
347802b2a4 working suggestions in add_logic 2025-08-31 21:48:54 +02:00
Priec
a5a8d98984 add Logic fully decoupled 2025-08-31 21:37:18 +02:00
filipriec
5b42da8290 separate page 2025-08-31 21:25:43 +02:00
filipriec
4e041f36ce move out of canvas properly fixed, now working everyhing properly well 2025-08-31 10:02:48 +02:00
filipriec
22926b7266 add logic now using general movement 2025-08-30 22:38:48 +02:00
filipriec
0a7f032028 add_logic cursor when refocus is proper again 2025-08-30 21:26:20 +02:00
filipriec
4edec5e72d add logic is now using canvas library now 2025-08-30 21:10:10 +02:00
filipriec
c7d524c76a add table and add logic removal from ui.rs and event.rs 2025-08-30 19:47:26 +02:00
filipriec
9ed558562b moved admin now 2025-08-30 19:26:12 +02:00
filipriec
43f5c1a764 login and register are now havving own handlers and loaders, moving logic out of event.rs and ui.rs 2025-08-30 19:13:12 +02:00
filipriec
46149c09db event.rs and ui.rs refactor for the forms page(moved logic to the forms page dir and just calling it now) 2025-08-30 16:42:04 +02:00
filipriec
a0757efe8b moved add_table and add_logic, needs more things done tho 2025-08-30 14:46:34 +02:00
filipriec
10f4b9d8e2 moved add_table to be feature based 2025-08-30 14:25:33 +02:00
filipriec
42db496ad7 admin page being rendered properly well now 2025-08-30 13:32:45 +02:00
filipriec
d6fd672409 roles are now better 2025-08-30 13:19:45 +02:00
filipriec
60eb1c9f51 register has dropdown now 2025-08-29 22:07:04 +02:00
filipriec
a09c804595 intro empty buffer fixed 2025-08-29 20:13:25 +02:00
filipriec
a17f73fd54 buffer bug fixed, now proper names are being displayed 2025-08-29 19:53:31 +02:00
filipriec
2373ae4b8c form pages robust finish chnages 2025-08-29 19:49:27 +02:00
filipriec
16dd460469 we compiled but buffer doesnt work 2025-08-29 18:11:27 +02:00
filipriec
58f109ca91 adding to have multiple forms pages 2025-08-29 16:18:42 +02:00
filipriec
75da9c0f4b login and register are sending data to the backend successfuly 2025-08-29 14:46:43 +02:00
filipriec
833b918c5b cursor style is handled properly now 2025-08-29 12:32:33 +02:00
filipriec
72c2691a17 registration now has working form 2025-08-29 12:22:25 +02:00
filipriec
cf79bc7bd5 bottom_panel decoupled 2025-08-29 08:25:24 +02:00
filipriec
f5f2f2cdef login page using canvas library 2025-08-28 21:26:21 +02:00
filipriec
19a9bab8c2 login page using canvas for forms 2025-08-28 21:07:23 +02:00
filipriec
6e221ef8c1 HARDEST COMMIT IN THE RECENT TIMES we fixed movement in the admin page 2025-08-28 13:43:17 +02:00
Priec
e142f56706 admin page 2025-08-28 09:15:14 +02:00
Priec
a794f22366 admin page is now featured 2025-08-27 16:32:20 +02:00
Priec
cfe4903c79 admin page is now featured 2025-08-27 16:32:09 +02:00
Priec
a0a473f96c admin page 2025-08-27 12:14:09 +02:00
Priec
9e4dd3b4c7 intro movement fully fixed 2025-08-27 01:38:51 +02:00
Priec
e5db0334c0 fixed intro movement, select not working yet 2025-08-27 01:34:56 +02:00
Priec
d641ad1bbb centralized general movement 2025-08-27 01:06:54 +02:00
filipriec
18393ff661 is edit mode is gone from the codebase 2025-08-24 16:54:18 +02:00
filipriec
b2a82fba30 fixing is_edit_mode flag removal 2025-08-24 16:37:30 +02:00
filipriec
f6c2fd627f fixing is_edit_mode 2025-08-24 16:00:58 +02:00
filipriec
15d9b31cb6 removing edit mode from the codebase 2025-08-24 15:32:24 +02:00
filipriec
06cc1663b3 working general mode only with canvas, removing highlight, readonly or edit 2025-08-23 23:34:14 +02:00
filipriec
88a4b2d69c intro is now separated 2025-08-23 21:58:29 +02:00
filipriec
e6072d25c5 register is now separated also 2025-08-23 21:47:18 +02:00
filipriec
fc2b65601e login function moved 2025-08-23 21:05:02 +02:00
filipriec
597bdde7e1 login moved to the pages 2025-08-23 20:58:12 +02:00
filipriec
f56092e86c login page now in a separate dir 2025-08-23 19:48:23 +02:00
filipriec
d5cfe59f47 dialog refactor comment, dialog crate finished for now 2025-08-23 13:36:46 +02:00
filipriec
f281eaa662 dialog is a feature 2025-08-23 13:29:28 +02:00
Priec
cbb3ed7c48 small cleanup 2025-08-23 00:22:07 +02:00
Priec
41a0b85376 forms page moved more2 2025-08-23 00:16:07 +02:00
Priec
b5a31ee81c forms page 2025-08-22 23:54:22 +02:00
Priec
dceb031822 removed docs book from git history 2025-08-22 23:31:08 +02:00
Priec
78bc9fc432 router4 compiled 2025-08-22 23:27:32 +02:00
Priec
b9072e4d7c router2, needs bug fixes3 2025-08-22 22:57:28 +02:00
Priec
5d97e63f93 router2, needs bug fixes 2025-08-22 22:52:20 +02:00
Priec
957f5bf9f0 router implementation 2025-08-22 22:19:59 +02:00
Priec
6833ac5fad find palette in the bottom panel 2025-08-22 17:11:52 +02:00
Priec
3dff2ced6c bottom panel moved 2025-08-22 16:48:25 +02:00
Priec
ea7ff3796f search grpc client isolated a bit mode 2025-08-22 16:09:16 +02:00
Priec
310617d62b cargo fix 2025-08-22 15:49:33 +02:00
Priec
1d94e82f4b search 2025-08-22 15:48:30 +02:00
Priec
00dad5d673 fixed buffer logic 2025-08-22 14:26:58 +02:00
Priec
414c6957e7 sidebar as a feature 2025-08-22 14:11:36 +02:00
Priec
f127298e5a buffer as a feature 2025-08-22 13:47:34 +02:00
Priec
f49899e66d general movement now works 2025-08-22 11:23:11 +02:00
Priec
5717c88857 proper config.toml 2025-08-22 10:55:37 +02:00
Priec
ae8aa16208 working entering to the edit mode 2025-08-22 09:56:55 +02:00
Priec
4ed8e7b421 fixed form state removed, but not won, aint working yet 2025-08-22 00:27:23 +02:00
Priec
3dd6808ea2 now no need for init_form_editor everywhere 2025-08-21 21:16:59 +02:00
Priec
f2b426851b compiled still not working 2025-08-21 13:23:21 +02:00
Priec
f9e0833bcf working keymap 2025-08-21 12:32:36 +02:00
Priec
11b073c2fd removing highlightmode from the app, handled by the library now 2025-08-21 10:33:52 +02:00
Priec
1320884409 more improvements 2025-08-20 23:52:14 +02:00
filipriec
aea2c39215 reverted core actions in canvas 2025-08-20 16:33:19 +02:00
filipriec
4c2464ab30 compiled 2025-08-20 16:28:31 +02:00
filipriec
26053a5fd8 fixes 9 2025-08-20 11:36:56 +02:00
filipriec
589220a2ba fixing 8 2025-08-20 11:33:34 +02:00
filipriec
2cda54633f not working, needs more fixes 2025-08-19 22:14:43 +02:00
filipriec
3eea6b9e88 fixes 7 2025-08-19 22:02:00 +02:00
filipriec
db9bb7e168 fixes 5 2025-08-19 20:05:18 +02:00
filipriec
3ccd094a22 fixing problems more6 2025-08-19 19:21:02 +02:00
filipriec
032f21edaa more canvas implementation4 2025-08-19 14:43:10 +02:00
Priec
42eb087363 canvas fixes3 2025-08-19 13:25:10 +02:00
Priec
d0ff449e3b going into a new canvas library structure 2025-08-19 13:24:48 +02:00
Priec
858f5137d8 migrating to the new canvas library 2025-08-19 10:56:54 +02:00
419 changed files with 439 additions and 72777 deletions

9
.gitmodules vendored Normal file
View File

@@ -0,0 +1,9 @@
[submodule "client"]
path = client
url = git@gitlab.com:filipriec/komp_ac_client.git
[submodule "canvas"]
path = canvas
url = git@gitlab.com:filipriec/tui-canvas.git
[submodule "server"]
path = server
url = git@gitlab.com:filipriec/komp_ac_server.git

135
Cargo.lock generated
View File

@@ -493,7 +493,7 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
name = "canvas"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"anyhow",
"async-trait",
@@ -584,7 +584,7 @@ dependencies = [
[[package]]
name = "client"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"anyhow",
"async-trait",
@@ -595,8 +595,8 @@ dependencies = [
"dotenvy",
"futures",
"lazy_static",
"prost",
"prost-types",
"prost 0.13.5",
"prost-types 0.13.5",
"ratatui",
"rstest",
"serde",
@@ -635,11 +635,13 @@ dependencies = [
[[package]]
name = "common"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"prost",
"prost-types",
"prost 0.13.5",
"prost-build 0.14.1",
"prost-types 0.13.5",
"serde",
"serde_json",
"tantivy",
"tonic",
"tonic-build",
@@ -1955,6 +1957,15 @@ version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a"
[[package]]
name = "matchers"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
dependencies = [
"regex-automata 0.1.10",
]
[[package]]
name = "matchit"
version = "0.8.4"
@@ -2495,7 +2506,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
dependencies = [
"bytes",
"prost-derive",
"prost-derive 0.13.5",
]
[[package]]
name = "prost"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d"
dependencies = [
"bytes",
"prost-derive 0.14.1",
]
[[package]]
@@ -2511,8 +2532,28 @@ dependencies = [
"once_cell",
"petgraph",
"prettyplease",
"prost",
"prost-types",
"prost 0.13.5",
"prost-types 0.13.5",
"regex",
"syn 2.0.104",
"tempfile",
]
[[package]]
name = "prost-build"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1"
dependencies = [
"heck",
"itertools 0.14.0",
"log",
"multimap",
"once_cell",
"petgraph",
"prettyplease",
"prost 0.14.1",
"prost-types 0.14.1",
"regex",
"syn 2.0.104",
"tempfile",
@@ -2531,13 +2572,35 @@ dependencies = [
"syn 2.0.104",
]
[[package]]
name = "prost-derive"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425"
dependencies = [
"anyhow",
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]]
name = "prost-types"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
dependencies = [
"prost",
"prost 0.13.5",
]
[[package]]
name = "prost-types"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72"
dependencies = [
"prost 0.14.1",
]
[[package]]
@@ -2747,8 +2810,17 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
"regex-automata 0.4.9",
"regex-syntax 0.8.5",
]
[[package]]
name = "regex-automata"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
dependencies = [
"regex-syntax 0.6.29",
]
[[package]]
@@ -2759,9 +2831,15 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
"regex-syntax 0.8.5",
]
[[package]]
name = "regex-syntax"
version = "0.6.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
[[package]]
name = "regex-syntax"
version = "0.8.5"
@@ -3022,11 +3100,11 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "search"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"anyhow",
"common",
"prost",
"prost 0.13.5",
"serde",
"serde_json",
"sqlx",
@@ -3121,7 +3199,7 @@ dependencies = [
[[package]]
name = "server"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"anyhow",
"bcrypt",
@@ -3132,8 +3210,9 @@ dependencies = [
"futures",
"jsonwebtoken",
"lazy_static",
"prost",
"prost-types",
"prost 0.13.5",
"prost-build 0.14.1",
"prost-types 0.13.5",
"rand 0.9.2",
"regex",
"rstest",
@@ -3751,7 +3830,7 @@ dependencies = [
"fnv",
"once_cell",
"plist",
"regex-syntax",
"regex-syntax 0.8.5",
"serde",
"serde_derive",
"serde_json",
@@ -3857,7 +3936,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18"
dependencies = [
"byteorder",
"regex-syntax",
"regex-syntax 0.8.5",
"utf8-ranges",
]
@@ -4169,7 +4248,7 @@ dependencies = [
"hyper-util",
"percent-encoding",
"pin-project",
"prost",
"prost 0.13.5",
"socket2 0.5.10",
"tokio",
"tokio-stream",
@@ -4187,8 +4266,8 @@ checksum = "eac6f67be712d12f0b41328db3137e0d0757645d8904b4cb7d51cd9c2279e847"
dependencies = [
"prettyplease",
"proc-macro2",
"prost-build",
"prost-types",
"prost-build 0.13.5",
"prost-types 0.13.5",
"quote",
"syn 2.0.104",
]
@@ -4199,8 +4278,8 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9687bd5bfeafebdded2356950f278bba8226f0b32109537c4253406e09aafe1"
dependencies = [
"prost",
"prost-types",
"prost 0.13.5",
"prost-types 0.13.5",
"tokio",
"tokio-stream",
"tonic",
@@ -4287,10 +4366,14 @@ version = "0.3.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]

View File

@@ -5,7 +5,7 @@ resolver = "2"
[workspace.package]
# TODO: idk how to do the name, fix later
# name = "komp_ac"
version = "0.4.2"
version = "0.5.0"
edition = "2021"
license = "GPL-3.0-or-later"
authors = ["Filip Priečinský <filippriec@gmail.com>"]

1
canvas Submodule

Submodule canvas added at 29fdc5a6c7

1
canvas/.gitignore vendored
View File

@@ -1 +0,0 @@
docs_prompts/

View File

@@ -1,324 +0,0 @@
# 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 suggestions features, providing better type safety and maintainability.
## Key Changes
### 1. **Modular Architecture**
```
# Old Structure (LEGACY)
src/
├── state.rs # Mixed canvas + suggestions
├── 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
├── suggestions/ # Suggestions dropdown features (not inline autocomplete)
│ ├── state.rs # Suggestion provider types
│ ├── gui.rs # Suggestions dropdown rendering
│ ├── actions.rs # Autocomplete actions
│ └── gui.rs # Autocomplete dropdown rendering
└── dispatcher.rs # Action routing
```
### 2. **Trait Separation**
- **CanvasState**: Core form functionality (navigation, input, validation)
- Suggestions module: Optional dropdown suggestions support
### 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 suggestions dropdown features:**
```rust
use canvas::{SuggestionItem};
impl YourFormState {
fn supports_suggestions(&self, field_index: usize) -> bool {
// Define which fields support suggestions
matches!(field_index, 2 | 3 | 5) // Example: only certain fields
}
// Manage your own suggestion state or rely on FormEditor APIs
// Manage your own suggestion state or rely on FormEditor APIs
}
```
**Add suggestions storage to your state (optional, if you need to persist outside the editor):**
```rust
pub struct YourFormState {
// ... existing fields
// Optional: your own suggestions cache if needed
// pub suggestion_cache: Vec<SuggestionItem>,
}
```
### 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 suggestions rendering
if editor.is_suggestions_active() {
suggestions::gui::render_suggestions_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);
// Suggestions dropdown (if active)
if editor.is_suggestions_active() {
canvas::suggestions::render_suggestions_dropdown(
f, f.area(), active_field_rect.unwrap(), theme, &editor
);
}
}
```
### 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_suggestions(&mut self) {
self.autocomplete_active = false;
self.autocomplete_suggestions.clear();
self.selected_suggestion_index = None;
}
}
editor.ui_state_mut().deactivate_suggestions();
# NEW - Option B: Suggestions via editor APIs
editor.ui_state_mut().deactivate_suggestions();
```
## 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 suggestion features
- Canvas features don't interfere with suggestions
- 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 SuggestionItem-based dropdown or custom methods
3. **No more simple suggestions**: Use `SuggestionItem` for typed suggestions
4. **Trait split**: `AutocompleteCanvasState` is now separate and optional
## 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 suggestion methods if needed
- [ ] Updated usage to SuggestionItem
- [ ] Updated rendering calls
- [ ] Tested form functionality
- [ ] Tested suggestions 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::SuggestionItem;
impl CanvasState for FormState {
// Only core canvas methods
fn current_field(&self) -> usize { /* ... */ }
fn get_current_input(&self) -> &str { /* ... */ }
// ... other core methods only
}
// Use FormEditor + SuggestionsProvider for suggestions dropdown
type SuggestionData = Hit;
fn supports_suggestions(&self, field_index: usize) -> bool {
self.fields[field_index].is_link
}
// Maintain suggestion state through FormEditor and DataProvider
// Maintain suggestion state through FormEditor and DataProvider
}
```
This migration results in cleaner, more maintainable, and more powerful code!

View File

@@ -1,108 +0,0 @@
[package]
name = "canvas"
version.workspace = true
edition.workspace = true
license = "MIT OR Apache-2.0"
authors.workspace = true
description = "Form/textarea for TUI"
readme.workspace = true
repository.workspace = true
categories.workspace = true
[dependencies]
ratatui = { workspace = true, optional = true }
crossterm = { workspace = true, optional = true }
anyhow.workspace = true
tokio = { workspace = true, optional = true }
toml = { workspace = true }
serde.workspace = true
unicode-width.workspace = true
thiserror = { workspace = true }
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
async-trait.workspace = true
regex = { workspace = true, optional = true }
ropey = { version = "1.6.1", optional = true }
once_cell = "1.21.3"
syntect = { version = "5.2.0", optional = true, default-features = false, features = ["default-fancy"] }
[dev-dependencies]
tokio-test = "0.4.4"
[features]
default = ["textmode-vim"]
gui = ["ratatui", "crossterm"]
suggestions = ["tokio"]
cursor-style = ["crossterm"]
validation = ["regex"]
computed = []
textarea = ["dep:ropey","gui"]
syntect = ["dep:syntect", "gui", "textarea"]
# text modes (mutually exclusive; default to vim)
textmode-vim = []
textmode-normal = []
all-nontextmodes = [
"gui",
"suggestions",
"cursor-style",
"validation",
"computed",
"textarea"
]
[[example]]
name = "suggestions"
required-features = ["suggestions", "gui", "cursor-style"]
path = "examples/suggestions.rs"
[[example]]
name = "suggestions2"
required-features = ["suggestions", "gui", "cursor-style"]
path = "examples/suggestions2.rs"
[[example]]
name = "canvas_cursor_auto"
required-features = ["gui", "cursor-style"]
path = "examples/canvas_cursor_auto.rs"
[[example]]
name = "validation_1"
required-features = ["gui", "validation", "cursor-style"]
[[example]]
name = "validation_2"
required-features = ["gui", "validation", "cursor-style"]
[[example]]
name = "validation_3"
required-features = ["gui", "validation", "cursor-style"]
[[example]]
name = "validation_4"
required-features = ["gui", "validation", "cursor-style"]
[[example]]
name = "validation_5"
required-features = ["gui", "validation", "cursor-style"]
[[example]]
name = "computed_fields"
required-features = ["gui", "computed"]
[[example]]
name = "textarea_vim"
required-features = ["gui", "cursor-style", "textarea", "textmode-vim"]
path = "examples/textarea_vim.rs"
[[example]]
name = "textarea_normal"
required-features = ["gui", "cursor-style", "textarea", "textmode-normal"]
path = "examples/textarea_normal.rs"
[[example]]
name = "textarea_syntax"
required-features = ["gui", "cursor-style", "textarea", "textmode-normal", "syntect"]
path = "examples/textarea_syntax.rs"

View File

@@ -1,113 +0,0 @@
# Canvas
Canvas is a Rust library for building formbased and textareadriven terminal user interfaces.
It provides the core logic for text editing, validation, suggestions, and cursor management.
The library does not enforce a specific terminal UI framework:
- Core functionality works without any rendering backend.
- Terminal rendering support is available through the `gui` feature, which enables integration with `ratatui` and `crossterm`.
- Applications may also integrate Canvas with other backends by handling input and rendering independently.
---
## Overview
Canvas is designed for applications that require structured text input in a terminal environment.
It provides:
- Text editing modes (Vimlike or normal)
- Validation (regex, masks, limits, formatting)
- Suggestions (asynchronous dropdowns)
- Computed fields (derived values)
- Textarea widget with cursor management
- Syntax highlighting (via syntect)
- Extensible architecture for custom behaviors
---
## Installation
Add the dependency to your `Cargo.toml`:
```toml
[dependencies]
canvas = { version = "0.x", features = ["gui", "cursor-style", "textarea", "validation"] }
```
---
## Features
The library is featuregated. Enable only what you need:
- `gui` terminal rendering support (ratatui + crossterm)
- `cursor-style` styled cursor support
- `validation` regex, masks, limits, formatting
- `suggestions` asynchronous suggestions dropdowns
- `computed` derived fields
- `textarea` textarea widget
- `syntect` syntax highlighting support
- `textmode-vim` Vimlike editing (default)
- `textmode-normal` normal editing mode
**Note:** `textmode-vim` and `textmode-normal` are mutually exclusive. Enable exactly one.
The default feature set is `["textmode-vim"]`.
---
## Running Examples
The repository includes several examples. Each requires specific feature flags.
Use the following commands to run them:
```bash
# Textarea with Vim mode
cargo run --example textarea_vim --features "gui cursor-style textarea textmode-vim"
# Textarea with Normal mode
cargo run --example textarea_normal --features "gui cursor-style textarea textmode-normal"
# Textarea with syntax highlighting
cargo run --example textarea_syntax --features "gui cursor-style textarea syntect textmode-normal"
# Validation examples
cargo run --example validation_1 --features "gui validation cursor-style"
cargo run --example validation_2 --features "gui validation cursor-style"
cargo run --example validation_3 --features "gui validation cursor-style"
cargo run --example validation_4 --features "gui validation cursor-style"
cargo run --example validation_5 --features "gui validation cursor-style"
# Suggestions
cargo run --example suggestions --features "suggestions gui cursor-style"
cargo run --example suggestions2 --features "suggestions gui cursor-style"
# Cursor auto movement
cargo run --example canvas_cursor_auto --features "gui cursor-style"
# Computed fields
cargo run --example computed_fields --features "gui computed"
```
---
## Documentation
- API documentation: `cargo doc --open`
- Migration notes: `CANVAS_MIGRATION.md`
---
## License
Licensed under either of:
- Apache License, Version 2.0
- MIT License
at your option.
---
## Contributing
Contributions are welcome. Please follow the existing code structure and featuregating conventions.

View File

@@ -1,16 +0,0 @@
# Aider Instructions
## General Rules
- Only modify files that I explicitly add with `/add`.
- If a prompt mentions multiple files, **ignore all files except the ones I have added**.
- Do not create, edit, or delete any files unless they are explicitly added.
- Keep all other files exactly as they are, even if the prompt suggests changes.
- Never move logic into or out of files that are not explicitly added.
- If a prompt suggests changes to multiple files, apply **only the subset of changes** that belong to the added file(s).
- If a change requires touching other files, ignore them, if they were not manually added.
## Coding Style
- Follow Rust 2021 edition idioms.
- No logic in `mod.rs` files (only exports/routing).
- Always update or create tests **only if the test file is explicitly added**.
- Do not think, only apply changes from the prompt

View File

@@ -1,865 +0,0 @@
// examples/canvas-cursor-auto.rs
//! Demonstrates automatic cursor management with the canvas library
//!
//! This example REQUIRES the `cursor-style` feature to compile.
//!
//! Run with:
//! cargo run --example canvas_cursor_auto --features "gui,cursor-style"
//!
//! This will fail without cursor-style:
//! cargo run --example canvas-cursor-auto --features "gui"
// REQUIRE cursor-style feature - example won't compile without it
#[cfg(not(feature = "cursor-style"))]
compile_error!(
"This example requires the 'cursor-style' feature. \
Run with: cargo run --example canvas-cursor-auto --features \"gui,cursor-style\""
);
use std::io;
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
},
execute,
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame, Terminal,
};
use canvas::{
canvas::{
gui::render_canvas_default,
modes::AppMode,
CursorManager, // This import only exists when cursor-style feature is enabled
},
DataProvider, FormEditor,
};
// Enhanced FormEditor that demonstrates automatic cursor management
struct AutoCursorFormEditor<D: DataProvider> {
editor: FormEditor<D>,
has_unsaved_changes: bool,
debug_message: String,
command_buffer: String, // For multi-key vim commands like "gg"
}
impl<D: DataProvider> AutoCursorFormEditor<D> {
fn new(data_provider: D) -> Self {
Self {
editor: FormEditor::new(data_provider),
has_unsaved_changes: false,
debug_message: "🎯 Automatic Cursor Demo - cursor-style feature enabled!".to_string(),
command_buffer: String::new(),
}
}
// === COMMAND BUFFER HANDLING ===
fn clear_command_buffer(&mut self) {
self.command_buffer.clear();
}
fn add_to_command_buffer(&mut self, ch: char) {
self.command_buffer.push(ch);
}
fn get_command_buffer(&self) -> &str {
&self.command_buffer
}
fn has_pending_command(&self) -> bool {
!self.command_buffer.is_empty()
}
// === VISUAL/HIGHLIGHT MODE SUPPORT ===
fn enter_visual_mode(&mut self) {
// Use the library method instead of manual state setting
self.editor.enter_highlight_mode();
self.debug_message = "🔥 VISUAL MODE - Cursor: Blinking Block █".to_string();
}
fn enter_visual_line_mode(&mut self) {
// Use the library method instead of manual state setting
self.editor.enter_highlight_line_mode();
self.debug_message = "🔥 VISUAL LINE MODE - Cursor: Blinking Block █".to_string();
}
fn exit_visual_mode(&mut self) {
// Use the library method
self.editor.exit_highlight_mode();
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █".to_string();
}
fn update_visual_selection(&mut self) {
if self.editor.is_highlight_mode() {
use canvas::canvas::state::SelectionState;
match self.editor.selection_state() {
SelectionState::Characterwise { anchor } => {
self.debug_message = format!(
"🎯 Visual selection: anchor=({},{}) current=({},{}) - Cursor: Blinking Block █",
anchor.0, anchor.1,
self.editor.current_field(),
self.editor.cursor_position()
);
}
SelectionState::Linewise { anchor_field } => {
self.debug_message = format!(
"🎯 Visual LINE selection: anchor={} current={} - Cursor: Blinking Block █",
anchor_field,
self.editor.current_field()
);
}
_ => {}
}
}
}
// === ENHANCED MOVEMENT WITH VISUAL UPDATES ===
fn move_left(&mut self) {
self.editor.move_left();
self.update_visual_selection();
}
fn move_right(&mut self) {
self.editor.move_right();
self.update_visual_selection();
}
fn move_up(&mut self) {
self.editor.move_up();
self.update_visual_selection();
}
fn move_down(&mut self) {
self.editor.move_down();
self.update_visual_selection();
}
fn move_word_next(&mut self) {
self.editor.move_word_next();
self.update_visual_selection();
}
fn move_word_prev(&mut self) {
self.editor.move_word_prev();
self.update_visual_selection();
}
fn move_word_end(&mut self) {
self.editor.move_word_end();
self.update_visual_selection();
}
fn move_word_end_prev(&mut self) {
self.editor.move_word_end_prev();
self.update_visual_selection();
}
fn move_line_start(&mut self) {
self.editor.move_line_start();
self.update_visual_selection();
}
fn move_line_end(&mut self) {
self.editor.move_line_end();
self.update_visual_selection();
}
fn move_first_line(&mut self) {
self.editor.move_first_line();
self.update_visual_selection();
}
fn move_last_line(&mut self) {
self.editor.move_last_line();
self.update_visual_selection();
}
fn prev_field(&mut self) {
self.editor.prev_field();
self.update_visual_selection();
}
fn next_field(&mut self) {
self.editor.next_field();
self.update_visual_selection();
}
// === DELETE OPERATIONS ===
fn delete_backward(&mut self) -> anyhow::Result<()> {
let result = self.editor.delete_backward();
if result.is_ok() {
self.has_unsaved_changes = true;
self.debug_message = "⌫ Deleted character backward".to_string();
}
result
}
fn delete_forward(&mut self) -> anyhow::Result<()> {
let result = self.editor.delete_forward();
if result.is_ok() {
self.has_unsaved_changes = true;
self.debug_message = "⌦ Deleted character forward".to_string();
}
result
}
// === MODE TRANSITIONS WITH AUTOMATIC CURSOR MANAGEMENT ===
fn enter_edit_mode(&mut self) {
self.editor.enter_edit_mode(); // 🎯 Library automatically sets cursor to bar |
self.debug_message = "✏️ INSERT MODE - Cursor: Steady Bar |".to_string();
}
fn enter_append_mode(&mut self) {
self.editor.enter_append_mode(); // 🎯 Library automatically positions cursor and sets mode
self.debug_message = "✏️ INSERT (append) - Cursor: Steady Bar |".to_string();
}
fn exit_edit_mode(&mut self) {
self.editor.exit_edit_mode(); // 🎯 Library automatically sets cursor to block █
self.exit_visual_mode();
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █".to_string();
}
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
let result = self.editor.insert_char(ch);
if result.is_ok() {
self.has_unsaved_changes = true;
}
result
}
// === MANUAL CURSOR OVERRIDE DEMONSTRATION ===
/// Demonstrate manual cursor control (for advanced users)
fn demo_manual_cursor_control(&mut self) -> std::io::Result<()> {
// Users can still manually control cursor if needed
CursorManager::update_for_mode(AppMode::Command)?;
self.debug_message = "🔧 Manual override: Command cursor _".to_string();
Ok(())
}
fn restore_automatic_cursor(&mut self) -> std::io::Result<()> {
// Restore automatic cursor based on current mode
CursorManager::update_for_mode(self.editor.mode())?;
self.debug_message = "🎯 Restored automatic cursor management".to_string();
Ok(())
}
// === DELEGATE TO ORIGINAL EDITOR ===
fn current_field(&self) -> usize {
self.editor.current_field()
}
fn cursor_position(&self) -> usize {
self.editor.cursor_position()
}
fn mode(&self) -> AppMode {
self.editor.mode()
}
fn current_text(&self) -> &str {
let field_index = self.editor.current_field();
self.editor.data_provider().field_value(field_index)
}
fn data_provider(&self) -> &D {
self.editor.data_provider()
}
fn ui_state(&self) -> &canvas::EditorState {
self.editor.ui_state()
}
fn set_mode(&mut self, mode: AppMode) {
self.editor.set_mode(mode); // 🎯 Library automatically updates cursor
if mode != AppMode::Highlight {
self.exit_visual_mode();
}
}
// === STATUS AND DEBUG ===
fn set_debug_message(&mut self, msg: String) {
self.debug_message = msg;
}
fn debug_message(&self) -> &str {
&self.debug_message
}
fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
fn open_line_below(&mut self) -> anyhow::Result<()> {
let result = self.editor.open_line_below();
if result.is_ok() {
self.debug_message = "✏️ INSERT (open line below) - Cursor: Steady Bar |".to_string();
}
result
}
fn open_line_above(&mut self) -> anyhow::Result<()> {
let result = self.editor.open_line_above();
if result.is_ok() {
self.debug_message = "✏️ INSERT (open line above) - Cursor: Steady Bar |".to_string();
}
result
}
fn move_big_word_next(&mut self) {
self.editor.move_big_word_next();
self.update_visual_selection();
}
fn move_big_word_prev(&mut self) {
self.editor.move_big_word_prev();
self.update_visual_selection();
}
fn move_big_word_end(&mut self) {
self.editor.move_big_word_end();
self.update_visual_selection();
}
fn move_big_word_end_prev(&mut self) {
self.editor.move_big_word_end_prev();
self.update_visual_selection();
}
}
// Demo form data with interesting text for cursor demonstration
struct CursorDemoData {
fields: Vec<(String, String)>,
}
impl CursorDemoData {
fn new() -> Self {
Self {
fields: vec![
("👤 Name".to_string(), "John-Paul McDonald".to_string()),
("📧 Email".to_string(), "user@example-domain.com".to_string()),
("📱 Phone".to_string(), "+1 (555) 123-4567".to_string()),
("🏠 Address".to_string(), "123 Main St, Apt 4B".to_string()),
("🏷️ Tags".to_string(), "urgent,important,follow-up".to_string()),
("📝 Notes".to_string(), "Watch the cursor change! Normal=█ Insert=| Visual=blinking█".to_string()),
("🎯 Cursor Demo".to_string(), "Press 'i' for insert, 'v' for visual, 'Esc' for normal".to_string()),
],
}
}
}
impl DataProvider for CursorDemoData {
fn field_count(&self) -> usize {
self.fields.len()
}
fn field_name(&self, index: usize) -> &str {
&self.fields[index].0
}
fn field_value(&self, index: usize) -> &str {
&self.fields[index].1
}
fn set_field_value(&mut self, index: usize, value: String) {
self.fields[index].1 = value;
}
fn supports_suggestions(&self, _field_index: usize) -> bool {
false
}
fn display_value(&self, _index: usize) -> Option<&str> {
None
}
}
/// Automatic cursor management demonstration
/// Features the CursorManager directly to show it's working
fn handle_key_press(
key: KeyCode,
modifiers: KeyModifiers,
editor: &mut AutoCursorFormEditor<CursorDemoData>,
) -> anyhow::Result<bool> {
let mode = editor.mode();
// Quit handling
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL))
|| (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL))
|| key == KeyCode::F(10)
{
return Ok(false);
}
match (mode, key, modifiers) {
// === MODE TRANSITIONS WITH AUTOMATIC CURSOR MANAGEMENT ===
(AppMode::ReadOnly, KeyCode::Char('i'), _) => {
editor.enter_edit_mode(); // 🎯 Automatic: cursor becomes bar |
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
editor.enter_append_mode();
editor.set_debug_message("✏️ INSERT (append) - Cursor: Steady Bar |".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('A'), _) => {
editor.move_line_end();
editor.enter_edit_mode(); // 🎯 Automatic: cursor becomes bar |
editor.set_debug_message("✏️ INSERT (end of line) - Cursor: Steady Bar |".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('o'), _) => {
if let Err(e) = editor.open_line_below() {
editor.set_debug_message(format!("Error opening line below: {e}"));
}
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('O'), _) => {
if let Err(e) = editor.open_line_above() {
editor.set_debug_message(format!("Error opening line above: {e}"));
}
editor.clear_command_buffer();
}
// From Normal Mode: Enter visual modes
(AppMode::ReadOnly, KeyCode::Char('v'), _) => {
editor.enter_visual_mode();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('V'), _) => {
editor.enter_visual_line_mode();
editor.clear_command_buffer();
}
// From Visual Mode: Switch between visual modes or exit
(AppMode::Highlight, KeyCode::Char('v'), _) => {
use canvas::canvas::state::SelectionState;
match editor.editor.selection_state() {
SelectionState::Characterwise { .. } => {
// Already in characterwise mode, exit visual mode (vim behavior)
editor.exit_visual_mode();
editor.set_debug_message("🔒 Exited visual mode".to_string());
}
_ => {
// Switch from linewise to characterwise mode
editor.editor.enter_highlight_mode();
editor.update_visual_selection();
editor.set_debug_message("🔥 Switched to VISUAL mode".to_string());
}
}
editor.clear_command_buffer();
}
(AppMode::Highlight, KeyCode::Char('V'), _) => {
use canvas::canvas::state::SelectionState;
match editor.editor.selection_state() {
SelectionState::Linewise { .. } => {
// Already in linewise mode, exit visual mode (vim behavior)
editor.exit_visual_mode();
editor.set_debug_message("🔒 Exited visual mode".to_string());
}
_ => {
// Switch from characterwise to linewise mode
editor.editor.enter_highlight_line_mode();
editor.update_visual_selection();
editor.set_debug_message("🔥 Switched to VISUAL LINE mode".to_string());
}
}
editor.clear_command_buffer();
}
// Escape: Exit any mode back to normal
(_, KeyCode::Esc, _) => {
match mode {
AppMode::Edit => {
editor.exit_edit_mode(); // Exit insert mode
}
AppMode::Highlight => {
editor.exit_visual_mode(); // Exit visual mode
}
_ => {
// Already in normal mode, just clear command buffer
editor.clear_command_buffer();
}
}
}
// === CURSOR MANAGEMENT DEMONSTRATION ===
(AppMode::ReadOnly, KeyCode::F(1), _) => {
editor.demo_manual_cursor_control()?;
}
(AppMode::ReadOnly, KeyCode::F(2), _) => {
editor.restore_automatic_cursor()?;
}
// === MOVEMENT: VIM-STYLE NAVIGATION ===
// Basic movement (hjkl and arrows)
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('h'), _)
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Left, _) => {
editor.move_left();
editor.set_debug_message("← left".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('l'), _)
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Right, _) => {
editor.move_right();
editor.set_debug_message("→ right".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('j'), _)
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Down, _) => {
editor.move_down();
editor.set_debug_message("↓ next field".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('k'), _)
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Up, _) => {
editor.move_up();
editor.set_debug_message("↑ previous field".to_string());
editor.clear_command_buffer();
}
// Word movement
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('w'), _) => {
editor.move_word_next();
editor.set_debug_message("w: next word start".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('b'), _) => {
editor.move_word_prev();
editor.set_debug_message("b: previous word start".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('e'), _) => {
// Check if this is 'ge' command
if editor.get_command_buffer() == "g" {
editor.move_word_end_prev();
editor.set_debug_message("ge: previous word end".to_string());
editor.clear_command_buffer();
} else {
editor.move_word_end();
editor.set_debug_message("e: word end".to_string());
editor.clear_command_buffer();
}
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('W'), _) => {
editor.move_big_word_next();
editor.set_debug_message("W: next WORD start".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('B'), _) => {
editor.move_big_word_prev();
editor.set_debug_message("B: previous WORD start".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('E'), _) => {
// Check if this is 'gE' command
if editor.get_command_buffer() == "g" {
editor.move_big_word_end_prev();
editor.set_debug_message("gE: previous WORD end".to_string());
editor.clear_command_buffer();
} else {
editor.move_big_word_end();
editor.set_debug_message("E: WORD end".to_string());
editor.clear_command_buffer();
}
}
// Line movement
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('0'), _)
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Home, _) => {
editor.move_line_start();
editor.set_debug_message("0: line start".to_string());
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('$'), _)
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::End, _) => {
editor.move_line_end();
editor.set_debug_message("$: line end".to_string());
}
// Field/document movement
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('g'), _) => {
if editor.get_command_buffer() == "g" {
editor.move_first_line();
editor.set_debug_message("gg: first field".to_string());
editor.clear_command_buffer();
} else {
editor.clear_command_buffer();
editor.add_to_command_buffer('g');
editor.set_debug_message("g".to_string());
}
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('G'), _) => {
editor.move_last_line();
editor.set_debug_message("G: last field".to_string());
editor.clear_command_buffer();
}
// === EDIT MODE MOVEMENT ===
(AppMode::Edit, KeyCode::Left, m) if m.contains(KeyModifiers::CONTROL) => {
editor.move_word_prev();
editor.set_debug_message("Ctrl+← word back".to_string());
}
(AppMode::Edit, KeyCode::Right, m) if m.contains(KeyModifiers::CONTROL) => {
editor.move_word_next();
editor.set_debug_message("Ctrl+→ word forward".to_string());
}
(AppMode::Edit, KeyCode::Left, _) => {
editor.move_left();
}
(AppMode::Edit, KeyCode::Right, _) => {
editor.move_right();
}
(AppMode::Edit, KeyCode::Up, _) => {
editor.move_up();
}
(AppMode::Edit, KeyCode::Down, _) => {
editor.move_down();
}
(AppMode::Edit, KeyCode::Home, _) => {
editor.move_line_start();
}
(AppMode::Edit, KeyCode::End, _) => {
editor.move_line_end();
}
// === DELETE OPERATIONS ===
(AppMode::Edit, KeyCode::Backspace, _) => {
editor.delete_backward()?;
}
(AppMode::Edit, KeyCode::Delete, _) => {
editor.delete_forward()?;
}
// Delete operations in normal mode (vim x)
(AppMode::ReadOnly, KeyCode::Char('x'), _) => {
editor.delete_forward()?;
editor.set_debug_message("x: deleted character".to_string());
}
(AppMode::ReadOnly, KeyCode::Char('X'), _) => {
editor.delete_backward()?;
editor.set_debug_message("X: deleted character backward".to_string());
}
// === TAB NAVIGATION ===
(_, KeyCode::Tab, _) => {
editor.next_field();
editor.set_debug_message("Tab: next field".to_string());
}
(_, KeyCode::BackTab, _) => {
editor.prev_field();
editor.set_debug_message("Shift+Tab: previous field".to_string());
}
// === CHARACTER INPUT ===
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
editor.insert_char(c)?;
}
// === DEBUG/INFO COMMANDS ===
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
editor.set_debug_message(format!(
"Field {}/{}, Pos {}, Mode: {:?} - Cursor managed automatically!",
editor.current_field() + 1,
editor.data_provider().field_count(),
editor.cursor_position(),
editor.mode()
));
}
_ => {
if editor.has_pending_command() {
editor.clear_command_buffer();
editor.set_debug_message("Invalid command sequence".to_string());
} else {
editor.set_debug_message(format!(
"Unhandled: {key:?} + {modifiers:?} in {mode:?} mode"
));
}
}
}
Ok(true)
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut editor: AutoCursorFormEditor<CursorDemoData>,
) -> io::Result<()> {
loop {
terminal.draw(|f| ui(f, &editor))?;
if let Event::Key(key) = event::read()? {
match handle_key_press(key.code, key.modifiers, &mut editor) {
Ok(should_continue) => {
if !should_continue {
break;
}
}
Err(e) => {
editor.set_debug_message(format!("Error: {e}"));
}
}
}
}
Ok(())
}
fn ui(f: &mut Frame, editor: &AutoCursorFormEditor<CursorDemoData>) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(10)])
.split(f.area());
render_enhanced_canvas(f, chunks[0], editor);
render_status_and_help(f, chunks[1], editor);
}
fn render_enhanced_canvas(
f: &mut Frame,
area: ratatui::layout::Rect,
editor: &AutoCursorFormEditor<CursorDemoData>,
) {
render_canvas_default(f, area, &editor.editor);
}
fn render_status_and_help(
f: &mut Frame,
area: ratatui::layout::Rect,
editor: &AutoCursorFormEditor<CursorDemoData>,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Length(7)])
.split(area);
// Status bar with cursor information - FIXED VERSION
let mode_text = match editor.mode() {
AppMode::Edit => "INSERT | (bar cursor)",
AppMode::ReadOnly => "NORMAL █ (block cursor)",
AppMode::Highlight => {
// Use library selection state instead of editor.highlight_state()
use canvas::canvas::state::SelectionState;
match editor.editor.selection_state() {
SelectionState::Characterwise { .. } => "VISUAL █ (blinking block)",
SelectionState::Linewise { .. } => "VISUAL LINE █ (blinking block)",
_ => "VISUAL █ (blinking block)",
}
},
_ => "NORMAL █ (block cursor)",
};
let status_text = if editor.has_pending_command() {
format!("-- {} -- {} [{}]", mode_text, editor.debug_message(), editor.get_command_buffer())
} else if editor.has_unsaved_changes() {
format!("-- {} -- [Modified] {}", mode_text, editor.debug_message())
} else {
format!("-- {} -- {}", mode_text, editor.debug_message())
};
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("🎯 Automatic Cursor Status"));
f.render_widget(status, chunks[0]);
// Enhanced help text (no changes needed here)
let help_text = match editor.mode() {
AppMode::ReadOnly => {
if editor.has_pending_command() {
match editor.get_command_buffer() {
"g" => "Press 'g' again for first field, or any other key to cancel",
_ => "Pending command... (Esc to cancel)"
}
} else {
"🎯 CURSOR-STYLE DEMO: Normal █ | Insert | | Visual blinking█\n\
Normal: hjkl/arrows=move, w/b/e=words, W/B/E=WORDS, 0/$=line, gg/G=first/last\n\
i/a/A/o/O=insert, v/V=visual, x/X=delete, ?=info\n\
F1=demo manual cursor, F2=restore automatic"
}
}
AppMode::Edit => {
"🎯 INSERT MODE - Cursor: | (bar)\n\
arrows=move, Ctrl+arrows=words, Backspace/Del=delete\n\
Esc=normal, Tab/Shift+Tab=fields"
}
AppMode::Highlight => {
"🎯 VISUAL MODE - Cursor: █ (blinking block)\n\
hjkl/arrows=extend selection, w/b/e=word selection\n\
Esc=normal"
}
_ => "🎯 Watch the cursor change automatically!"
};
let help = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title("🚀 Automatic Cursor Management"))
.style(Style::default().fg(Color::Gray));
f.render_widget(help, chunks[1]);
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Print feature status
println!("🎯 Canvas Cursor Auto Demo");
println!("✅ cursor-style feature: ENABLED");
println!("🚀 Automatic cursor management: ACTIVE");
println!("📖 Watch your terminal cursor change based on mode!");
println!();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let data = CursorDemoData::new();
let mut editor = AutoCursorFormEditor::new(data);
// Initialize with normal mode - library automatically sets block cursor
editor.set_mode(AppMode::ReadOnly);
// Demonstrate that CursorManager is available and working
CursorManager::update_for_mode(AppMode::ReadOnly)?;
let res = run_app(&mut terminal, editor);
// Library automatically resets cursor on FormEditor::drop()
// But we can also manually reset if needed
CursorManager::reset()?;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{err:?}");
}
println!("🎯 Cursor automatically reset to default!");
Ok(())
}

View File

@@ -1,621 +0,0 @@
// examples/computed_fields.rs - COMPLETE WORKING VERSION
//! Demonstrates computed fields with the canvas library - Invoice Calculator Example
//!
//! This example REQUIRES the `computed` feature to compile.
//!
//! Run with:
//! cargo run --example computed_fields --features "gui,computed"
#[cfg(not(feature = "computed"))]
compile_error!(
"This example requires the 'computed' feature. \
Run with: cargo run --example computed_fields --features \"gui,computed\""
);
use std::io;
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style, Modifier},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
Frame, Terminal,
};
use canvas::{
canvas::{gui::render_canvas_default, modes::AppMode},
DataProvider, FormEditor,
computed::{ComputedProvider, ComputedContext},
};
/// Invoice data with computed fields
struct InvoiceData {
fields: Vec<(String, String)>,
computed_indices: std::collections::HashSet<usize>,
}
impl InvoiceData {
fn new() -> Self {
let mut computed_indices = std::collections::HashSet::new();
// Mark computed fields (read-only, calculated)
computed_indices.insert(4); // Subtotal
computed_indices.insert(5); // Tax Amount
computed_indices.insert(6); // Total
Self {
fields: vec![
("📦 Product Name".to_string(), "".to_string()),
("🔢 Quantity".to_string(), "".to_string()),
("💰 Unit Price ($)".to_string(), "".to_string()),
("📊 Tax Rate (%)".to_string(), "".to_string()),
(" Subtotal ($)".to_string(), "".to_string()), // COMPUTED
("🧾 Tax Amount ($)".to_string(), "".to_string()), // COMPUTED
("💳 Total ($)".to_string(), "".to_string()), // COMPUTED
("📝 Notes".to_string(), "".to_string()),
],
computed_indices,
}
}
}
impl DataProvider for InvoiceData {
fn field_count(&self) -> usize {
self.fields.len()
}
fn field_name(&self, index: usize) -> &str {
&self.fields[index].0
}
fn field_value(&self, index: usize) -> &str {
&self.fields[index].1
}
fn set_field_value(&mut self, index: usize, value: String) {
// 🔥 FIXED: Allow computed fields to be updated for display purposes
// The editing protection happens at the editor level, not here
self.fields[index].1 = value;
}
fn supports_suggestions(&self, _field_index: usize) -> bool {
false
}
fn display_value(&self, _index: usize) -> Option<&str> {
None
}
/// Mark computed fields
fn is_computed_field(&self, field_index: usize) -> bool {
self.computed_indices.contains(&field_index)
}
/// Get computed field values
fn computed_field_value(&self, field_index: usize) -> Option<String> {
if self.computed_indices.contains(&field_index) {
Some(self.fields[field_index].1.clone())
} else {
None
}
}
}
/// Invoice calculator - computes totals based on input fields
struct InvoiceCalculator;
impl ComputedProvider for InvoiceCalculator {
fn compute_field(&mut self, context: ComputedContext) -> String {
// Helper to parse field values safely
let parse_field = |index: usize| -> f64 {
let value = context.field_values[index].trim();
if value.is_empty() {
0.0
} else {
value.parse().unwrap_or(0.0)
}
};
match context.target_field {
4 => {
// Subtotal = Quantity × Unit Price
let qty = parse_field(1);
let price = parse_field(2);
let subtotal = qty * price;
if qty == 0.0 || price == 0.0 {
"".to_string() // Show empty if no meaningful calculation
} else {
format!("{subtotal:.2}")
}
}
5 => {
// Tax Amount = Subtotal × (Tax Rate / 100)
let qty = parse_field(1);
let price = parse_field(2);
let tax_rate = parse_field(3);
let subtotal = qty * price;
let tax_amount = subtotal * (tax_rate / 100.0);
if subtotal == 0.0 || tax_rate == 0.0 {
"".to_string()
} else {
format!("{tax_amount:.2}")
}
}
6 => {
// Total = Subtotal + Tax Amount
let qty = parse_field(1);
let price = parse_field(2);
let tax_rate = parse_field(3);
let subtotal = qty * price;
if subtotal == 0.0 {
"".to_string()
} else {
let tax_amount = subtotal * (tax_rate / 100.0);
let total = subtotal + tax_amount;
format!("{total:.2}")
}
}
_ => "".to_string(),
}
}
fn handles_field(&self, field_index: usize) -> bool {
matches!(field_index, 4..=6) // Subtotal, Tax Amount, Total
}
fn field_dependencies(&self, field_index: usize) -> Vec<usize> {
match field_index {
4 => vec![1, 2], // Subtotal depends on Quantity, Unit Price
5 => vec![1, 2, 3], // Tax Amount depends on Quantity, Unit Price, Tax Rate
6 => vec![1, 2, 3], // Total depends on Quantity, Unit Price, Tax Rate
_ => vec![],
}
}
}
/// Enhanced editor with computed fields
struct ComputedFieldsEditor<D: DataProvider> {
editor: FormEditor<D>,
calculator: InvoiceCalculator,
debug_message: String,
last_computed_values: Vec<String>,
}
impl<D: DataProvider> ComputedFieldsEditor<D> {
fn new(data_provider: D) -> Self {
let mut editor = FormEditor::new(data_provider);
editor.set_computed_provider(InvoiceCalculator);
let calculator = InvoiceCalculator;
let last_computed_values = vec!["".to_string(); 8];
Self {
editor,
calculator,
debug_message: "💰 Invoice Calculator - Start typing in fields to see calculations!".to_string(),
last_computed_values,
}
}
fn is_computed_field(&self, field_index: usize) -> bool {
self.editor.ui_state().is_computed_field(field_index)
}
fn update_computed_fields(&mut self) {
// Trigger recomputation of all computed fields
self.editor.recompute_all_fields(&mut self.calculator);
// 🔥 CRITICAL FIX: Sync computed values to DataProvider so GUI shows them!
for i in [4, 5, 6] { // Computed field indices
let computed_value = self.editor.effective_field_value(i);
self.editor.data_provider_mut().set_field_value(i, computed_value.clone());
}
// Check if values changed to show feedback
let mut changed = false;
let mut has_calculations = false;
for i in [4, 5, 6] {
let new_value = self.editor.effective_field_value(i);
if new_value != self.last_computed_values[i] {
changed = true;
self.last_computed_values[i] = new_value.clone();
}
if !new_value.is_empty() {
has_calculations = true;
}
}
if changed {
if has_calculations {
let subtotal = &self.last_computed_values[4];
let tax = &self.last_computed_values[5];
let total = &self.last_computed_values[6];
let mut parts = Vec::new();
if !subtotal.is_empty() {
parts.push(format!("Subtotal=${subtotal}"));
}
if !tax.is_empty() {
parts.push(format!("Tax=${tax}"));
}
if !total.is_empty() {
parts.push(format!("Total=${total}"));
}
if !parts.is_empty() {
self.debug_message = format!("🧮 Calculated: {}", parts.join(", "));
} else {
self.debug_message = "💰 Enter Quantity and Unit Price to see calculations".to_string();
}
} else {
self.debug_message = "💰 Enter Quantity and Unit Price to see calculations".to_string();
}
}
}
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
let current_field = self.editor.current_field();
let result = self.editor.insert_char(ch);
if result.is_ok() && matches!(current_field, 1..=3) {
self.editor.on_field_changed(&mut self.calculator, current_field);
self.update_computed_fields();
}
result
}
fn delete_backward(&mut self) -> anyhow::Result<()> {
let current_field = self.editor.current_field();
let result = self.editor.delete_backward();
if result.is_ok() && matches!(current_field, 1..=3) {
self.editor.on_field_changed(&mut self.calculator, current_field);
self.update_computed_fields();
}
result
}
fn delete_forward(&mut self) -> anyhow::Result<()> {
let current_field = self.editor.current_field();
let result = self.editor.delete_forward();
if result.is_ok() && matches!(current_field, 1..=3) {
self.editor.on_field_changed(&mut self.calculator, current_field);
self.update_computed_fields();
}
result
}
fn next_field(&mut self) {
let old_field = self.editor.current_field();
let _ = self.editor.next_field();
let new_field = self.editor.current_field();
if old_field != new_field {
let field_name = self.editor.data_provider().field_name(new_field);
let field_type = if self.is_computed_field(new_field) {
"computed (read-only)"
} else {
"editable"
};
self.debug_message = format!("{field_name} - {field_type} field");
}
}
fn prev_field(&mut self) {
let old_field = self.editor.current_field();
let _ = self.editor.prev_field();
let new_field = self.editor.current_field();
if old_field != new_field {
let field_name = self.editor.data_provider().field_name(new_field);
let field_type = if self.is_computed_field(new_field) {
"computed (read-only)"
} else {
"editable"
};
self.debug_message = format!("{field_name} - {field_type} field");
}
}
fn enter_edit_mode(&mut self) {
let current = self.editor.current_field();
// Double protection: check both ways
if self.editor.data_provider().is_computed_field(current) || self.is_computed_field(current) {
let field_name = self.editor.data_provider().field_name(current);
self.debug_message = format!(
"🚫 {field_name} is computed (read-only) - Press Tab to move to editable fields"
);
return;
}
self.editor.enter_edit_mode();
let field_name = self.editor.data_provider().field_name(current);
self.debug_message = format!("✏️ Editing {field_name} - Type to see calculations update");
}
fn enter_append_mode(&mut self) {
let current = self.editor.current_field();
if self.editor.data_provider().is_computed_field(current) || self.is_computed_field(current) {
let field_name = self.editor.data_provider().field_name(current);
self.debug_message = format!(
"🚫 {field_name} is computed (read-only) - Press Tab to move to editable fields"
);
return;
}
self.editor.enter_append_mode();
let field_name = self.editor.data_provider().field_name(current);
self.debug_message = format!("✏️ Appending to {field_name} - Type to see calculations");
}
fn exit_edit_mode(&mut self) {
let current_field = self.editor.current_field();
self.editor.exit_edit_mode();
if matches!(current_field, 1..=3) {
self.editor.on_field_changed(&mut self.calculator, current_field);
self.update_computed_fields();
}
self.debug_message = "🔒 Normal mode - Press 'i' to edit fields".to_string();
}
// Delegate methods
fn current_field(&self) -> usize { self.editor.current_field() }
fn cursor_position(&self) -> usize { self.editor.cursor_position() }
fn mode(&self) -> AppMode { self.editor.mode() }
fn current_text(&self) -> &str {
let field_index = self.editor.current_field();
self.editor.data_provider().field_value(field_index)
}
fn data_provider(&self) -> &D { self.editor.data_provider() }
fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() }
fn move_left(&mut self) { self.editor.move_left(); }
fn move_right(&mut self) { self.editor.move_right(); }
fn move_up(&mut self) { let _ = self.editor.move_up(); }
fn move_down(&mut self) { let _ = self.editor.move_down(); }
}
fn handle_key_press(
key: KeyCode,
modifiers: KeyModifiers,
editor: &mut ComputedFieldsEditor<InvoiceData>,
) -> anyhow::Result<bool> {
let mode = editor.mode();
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL))
|| (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL))
|| key == KeyCode::F(10)
{
return Ok(false);
}
match (mode, key, modifiers) {
(AppMode::ReadOnly, KeyCode::Char('i'), _) => {
editor.enter_edit_mode();
}
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
editor.enter_append_mode();
}
(AppMode::ReadOnly, KeyCode::Char('A'), _) => {
editor.editor.move_line_end();
editor.enter_edit_mode();
}
(_, KeyCode::Esc, _) => {
if mode == AppMode::Edit {
editor.exit_edit_mode();
}
}
// Movement
(AppMode::ReadOnly, KeyCode::Char('h'), _) | (AppMode::ReadOnly, KeyCode::Left, _) => {
editor.move_left();
}
(AppMode::ReadOnly, KeyCode::Char('l'), _) | (AppMode::ReadOnly, KeyCode::Right, _) => {
editor.move_right();
}
(AppMode::ReadOnly, KeyCode::Char('j'), _) | (AppMode::ReadOnly, KeyCode::Down, _) => {
editor.move_down();
}
(AppMode::ReadOnly, KeyCode::Char('k'), _) | (AppMode::ReadOnly, KeyCode::Up, _) => {
editor.move_up();
}
// Edit mode movement
(AppMode::Edit, KeyCode::Left, _) => { editor.move_left(); }
(AppMode::Edit, KeyCode::Right, _) => { editor.move_right(); }
(AppMode::Edit, KeyCode::Up, _) => { editor.move_up(); }
(AppMode::Edit, KeyCode::Down, _) => { editor.move_down(); }
// Navigation
(_, KeyCode::Tab, _) => {
editor.next_field();
}
(_, KeyCode::BackTab, _) => {
editor.prev_field();
}
// Editing
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
editor.insert_char(c)?;
}
(AppMode::Edit, KeyCode::Backspace, _) => {
editor.delete_backward()?;
}
(AppMode::Edit, KeyCode::Delete, _) => {
editor.delete_forward()?;
}
// Debug info
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
let current = editor.current_field();
let field_name = editor.data_provider().field_name(current);
let field_type = if editor.is_computed_field(current) {
"COMPUTED (read-only)"
} else {
"EDITABLE"
};
editor.debug_message = format!(
"{} - {} - Position {} - Mode: {:?}",
field_name, field_type, editor.cursor_position(), mode
);
}
_ => {}
}
Ok(true)
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut editor: ComputedFieldsEditor<InvoiceData>,
) -> io::Result<()> {
editor.update_computed_fields(); // Initial computation
loop {
terminal.draw(|f| ui(f, &editor))?;
if let Event::Key(key) = event::read()? {
match handle_key_press(key.code, key.modifiers, &mut editor) {
Ok(should_continue) => {
if !should_continue {
break;
}
}
Err(e) => {
editor.debug_message = format!("Error: {e}");
}
}
}
}
Ok(())
}
fn ui(f: &mut Frame, editor: &ComputedFieldsEditor<InvoiceData>) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(10)])
.split(f.area());
render_enhanced_canvas(f, chunks[0], editor);
render_computed_status(f, chunks[1], editor);
}
fn render_enhanced_canvas(f: &mut Frame, area: Rect, editor: &ComputedFieldsEditor<InvoiceData>) {
render_canvas_default(f, area, &editor.editor);
}
fn render_computed_status(f: &mut Frame, area: Rect, editor: &ComputedFieldsEditor<InvoiceData>) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Length(7)])
.split(area);
let mode_text = match editor.mode() {
AppMode::Edit => "INSERT",
AppMode::ReadOnly => "NORMAL",
_ => "OTHER",
};
let current = editor.current_field();
let field_status = if editor.is_computed_field(current) {
"📊 COMPUTED FIELD (read-only)"
} else {
"✏️ EDITABLE FIELD"
};
let status_text = format!("-- {} -- {} | {}", mode_text, field_status, editor.debug_message);
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("💰 Invoice Calculator"));
f.render_widget(status, chunks[0]);
let help_text = match editor.mode() {
AppMode::ReadOnly => {
"💰 COMPUTED FIELDS DEMO: Real-time invoice calculations!\n\
🔢 EDITABLE: Product, Quantity, Unit Price, Tax Rate, Notes\n\
📊 COMPUTED: Subtotal, Tax Amount, Total (calculated automatically)\n\
\n\
🚀 START: Press 'i' to edit Quantity, type '5', Tab to Unit Price, type '19.99'\n\
Watch Subtotal and Total appear! Add Tax Rate to see tax calculations.\n\
Navigation: Tab/Shift+Tab skips computed fields automatically"
}
AppMode::Edit => {
"✏️ EDIT MODE: Type numbers to see calculations appear!\n\
\n\
💡 EXAMPLE: Type '5' in Quantity, then Tab to Unit Price and type '19.99'\n\
• Subtotal appears: $99.95\n\
• Total appears: $99.95\n\
• Add Tax Rate (like '10') to see tax: $9.99, Total: $109.94\n\
\n\
Esc=normal, Tab=next field (auto-skips computed fields)"
}
_ => "💰 Invoice Calculator with Computed Fields"
};
let help_style = if editor.is_computed_field(editor.current_field()) {
Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC)
} else {
Style::default().fg(Color::Gray)
};
let help = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title("🚀 Try It Now!"))
.style(help_style)
.wrap(Wrap { trim: true });
f.render_widget(help, chunks[1]);
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("💰 Canvas Computed Fields Demo - Invoice Calculator");
println!("✅ computed feature: ENABLED");
println!("🚀 QUICK TEST:");
println!(" 1. Press 'i' to edit Quantity");
println!(" 2. Type '5' and press Tab");
println!(" 3. Type '19.99' in Unit Price");
println!(" 4. Watch Subtotal ($99.95) and Total ($99.95) appear!");
println!();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let data = InvoiceData::new();
let editor = ComputedFieldsEditor::new(data);
let res = run_app(&mut terminal, editor);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{err:?}");
}
println!("💰 Demo completed! Computed fields should have updated in real-time!");
Ok(())
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,397 +0,0 @@
// examples/textarea_normal.rs
//! Demonstrates automatic cursor management with the textarea widget
//!
//! This example REQUIRES the `cursor-style` and `textarea` features to compile,
//! and is adapted for `textmode-normal` (always editing, no vim modes).
//!
//! Run with:
//! cargo run --example canvas_textarea_cursor_auto_normal --features "gui,cursor-style,textarea,textmode-normal"
#[cfg(not(feature = "cursor-style"))]
compile_error!(
"This example requires the 'cursor-style' feature. \
Run with: cargo run --example canvas_textarea_cursor_auto_normal --features \"gui,cursor-style,textarea,textmode-normal\""
);
#[cfg(not(feature = "textarea"))]
compile_error!(
"This example requires the 'textarea' feature. \
Run with: cargo run --example canvas_textarea_cursor_auto_normal --features \"gui,cursor-style,textarea,textmode-normal\""
);
use std::io;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, 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::CursorManager,
textarea::{TextArea, TextAreaState},
};
/// TextArea demo adapted for NORMALMODE (always editing)
struct AutoCursorTextArea {
textarea: TextAreaState,
has_unsaved_changes: bool,
debug_message: String,
command_buffer: String,
}
impl AutoCursorTextArea {
fn new() -> Self {
let initial_text = "🎯 Automatic Cursor Management Demo (NORMALMODE)\n\
Welcome to the textarea cursor demo!\n\
\n\
This demo runs in NORMALMODE:\n\
• Always editing (no insert/normal toggle)\n\
• Cursor is always underscore _\n\
\n\
Navigation commands:\n\
• hjkl or arrow keys: move cursor\n\
• w/b/e/W/B/E: word movements\n\
• 0/$: line start/end\n\
• g/gG: first/last line\n\
\n\
Editing commands:\n\
• x/X: delete characters\n\
\n\
Press ? for help, Ctrl+Q to quit.";
let mut textarea = TextAreaState::from_text(initial_text);
textarea.set_placeholder("Start typing...");
Self {
textarea,
has_unsaved_changes: false,
debug_message: "🎯 NORMALMODE Demo - always editing".to_string(),
command_buffer: String::new(),
}
}
fn handle_textarea_input(&mut self, key: KeyEvent) {
self.textarea.input(key);
self.has_unsaved_changes = true;
}
fn move_left(&mut self) {
self.textarea.move_left();
self.debug_message = "← left".to_string();
}
fn move_right(&mut self) {
self.textarea.move_right();
self.debug_message = "→ right".to_string();
}
fn move_up(&mut self) {
self.textarea.move_up();
self.debug_message = "↑ up".to_string();
}
fn move_down(&mut self) {
self.textarea.move_down();
self.debug_message = "↓ down".to_string();
}
fn move_word_next(&mut self) {
self.textarea.move_word_next();
self.debug_message = "w: next word".to_string();
}
fn move_word_prev(&mut self) {
self.textarea.move_word_prev();
self.debug_message = "b: previous word".to_string();
}
fn move_word_end(&mut self) {
self.textarea.move_word_end();
self.debug_message = "e: word end".to_string();
}
fn move_word_end_prev(&mut self) {
self.textarea.move_word_end_prev();
self.debug_message = "ge: previous word end".to_string();
}
fn move_line_start(&mut self) {
self.textarea.move_line_start();
self.debug_message = "0: line start".to_string();
}
fn move_line_end(&mut self) {
self.textarea.move_line_end();
self.debug_message = "$: line end".to_string();
}
fn move_first_line(&mut self) {
self.textarea.move_first_line();
self.debug_message = "gg: first line".to_string();
}
fn move_last_line(&mut self) {
self.textarea.move_last_line();
self.debug_message = "G: last line".to_string();
}
fn delete_char_forward(&mut self) {
if let Ok(_) = self.textarea.delete_forward() {
self.has_unsaved_changes = true;
self.debug_message = "x: deleted character".to_string();
}
}
fn delete_char_backward(&mut self) {
if let Ok(_) = self.textarea.delete_backward() {
self.has_unsaved_changes = true;
self.debug_message = "X: deleted character backward".to_string();
}
}
fn clear_command_buffer(&mut self) {
self.command_buffer.clear();
}
fn add_to_command_buffer(&mut self, ch: char) {
self.command_buffer.push(ch);
}
fn get_command_buffer(&self) -> &str {
&self.command_buffer
}
fn has_pending_command(&self) -> bool {
!self.command_buffer.is_empty()
}
fn debug_message(&self) -> &str {
&self.debug_message
}
fn set_debug_message(&mut self, msg: String) {
self.debug_message = msg;
}
fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
fn get_cursor_info(&self) -> String {
format!(
"Line {}, Col {}",
self.textarea.current_field() + 1,
self.textarea.cursor_position() + 1
)
}
// === BIG WORD MOVEMENTS ===
fn move_big_word_next(&mut self) {
self.textarea.move_big_word_next();
self.debug_message = "W: next WORD".to_string();
}
fn move_big_word_prev(&mut self) {
self.textarea.move_big_word_prev();
self.debug_message = "B: previous WORD".to_string();
}
fn move_big_word_end(&mut self) {
self.textarea.move_big_word_end();
self.debug_message = "E: WORD end".to_string();
}
fn move_big_word_end_prev(&mut self) {
self.textarea.move_big_word_end_prev();
self.debug_message = "gE: previous WORD end".to_string();
}
}
/// Handle key press in NORMALMODE (always editing, casual editor style)
fn handle_key_press(
key_event: KeyEvent,
editor: &mut AutoCursorTextArea,
) -> anyhow::Result<bool> {
let KeyEvent {
code: key,
modifiers,
..
} = key_event;
// Quit
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL))
|| (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL))
|| key == KeyCode::F(10)
{
return Ok(false);
}
match (key, modifiers) {
// Movement
(KeyCode::Left, _) => editor.move_left(),
(KeyCode::Right, _) => editor.move_right(),
(KeyCode::Up, _) => editor.move_up(),
(KeyCode::Down, _) => editor.move_down(),
// Word movement (Ctrl+Arrows)
(KeyCode::Left, m) if m.contains(KeyModifiers::CONTROL) => editor.move_word_prev(),
(KeyCode::Right, m) if m.contains(KeyModifiers::CONTROL) => editor.move_word_next(),
(KeyCode::Right, m) if m.contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT) => {
editor.move_word_end()
}
// Line/document movement
(KeyCode::Home, _) => editor.move_line_start(),
(KeyCode::End, _) => editor.move_line_end(),
(KeyCode::Home, m) if m.contains(KeyModifiers::CONTROL) => editor.move_first_line(),
(KeyCode::End, m) if m.contains(KeyModifiers::CONTROL) => editor.move_last_line(),
// Delete
(KeyCode::Delete, _) => editor.delete_char_forward(),
(KeyCode::Backspace, _) => editor.delete_char_backward(),
(KeyCode::F(1), _) => {
// Switch to indicator mode
editor.textarea.use_overflow_indicator('$');
editor.set_debug_message("Overflow: indicator '$' (wrap OFF)".to_string());
}
(KeyCode::F(2), _) => {
// Switch to wrap mode
editor.textarea.use_wrap();
editor.set_debug_message("Overflow: wrap ON".to_string());
}
(KeyCode::F(3), _) => {
editor.textarea.set_wrap_indent_cols(3);
editor.set_debug_message("Wrap indent: 3 columns".to_string());
}
(KeyCode::F(4), _) => {
editor.textarea.set_wrap_indent_cols(0);
editor.set_debug_message("Wrap indent: 0 columns".to_string());
}
// Debug/info
(KeyCode::Char('?'), _) => {
editor.set_debug_message(format!(
"{}, Mode: NORMALMODE (casual editor, underscore cursor)",
editor.get_cursor_info()
));
editor.clear_command_buffer();
}
// Default: treat as text input
_ => editor.handle_textarea_input(key_event),
}
Ok(true)
}
fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut editor: AutoCursorTextArea) -> io::Result<()> {
loop {
terminal.draw(|f| ui(f, &mut editor))?;
if let Event::Key(key) = event::read()? {
match handle_key_press(key, &mut editor) {
Ok(should_continue) => {
if !should_continue {
break;
}
}
Err(e) => {
editor.set_debug_message(format!("Error: {e}"));
}
}
}
}
Ok(())
}
fn ui(f: &mut Frame, editor: &mut AutoCursorTextArea) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(8)])
.split(f.area());
render_textarea(f, chunks[0], editor);
render_status_and_help(f, chunks[1], editor);
}
fn render_textarea(f: &mut Frame, area: ratatui::layout::Rect, editor: &mut AutoCursorTextArea) {
let block = Block::default()
.borders(Borders::ALL)
.title("🎯 Textarea with NORMALMODE (always editing)");
let textarea_widget = TextArea::default().block(block.clone());
f.render_stateful_widget(textarea_widget, area, &mut editor.textarea);
let (cx, cy) = editor.textarea.cursor(area, Some(&block));
f.set_cursor_position((cx, cy));
}
fn render_status_and_help(f: &mut Frame, area: ratatui::layout::Rect, editor: &AutoCursorTextArea) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Length(5)])
.split(area);
let status_text = if editor.has_pending_command() {
format!(
"-- NORMALMODE (underscore cursor) -- {} [{}]",
editor.debug_message(),
editor.get_command_buffer()
)
} else if editor.has_unsaved_changes() {
format!(
"-- NORMALMODE (underscore cursor) -- [Modified] {} | {}",
editor.debug_message(),
editor.get_cursor_info()
)
} else {
format!(
"-- NORMALMODE (underscore cursor) -- {} | {}",
editor.debug_message(),
editor.get_cursor_info()
)
};
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("🎯 Cursor Status"));
f.render_widget(status, chunks[0]);
let help_text = "🎯 NORMALMODE (always editing)\n\
hjkl/arrows=move, w/b/e=words, W/B/E=WORDS, 0/$=line, g/G=first/last\n\
x/X=delete, typing inserts text\n\
?=info, Ctrl+Q=quit";
let help = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title("🚀 Help"))
.style(Style::default().fg(Color::Gray));
f.render_widget(help, chunks[1]);
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🎯 Canvas Textarea Cursor Auto Demo (NORMALMODE)");
println!("✅ cursor-style feature: ENABLED");
println!("✅ textarea feature: ENABLED");
println!("✅ textmode-normal feature: ENABLED");
println!("🚀 Always editing, underscore cursor active");
println!();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let editor = AutoCursorTextArea::new();
let res = run_app(&mut terminal, editor);
CursorManager::reset()?;
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{err:?}");
}
println!("🎯 Cursor automatically reset to default!");
Ok(())
}

View File

@@ -1,413 +0,0 @@
// examples/textarea_syntax.rs
//! Demonstrates syntax highlighting with the textarea widget
//!
//! This example REQUIRES the `syntect` feature to compile.
//!
//! Run with:
//! cargo run --example textarea_syntax --features "gui,cursor-style,textarea,syntect,textmode-normal"
#[cfg(not(feature = "syntect"))]
compile_error!(
"This example requires the 'syntect' feature. \
Run with: cargo run --example textarea_syntax --features \"gui,cursor-style,textarea,syntect,textmode-normal\""
);
use std::io;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, 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::CursorManager,
textarea::highlight::{TextAreaSyntax, TextAreaSyntaxState},
};
/// Syntax highlighting TextArea demo
struct SyntaxTextAreaDemo {
textarea: TextAreaSyntaxState,
has_unsaved_changes: bool,
debug_message: String,
current_language: String,
current_theme: String,
}
impl SyntaxTextAreaDemo {
fn new() -> Self {
let initial_text = r#"// 🎯 Multi-language Syntax Highlighting Demo
// ==========================
// Rust
// ==========================
fn main() {
println!("Hello, Rust 🦀");
let nums = vec![1, 2, 3, 4, 5];
for n in nums {
println!("n = {}", n);
}
}
// ==========================
// Python
// ==========================
# 🐍 Python example
def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)
print([fib(i) for i in range(6)])
# ==========================
// JavaScript
// ==========================
// 🟨 JavaScript example
function greet(name) {
console.log(`Hello, ${name}!`);
}
greet("World");
// ==========================
// Scheme
// ==========================
;; 🎭 Scheme example
(define (square x) (* x x))
(display (square 5))
(newline)
"#;
let mut textarea = TextAreaSyntaxState::from_text(initial_text);
textarea.set_placeholder("Start typing code...");
// Pick a colorful default theme
let default_theme = "base16-ocean.dark";
let _ = textarea.set_syntax_theme(default_theme);
// Default to Rust syntax
let _ = textarea.set_syntax_by_extension("rs");
Self {
textarea,
has_unsaved_changes: false,
debug_message: format!("🎯 Syntax highlighting enabled - Rust ({})", default_theme),
current_language: "Rust".to_string(),
current_theme: default_theme.to_string(),
}
}
fn handle_textarea_input(&mut self, key: KeyEvent) {
self.textarea.input(key);
self.has_unsaved_changes = true;
}
fn switch_to_rust(&mut self) {
let _ = self.textarea.set_syntax_by_extension("rs");
self.current_language = "Rust".to_string();
self.debug_message = format!("🦀 Switched to Rust syntax ({})", self.current_theme);
let rust_code = r#"// Rust example
fn fibonacci(n: u32) -> u32 {
match n {
0 => 0,
1 => 1,
_ => fibonacci(n - 1) + fibonacci(n - 2),
}
}
fn main() {
for i in 0..10 {
println!("fib({}) = {}", i, fibonacci(i));
}
}"#;
self.textarea.set_text(rust_code);
self.has_unsaved_changes = false;
}
fn switch_to_python(&mut self) {
let _ = self.textarea.set_syntax_by_extension("py");
self.current_language = "Python".to_string();
self.debug_message = format!("🐍 Switched to Python syntax ({})", self.current_theme);
let python_code = r#"# Python example
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
def main():
for i in range(10):
print(f"fib({i}) = {fibonacci(i)}")
if __name__ == "__main__":
main()"#;
self.textarea.set_text(python_code);
self.has_unsaved_changes = false;
}
fn switch_to_javascript(&mut self) {
let _ = self.textarea.set_syntax_by_extension("js");
self.current_language = "JavaScript".to_string();
self.debug_message = format!("🟨 Switched to JavaScript syntax ({})", self.current_theme);
let js_code = r#"// JavaScript example
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
function main() {
for (let i = 0; i < 10; i++) {
console.log(`fib(${i}) = ${fibonacci(i)}`);
}
}
main();"#;
self.textarea.set_text(js_code);
self.has_unsaved_changes = false;
}
fn switch_to_scheme(&mut self) {
let _ = self.textarea.set_syntax_by_name("Scheme");
self.current_language = "Scheme".to_string();
self.debug_message = format!("🎭 Switched to Scheme syntax ({})", self.current_theme);
let scheme_code = r#";; Scheme example
(define (fibonacci n)
(cond ((= n 0) 0)
((= n 1) 1)
(else (+ (fibonacci (- n 1))
(fibonacci (- n 2))))))
(define (main)
(do ((i 0 (+ i 1)))
((= i 10))
(display (format "fib(~a) = ~a~n" i (fibonacci i)))))
(main)"#;
self.textarea.set_text(scheme_code);
self.has_unsaved_changes = false;
}
fn cycle_theme(&mut self) {
let themes = [
"InspiredGitHub",
"base16-ocean.dark",
"base16-eighties.dark",
"Solarized (dark)",
"Monokai Extended",
];
let current_pos = themes.iter().position(|t| *t == self.current_theme);
let next_pos = match current_pos {
Some(p) => (p + 1) % themes.len(),
None => 0,
};
let next_theme = themes[next_pos];
let _ = self.textarea.set_syntax_theme(next_theme);
self.current_theme = next_theme.to_string();
self.debug_message = format!("🎨 Theme switched to {}", next_theme);
}
fn get_cursor_info(&self) -> String {
format!(
"Line {}, Col {} | Lang: {} | Theme: {}",
self.textarea.current_field() + 1,
self.textarea.cursor_position() + 1,
self.current_language,
self.current_theme
)
}
fn debug_message(&self) -> &str {
&self.debug_message
}
fn set_debug_message(&mut self, msg: String) {
self.debug_message = msg;
}
fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
}
fn handle_key_press(
key_event: KeyEvent,
editor: &mut SyntaxTextAreaDemo,
) -> anyhow::Result<bool> {
let KeyEvent {
code: key,
modifiers,
..
} = key_event;
// Quit
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL))
|| (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL))
|| key == KeyCode::F(10)
{
return Ok(false);
}
match (key, modifiers) {
// Language switching
(KeyCode::F(5), _) => editor.switch_to_rust(),
(KeyCode::F(6), _) => editor.switch_to_python(),
(KeyCode::F(7), _) => editor.switch_to_javascript(),
(KeyCode::F(8), _) => editor.switch_to_scheme(),
// Theme cycling
(KeyCode::F(9), _) => editor.cycle_theme(),
// Overflow modes
(KeyCode::F(1), _) => {
editor.textarea.use_overflow_indicator('$');
editor.set_debug_message(format!("Overflow: indicator '$' (wrap OFF) | Theme: {}", editor.current_theme));
}
(KeyCode::F(2), _) => {
editor.textarea.use_wrap();
editor.set_debug_message(format!("Overflow: wrap ON | Theme: {}", editor.current_theme));
}
// Wrap indent
(KeyCode::F(3), _) => {
editor.textarea.set_wrap_indent_cols(4);
editor.set_debug_message(format!("Wrap indent: 4 columns | Theme: {}", editor.current_theme));
}
(KeyCode::F(4), _) => {
editor.textarea.set_wrap_indent_cols(0);
editor.set_debug_message(format!("Wrap indent: 0 columns | Theme: {}", editor.current_theme));
}
// Info
(KeyCode::Char('?'), _) => {
editor.set_debug_message(format!(
"{} | Syntax highlighting enabled",
editor.get_cursor_info()
));
}
// Default: pass to textarea
_ => editor.handle_textarea_input(key_event),
}
Ok(true)
}
fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut editor: SyntaxTextAreaDemo) -> io::Result<()> {
loop {
terminal.draw(|f| ui(f, &mut editor))?;
if let Event::Key(key) = event::read()? {
match handle_key_press(key, &mut editor) {
Ok(should_continue) => {
if !should_continue {
break;
}
}
Err(e) => {
editor.set_debug_message(format!("Error: {e}"));
}
}
}
}
Ok(())
}
fn ui(f: &mut Frame, editor: &mut SyntaxTextAreaDemo) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(8)])
.split(f.area());
render_textarea(f, chunks[0], editor);
render_status_and_help(f, chunks[1], editor);
}
fn render_textarea(f: &mut Frame, area: ratatui::layout::Rect, editor: &mut SyntaxTextAreaDemo) {
let block = Block::default()
.borders(Borders::ALL)
.title("🎨 Syntax Highlighted Code Editor");
let textarea_widget = TextAreaSyntax::default().block(block.clone());
f.render_stateful_widget(textarea_widget, area, &mut editor.textarea);
// Reuse cursor calculation from the wrapped textarea
let (cx, cy) = editor.textarea.cursor(area, Some(&block));
f.set_cursor_position((cx, cy));
}
fn render_status_and_help(f: &mut Frame, area: ratatui::layout::Rect, editor: &SyntaxTextAreaDemo) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Length(5)])
.split(area);
let status_text = if editor.has_unsaved_changes() {
format!(
"-- SYNTAX MODE (highlighting enabled) -- [Modified] {} | {}",
editor.debug_message(),
editor.get_cursor_info()
)
} else {
format!(
"-- SYNTAX MODE (highlighting enabled) -- {} | {}",
editor.debug_message(),
editor.get_cursor_info()
)
};
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("🎨 Syntax Status"));
f.render_widget(status, chunks[0]);
let help_text = "🎨 SYNTAX HIGHLIGHTING DEMO\n\
F5=Rust, F6=Python, F7=JavaScript, F8=Scheme\n\
F1/F2=overflow modes, F3/F4=wrap indent\n\
F9=cycle themes, ?=info, Ctrl+Q=quit";
let help = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title("🚀 Help"))
.style(Style::default().fg(Color::Cyan));
f.render_widget(help, chunks[1]);
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🎨 Canvas Textarea Syntax Highlighting Demo");
println!("✅ cursor-style feature: ENABLED");
println!("✅ textarea feature: ENABLED");
println!("✅ syntect feature: ENABLED");
println!("🎨 Syntax highlighting active");
println!();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let editor = SyntaxTextAreaDemo::new();
let res = run_app(&mut terminal, editor);
CursorManager::reset()?;
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{err:?}");
}
println!("🎨 Syntax highlighting demo complete!");
Ok(())
}

View File

@@ -1,652 +0,0 @@
// examples/textarea_vim.rs
//! Demonstrates automatic cursor management with the textarea widget
//!
//! This example REQUIRES the `cursor-style` and `textarea` features to compile.
//!
//! Run with:
//! cargo run --example canvas_textarea_cursor_auto --features "gui,cursor-style,textarea"
// REQUIRE cursor-style and textarea features
#[cfg(not(feature = "cursor-style"))]
compile_error!(
"This example requires the 'cursor-style' feature. \
Run with: cargo run --example canvas_textarea_cursor_auto --features \"gui,cursor-style,textarea\""
);
#[cfg(not(feature = "textarea"))]
compile_error!(
"This example requires the 'textarea' feature. \
Run with: cargo run --example canvas_textarea_cursor_auto --features \"gui,cursor-style,textarea\""
);
use std::io;
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, 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::{
modes::AppMode,
CursorManager, // This import only exists when cursor-style feature is enabled
},
textarea::{TextArea, TextAreaState},
};
/// Enhanced TextArea that demonstrates automatic cursor management
/// Now uses direct FormEditor method calls via Deref!
struct AutoCursorTextArea {
textarea: TextAreaState,
has_unsaved_changes: bool,
debug_message: String,
command_buffer: String,
}
impl AutoCursorTextArea {
fn new() -> Self {
let initial_text = "🎯 Automatic Cursor Management Demo\n\
Welcome to the textarea cursor demo!\n\
\n\
Try different modes:\n\
• Normal mode: Block cursor █\n\
• Insert mode: Bar cursor |\n\
\n\
Navigation commands:\n\
• hjkl or arrow keys: move cursor\n\
• i/a/A/o/O: enter insert mode\n\
• w/b/e/W/B/E: word movements\n\
• Esc: return to normal mode\n\
\n\
Watch how the terminal cursor changes automatically!\n\
This text can be edited when in insert mode.\n\
\n\
Press ? for help, F1/F2 for manual cursor control demo.";
let mut textarea = TextAreaState::from_text(initial_text);
textarea.set_placeholder("Start typing...");
textarea.use_wrap();
Self {
textarea,
has_unsaved_changes: false,
debug_message: "🎯 Automatic Cursor Demo - cursor-style feature enabled!".to_string(),
command_buffer: String::new(),
}
}
// === MODE TRANSITIONS WITH AUTOMATIC CURSOR MANAGEMENT ===
fn enter_insert_mode(&mut self) -> std::io::Result<()> {
self.textarea.enter_edit_mode(); // 🎯 Direct FormEditor method call via Deref!
CursorManager::update_for_mode(AppMode::Edit)?; // 🎯 Automatic: cursor becomes bar |
self.debug_message = "✏️ INSERT MODE - Cursor: Steady Bar |".to_string();
Ok(())
}
fn enter_append_mode(&mut self) -> std::io::Result<()> {
self.textarea.enter_append_mode(); // 🎯 Direct FormEditor method call!
CursorManager::update_for_mode(AppMode::Edit)?;
self.debug_message = "✏️ INSERT (append) - Cursor: Steady Bar |".to_string();
Ok(())
}
fn exit_to_normal_mode(&mut self) -> std::io::Result<()> {
self.textarea.exit_edit_mode(); // 🎯 Direct FormEditor method call!
CursorManager::update_for_mode(AppMode::ReadOnly)?; // 🎯 Automatic: cursor becomes steady block
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █".to_string();
Ok(())
}
// === MANUAL CURSOR OVERRIDE DEMONSTRATION ===
fn demo_manual_cursor_control(&mut self) -> std::io::Result<()> {
// Users can still manually control cursor if needed
CursorManager::update_for_mode(AppMode::Command)?;
self.debug_message = "🔧 Manual override: Command cursor _".to_string();
Ok(())
}
fn restore_automatic_cursor(&mut self) -> std::io::Result<()> {
// Restore automatic cursor based on current mode
CursorManager::update_for_mode(self.textarea.mode())?; // 🎯 Direct method call!
self.debug_message = "🎯 Restored automatic cursor management".to_string();
Ok(())
}
// === TEXTAREA OPERATIONS ===
fn handle_textarea_input(&mut self, key: KeyEvent) {
self.textarea.input(key);
self.has_unsaved_changes = true;
}
// === MOVEMENT OPERATIONS (using direct FormEditor methods!) ===
fn move_left(&mut self) {
self.textarea.move_left(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("← left");
}
fn move_right(&mut self) {
self.textarea.move_right(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("→ right");
}
fn move_up(&mut self) {
self.textarea.move_up(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("↑ up");
}
fn move_down(&mut self) {
self.textarea.move_down(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("↓ down");
}
fn move_word_next(&mut self) {
self.textarea.move_word_next(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("w: next word");
}
fn move_word_prev(&mut self) {
self.textarea.move_word_prev(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("b: previous word");
}
fn move_word_end(&mut self) {
self.textarea.move_word_end(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("e: word end");
}
fn move_word_end_prev(&mut self) {
self.textarea.move_word_end_prev(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("ge: previous word end");
}
fn move_line_start(&mut self) {
self.textarea.move_line_start(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("0: line start");
}
fn move_line_end(&mut self) {
self.textarea.move_line_end(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("$: line end");
}
fn move_first_line(&mut self) {
self.textarea.move_first_line(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("gg: first line");
}
fn move_last_line(&mut self) {
self.textarea.move_last_line(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("G: last line");
}
// === BIG WORD MOVEMENTS ===
fn move_big_word_next(&mut self) {
self.textarea.move_big_word_next(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("W: next WORD");
}
fn move_big_word_prev(&mut self) {
self.textarea.move_big_word_prev(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("B: previous WORD");
}
fn move_big_word_end(&mut self) {
self.textarea.move_big_word_end(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("E: WORD end");
}
fn move_big_word_end_prev(&mut self) {
self.textarea.move_big_word_end_prev(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("gE: previous WORD end");
}
fn update_debug_for_movement(&mut self, action: &str) {
self.debug_message = action.to_string();
}
// === DELETE OPERATIONS ===
fn delete_char_forward(&mut self) {
if let Ok(_) = self.textarea.delete_forward() { // 🎯 Direct FormEditor method call!
self.has_unsaved_changes = true;
self.debug_message = "x: deleted character".to_string();
}
}
fn delete_char_backward(&mut self) {
if let Ok(_) = self.textarea.delete_backward() { // 🎯 Direct FormEditor method call!
self.has_unsaved_changes = true;
self.debug_message = "X: deleted character backward".to_string();
}
}
// === VIM-STYLE EDITING ===
fn open_line_below(&mut self) -> anyhow::Result<()> {
let result = self.textarea.open_line_below(); // 🎯 Textarea-specific override!
if result.is_ok() {
CursorManager::update_for_mode(AppMode::Edit)?;
self.debug_message = "✏️ INSERT (open line below) - Cursor: Steady Bar |".to_string();
self.has_unsaved_changes = true;
}
result
}
fn open_line_above(&mut self) -> anyhow::Result<()> {
let result = self.textarea.open_line_above(); // 🎯 Textarea-specific override!
if result.is_ok() {
CursorManager::update_for_mode(AppMode::Edit)?;
self.debug_message = "✏️ INSERT (open line above) - Cursor: Steady Bar |".to_string();
self.has_unsaved_changes = true;
}
result
}
// === COMMAND BUFFER HANDLING ===
fn clear_command_buffer(&mut self) {
self.command_buffer.clear();
}
fn add_to_command_buffer(&mut self, ch: char) {
self.command_buffer.push(ch);
}
fn get_command_buffer(&self) -> &str {
&self.command_buffer
}
fn has_pending_command(&self) -> bool {
!self.command_buffer.is_empty()
}
// === GETTERS ===
fn mode(&self) -> AppMode {
self.textarea.mode() // 🎯 Direct FormEditor method call!
}
fn debug_message(&self) -> &str {
&self.debug_message
}
fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
fn set_debug_message(&mut self, msg: String) {
self.debug_message = msg;
}
fn get_cursor_info(&self) -> String {
format!(
"Line {}, Col {}",
self.textarea.current_field() + 1, // 🎯 Direct FormEditor method call!
self.textarea.cursor_position() + 1 // 🎯 Direct FormEditor method call!
)
}
}
/// Handle key press with automatic cursor management
fn handle_key_press(
key_event: KeyEvent,
editor: &mut AutoCursorTextArea,
) -> anyhow::Result<bool> {
let KeyEvent { code: key, modifiers, .. } = key_event;
let mode = editor.mode();
// Quit handling
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL))
|| (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL))
|| key == KeyCode::F(10)
{
return Ok(false);
}
match (mode, key, modifiers) {
// === MODE TRANSITIONS WITH AUTOMATIC CURSOR MANAGEMENT ===
(AppMode::ReadOnly, KeyCode::Char('i'), _) => {
editor.enter_insert_mode()?;
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
editor.enter_append_mode()?;
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('A'), _) => {
editor.move_line_end();
editor.enter_insert_mode()?;
editor.clear_command_buffer();
}
// Vim o/O commands
(AppMode::ReadOnly, KeyCode::Char('o'), _) => {
if let Err(e) = editor.open_line_below() {
editor.set_debug_message(format!("Error opening line below: {e}"));
}
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('O'), _) => {
if let Err(e) = editor.open_line_above() {
editor.set_debug_message(format!("Error opening line above: {e}"));
}
editor.clear_command_buffer();
}
// Escape: Exit any mode back to normal
(AppMode::Edit, KeyCode::Esc, _) => {
editor.exit_to_normal_mode()?;
}
// === INSERT MODE: Pass to textarea ===
(AppMode::Edit, _, _) => {
editor.handle_textarea_input(key_event);
}
// === CURSOR MANAGEMENT DEMONSTRATION ===
(AppMode::ReadOnly, KeyCode::F(1), _) => {
editor.demo_manual_cursor_control()?;
}
(AppMode::ReadOnly, KeyCode::F(2), _) => {
editor.restore_automatic_cursor()?;
}
// === MOVEMENT: VIM-STYLE NAVIGATION (Normal mode) ===
(AppMode::ReadOnly, KeyCode::Char('h'), _)
| (AppMode::ReadOnly, KeyCode::Left, _) => {
editor.move_left();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('l'), _)
| (AppMode::ReadOnly, KeyCode::Right, _) => {
editor.move_right();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('j'), _)
| (AppMode::ReadOnly, KeyCode::Down, _) => {
editor.move_down();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('k'), _)
| (AppMode::ReadOnly, KeyCode::Up, _) => {
editor.move_up();
editor.clear_command_buffer();
}
// Word movement
(AppMode::ReadOnly, KeyCode::Char('w'), _) => {
editor.move_word_next();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('b'), _) => {
editor.move_word_prev();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('e'), _) => {
if editor.get_command_buffer() == "g" {
editor.move_word_end_prev();
editor.clear_command_buffer();
} else {
editor.move_word_end();
editor.clear_command_buffer();
}
}
// Big word movement (vim W/B/E commands)
(AppMode::ReadOnly, KeyCode::Char('W'), _) => {
editor.move_big_word_next();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('B'), _) => {
editor.move_big_word_prev();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('E'), _) => {
if editor.get_command_buffer() == "g" {
editor.move_big_word_end_prev();
editor.clear_command_buffer();
} else {
editor.move_big_word_end();
editor.clear_command_buffer();
}
}
// Line movement
(AppMode::ReadOnly, KeyCode::Char('0'), _)
| (AppMode::ReadOnly, KeyCode::Home, _) => {
editor.move_line_start();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('$'), _)
| (AppMode::ReadOnly, KeyCode::End, _) => {
editor.move_line_end();
editor.clear_command_buffer();
}
// Document movement with command buffer
(AppMode::ReadOnly, KeyCode::Char('g'), _) => {
if editor.get_command_buffer() == "g" {
editor.move_first_line();
editor.clear_command_buffer();
} else {
editor.clear_command_buffer();
editor.add_to_command_buffer('g');
editor.set_debug_message("g".to_string());
}
}
(AppMode::ReadOnly, KeyCode::Char('G'), _) => {
editor.move_last_line();
editor.clear_command_buffer();
}
// === DELETE OPERATIONS (Normal mode) ===
(AppMode::ReadOnly, KeyCode::Char('x'), _) => {
editor.delete_char_forward();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('X'), _) => {
editor.delete_char_backward();
editor.clear_command_buffer();
}
// === DEBUG/INFO COMMANDS ===
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
editor.set_debug_message(format!(
"{}, Mode: {:?} - Cursor managed automatically!",
editor.get_cursor_info(),
mode
));
editor.clear_command_buffer();
}
_ => {
if editor.has_pending_command() {
editor.clear_command_buffer();
editor.set_debug_message("Invalid command sequence".to_string());
} else {
editor.set_debug_message(format!(
"Unhandled: {key:?} + {modifiers:?} in {mode:?} mode"
));
}
}
}
Ok(true)
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut editor: AutoCursorTextArea,
) -> io::Result<()> {
loop {
terminal.draw(|f| ui(f, &mut editor))?;
if let Event::Key(key) = event::read()? {
match handle_key_press(key, &mut editor) {
Ok(should_continue) => {
if !should_continue {
break;
}
}
Err(e) => {
editor.set_debug_message(format!("Error: {e}"));
}
}
}
}
Ok(())
}
fn ui(f: &mut Frame, editor: &mut AutoCursorTextArea) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(8)])
.split(f.area());
render_textarea(f, chunks[0], editor);
render_status_and_help(f, chunks[1], editor);
}
fn render_textarea(
f: &mut Frame,
area: ratatui::layout::Rect,
editor: &mut AutoCursorTextArea,
) {
let block = Block::default()
.borders(Borders::ALL)
.title("🎯 Textarea with Automatic Cursor Management");
let textarea_widget = TextArea::default().block(block.clone());
f.render_stateful_widget(textarea_widget, area, &mut editor.textarea);
// Set cursor position for terminal cursor
// Always show cursor - CursorManager handles the style (block/bar/blinking)
let (cx, cy) = editor.textarea.cursor(area, Some(&block));
f.set_cursor_position((cx, cy));
}
fn render_status_and_help(
f: &mut Frame,
area: ratatui::layout::Rect,
editor: &AutoCursorTextArea,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Length(5)])
.split(area);
// Status bar with cursor information
let mode_text = match editor.mode() {
AppMode::Edit => "INSERT | (bar cursor)",
AppMode::ReadOnly => "NORMAL █ (block cursor)",
AppMode::Highlight => "VISUAL █ (blinking block)",
_ => "NORMAL █ (block cursor)",
};
let status_text = if editor.has_pending_command() {
format!("-- {} -- {} [{}]", mode_text, editor.debug_message(), editor.get_command_buffer())
} else if editor.has_unsaved_changes() {
format!("-- {} -- [Modified] {} | {}", mode_text, editor.debug_message(), editor.get_cursor_info())
} else {
format!("-- {} -- {} | {}", mode_text, editor.debug_message(), editor.get_cursor_info())
};
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("🎯 Automatic Cursor Status"));
f.render_widget(status, chunks[0]);
// Help text
let help_text = match editor.mode() {
AppMode::ReadOnly => {
if editor.has_pending_command() {
match editor.get_command_buffer() {
"g" => "Press 'g' again for first line, or any other key to cancel",
_ => "Pending command... (Esc to cancel)"
}
} else {
"🎯 CURSOR-STYLE DEMO: Normal █ | Insert | \n\
Normal: hjkl/arrows=move, w/b/e=words, W/B/E=WORDS, 0/$=line, g/G=first/last\n\
i/a/A/o/O=insert, x/X=delete, ?=info\n\
F1=demo manual cursor, F2=restore automatic, Ctrl+Q=quit"
}
}
AppMode::Edit => {
"🎯 INSERT MODE - Cursor: | (bar)\n\
Type to edit text, arrows=move, Enter=new line\n\
Esc=normal mode"
}
AppMode::Highlight => {
"🎯 VISUAL MODE - Cursor: █ (blinking block)\n\
hjkl/arrows=extend selection\n\
Esc=normal mode"
}
_ => "🎯 Watch the cursor change automatically!"
};
let help = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title("🚀 Automatic Cursor Management"))
.style(Style::default().fg(Color::Gray));
f.render_widget(help, chunks[1]);
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Print feature status
println!("🎯 Canvas Textarea Cursor Auto Demo");
println!("✅ cursor-style feature: ENABLED");
println!("✅ textarea feature: ENABLED");
println!("🚀 Automatic cursor management: ACTIVE");
println!("📖 Watch your terminal cursor change based on mode!");
println!();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut editor = AutoCursorTextArea::new();
// Initialize with normal mode - library automatically sets block cursor
editor.exit_to_normal_mode()?;
let res = run_app(&mut terminal, editor);
// Reset cursor on exit
CursorManager::reset()?;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{err:?}");
}
println!("🎯 Cursor automatically reset to default!");
Ok(())
}

View File

@@ -1,831 +0,0 @@
// examples/validation_1.rs
//! Demonstrates field validation with the canvas library
//!
//! This example REQUIRES the `validation` and `cursor-style` features to compile.
//!
//! Run with:
//! cargo run --example validation_1 --features "gui,validation"
//!
//! This will fail without validation:
//! cargo run --example validation_1 --features "gui"
// REQUIRE validation feature - example won't compile without it
#[cfg(not(all(feature = "validation", feature = "cursor-style")))]
compile_error!(
"This example requires the 'validation' and 'cursor-style' features. \
Run with: cargo run --example validation_1 --features \"gui,validation,cursor-style\""
);
use std::io;
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
},
execute,
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
Frame, Terminal,
};
use canvas::{
canvas::{
gui::render_canvas_default,
modes::AppMode,
CursorManager,
},
DataProvider, FormEditor,
ValidationConfig, ValidationConfigBuilder, CharacterLimits, ValidationResult,
};
// Import CountMode from the validation module directly
use canvas::validation::limits::CountMode;
// Enhanced FormEditor that demonstrates validation functionality
struct ValidationFormEditor<D: DataProvider> {
editor: FormEditor<D>,
has_unsaved_changes: bool,
debug_message: String,
command_buffer: String,
validation_enabled: bool,
field_switch_blocked: bool,
block_reason: Option<String>,
}
impl<D: DataProvider> ValidationFormEditor<D> {
fn new(data_provider: D) -> Self {
let mut editor = FormEditor::new(data_provider);
// Enable validation by default
editor.set_validation_enabled(true);
Self {
editor,
has_unsaved_changes: false,
debug_message: "🔍 Validation Demo - Try typing in different fields!".to_string(),
command_buffer: String::new(),
validation_enabled: true,
field_switch_blocked: false,
block_reason: None,
}
}
// === COMMAND BUFFER HANDLING ===
fn clear_command_buffer(&mut self) {
self.command_buffer.clear();
}
fn add_to_command_buffer(&mut self, ch: char) {
self.command_buffer.push(ch);
}
fn get_command_buffer(&self) -> &str {
&self.command_buffer
}
fn has_pending_command(&self) -> bool {
!self.command_buffer.is_empty()
}
// === VALIDATION CONTROL ===
fn toggle_validation(&mut self) {
self.validation_enabled = !self.validation_enabled;
self.editor.set_validation_enabled(self.validation_enabled);
if self.validation_enabled {
self.debug_message = "✅ Validation ENABLED - Try exceeding limits!".to_string();
} else {
self.debug_message = "❌ Validation DISABLED - No limits enforced".to_string();
}
}
fn check_field_switch_allowed(&self) -> (bool, Option<String>) {
if !self.validation_enabled {
return (true, None);
}
let can_switch = self.editor.can_switch_fields();
let reason = if !can_switch {
self.editor.field_switch_block_reason()
} else {
None
};
(can_switch, reason)
}
fn get_validation_status(&self) -> String {
if !self.validation_enabled {
return "❌ DISABLED".to_string();
}
if self.field_switch_blocked {
return "🚫 SWITCH BLOCKED".to_string();
}
let summary = self.editor.validation_summary();
if summary.has_errors() {
format!("{} ERRORS", summary.error_fields)
} else if summary.has_warnings() {
format!("⚠️ {} WARNINGS", summary.warning_fields)
} else if summary.validated_fields > 0 {
format!("{} VALID", summary.valid_fields)
} else {
"🔍 READY".to_string()
}
}
fn validate_current_field(&mut self) {
let result = self.editor.validate_current_field();
match result {
ValidationResult::Valid => {
self.debug_message = "✅ Current field is valid!".to_string();
}
ValidationResult::Warning { message } => {
self.debug_message = format!("⚠️ Warning: {message}");
}
ValidationResult::Error { message } => {
self.debug_message = format!("❌ Error: {message}");
}
}
}
fn validate_all_fields(&mut self) {
let field_count = self.editor.data_provider().field_count();
for i in 0..field_count {
self.editor.validate_field(i);
}
let summary = self.editor.validation_summary();
self.debug_message = format!(
"🔍 Validated all fields: {} valid, {} warnings, {} errors",
summary.valid_fields, summary.warning_fields, summary.error_fields
);
}
fn clear_validation_results(&mut self) {
self.editor.clear_validation_results();
self.debug_message = "🧹 Cleared all validation results".to_string();
}
// === ENHANCED MOVEMENT WITH VALIDATION ===
fn move_left(&mut self) {
self.editor.move_left();
self.field_switch_blocked = false;
self.block_reason = None;
}
fn move_right(&mut self) {
self.editor.move_right();
self.field_switch_blocked = false;
self.block_reason = None;
}
fn move_up(&mut self) {
match self.editor.move_up() {
Ok(()) => {
self.update_field_validation_status();
self.field_switch_blocked = false;
self.block_reason = None;
}
Err(e) => {
self.field_switch_blocked = true;
self.block_reason = Some(e.to_string());
self.debug_message = format!("🚫 Field switch blocked: {e}");
}
}
}
fn move_down(&mut self) {
match self.editor.move_down() {
Ok(()) => {
self.update_field_validation_status();
self.field_switch_blocked = false;
self.block_reason = None;
}
Err(e) => {
self.field_switch_blocked = true;
self.block_reason = Some(e.to_string());
self.debug_message = format!("🚫 Field switch blocked: {e}");
}
}
}
fn move_line_start(&mut self) {
self.editor.move_line_start();
}
fn move_line_end(&mut self) {
self.editor.move_line_end();
}
fn move_word_next(&mut self) {
self.editor.move_word_next();
}
fn move_word_prev(&mut self) {
self.editor.move_word_prev();
}
fn move_word_end(&mut self) {
self.editor.move_word_end();
}
fn move_first_line(&mut self) {
self.editor.move_first_line();
}
fn move_last_line(&mut self) {
self.editor.move_last_line();
}
fn update_field_validation_status(&mut self) {
if !self.validation_enabled {
return;
}
let result = self.editor.validate_current_field();
match result {
ValidationResult::Valid => {
self.debug_message = format!("Field {}: ✅ Valid", self.editor.current_field() + 1);
}
ValidationResult::Warning { message } => {
self.debug_message = format!("Field {}: ⚠️ {}", self.editor.current_field() + 1, message);
}
ValidationResult::Error { message } => {
self.debug_message = format!("Field {}: ❌ {}", self.editor.current_field() + 1, message);
}
}
}
// === MODE TRANSITIONS ===
fn enter_edit_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_edit_mode();
self.debug_message = "✏️ INSERT MODE - Cursor: Steady Bar | - Type to test validation".to_string();
}
fn enter_append_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_append_mode();
self.debug_message = "✏️ INSERT (append) - Cursor: Steady Bar | - Validation active".to_string();
}
fn exit_edit_mode(&mut self) {
// Library will automatically update cursor to block █ in normal mode
self.editor.exit_edit_mode();
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █ - Press 'v' to validate current field".to_string();
self.update_field_validation_status();
}
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
let result = self.editor.insert_char(ch);
if result.is_ok() {
self.has_unsaved_changes = true;
// Show real-time validation feedback
let validation_result = self.editor.validate_current_field();
match validation_result {
ValidationResult::Valid => {
// Don't spam with valid messages, just show character count if applicable
if let Some(limits) = self.get_current_field_limits() {
let field_index = self.editor.current_field();
if let Some(status) = limits.status_text(
self.editor.data_provider().field_value(field_index)
) {
self.debug_message = format!("✏️ {status}");
}
}
}
ValidationResult::Warning { message } => {
self.debug_message = format!("⚠️ {message}");
}
ValidationResult::Error { message } => {
self.debug_message = format!("{message}");
}
}
}
result
}
fn get_current_field_limits(&self) -> Option<&CharacterLimits> {
let validation_state = self.editor.validation_state();
let config = validation_state.get_field_config(self.editor.current_field())?;
config.character_limits.as_ref()
}
// === DELETE OPERATIONS ===
fn delete_backward(&mut self) -> anyhow::Result<()> {
let result = self.editor.delete_backward();
if result.is_ok() {
self.has_unsaved_changes = true;
self.debug_message = "⌫ Deleted character".to_string();
}
result
}
fn delete_forward(&mut self) -> anyhow::Result<()> {
let result = self.editor.delete_forward();
if result.is_ok() {
self.has_unsaved_changes = true;
self.debug_message = "⌦ Deleted character".to_string();
}
result
}
// === DELEGATE TO ORIGINAL EDITOR ===
fn current_field(&self) -> usize {
self.editor.current_field()
}
fn cursor_position(&self) -> usize {
self.editor.cursor_position()
}
fn mode(&self) -> AppMode {
self.editor.mode()
}
fn current_text(&self) -> &str {
let field_index = self.editor.current_field();
self.editor.data_provider().field_value(field_index)
}
fn data_provider(&self) -> &D {
self.editor.data_provider()
}
fn ui_state(&self) -> &canvas::EditorState {
self.editor.ui_state()
}
fn set_mode(&mut self, mode: AppMode) {
// Library automatically updates cursor for the mode
self.editor.set_mode(mode);
}
fn next_field(&mut self) {
match self.editor.next_field() {
Ok(()) => {
self.update_field_validation_status();
self.field_switch_blocked = false;
self.block_reason = None;
}
Err(e) => {
self.field_switch_blocked = true;
self.block_reason = Some(e.to_string());
self.debug_message = format!("🚫 Cannot move to next field: {e}");
}
}
}
fn prev_field(&mut self) {
match self.editor.prev_field() {
Ok(()) => {
self.update_field_validation_status();
self.field_switch_blocked = false;
self.block_reason = None;
}
Err(e) => {
self.field_switch_blocked = true;
self.block_reason = Some(e.to_string());
self.debug_message = format!("🚫 Cannot move to previous field: {e}");
}
}
}
// === STATUS AND DEBUG ===
fn set_debug_message(&mut self, msg: String) {
self.debug_message = msg;
}
fn debug_message(&self) -> &str {
&self.debug_message
}
fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
}
// Demo form data with different validation rules
struct ValidationDemoData {
fields: Vec<(String, String)>,
}
impl ValidationDemoData {
fn new() -> Self {
Self {
fields: vec![
("👤 Name (max 20)".to_string(), "".to_string()),
("📧 Email (max 50, warn@40)".to_string(), "".to_string()),
("🔑 Password (5-20 chars)".to_string(), "".to_string()),
("🔢 ID (min 3, max 10)".to_string(), "".to_string()),
("📝 Comment (min 10, max 100)".to_string(), "".to_string()),
("🏷️ Tag (max 30, bytes)".to_string(), "".to_string()),
("🌍 Unicode (width, min 2)".to_string(), "".to_string()),
],
}
}
}
impl DataProvider for ValidationDemoData {
fn field_count(&self) -> usize {
self.fields.len()
}
fn field_name(&self, index: usize) -> &str {
&self.fields[index].0
}
fn field_value(&self, index: usize) -> &str {
&self.fields[index].1
}
fn set_field_value(&mut self, index: usize, value: String) {
self.fields[index].1 = value;
}
fn supports_suggestions(&self, _field_index: usize) -> bool {
false
}
fn display_value(&self, _index: usize) -> Option<&str> {
None
}
// 🎯 NEW: Validation configuration per field
fn validation_config(&self, field_index: usize) -> Option<ValidationConfig> {
match field_index {
0 => Some(ValidationConfig::with_max_length(20)), // Name: simple 20 char limit
1 => Some(
ValidationConfigBuilder::new()
.with_character_limits(
CharacterLimits::new(50).with_warning_threshold(40)
)
.build()
), // Email: 50 chars with warning at 40
2 => Some(
ValidationConfigBuilder::new()
.with_character_limits(CharacterLimits::new_range(5, 20))
.build()
), // Password: must be 5-20 characters (blocks field switching if 1-4 chars)
3 => Some(
ValidationConfigBuilder::new()
.with_character_limits(CharacterLimits::new_range(3, 10))
.build()
), // ID: must be 3-10 characters (blocks field switching if 1-2 chars)
4 => Some(
ValidationConfigBuilder::new()
.with_character_limits(CharacterLimits::new_range(10, 100))
.build()
), // Comment: must be 10-100 characters (blocks field switching if 1-9 chars)
5 => Some(
ValidationConfigBuilder::new()
.with_character_limits(
CharacterLimits::new(30).with_count_mode(CountMode::Bytes)
)
.build()
), // Tag: 30 bytes (useful for UTF-8)
6 => Some(
ValidationConfigBuilder::new()
.with_character_limits(
CharacterLimits::new_range(2, 20).with_count_mode(CountMode::DisplayWidth)
)
.build()
), // Unicode: 2-20 display width (useful for CJK characters, blocks if 1 char)
_ => None,
}
}
}
/// Handle key presses with validation-focused commands
fn handle_key_press(
key: KeyCode,
modifiers: KeyModifiers,
editor: &mut ValidationFormEditor<ValidationDemoData>,
) -> anyhow::Result<bool> {
let mode = editor.mode();
// Quit handling
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL))
|| (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL))
|| key == KeyCode::F(10)
{
return Ok(false);
}
match (mode, key, modifiers) {
// === MODE TRANSITIONS ===
(AppMode::ReadOnly, KeyCode::Char('i'), _) => {
editor.enter_edit_mode();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
editor.enter_append_mode();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('A'), _) => {
editor.move_line_end();
editor.enter_edit_mode();
editor.clear_command_buffer();
}
// Escape: Exit edit mode
(_, KeyCode::Esc, _) => {
if mode == AppMode::Edit {
editor.exit_edit_mode();
} else {
editor.clear_command_buffer();
}
}
// === VALIDATION COMMANDS ===
(AppMode::ReadOnly, KeyCode::Char('v'), _) => {
editor.validate_current_field();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('V'), _) => {
editor.validate_all_fields();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('c'), _) => {
editor.clear_validation_results();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::F(1), _) => {
editor.toggle_validation();
}
// === MOVEMENT ===
(AppMode::ReadOnly, KeyCode::Char('h'), _) | (AppMode::ReadOnly, KeyCode::Left, _) => {
editor.move_left();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('l'), _) | (AppMode::ReadOnly, KeyCode::Right, _) => {
editor.move_right();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('j'), _) | (AppMode::ReadOnly, KeyCode::Down, _) => {
editor.move_down();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('k'), _) | (AppMode::ReadOnly, KeyCode::Up, _) => {
editor.move_up();
editor.clear_command_buffer();
}
// === EDIT MODE MOVEMENT ===
(AppMode::Edit, KeyCode::Left, _) => {
editor.move_left();
}
(AppMode::Edit, KeyCode::Right, _) => {
editor.move_right();
}
(AppMode::Edit, KeyCode::Up, _) => {
editor.move_up();
}
(AppMode::Edit, KeyCode::Down, _) => {
editor.move_down();
}
// === DELETE OPERATIONS ===
(AppMode::Edit, KeyCode::Backspace, _) => {
editor.delete_backward()?;
}
(AppMode::Edit, KeyCode::Delete, _) => {
editor.delete_forward()?;
}
// === TAB NAVIGATION ===
(_, KeyCode::Tab, _) => {
editor.next_field();
}
(_, KeyCode::BackTab, _) => {
editor.prev_field();
}
// === CHARACTER INPUT ===
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
editor.insert_char(c)?;
}
// === DEBUG/INFO COMMANDS ===
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
let summary = editor.editor.validation_summary();
editor.set_debug_message(format!(
"Field {}/{}, Pos {}, Mode: {:?}, Validation: {} fields configured, {} validated",
editor.current_field() + 1,
editor.data_provider().field_count(),
editor.cursor_position(),
editor.mode(),
summary.total_fields,
summary.validated_fields
));
}
_ => {
if editor.has_pending_command() {
editor.clear_command_buffer();
editor.set_debug_message("Invalid command sequence".to_string());
}
}
}
Ok(true)
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut editor: ValidationFormEditor<ValidationDemoData>,
) -> io::Result<()> {
loop {
terminal.draw(|f| ui(f, &editor))?;
if let Event::Key(key) = event::read()? {
match handle_key_press(key.code, key.modifiers, &mut editor) {
Ok(should_continue) => {
if !should_continue {
break;
}
}
Err(e) => {
editor.set_debug_message(format!("Error: {e}"));
}
}
}
}
Ok(())
}
fn ui(f: &mut Frame, editor: &ValidationFormEditor<ValidationDemoData>) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(12)])
.split(f.area());
render_enhanced_canvas(f, chunks[0], editor);
render_validation_status(f, chunks[1], editor);
}
fn render_enhanced_canvas(
f: &mut Frame,
area: Rect,
editor: &ValidationFormEditor<ValidationDemoData>,
) {
render_canvas_default(f, area, &editor.editor);
}
fn render_validation_status(
f: &mut Frame,
area: Rect,
editor: &ValidationFormEditor<ValidationDemoData>,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Status bar
Constraint::Length(4), // Validation summary
Constraint::Length(5), // Help
])
.split(area);
// Status bar with validation information
let mode_text = match editor.mode() {
AppMode::Edit => "INSERT | (bar cursor)",
AppMode::ReadOnly => "NORMAL █ (block cursor)",
_ => "NORMAL █ (block cursor)",
};
let validation_status = editor.get_validation_status();
let status_text = if editor.has_pending_command() {
format!("-- {} -- {} [{}] | Validation: {}",
mode_text, editor.debug_message(), editor.get_command_buffer(), validation_status)
} else if editor.has_unsaved_changes() {
format!("-- {} -- [Modified] {} | Validation: {}",
mode_text, editor.debug_message(), validation_status)
} else {
format!("-- {} -- {} | Validation: {}",
mode_text, editor.debug_message(), validation_status)
};
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("🔍 Validation Status"));
f.render_widget(status, chunks[0]);
// Validation summary with field switching info
let summary = editor.editor.validation_summary();
let summary_text = if editor.validation_enabled {
let switch_info = if editor.field_switch_blocked {
format!("\n🚫 Field switching blocked: {}",
editor.block_reason.as_deref().unwrap_or("Unknown reason"))
} else {
let (can_switch, reason) = editor.check_field_switch_allowed();
if !can_switch {
format!("\n⚠️ Field switching will be blocked: {}",
reason.as_deref().unwrap_or("Unknown reason"))
} else {
"\n✅ Field switching allowed".to_string()
}
};
format!(
"📊 Validation Summary: {} fields configured, {} validated{}\n\
✅ Valid: {} ⚠️ Warnings: {} ❌ Errors: {} 📈 Progress: {:.0}%",
summary.total_fields,
summary.validated_fields,
switch_info,
summary.valid_fields,
summary.warning_fields,
summary.error_fields,
summary.completion_percentage() * 100.0
)
} else {
"❌ Validation is currently DISABLED\nPress F1 to enable validation".to_string()
};
let summary_style = if summary.has_errors() {
Style::default().fg(Color::Red)
} else if summary.has_warnings() {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Green)
};
let validation_summary = Paragraph::new(summary_text)
.block(Block::default().borders(Borders::ALL).title("📈 Validation Overview"))
.style(summary_style)
.wrap(Wrap { trim: true });
f.render_widget(validation_summary, chunks[1]);
// Enhanced help text
let help_text = match editor.mode() {
AppMode::ReadOnly => {
"🎯 CURSOR-STYLE: Normal █ | Insert |\n\
🔍 VALIDATION: Different fields have different limits (some block field switching)!\n\
Movement: hjkl/arrows=move, Tab/Shift+Tab=fields\n\
Edit: i/a/A=insert modes, Esc=normal\n\
Validation: v=validate current, V=validate all, c=clear results, F1=toggle\n\
?=info, Ctrl+C/Ctrl+Q=quit"
}
AppMode::Edit => {
"🎯 INSERT MODE - Cursor: | (bar)\n\
🔍 Type to test validation limits (some fields have MIN requirements)!\n\
Try typing 1-2 chars in Password/ID/Comment fields, then try to switch!\n\
arrows=move, Backspace/Del=delete, Esc=normal, Tab=next field\n\
Field switching may be BLOCKED if minimum requirements not met!"
}
_ => "🎯 Watch the cursor change automatically while validating!"
};
let help = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title("🚀 Validation Commands"))
.style(Style::default().fg(Color::Gray))
.wrap(Wrap { trim: true });
f.render_widget(help, chunks[2]);
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Print feature status
println!("🔍 Canvas Validation Demo");
println!("✅ validation feature: ENABLED");
println!("🚀 Field validation: ACTIVE");
println!("🚫 Field switching validation: ACTIVE");
println!("📊 Try typing in fields with minimum requirements!");
println!(" - Password (min 5): Type 1-4 chars, then try to switch fields");
println!(" - ID (min 3): Type 1-2 chars, then try to switch fields");
println!(" - Comment (min 10): Type 1-9 chars, then try to switch fields");
println!(" - Unicode (min 2): Type 1 char, then try to switch fields");
println!();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let data = ValidationDemoData::new();
let mut editor = ValidationFormEditor::new(data);
// Initialize with normal mode - library automatically sets block cursor
editor.set_mode(AppMode::ReadOnly);
// Demonstrate that CursorManager is available and working
CursorManager::update_for_mode(AppMode::ReadOnly)?;
let res = run_app(&mut terminal, editor);
// Library automatically resets cursor on FormEditor::drop()
// But we can also manually reset if needed
CursorManager::reset()?;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{err:?}");
}
println!("🔍 Validation demo completed!");
Ok(())
}

View File

@@ -1,663 +0,0 @@
// examples/validation_2.rs
//! Advanced TUI Example demonstrating complex pattern filtering edge cases
//!
//! This example showcases the full potential of the pattern validation system
//! with creative real-world scenarios and edge cases.
//!
//! Run with: cargo run --example validation_advanced_patterns --features "validation,gui,cursor-style"
// REQUIRE validation, gui and cursor-style features
#[cfg(not(all(feature = "validation", feature = "gui", feature = "cursor-style")))]
compile_error!(
"This example requires the 'validation', 'gui' and 'cursor-style' features. \
Run with: cargo run --example validation_advanced_patterns --features \"validation,gui,cursor-style\""
);
use std::io;
use std::sync::Arc;
use canvas::ValidationResult;
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
},
execute,
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
Frame, Terminal,
};
use canvas::{
canvas::{
gui::render_canvas_default,
modes::AppMode,
CursorManager,
},
DataProvider, FormEditor,
ValidationConfig, ValidationConfigBuilder, PatternFilters, PositionFilter, PositionRange, CharacterFilter,
};
// Enhanced FormEditor wrapper (keeping the same structure as before)
struct AdvancedPatternFormEditor<D: DataProvider> {
editor: FormEditor<D>,
debug_message: String,
command_buffer: String,
validation_enabled: bool,
field_switch_blocked: bool,
block_reason: Option<String>,
}
impl<D: DataProvider> AdvancedPatternFormEditor<D> {
fn new(data_provider: D) -> Self {
let mut editor = FormEditor::new(data_provider);
editor.set_validation_enabled(true);
Self {
editor,
debug_message: "🚀 Advanced Pattern Validation - Showcasing edge cases and complex patterns!".to_string(),
command_buffer: String::new(),
validation_enabled: true,
field_switch_blocked: false,
block_reason: None,
}
}
// ... (keeping all the same methods as before for brevity)
// [All the previous methods: clear_command_buffer, add_to_command_buffer, etc.]
fn clear_command_buffer(&mut self) { self.command_buffer.clear(); }
fn add_to_command_buffer(&mut self, ch: char) { self.command_buffer.push(ch); }
fn get_command_buffer(&self) -> &str { &self.command_buffer }
fn has_pending_command(&self) -> bool { !self.command_buffer.is_empty() }
fn toggle_validation(&mut self) {
self.validation_enabled = !self.validation_enabled;
self.editor.set_validation_enabled(self.validation_enabled);
if self.validation_enabled {
self.debug_message = "✅ Advanced Pattern Validation ENABLED".to_string();
} else {
self.debug_message = "❌ Advanced Pattern Validation DISABLED".to_string();
}
}
fn move_left(&mut self) { self.editor.move_left(); self.field_switch_blocked = false; self.block_reason = None; }
fn move_right(&mut self) { self.editor.move_right(); self.field_switch_blocked = false; self.block_reason = None; }
fn move_up(&mut self) {
match self.editor.move_up() {
Ok(()) => { self.update_field_validation_status(); self.field_switch_blocked = false; self.block_reason = None; }
Err(e) => { self.field_switch_blocked = true; self.block_reason = Some(e.to_string()); self.debug_message = format!("🚫 Field switch blocked: {e}"); }
}
}
fn move_down(&mut self) {
match self.editor.move_down() {
Ok(()) => { self.update_field_validation_status(); self.field_switch_blocked = false; self.block_reason = None; }
Err(e) => { self.field_switch_blocked = true; self.block_reason = Some(e.to_string()); self.debug_message = format!("🚫 Field switch blocked: {e}"); }
}
}
fn move_line_start(&mut self) { self.editor.move_line_start(); }
fn move_line_end(&mut self) { self.editor.move_line_end(); }
fn enter_edit_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_edit_mode();
self.debug_message = "✏️ INSERT MODE - Cursor: Steady Bar | - Testing advanced pattern validation".to_string();
}
fn enter_append_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_append_mode();
self.debug_message = "✏️ INSERT (append) - Cursor: Steady Bar | - Advanced patterns active".to_string();
}
fn exit_edit_mode(&mut self) {
// Library will automatically update cursor to block █ in normal mode
self.editor.exit_edit_mode();
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █".to_string();
self.update_field_validation_status();
}
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
let result = self.editor.insert_char(ch);
if result.is_ok() {
let validation_result = self.editor.validate_current_field();
match validation_result {
ValidationResult::Valid => { self.debug_message = "✅ Character accepted".to_string(); }
ValidationResult::Warning { message } => { self.debug_message = format!("⚠️ Warning: {message}"); }
ValidationResult::Error { message } => { self.debug_message = format!("❌ Pattern violation: {message}"); }
}
}
result
}
fn delete_backward(&mut self) -> anyhow::Result<()> {
let result = self.editor.delete_backward();
if result.is_ok() { self.debug_message = "⌫ Character deleted".to_string(); }
result
}
fn delete_forward(&mut self) -> anyhow::Result<()> {
let result = self.editor.delete_forward();
if result.is_ok() { self.debug_message = "⌦ Character deleted".to_string(); }
result
}
// Delegate methods
fn current_field(&self) -> usize { self.editor.current_field() }
fn cursor_position(&self) -> usize { self.editor.cursor_position() }
fn mode(&self) -> AppMode { self.editor.mode() }
fn current_text(&self) -> &str {
let field_index = self.editor.current_field();
self.editor.data_provider().field_value(field_index)
}
fn data_provider(&self) -> &D { self.editor.data_provider() }
fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() }
fn set_mode(&mut self, mode: AppMode) { self.editor.set_mode(mode); }
fn next_field(&mut self) {
match self.editor.next_field() {
Ok(()) => { self.update_field_validation_status(); self.field_switch_blocked = false; self.block_reason = None; }
Err(e) => { self.field_switch_blocked = true; self.block_reason = Some(e.to_string()); self.debug_message = format!("🚫 Cannot move to next field: {e}"); }
}
}
fn prev_field(&mut self) {
match self.editor.prev_field() {
Ok(()) => { self.update_field_validation_status(); self.field_switch_blocked = false; self.block_reason = None; }
Err(e) => { self.field_switch_blocked = true; self.block_reason = Some(e.to_string()); self.debug_message = format!("🚫 Cannot move to previous field: {e}"); }
}
}
fn set_debug_message(&mut self, msg: String) { self.debug_message = msg; }
fn debug_message(&self) -> &str { &self.debug_message }
fn update_field_validation_status(&mut self) {
if !self.validation_enabled { return; }
let result = self.editor.validate_current_field();
match result {
ValidationResult::Valid => { self.debug_message = format!("Field {}: ✅ Pattern valid", self.editor.current_field() + 1); }
ValidationResult::Warning { message } => { self.debug_message = format!("Field {}: ⚠️ {}", self.editor.current_field() + 1, message); }
ValidationResult::Error { message } => { self.debug_message = format!("Field {}: ❌ {}", self.editor.current_field() + 1, message); }
}
}
fn get_validation_status(&self) -> String {
if !self.validation_enabled { return "❌ DISABLED".to_string(); }
if self.field_switch_blocked { return "🚫 SWITCH BLOCKED".to_string(); }
let summary = self.editor.validation_summary();
if summary.has_errors() { format!("{} ERRORS", summary.error_fields) }
else if summary.has_warnings() { format!("⚠️ {} WARNINGS", summary.warning_fields) }
else if summary.validated_fields > 0 { format!("{} VALID", summary.valid_fields) }
else { "🔍 READY".to_string() }
}
}
// Advanced demo form with creative and edge-case-heavy validation patterns
struct AdvancedPatternData {
fields: Vec<(String, String)>,
}
impl AdvancedPatternData {
fn new() -> Self {
Self {
fields: vec![
("🕐 Time (HH:MM) - 24hr format".to_string(), "".to_string()),
("🎨 Hex Color (#RRGGBB) - Web colors".to_string(), "".to_string()),
("🌐 IPv4 (XXX.XXX.XXX.XXX) - Network address".to_string(), "".to_string()),
("🏷️ Product Code (ABC-123-XYZ) - Mixed format".to_string(), "".to_string()),
("📅 Date Code (2024W15) - Year + Week".to_string(), "".to_string()),
("🔢 Binary (101010) - Only 0s and 1s".to_string(), "".to_string()),
("🎯 Complex ID (A1-B2C-3D4E) - Multi-rule".to_string(), "".to_string()),
("🚀 Custom Pattern - Advanced logic".to_string(), "".to_string()),
],
}
}
}
impl DataProvider for AdvancedPatternData {
fn field_count(&self) -> usize { self.fields.len() }
fn field_name(&self, index: usize) -> &str { &self.fields[index].0 }
fn field_value(&self, index: usize) -> &str { &self.fields[index].1 }
fn set_field_value(&mut self, index: usize, value: String) { self.fields[index].1 = value; }
fn validation_config(&self, field_index: usize) -> Option<ValidationConfig> {
match field_index {
0 => {
// 🕐 Time (HH:MM) - Hours 00-23, Minutes 00-59
// This showcases: Multiple position ranges, exact character matching, custom validation
let time_pattern = PatternFilters::new()
.add_filter(PositionFilter::new(
PositionRange::Multiple(vec![0, 1, 3, 4]), // Hours and minutes positions
CharacterFilter::Numeric,
))
.add_filter(PositionFilter::new(
PositionRange::Single(2), // Colon separator
CharacterFilter::Exact(':'),
));
Some(ValidationConfigBuilder::new()
.with_pattern_filters(time_pattern)
.with_max_length(5) // HH:MM = 5 characters
.build())
}
1 => {
// 🎨 Hex Color (#RRGGBB) - Web color format
// This showcases: OneOf filter with hex digits, exact character at start
let hex_digits = vec!['0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F','a','b','c','d','e','f'];
let hex_color_pattern = PatternFilters::new()
.add_filter(PositionFilter::new(
PositionRange::Single(0), // Hash symbol
CharacterFilter::Exact('#'),
))
.add_filter(PositionFilter::new(
PositionRange::Range(1, 6), // 6 hex digits for RGB
CharacterFilter::OneOf(hex_digits),
));
Some(ValidationConfigBuilder::new()
.with_pattern_filters(hex_color_pattern)
.with_max_length(7) // #RRGGBB = 7 characters
.build())
}
2 => {
// 🌐 IPv4 Address (XXX.XXX.XXX.XXX) - Network address
// This showcases: Complex pattern with dots at specific positions
let ipv4_pattern = PatternFilters::new()
.add_filter(PositionFilter::new(
PositionRange::Multiple(vec![3, 7, 11]), // Dots at specific positions
CharacterFilter::Exact('.'),
))
.add_filter(PositionFilter::new(
PositionRange::Multiple(vec![0,1,2,4,5,6,8,9,10,12,13,14]), // Number positions
CharacterFilter::Numeric,
));
Some(ValidationConfigBuilder::new()
.with_pattern_filters(ipv4_pattern)
.with_max_length(15) // XXX.XXX.XXX.XXX = up to 15 chars
.build())
}
3 => {
// 🏷️ Product Code (ABC-123-XYZ) - Mixed format sections
// This showcases: Different rules for different sections
let product_code_pattern = PatternFilters::new()
.add_filter(PositionFilter::new(
PositionRange::Range(0, 2), // First 3 positions: letters
CharacterFilter::Alphabetic,
))
.add_filter(PositionFilter::new(
PositionRange::Multiple(vec![3, 7]), // Dashes
CharacterFilter::Exact('-'),
))
.add_filter(PositionFilter::new(
PositionRange::Range(4, 6), // Middle 3 positions: numbers
CharacterFilter::Numeric,
))
.add_filter(PositionFilter::new(
PositionRange::Range(8, 10), // Last 3 positions: letters
CharacterFilter::Alphabetic,
));
Some(ValidationConfigBuilder::new()
.with_pattern_filters(product_code_pattern)
.with_max_length(11) // ABC-123-XYZ = 11 characters
.build())
}
4 => {
// 📅 Date Code (2024W15) - Year + Week format
// This showcases: From position filtering and mixed patterns
let date_code_pattern = PatternFilters::new()
.add_filter(PositionFilter::new(
PositionRange::Range(0, 3), // Year: 4 digits
CharacterFilter::Numeric,
))
.add_filter(PositionFilter::new(
PositionRange::Single(4), // Week indicator
CharacterFilter::Exact('W'),
))
.add_filter(PositionFilter::new(
PositionRange::From(5), // Week number: rest are digits
CharacterFilter::Numeric,
));
Some(ValidationConfigBuilder::new()
.with_pattern_filters(date_code_pattern)
.with_max_length(7) // 2024W15 = 7 characters
.build())
}
5 => {
// 🔢 Binary (101010) - Only 0s and 1s
// This showcases: OneOf filter with limited character set
let binary_pattern = PatternFilters::new()
.add_filter(PositionFilter::new(
PositionRange::From(0), // All positions
CharacterFilter::OneOf(vec!['0', '1']),
));
Some(ValidationConfigBuilder::new()
.with_pattern_filters(binary_pattern)
.with_max_length(16) // Allow up to 16 binary digits
.build())
}
6 => {
// 🎯 Complex ID (A1-B2C-3D4E) - Multiple overlapping rules
// This showcases: Complex overlapping patterns and edge cases
let complex_id_pattern = PatternFilters::new()
.add_filter(PositionFilter::new(
PositionRange::Multiple(vec![0, 3, 6, 8]), // Letter positions
CharacterFilter::Alphabetic,
))
.add_filter(PositionFilter::new(
PositionRange::Multiple(vec![1, 4, 7, 9]), // Number positions
CharacterFilter::Numeric,
))
.add_filter(PositionFilter::new(
PositionRange::Multiple(vec![2, 5]), // Dashes
CharacterFilter::Exact('-'),
))
.add_filter(PositionFilter::new(
PositionRange::Single(5), // Special case: override dash with letter C
CharacterFilter::Alphabetic, // This creates an interesting edge case
));
Some(ValidationConfigBuilder::new()
.with_pattern_filters(complex_id_pattern)
.with_max_length(10) // A1-B2C-3D4E = 10 characters
.build())
}
7 => {
// 🚀 Custom Pattern - Advanced logic with custom function
// This showcases: Custom validation function for complex rules
let custom_pattern = PatternFilters::new()
.add_filter(PositionFilter::new(
PositionRange::From(0),
CharacterFilter::Custom(Arc::new(|c| {
// Advanced rule: Alternating vowels and consonants!
// Even positions (0,2,4...): vowels (a,e,i,o,u)
// Odd positions (1,3,5...): consonants
let vowels = ['a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U'];
// For demo purposes, we'll just accept alphabetic characters
// In real usage, you'd implement the alternating logic based on position
c.is_alphabetic()
})),
));
Some(ValidationConfigBuilder::new()
.with_pattern_filters(custom_pattern)
.with_max_length(12) // Allow up to 12 characters
.build())
}
_ => None,
}
}
}
// Key handling (same structure as before)
fn handle_key_press(
key: KeyCode,
modifiers: KeyModifiers,
editor: &mut AdvancedPatternFormEditor<AdvancedPatternData>,
) -> anyhow::Result<bool> {
let mode = editor.mode();
// Quit handling
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL))
|| (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL))
|| key == KeyCode::F(10)
{
return Ok(false);
}
match (mode, key, modifiers) {
// Mode transitions
(AppMode::ReadOnly, KeyCode::Char('i'), _) => { editor.enter_edit_mode(); editor.clear_command_buffer(); }
(AppMode::ReadOnly, KeyCode::Char('a'), _) => { editor.enter_append_mode(); editor.clear_command_buffer(); }
(AppMode::ReadOnly, KeyCode::Char('A'), _) => { editor.move_line_end(); editor.enter_edit_mode(); editor.clear_command_buffer(); }
(_, KeyCode::Esc, _) => { if mode == AppMode::Edit { editor.exit_edit_mode(); } else { editor.clear_command_buffer(); } }
// Validation commands
(AppMode::ReadOnly, KeyCode::F(1), _) => { editor.toggle_validation(); }
// Movement in ReadOnly mode
(AppMode::ReadOnly, KeyCode::Char('h'), _) | (AppMode::ReadOnly, KeyCode::Left, _) => { editor.move_left(); editor.clear_command_buffer(); }
(AppMode::ReadOnly, KeyCode::Char('l'), _) | (AppMode::ReadOnly, KeyCode::Right, _) => { editor.move_right(); editor.clear_command_buffer(); }
(AppMode::ReadOnly, KeyCode::Char('j'), _) | (AppMode::ReadOnly, KeyCode::Down, _) => { editor.move_down(); editor.clear_command_buffer(); }
(AppMode::ReadOnly, KeyCode::Char('k'), _) | (AppMode::ReadOnly, KeyCode::Up, _) => { editor.move_up(); editor.clear_command_buffer(); }
// Movement in Edit mode
(AppMode::Edit, KeyCode::Left, _) => { editor.move_left(); }
(AppMode::Edit, KeyCode::Right, _) => { editor.move_right(); }
(AppMode::Edit, KeyCode::Up, _) => { editor.move_up(); }
(AppMode::Edit, KeyCode::Down, _) => { editor.move_down(); }
// Delete operations
(AppMode::Edit, KeyCode::Backspace, _) => { editor.delete_backward()?; }
(AppMode::Edit, KeyCode::Delete, _) => { editor.delete_forward()?; }
// Tab navigation
(_, KeyCode::Tab, _) => { editor.next_field(); }
(_, KeyCode::BackTab, _) => { editor.prev_field(); }
// Character input
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
editor.insert_char(c)?;
}
// Debug info
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
let summary = editor.editor.validation_summary();
editor.set_debug_message(format!(
"Field {}/{}, Pos {}, Mode: {:?}, Advanced patterns: {} configured",
editor.current_field() + 1,
editor.data_provider().field_count(),
editor.cursor_position(),
editor.mode(),
summary.total_fields
));
}
_ => {
if editor.has_pending_command() {
editor.clear_command_buffer();
editor.set_debug_message("Invalid command sequence".to_string());
}
}
}
Ok(true)
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut editor: AdvancedPatternFormEditor<AdvancedPatternData>,
) -> io::Result<()> {
loop {
terminal.draw(|f| ui(f, &editor))?;
if let Event::Key(key) = event::read()? {
match handle_key_press(key.code, key.modifiers, &mut editor) {
Ok(should_continue) => {
if !should_continue {
break;
}
}
Err(e) => {
editor.set_debug_message(format!("Error: {e}"));
}
}
}
}
Ok(())
}
fn ui(f: &mut Frame, editor: &AdvancedPatternFormEditor<AdvancedPatternData>) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(15)])
.split(f.area());
render_canvas_default(f, chunks[0], &editor.editor);
render_advanced_validation_status(f, chunks[1], editor);
}
fn render_advanced_validation_status(
f: &mut Frame,
area: Rect,
editor: &AdvancedPatternFormEditor<AdvancedPatternData>,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Status bar
Constraint::Length(5), // Validation summary
Constraint::Length(7), // Help
])
.split(area);
// Status bar
let mode_text = match editor.mode() {
AppMode::Edit => "INSERT | (bar cursor)",
AppMode::ReadOnly => "NORMAL █ (block cursor)",
_ => "NORMAL █ (block cursor)",
};
let validation_status = editor.get_validation_status();
let status_text = format!("-- {} -- {} | Advanced Patterns: {}", mode_text, editor.debug_message(), validation_status);
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("🚀 Advanced Pattern Validation"));
f.render_widget(status, chunks[0]);
// Enhanced validation summary
let summary = editor.editor.validation_summary();
let field_info = match editor.current_field() {
0 => "Time format (HH:MM) - Tests exact chars + numeric ranges",
1 => "Hex color (#RRGGBB) - Tests OneOf filter with case insensitive",
2 => "IPv4 address - Tests complex dot positioning",
3 => "Product code (ABC-123-XYZ) - Tests section-based patterns",
4 => "Date code (2024W15) - Tests From position filtering",
5 => "Binary input - Tests limited character set (0,1 only)",
6 => "Complex ID - Tests overlapping/conflicting rules",
7 => "Custom pattern - Tests advanced custom validation logic",
_ => "Unknown field",
};
let summary_text = if editor.validation_enabled {
format!(
"📊 Advanced Pattern Summary: {} fields with complex rules\n\
Current Field: {}\n\
✅ Valid: {} ⚠️ Warnings: {} ❌ Errors: {} 📈 Progress: {:.0}%\n\
🎯 Pattern Focus: {}",
summary.total_fields,
editor.current_field() + 1,
summary.valid_fields,
summary.warning_fields,
summary.error_fields,
summary.completion_percentage() * 100.0,
field_info
)
} else {
"❌ Advanced pattern validation is DISABLED\nPress F1 to enable and see the magic!".to_string()
};
let summary_style = if summary.has_errors() {
Style::default().fg(Color::Red)
} else if summary.has_warnings() {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Green)
};
let validation_summary = Paragraph::new(summary_text)
.block(Block::default().borders(Borders::ALL).title("🎯 Advanced Pattern Analysis"))
.style(summary_style)
.wrap(Wrap { trim: true });
f.render_widget(validation_summary, chunks[1]);
// Enhanced help text
let help_text = match editor.mode() {
AppMode::ReadOnly => {
"🚀 ADVANCED PATTERN SHOWCASE - Each field demonstrates different edge cases!\n\
🕐 Time: Numeric+exact chars 🎨 Hex: OneOf with case-insensitive 🌐 IPv4: Complex positioning\n\
🏷️ Product: Multi-section rules 📅 Date: From-position filtering 🔢 Binary: Limited charset\n\
🎯 Complex: Overlapping rules 🚀 Custom: Advanced logic functions\n\
\n\
Movement: hjkl/arrows=move, Tab/Shift+Tab=fields, i/a=insert, F1=toggle, ?=info"
}
AppMode::Edit => {
"✏️ INSERT MODE - Testing advanced pattern validation!\n\
Each character is validated against complex rules in real-time\n\
Try entering invalid characters to see detailed error messages\n\
arrows=move, Backspace/Del=delete, Esc=normal, Tab=next field"
}
_ => "🚀 Advanced Pattern Validation Active!"
};
let help = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title("🎯 Advanced Pattern Commands & Info"))
.style(Style::default().fg(Color::Gray))
.wrap(Wrap { trim: true });
f.render_widget(help, chunks[2]);
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🚀 Canvas Advanced Pattern Validation Demo");
println!("✅ validation feature: ENABLED");
println!("✅ gui feature: ENABLED");
println!("✅ cursor-style feature: ENABLED");
println!("🎯 Advanced pattern filtering: ACTIVE");
println!("🧪 Edge cases and complex patterns: READY");
println!("💡 Each field showcases different validation capabilities!");
println!();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let data = AdvancedPatternData::new();
let mut editor = AdvancedPatternFormEditor::new(data);
// Initialize with normal mode - library automatically sets block cursor
editor.set_mode(AppMode::ReadOnly);
// Demonstrate that CursorManager is available and working
CursorManager::update_for_mode(AppMode::ReadOnly)?;
let res = run_app(&mut terminal, editor);
// Library automatically resets cursor on FormEditor::drop()
// But we can also manually reset if needed
CursorManager::reset()?;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{err:?}");
}
println!("🚀 Advanced pattern validation demo completed!");
println!("🎯 Hope you enjoyed seeing all the edge cases in action!");
Ok(())
}

View File

@@ -1,734 +0,0 @@
// examples/validation_3.rs
//! Comprehensive Display Mask Features Demo
//!
//! This example showcases the full power of the display mask system (Feature 3)
//! demonstrating visual formatting that keeps business logic clean.
//!
//! Key Features Demonstrated:
//! - Dynamic vs Template display modes
//! - Custom patterns for different data types
//! - Custom input characters and separators
//! - Custom placeholder characters
//! - Real-time visual formatting with clean raw data
//! - Cursor movement through formatted displays
//! - 🔥 CRITICAL: Perfect mask/character-limit coordination to prevent invisible character bugs
//!
//! ⚠️ IMPORTANT BUG PREVENTION:
//! This example demonstrates the CORRECT way to configure masks with character limits.
//! Each mask's input position count EXACTLY matches its character limit to prevent
//! the critical bug where users can type more characters than they can see.
//!
//! Run with: cargo run --example validation_3 --features "gui,validation,cursor-style"
// REQUIRE validation, gui and cursor-style features for mask functionality
#[cfg(not(all(feature = "validation", feature = "gui", feature = "cursor-style")))]
compile_error!(
"This example requires the 'validation', 'gui' and 'cursor-style' features. \
Run with: cargo run --example validation_3 --features \"gui,validation,cursor-style\""
);
use std::io;
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
},
execute,
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
Frame, Terminal,
};
use canvas::{
canvas::{
gui::render_canvas_default,
modes::AppMode,
CursorManager,
},
DataProvider, FormEditor,
ValidationConfig, ValidationConfigBuilder, DisplayMask,
};
// Enhanced FormEditor wrapper for mask demonstration
struct MaskDemoFormEditor<D: DataProvider> {
editor: FormEditor<D>,
debug_message: String,
command_buffer: String,
validation_enabled: bool,
show_raw_data: bool,
}
impl<D: DataProvider> MaskDemoFormEditor<D> {
fn new(data_provider: D) -> Self {
let mut editor = FormEditor::new(data_provider);
editor.set_validation_enabled(true);
Self {
editor,
debug_message: "🎭 Display Mask Demo - Visual formatting with clean business logic!".to_string(),
command_buffer: String::new(),
validation_enabled: true,
show_raw_data: false,
}
}
// === COMMAND BUFFER HANDLING ===
fn clear_command_buffer(&mut self) { self.command_buffer.clear(); }
fn add_to_command_buffer(&mut self, ch: char) { self.command_buffer.push(ch); }
fn get_command_buffer(&self) -> &str { &self.command_buffer }
fn has_pending_command(&self) -> bool { !self.command_buffer.is_empty() }
// === MASK CONTROL ===
fn toggle_validation(&mut self) {
self.validation_enabled = !self.validation_enabled;
self.editor.set_validation_enabled(self.validation_enabled);
if self.validation_enabled {
self.debug_message = "✅ Display Masks ENABLED - See visual formatting in action!".to_string();
} else {
self.debug_message = "❌ Display Masks DISABLED - Raw text only".to_string();
}
}
fn toggle_raw_data_view(&mut self) {
self.show_raw_data = !self.show_raw_data;
if self.show_raw_data {
self.debug_message = "👁️ Showing RAW business data (what's actually stored)".to_string();
} else {
self.debug_message = "🎭 Showing FORMATTED display (what users see)".to_string();
}
}
fn get_current_field_info(&self) -> (String, String, String) {
let field_index = self.editor.current_field();
let raw_data = self.editor.data_provider().field_value(field_index);
let display_data = if self.validation_enabled {
self.editor.current_display_text()
} else {
raw_data.to_string()
};
let mask_info = if let Some(config) = self.editor.validation_state().get_field_config(field_index) {
if let Some(mask) = &config.display_mask {
format!("Pattern: '{}', Mode: {:?}",
mask.pattern(),
mask.display_mode())
} else {
"No mask configured".to_string()
}
} else {
"No validation config".to_string()
};
(raw_data.to_string(), display_data, mask_info)
}
// === ENHANCED MOVEMENT WITH MASK AWARENESS ===
fn move_left(&mut self) {
self.editor.move_left();
self.update_cursor_info();
}
fn move_right(&mut self) {
self.editor.move_right();
self.update_cursor_info();
}
fn move_up(&mut self) {
match self.editor.move_up() {
Ok(()) => { self.update_field_info(); }
Err(e) => { self.debug_message = format!("🚫 Field switch blocked: {e}"); }
}
}
fn move_down(&mut self) {
match self.editor.move_down() {
Ok(()) => { self.update_field_info(); }
Err(e) => { self.debug_message = format!("🚫 Field switch blocked: {e}"); }
}
}
fn move_line_start(&mut self) {
self.editor.move_line_start();
self.update_cursor_info();
}
fn move_line_end(&mut self) {
self.editor.move_line_end();
self.update_cursor_info();
}
fn update_cursor_info(&mut self) {
if self.validation_enabled {
let raw_pos = self.editor.cursor_position();
let display_pos = self.editor.display_cursor_position();
if raw_pos != display_pos {
self.debug_message = format!("📍 Cursor: Raw pos {raw_pos} → Display pos {display_pos} (mask active)");
} else {
self.debug_message = format!("📍 Cursor at position {raw_pos} (no mask offset)");
}
}
}
fn update_field_info(&mut self) {
let field_name = self.editor.data_provider().field_name(self.editor.current_field());
self.debug_message = format!("📝 Switched to: {field_name}");
}
// === MODE TRANSITIONS ===
fn enter_edit_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_edit_mode();
self.debug_message = "✏️ INSERT MODE - Cursor: Steady Bar | - Type to see mask formatting in real-time".to_string();
}
fn enter_append_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_append_mode();
self.debug_message = "✏️ INSERT (append) - Cursor: Steady Bar | - Mask formatting active".to_string();
}
fn exit_edit_mode(&mut self) {
// Library will automatically update cursor to block █ in normal mode
self.editor.exit_edit_mode();
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █ - Press 'r' to see raw data, 'm' for mask info".to_string();
}
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
let result = self.editor.insert_char(ch);
if result.is_ok() {
let (raw, display, _) = self.get_current_field_info();
if raw != display {
self.debug_message = format!("✏️ Added '{ch}': Raw='{raw}' Display='{display}'");
} else {
self.debug_message = format!("✏️ Added '{ch}': '{raw}'");
}
}
result
}
// === DELETE OPERATIONS ===
fn delete_backward(&mut self) -> anyhow::Result<()> {
let result = self.editor.delete_backward();
if result.is_ok() {
self.debug_message = "⌫ Character deleted".to_string();
self.update_cursor_info();
}
result
}
fn delete_forward(&mut self) -> anyhow::Result<()> {
let result = self.editor.delete_forward();
if result.is_ok() {
self.debug_message = "⌦ Character deleted".to_string();
self.update_cursor_info();
}
result
}
// === DELEGATE TO ORIGINAL EDITOR ===
fn current_field(&self) -> usize { self.editor.current_field() }
fn cursor_position(&self) -> usize { self.editor.cursor_position() }
fn mode(&self) -> AppMode { self.editor.mode() }
fn current_text(&self) -> &str {
let field_index = self.editor.current_field();
self.editor.data_provider().field_value(field_index)
}
fn data_provider(&self) -> &D { self.editor.data_provider() }
fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() }
fn set_mode(&mut self, mode: AppMode) {
// Library automatically updates cursor for the mode
self.editor.set_mode(mode);
}
fn next_field(&mut self) {
match self.editor.next_field() {
Ok(()) => { self.update_field_info(); }
Err(e) => { self.debug_message = format!("🚫 Cannot move to next field: {e}"); }
}
}
fn prev_field(&mut self) {
match self.editor.prev_field() {
Ok(()) => { self.update_field_info(); }
Err(e) => { self.debug_message = format!("🚫 Cannot move to previous field: {e}"); }
}
}
// === STATUS AND DEBUG ===
fn set_debug_message(&mut self, msg: String) { self.debug_message = msg; }
fn debug_message(&self) -> &str { &self.debug_message }
fn show_mask_details(&mut self) {
let (raw, display, mask_info) = self.get_current_field_info();
self.debug_message = format!("🔍 Field {}: {} | Raw: '{}' Display: '{}'",
self.current_field() + 1, mask_info, raw, display);
}
fn get_mask_status(&self) -> String {
if !self.validation_enabled {
return "❌ DISABLED".to_string();
}
let field_count = self.editor.data_provider().field_count();
let mut mask_count = 0;
for i in 0..field_count {
if let Some(config) = self.editor.validation_state().get_field_config(i) {
if config.display_mask.is_some() {
mask_count += 1;
}
}
}
format!("🎭 {mask_count} MASKS")
}
}
// Demo data with comprehensive mask examples
struct MaskDemoData {
fields: Vec<(String, String)>,
}
impl MaskDemoData {
fn new() -> Self {
Self {
fields: vec![
("📞 Phone (Dynamic)".to_string(), "".to_string()),
("📞 Phone (Template)".to_string(), "".to_string()),
("📅 Date US (MM/DD/YYYY)".to_string(), "".to_string()),
("📅 Date EU (DD.MM.YYYY)".to_string(), "".to_string()),
("📅 Date ISO (YYYY-MM-DD)".to_string(), "".to_string()),
("🏛️ SSN (XXX-XX-XXXX)".to_string(), "".to_string()),
("💳 Credit Card".to_string(), "".to_string()),
("🏢 Employee ID (EMP-####)".to_string(), "".to_string()),
("📦 Product Code (ABC###XYZ)".to_string(), "".to_string()),
("🌈 Custom Separators".to_string(), "".to_string()),
("⭐ Custom Placeholders".to_string(), "".to_string()),
("🎯 Mixed Input Chars".to_string(), "".to_string()),
],
}
}
}
impl DataProvider for MaskDemoData {
fn field_count(&self) -> usize { self.fields.len() }
fn field_name(&self, index: usize) -> &str { &self.fields[index].0 }
fn field_value(&self, index: usize) -> &str { &self.fields[index].1 }
fn set_field_value(&mut self, index: usize, value: String) { self.fields[index].1 = value; }
fn validation_config(&self, field_index: usize) -> Option<ValidationConfig> {
match field_index {
0 => {
// 📞 Phone (Dynamic) - FIXED: Perfect mask/limit coordination
let phone_mask = DisplayMask::new("(###) ###-####", '#');
Some(ValidationConfigBuilder::new()
.with_display_mask(phone_mask)
.with_max_length(10) // ✅ CRITICAL: Exactly matches 10 input positions
.build())
}
1 => {
// 📞 Phone (Template) - FIXED: Perfect mask/limit coordination
let phone_template = DisplayMask::new("(###) ###-####", '#')
.with_template('_');
Some(ValidationConfigBuilder::new()
.with_display_mask(phone_template)
.with_max_length(10) // ✅ CRITICAL: Exactly matches 10 input positions
.build())
}
2 => {
// 📅 Date US (MM/DD/YYYY) - American date format
let us_date = DisplayMask::new("##/##/####", '#');
Some(ValidationConfig::with_mask(us_date))
}
3 => {
// 📅 Date EU (DD.MM.YYYY) - European date format with dots
let eu_date = DisplayMask::new("##.##.####", '#')
.with_template('•');
Some(ValidationConfig::with_mask(eu_date))
}
4 => {
// 📅 Date ISO (YYYY-MM-DD) - ISO date format
let iso_date = DisplayMask::new("####-##-##", '#')
.with_template('-');
Some(ValidationConfig::with_mask(iso_date))
}
5 => {
// 🏛️ SSN using custom input character 'X' - FIXED: Perfect coordination
let ssn_mask = DisplayMask::new("XXX-XX-XXXX", 'X');
Some(ValidationConfigBuilder::new()
.with_display_mask(ssn_mask)
.with_max_length(9) // ✅ CRITICAL: Exactly matches 9 input positions
.build())
}
6 => {
// 💳 Credit Card (16 digits with spaces) - FIXED: Perfect coordination
let cc_mask = DisplayMask::new("#### #### #### ####", '#')
.with_template('•');
Some(ValidationConfigBuilder::new()
.with_display_mask(cc_mask)
.with_max_length(16) // ✅ CRITICAL: Exactly matches 16 input positions
.build())
}
7 => {
// 🏢 Employee ID with business prefix
let emp_id = DisplayMask::new("EMP-####", '#');
Some(ValidationConfig::with_mask(emp_id))
}
8 => {
// 📦 Product Code with mixed letters and numbers
let product_code = DisplayMask::new("ABC###XYZ", '#');
Some(ValidationConfig::with_mask(product_code))
}
9 => {
// 🌈 Custom Separators - Using | and ~ as separators
let custom_sep = DisplayMask::new("##|##~####", '#')
.with_template('?');
Some(ValidationConfig::with_mask(custom_sep))
}
10 => {
// ⭐ Custom Placeholders - Using different placeholder characters
let custom_placeholder = DisplayMask::new("##-##-##", '#')
.with_template('★');
Some(ValidationConfig::with_mask(custom_placeholder))
}
11 => {
// 🎯 Mixed Input Characters - Using 'N' for numbers
let mixed_input = DisplayMask::new("ID:NNN-NNN", 'N');
Some(ValidationConfig::with_mask(mixed_input))
}
_ => None,
}
}
}
// Enhanced key handling with mask-specific commands
fn handle_key_press(
key: KeyCode,
modifiers: KeyModifiers,
editor: &mut MaskDemoFormEditor<MaskDemoData>,
) -> anyhow::Result<bool> {
let mode = editor.mode();
// Quit handling
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL))
|| (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL))
|| key == KeyCode::F(10)
{
return Ok(false);
}
match (mode, key, modifiers) {
// === MODE TRANSITIONS ===
(AppMode::ReadOnly, KeyCode::Char('i'), _) => {
editor.enter_edit_mode();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
editor.enter_append_mode();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('A'), _) => {
editor.move_line_end();
editor.enter_edit_mode();
editor.clear_command_buffer();
}
// Escape: Exit edit mode
(_, KeyCode::Esc, _) => {
if mode == AppMode::Edit {
editor.exit_edit_mode();
} else {
editor.clear_command_buffer();
}
}
// === MASK SPECIFIC COMMANDS ===
(AppMode::ReadOnly, KeyCode::Char('m'), _) => {
editor.show_mask_details();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('r'), _) => {
editor.toggle_raw_data_view();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::F(1), _) => {
editor.toggle_validation();
}
// === MOVEMENT ===
(AppMode::ReadOnly, KeyCode::Char('h'), _) | (AppMode::ReadOnly, KeyCode::Left, _) => {
editor.move_left();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('l'), _) | (AppMode::ReadOnly, KeyCode::Right, _) => {
editor.move_right();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('j'), _) | (AppMode::ReadOnly, KeyCode::Down, _) => {
editor.move_down();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('k'), _) | (AppMode::ReadOnly, KeyCode::Up, _) => {
editor.move_up();
editor.clear_command_buffer();
}
// Line movement
(AppMode::ReadOnly, KeyCode::Char('0'), _) => {
editor.move_line_start();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('$'), _) => {
editor.move_line_end();
editor.clear_command_buffer();
}
// === EDIT MODE MOVEMENT ===
(AppMode::Edit, KeyCode::Left, _) => { editor.move_left(); }
(AppMode::Edit, KeyCode::Right, _) => { editor.move_right(); }
(AppMode::Edit, KeyCode::Up, _) => { editor.move_up(); }
(AppMode::Edit, KeyCode::Down, _) => { editor.move_down(); }
// === DELETE OPERATIONS ===
(AppMode::Edit, KeyCode::Backspace, _) => { editor.delete_backward()?; }
(AppMode::Edit, KeyCode::Delete, _) => { editor.delete_forward()?; }
// === TAB NAVIGATION ===
(_, KeyCode::Tab, _) => { editor.next_field(); }
(_, KeyCode::BackTab, _) => { editor.prev_field(); }
// === CHARACTER INPUT ===
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
editor.insert_char(c)?;
}
// === DEBUG/INFO COMMANDS ===
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
let (raw, display, mask_info) = editor.get_current_field_info();
editor.set_debug_message(format!(
"Field {}/{}, Cursor {}, {}, Raw: '{}', Display: '{}'",
editor.current_field() + 1,
editor.data_provider().field_count(),
editor.cursor_position(),
mask_info,
raw,
display
));
}
_ => {
if editor.has_pending_command() {
editor.clear_command_buffer();
editor.set_debug_message("Invalid command sequence".to_string());
}
}
}
Ok(true)
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut editor: MaskDemoFormEditor<MaskDemoData>,
) -> io::Result<()> {
loop {
terminal.draw(|f| ui(f, &editor))?;
if let Event::Key(key) = event::read()? {
match handle_key_press(key.code, key.modifiers, &mut editor) {
Ok(should_continue) => {
if !should_continue {
break;
}
}
Err(e) => {
editor.set_debug_message(format!("Error: {e}"));
}
}
}
}
Ok(())
}
fn ui(f: &mut Frame, editor: &MaskDemoFormEditor<MaskDemoData>) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(16)])
.split(f.area());
render_enhanced_canvas(f, chunks[0], editor);
render_mask_status(f, chunks[1], editor);
}
fn render_enhanced_canvas(
f: &mut Frame,
area: Rect,
editor: &MaskDemoFormEditor<MaskDemoData>,
) {
render_canvas_default(f, area, &editor.editor);
}
fn render_mask_status(
f: &mut Frame,
area: Rect,
editor: &MaskDemoFormEditor<MaskDemoData>,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Status bar
Constraint::Length(6), // Data comparison
Constraint::Length(7), // Help
])
.split(area);
// Status bar with mask information
let mode_text = match editor.mode() {
AppMode::Edit => "INSERT | (bar cursor)",
AppMode::ReadOnly => "NORMAL █ (block cursor)",
_ => "NORMAL █ (block cursor)",
};
let mask_status = editor.get_mask_status();
let status_text = format!("-- {} -- {} | Masks: {} | View: {}",
mode_text,
editor.debug_message(),
mask_status,
if editor.show_raw_data { "RAW" } else { "FORMATTED" });
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("🎭 Display Mask Demo"));
f.render_widget(status, chunks[0]);
// Data comparison showing raw vs display
let (raw_data, display_data, mask_info) = editor.get_current_field_info();
let field_name = editor.data_provider().field_name(editor.current_field());
let comparison_text = format!(
"📝 Current Field: {}\n\
🔧 Mask Config: {}\n\
\n\
💾 Raw Business Data: '{}' ← What's actually stored in your database\n\
🎭 Formatted Display: '{}' ← What users see in the interface\n\
📍 Cursor: Raw pos {} → Display pos {}",
field_name,
mask_info,
raw_data,
display_data,
editor.cursor_position(),
editor.editor.display_cursor_position()
);
let comparison_style = if raw_data != display_data {
Style::default().fg(Color::Green) // Green when mask is active
} else {
Style::default().fg(Color::Gray) // Gray when no formatting
};
let data_comparison = Paragraph::new(comparison_text)
.block(Block::default().borders(Borders::ALL).title("📊 Raw Data vs Display Formatting"))
.style(comparison_style)
.wrap(Wrap { trim: true });
f.render_widget(data_comparison, chunks[1]);
// Enhanced help text
let help_text = match editor.mode() {
AppMode::ReadOnly => {
"🎯 CURSOR-STYLE: Normal █ | Insert |\n\
🎭 MASK DEMO: Visual formatting keeps business logic clean!\n\
\n\
📱 Try different fields to see various mask patterns:\n\
• Dynamic vs Template modes • Custom separators • Different input chars\n\
\n\
Commands: i/a=insert, m=mask details, r=toggle raw/display view\n\
Movement: hjkl/arrows=move, 0/$=line start/end, Tab=next field, F1=toggle masks\n\
?=detailed info, Ctrl+C=quit"
}
AppMode::Edit => {
"🎯 INSERT MODE - Cursor: | (bar)\n\
✏️ Type to see real-time mask formatting!\n\
\n\
🔥 Key Features in Action:\n\
• Separators auto-appear as you type • Cursor skips over separators\n\
• Template fields show placeholders • Raw data stays clean for business logic\n\
\n\
arrows=move through mask, Backspace/Del=delete, Esc=normal, Tab=next field\n\
Notice how cursor position maps between raw data and display!"
}
_ => "🎭 Display Mask Demo Active!"
};
let help = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title("🚀 Mask Features & Commands"))
.style(Style::default().fg(Color::Gray))
.wrap(Wrap { trim: true });
f.render_widget(help, chunks[2]);
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Print feature status
println!("🎭 Canvas Display Mask Demo (Feature 3)");
println!("✅ validation feature: ENABLED");
println!("✅ gui feature: ENABLED");
println!("🎭 Display masks: ACTIVE");
println!("✅ cursor-style feature: ENABLED");
println!("🔥 Key Benefits Demonstrated:");
println!(" • Clean separation: Visual formatting ≠ Business logic");
println!(" • User-friendly: Pretty displays with automatic cursor handling");
println!(" • Flexible: Custom patterns, separators, and placeholders");
println!(" • Transparent: Library handles all complexity, API stays simple");
println!();
println!("💡 Try typing in different fields to see mask magic!");
println!(" 📞 Phone fields show dynamic vs template modes");
println!(" 📅 Date fields show different regional formats");
println!(" 💳 Credit card shows spaced formatting");
println!(" ⭐ Custom fields show advanced separator/placeholder options");
println!();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let data = MaskDemoData::new();
let mut editor = MaskDemoFormEditor::new(data);
// Initialize with normal mode - library automatically sets block cursor
editor.set_mode(AppMode::ReadOnly);
// Demonstrate that CursorManager is available and working
CursorManager::update_for_mode(AppMode::ReadOnly)?;
let res = run_app(&mut terminal, editor);
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
// Library automatically resets cursor on FormEditor::drop()
// But we can also manually reset if needed
CursorManager::reset()?;
if let Err(err) = res {
println!("{err:?}");
}
println!("🎭 Display mask demo completed!");
println!("🏆 You've seen how masks provide beautiful UX while keeping business logic clean!");
Ok(())
}

View File

@@ -1,755 +0,0 @@
/* examples/validation_4.rs
Enhanced Feature 4 Demo: Multiple custom formatters with comprehensive edge cases
Demonstrates:
- Multiple formatter types: PSC, Phone, Credit Card, Date
- Edge case handling: incomplete input, invalid chars, overflow
- Real-time validation feedback and format preview
- Advanced cursor position mapping
- Raw vs formatted data separation
- Error handling and fallback behavior
*/
#![allow(clippy::needless_return)]
#[cfg(not(all(feature = "validation", feature = "gui", feature = "cursor-style")))]
compile_error!(
"This example requires the 'validation', 'gui' and 'cursor-style' features. \
Run with: cargo run --example validation_4 --features \"gui,validation,cursor-style\""
);
use std::io;
use std::sync::Arc;
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
Frame, Terminal,
};
use canvas::{
canvas::{gui::render_canvas_default, modes::AppMode, CursorManager},
DataProvider, FormEditor,
ValidationConfig, ValidationConfigBuilder,
CustomFormatter, FormattingResult,
};
/// PSC (Postal Code) Formatter: "01001" -> "010 01"
struct PSCFormatter;
impl CustomFormatter for PSCFormatter {
fn format(&self, raw: &str) -> FormattingResult {
if raw.is_empty() {
return FormattingResult::success("");
}
// Validate: only digits allowed
if !raw.chars().all(|c| c.is_ascii_digit()) {
return FormattingResult::error("PSC must contain only digits");
}
let len = raw.chars().count();
match len {
0 => FormattingResult::success(""),
1..=3 => FormattingResult::success(raw),
4 => FormattingResult::warning(
format!("{} ", &raw[..3]),
"PSC incomplete (4/5 digits)"
),
5 => {
let formatted = format!("{} {}", &raw[..3], &raw[3..]);
if raw == "00000" {
FormattingResult::warning(formatted, "Invalid PSC: 00000")
} else {
FormattingResult::success(formatted)
}
},
_ => FormattingResult::error("PSC too long (max 5 digits)"),
}
}
}
/// Phone Number Formatter: "1234567890" -> "(123) 456-7890"
struct PhoneFormatter;
impl CustomFormatter for PhoneFormatter {
fn format(&self, raw: &str) -> FormattingResult {
if raw.is_empty() {
return FormattingResult::success("");
}
// Only digits allowed
if !raw.chars().all(|c| c.is_ascii_digit()) {
return FormattingResult::error("Phone must contain only digits");
}
let len = raw.chars().count();
match len {
0 => FormattingResult::success(""),
1..=3 => FormattingResult::success(format!("({raw})")),
4..=6 => FormattingResult::success(format!("({}) {}", &raw[..3], &raw[3..])),
7..=10 => FormattingResult::success(format!("({}) {}-{}", &raw[..3], &raw[3..6], &raw[6..])),
10 => {
let formatted = format!("({}) {}-{}", &raw[..3], &raw[3..6], &raw[6..]);
FormattingResult::success(formatted)
},
_ => FormattingResult::warning(
format!("({}) {}-{}", &raw[..3], &raw[3..6], &raw[6..10]),
"Phone too long (extra digits ignored)"
),
}
}
}
/// Credit Card Formatter: "1234567890123456" -> "1234 5678 9012 3456"
struct CreditCardFormatter;
impl CustomFormatter for CreditCardFormatter {
fn format(&self, raw: &str) -> FormattingResult {
if raw.is_empty() {
return FormattingResult::success("");
}
if !raw.chars().all(|c| c.is_ascii_digit()) {
return FormattingResult::error("Card number must contain only digits");
}
let mut formatted = String::new();
for (i, ch) in raw.chars().enumerate() {
if i > 0 && i % 4 == 0 {
formatted.push(' ');
}
formatted.push(ch);
}
let len = raw.chars().count();
match len {
0..=15 => FormattingResult::warning(formatted, format!("Card incomplete ({len}/16 digits)")),
16 => FormattingResult::success(formatted),
_ => FormattingResult::warning(formatted, "Card too long (extra digits shown)"),
}
}
}
/// Date Formatter: "12012024" -> "12/01/2024"
struct DateFormatter;
impl CustomFormatter for DateFormatter {
fn format(&self, raw: &str) -> FormattingResult {
if raw.is_empty() {
return FormattingResult::success("");
}
if !raw.chars().all(|c| c.is_ascii_digit()) {
return FormattingResult::error("Date must contain only digits");
}
let len = raw.len();
match len {
0 => FormattingResult::success(""),
1..=2 => FormattingResult::success(raw.to_string()),
3..=4 => FormattingResult::success(format!("{}/{}", &raw[..2], &raw[2..])),
5..=8 => FormattingResult::success(format!("{}/{}/{}", &raw[..2], &raw[2..4], &raw[4..])),
8 => {
let month = &raw[..2];
let day = &raw[2..4];
let year = &raw[4..];
// Basic validation
let m: u32 = month.parse().unwrap_or(0);
let d: u32 = day.parse().unwrap_or(0);
if m == 0 || m > 12 {
FormattingResult::warning(
format!("{month}/{day}/{year}"),
"Invalid month (01-12)"
)
} else if d == 0 || d > 31 {
FormattingResult::warning(
format!("{month}/{day}/{year}"),
"Invalid day (01-31)"
)
} else {
FormattingResult::success(format!("{month}/{day}/{year}"))
}
},
_ => FormattingResult::error("Date too long (MMDDYYYY format)"),
}
}
}
// Enhanced demo data with multiple formatter types
struct MultiFormatterDemoData {
fields: Vec<(String, String)>,
}
impl MultiFormatterDemoData {
fn new() -> Self {
Self {
fields: vec![
("🏁 PSC (01001)".to_string(), "".to_string()),
("📞 Phone (1234567890)".to_string(), "".to_string()),
("💳 Credit Card (16 digits)".to_string(), "".to_string()),
("📅 Date (12012024)".to_string(), "".to_string()),
("📝 Plain Text".to_string(), "".to_string()),
],
}
}
}
impl DataProvider for MultiFormatterDemoData {
fn field_count(&self) -> usize {
self.fields.len()
}
fn field_name(&self, index: usize) -> &str {
&self.fields[index].0
}
fn field_value(&self, index: usize) -> &str {
&self.fields[index].1
}
fn set_field_value(&mut self, index: usize, value: String) {
self.fields[index].1 = value;
}
#[cfg(feature = "validation")]
fn validation_config(&self, field_index: usize) -> Option<ValidationConfig> {
match field_index {
0 => Some(ValidationConfigBuilder::new()
.with_custom_formatter(Arc::new(PSCFormatter))
.with_max_length(5)
.build()),
1 => Some(ValidationConfigBuilder::new()
.with_custom_formatter(Arc::new(PhoneFormatter))
.with_max_length(12)
.build()),
2 => Some(ValidationConfigBuilder::new()
.with_custom_formatter(Arc::new(CreditCardFormatter))
.with_max_length(20)
.build()),
3 => Some(ValidationConfigBuilder::new()
.with_custom_formatter(Arc::new(DateFormatter))
.with_max_length(8)
.build()),
4 => Some(ValidationConfigBuilder::new()
.with_custom_formatter(Arc::new(DateFormatter))
.with_max_length(8)
.build()),
_ => None, // Plain text field - no formatter
}
}
}
// Enhanced demo editor with comprehensive status tracking
struct EnhancedDemoEditor<D: DataProvider> {
editor: FormEditor<D>,
debug_message: String,
validation_enabled: bool,
show_raw_data: bool,
show_cursor_details: bool,
example_mode: usize,
}
impl<D: DataProvider> EnhancedDemoEditor<D> {
fn new(data_provider: D) -> Self {
let mut editor = FormEditor::new(data_provider);
editor.set_validation_enabled(true);
Self {
editor,
debug_message: "🧩 Enhanced Custom Formatter Demo - Multiple formatters with rich edge cases!".to_string(),
validation_enabled: true,
show_raw_data: false,
show_cursor_details: false,
example_mode: 0,
}
}
// Field type detection
fn current_field_type(&self) -> &'static str {
match self.editor.current_field() {
0 => "PSC",
1 => "Phone",
2 => "Credit Card",
3 => "Date",
_ => "Plain Text",
}
}
fn has_formatter(&self) -> bool {
self.editor.current_field() < 5 // First 5 fields have formatters
}
fn get_input_rules(&self) -> &'static str {
match self.editor.current_field() {
0 => "5 digits only (PSC format)",
1 => "10+ digits (US phone format)",
2 => "16+ digits (credit card)",
3 => "Digits as cents (12345 = $123.45)",
4 => "8 digits MMDDYYYY (date format)",
_ => "Any text (no formatting)",
}
}
fn cycle_example_data(&mut self) {
let examples = [
// PSC examples
vec!["01001", "1234567890", "1234567890123456", "12345", "12012024", "Plain text here"],
// Incomplete examples
vec!["010", "123", "1234", "123", "1201", "More text"],
// Invalid examples (will show error handling)
vec!["0abc1", "12a45", "123abc", "abc", "ab01cd", "Special chars!"],
// Edge cases
vec!["00000", "0000000000", "0000000000000000", "99", "13012024", ""],
];
self.example_mode = (self.example_mode + 1) % examples.len();
let current_examples = &examples[self.example_mode];
for (i, example) in current_examples.iter().enumerate() {
if i < self.editor.data_provider().field_count() {
self.editor.data_provider_mut().set_field_value(i, example.to_string());
}
}
let mode_names = ["Valid Examples", "Incomplete Input", "Invalid Characters", "Edge Cases"];
self.debug_message = format!("📋 Loaded: {}", mode_names[self.example_mode]);
}
// Enhanced status methods
fn toggle_validation(&mut self) {
self.validation_enabled = !self.validation_enabled;
self.editor.set_validation_enabled(self.validation_enabled);
self.debug_message = if self.validation_enabled {
"✅ Custom Formatters ENABLED".to_string()
} else {
"❌ Custom Formatters DISABLED".to_string()
};
}
fn toggle_raw_data_view(&mut self) {
self.show_raw_data = !self.show_raw_data;
self.debug_message = if self.show_raw_data {
"👁️ Showing RAW data focus".to_string()
} else {
"✨ Showing FORMATTED display focus".to_string()
};
}
fn toggle_cursor_details(&mut self) {
self.show_cursor_details = !self.show_cursor_details;
self.debug_message = if self.show_cursor_details {
"📍 Detailed cursor mapping info ON".to_string()
} else {
"📍 Detailed cursor mapping info OFF".to_string()
};
}
fn get_current_field_analysis(&self) -> (String, String, String, Option<String>) {
let field_index = self.editor.current_field();
let raw = self.editor.data_provider().field_value(field_index);
let display = self.editor.current_display_text();
let status = if raw == display {
if self.has_formatter() {
if self.mode() == AppMode::Edit {
"Raw (editing)".to_string()
} else {
"No formatting needed".to_string()
}
} else {
"No formatter".to_string()
}
} else {
"Custom formatted".to_string()
};
let warning = if self.validation_enabled && self.has_formatter() {
// Check if there are any formatting warnings
if !raw.is_empty() {
match self.editor.current_field() {
0 if raw.len() < 5 => Some(format!("PSC incomplete: {}/5", raw.len())),
1 if raw.len() < 10 => Some(format!("Phone incomplete: {}/10", raw.len())),
2 if raw.len() < 16 => Some(format!("Card incomplete: {}/16", raw.len())),
4 if raw.len() < 8 => Some(format!("Date incomplete: {}/8", raw.len())),
_ => None,
}
} else {
None
}
} else {
None
};
(raw.to_string(), display, status, warning)
}
// Delegate methods with enhanced feedback
fn enter_edit_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_edit_mode();
let field_type = self.current_field_type();
let rules = self.get_input_rules();
self.debug_message = format!("✏️ INSERT MODE - Cursor: Steady Bar | - {field_type} - {rules}");
}
fn exit_edit_mode(&mut self) {
// Library will automatically update cursor to block █ in normal mode
self.editor.exit_edit_mode();
let (raw, display, _, warning) = self.get_current_field_analysis();
if let Some(warn) = warning {
self.debug_message = format!("🔒 NORMAL - Cursor: Steady Block █ - {} | ⚠️ {}", self.current_field_type(), warn);
} else if raw != display {
self.debug_message = format!("🔒 NORMAL - Cursor: Steady Block █ - {} formatted successfully", self.current_field_type());
} else {
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █".to_string();
}
}
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
let result = self.editor.insert_char(ch);
if result.is_ok() {
let (raw, display, _, _) = self.get_current_field_analysis();
if raw != display && self.validation_enabled {
self.debug_message = format!("✏️ '{ch}' added - Real-time formatting active");
} else {
self.debug_message = format!("✏️ '{ch}' added");
}
}
result
}
// Position mapping demo
fn show_position_mapping(&mut self) {
if !self.has_formatter() {
self.debug_message = "📍 No position mapping (plain text field)".to_string();
return;
}
let raw_pos = self.editor.cursor_position();
let display_pos = self.editor.display_cursor_position();
let field_index = self.editor.current_field();
let raw = self.editor.data_provider().field_value(field_index);
let display = self.editor.current_display_text();
if raw_pos != display_pos {
self.debug_message = format!(
"🗺️ Position mapping: Raw[{}]='{}' ↔ Display[{}]='{}'",
raw_pos,
raw.chars().nth(raw_pos).unwrap_or('∅'),
display_pos,
display.chars().nth(display_pos).unwrap_or('∅')
);
} else {
self.debug_message = format!("📍 Cursor at position {raw_pos} (no mapping needed)");
}
}
// Delegate remaining methods
fn mode(&self) -> AppMode { self.editor.mode() }
fn current_field(&self) -> usize { self.editor.current_field() }
fn cursor_position(&self) -> usize { self.editor.cursor_position() }
fn data_provider(&self) -> &D { self.editor.data_provider() }
fn data_provider_mut(&mut self) -> &mut D { self.editor.data_provider_mut() }
fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() }
fn move_up(&mut self) { let _ = self.editor.move_up(); }
fn move_down(&mut self) { let _ = self.editor.move_down(); }
fn move_left(&mut self) { let _ = self.editor.move_left(); }
fn move_right(&mut self) { let _ = self.editor.move_right(); }
fn delete_backward(&mut self) -> anyhow::Result<()> { self.editor.delete_backward() }
fn delete_forward(&mut self) -> anyhow::Result<()> { self.editor.delete_forward() }
fn next_field(&mut self) { let _ = self.editor.next_field(); }
fn prev_field(&mut self) { let _ = self.editor.prev_field(); }
}
// Enhanced key handling
fn handle_key_press(
key: KeyCode,
modifiers: KeyModifiers,
editor: &mut EnhancedDemoEditor<MultiFormatterDemoData>,
) -> anyhow::Result<bool> {
let mode = editor.mode();
// Quit
if matches!(key, KeyCode::F(10)) ||
(key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL)) ||
(key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) {
return Ok(false);
}
match (mode, key, modifiers) {
// Mode transitions
(AppMode::ReadOnly, KeyCode::Char('i'), _) => editor.enter_edit_mode(),
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
editor.editor.enter_append_mode();
editor.debug_message = format!("✏️ APPEND {} - {}", editor.current_field_type(), editor.get_input_rules());
},
(_, KeyCode::Esc, _) => editor.exit_edit_mode(),
// Enhanced demo features
(AppMode::ReadOnly, KeyCode::Char('e'), _) => editor.cycle_example_data(),
(AppMode::ReadOnly, KeyCode::Char('r'), _) => editor.toggle_raw_data_view(),
(AppMode::ReadOnly, KeyCode::Char('c'), _) => editor.toggle_cursor_details(),
(AppMode::ReadOnly, KeyCode::Char('m'), _) => editor.show_position_mapping(),
(AppMode::ReadOnly, KeyCode::F(1), _) => editor.toggle_validation(),
// Movement
(_, KeyCode::Up, _) | (AppMode::ReadOnly, KeyCode::Char('k'), _) => editor.move_up(),
(_, KeyCode::Down, _) | (AppMode::ReadOnly, KeyCode::Char('j'), _) => editor.move_down(),
(_, KeyCode::Left, _) | (AppMode::ReadOnly, KeyCode::Char('h'), _) => editor.move_left(),
(_, KeyCode::Right, _) | (AppMode::ReadOnly, KeyCode::Char('l'), _) => editor.move_right(),
(_, KeyCode::Tab, _) => editor.next_field(),
(_, KeyCode::BackTab, _) => editor.prev_field(),
// Editing
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
editor.insert_char(c)?;
},
(AppMode::Edit, KeyCode::Backspace, _) => { editor.delete_backward()?; },
(AppMode::Edit, KeyCode::Delete, _) => { editor.delete_forward()?; },
// Field analysis
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
let (raw, display, status, warning) = editor.get_current_field_analysis();
let warning_text = warning.map(|w| format!(" ⚠️ {w}")).unwrap_or_default();
editor.debug_message = format!(
"🔍 Field {}: {} | Raw: '{}' | Display: '{}'{}",
editor.current_field() + 1, status, raw, display, warning_text
);
},
_ => {}
}
Ok(true)
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut editor: EnhancedDemoEditor<MultiFormatterDemoData>,
) -> io::Result<()> {
loop {
terminal.draw(|f| ui(f, &editor))?;
if let Event::Key(key) = event::read()? {
match handle_key_press(key.code, key.modifiers, &mut editor) {
Ok(should_continue) => {
if !should_continue {
break;
}
}
Err(e) => {
editor.debug_message = format!("❌ Error: {e}");
}
}
}
}
Ok(())
}
fn ui(f: &mut Frame, editor: &EnhancedDemoEditor<MultiFormatterDemoData>) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(18)])
.split(f.area());
render_canvas_default(f, chunks[0], &editor.editor);
render_enhanced_status(f, chunks[1], editor);
}
fn render_enhanced_status(
f: &mut Frame,
area: Rect,
editor: &EnhancedDemoEditor<MultiFormatterDemoData>,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Status bar
Constraint::Length(6), // Current field analysis
Constraint::Length(9), // Help
])
.split(area);
// Status bar
let mode_text = match editor.mode() {
AppMode::Edit => "INSERT | (bar cursor)",
AppMode::ReadOnly => "NORMAL █ (block cursor)",
_ => "NORMAL █ (block cursor)",
};
let formatter_count = (0..editor.data_provider().field_count())
.filter(|&i| editor.data_provider().validation_config(i).is_some())
.count();
let status_text = format!(
"-- {} -- {} | Formatters: {}/{} active | View: {}{}",
mode_text,
editor.debug_message,
formatter_count,
editor.data_provider().field_count(),
if editor.show_raw_data { "RAW" } else { "DISPLAY" },
if editor.show_cursor_details { " | CURSOR+" } else { "" }
);
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("🧩 Enhanced Custom Formatter Demo"));
f.render_widget(status, chunks[0]);
// Current field analysis
let (raw, display, status, warning) = editor.get_current_field_analysis();
let field_name = editor.data_provider().field_name(editor.current_field());
let field_type = editor.current_field_type();
let mut analysis_lines = vec![
format!("📝 Current: {} ({})", field_name, field_type),
format!("🔧 Status: {}", status),
];
if editor.show_raw_data || editor.mode() == AppMode::Edit {
analysis_lines.push(format!("💾 Raw Data: '{raw}'"));
analysis_lines.push(format!("✨ Display: '{display}'"));
} else {
analysis_lines.push(format!("✨ User Sees: '{display}'"));
analysis_lines.push(format!("💾 Stored As: '{raw}'"));
}
if editor.show_cursor_details {
analysis_lines.push(format!(
"📍 Cursor: Raw[{}] → Display[{}]",
editor.cursor_position(),
editor.editor.display_cursor_position()
));
}
if let Some(ref warn) = warning {
analysis_lines.push(format!("⚠️ Warning: {warn}"));
}
let analysis_color = if warning.is_some() {
Color::Yellow
} else if raw != display && editor.validation_enabled {
Color::Green
} else {
Color::Gray
};
let analysis = Paragraph::new(analysis_lines.join("\n"))
.block(Block::default().borders(Borders::ALL).title("🔍 Field Analysis"))
.style(Style::default().fg(analysis_color))
.wrap(Wrap { trim: true });
f.render_widget(analysis, chunks[1]);
// Enhanced help
let help_text = match editor.mode() {
AppMode::ReadOnly => {
"🎯 CURSOR-STYLE: Normal █ | Insert |\n\
🧩 ENHANCED CUSTOM FORMATTER DEMO\n\
\n\
Try these formatters:
• PSC: 01001 → 010 01 | Phone: 1234567890 → (123) 456-7890 | Card: 1234567890123456 → 1234 5678 9012 3456
• Date: 12012024 → 12/01/2024 | Plain: no formatting
\n\
Commands: i=insert, e=cycle examples, r=toggle raw/display, c=cursor details, m=position mapping\n\
Movement: hjkl/arrows, Tab=next field, ?=analyze current field, F1=toggle formatters\n\
Ctrl+C/F10=quit"
}
AppMode::Edit => {
"🎯 INSERT MODE - Cursor: | (bar)\n\
✏️ Real-time formatting as you type!\n\
\n\
Current field rules: {}\n\
• Raw input is authoritative (what gets stored)\n\
• Display formatting updates in real-time (what users see)\n\
• Cursor position is mapped between raw and display\n\
\n\
Esc=normal mode, arrows=navigate, Backspace/Del=delete"
}
_ => "🧩 Enhanced Custom Formatter Demo"
};
let formatted_help = if editor.mode() == AppMode::Edit {
help_text.replace("{}", editor.get_input_rules())
} else {
help_text.to_string()
};
let help = Paragraph::new(formatted_help)
.block(Block::default().borders(Borders::ALL).title("🚀 Enhanced Features & Commands"))
.style(Style::default().fg(Color::Gray))
.wrap(Wrap { trim: true });
f.render_widget(help, chunks[2]);
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🧩 Enhanced Canvas Custom Formatter Demo (Feature 4)");
println!("✅ validation feature: ENABLED");
println!("✅ gui feature: ENABLED");
println!("✅ cursor-style feature: ENABLED");
println!("🧩 Enhanced features:");
println!(" • 5 different custom formatters with edge cases");
println!(" • Real-time format preview and validation");
println!(" • Advanced cursor position mapping");
println!(" • Comprehensive error handling and warnings");
println!(" • Raw vs formatted data separation demos");
println!();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let data = MultiFormatterDemoData::new();
let mut editor = EnhancedDemoEditor::new(data);
// Initialize with normal mode - library automatically sets block cursor
editor.editor.set_mode(AppMode::ReadOnly);
// Demonstrate that CursorManager is available and working
CursorManager::update_for_mode(AppMode::ReadOnly)?;
let res = run_app(&mut terminal, editor);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
// Library automatically resets cursor on FormEditor::drop()
// But we can also manually reset if needed
CursorManager::reset()?;
if let Err(err) = res {
println!("{err:?}");
}
println!("🧩 Enhanced custom formatter demo completed!");
println!("🏆 You experienced comprehensive custom formatting with:");
println!(" • Multiple formatter types (PSC, Phone, Credit Card, Date)");
println!(" • Edge case handling (incomplete, invalid, overflow)");
println!(" • Real-time format preview and cursor mapping");
println!(" • Clear separation between raw business data and display formatting");
Ok(())
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,620 +0,0 @@
// 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);
}

View File

@@ -1,8 +0,0 @@
// src/canvas/actions/mod.rs
//! Canvas action definitions and movement utilities
pub mod types;
pub mod movement;
// Re-export the main API
pub use types::{CanvasAction, ActionResult};

View File

@@ -1,50 +0,0 @@
// src/canvas/actions/movement/char.rs
//! Character-level cursor movement functions
/// 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))
}
}

View File

@@ -1,33 +0,0 @@
// src/canvas/actions/movement/line.rs
//! Line-level cursor movement and positioning
/// 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))
}
}

View File

@@ -1,17 +0,0 @@
// src/canvas/actions/movement/mod.rs
//! Movement utilities for character, word, and line navigation
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,
find_next_big_word_start, find_prev_big_word_start, find_big_word_end, find_prev_big_word_end,
// Add these new exports:
find_last_word_start_in_field, find_last_word_end_in_field,
find_last_big_word_start_in_field, find_last_big_word_end_in_field,
};
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};

View File

@@ -1,403 +0,0 @@
// src/canvas/actions/movement/word.rs
//! Word-based cursor movement with vim-like semantics
#[derive(PartialEq, Copy, Clone)]
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 (CORRECTED VERSION for vim's ge command)
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;
}
// Find all word end positions using boundary detection
let mut word_ends = Vec::new();
let mut in_word = false;
let mut current_word_type: Option<CharType> = None;
for (i, &ch) in chars.iter().enumerate() {
let char_type = get_char_type(ch);
match char_type {
CharType::Whitespace => {
if in_word {
// End of a word
word_ends.push(i - 1);
in_word = false;
current_word_type = None;
}
}
_ => {
if !in_word || current_word_type != Some(char_type) {
// Start of a new word (or word type change)
if in_word {
// End the previous word first
word_ends.push(i - 1);
}
in_word = true;
current_word_type = Some(char_type);
}
}
}
}
// Add the final word end if text doesn't end with whitespace
if in_word && !chars.is_empty() {
word_ends.push(chars.len() - 1);
}
// Find the largest word end position that's before current_pos
for &end_pos in word_ends.iter().rev() {
if end_pos < current_pos {
return end_pos;
}
}
0
}
/// Find the start of the next big_word (whitespace-separated)
pub fn find_next_big_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos >= chars.len() {
return text.chars().count();
}
let mut pos = current_pos;
// If we're on non-whitespace, skip to end of current big_word
while pos < chars.len() && !chars[pos].is_whitespace() {
pos += 1;
}
// Skip whitespace to find start of next big_word
while pos < chars.len() && chars[pos].is_whitespace() {
pos += 1;
}
pos
}
/// Find the start of the previous big_word (whitespace-separated)
pub fn find_prev_big_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 && chars[pos].is_whitespace() {
pos -= 1;
}
// Find start of current big_word by going back while non-whitespace
while pos > 0 && !chars[pos - 1].is_whitespace() {
pos -= 1;
}
pos
}
/// Find the end of the current/next big_word (whitespace-separated)
pub fn find_big_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
let mut pos = current_pos;
// If we're on whitespace, skip to start of next big_word
while pos < chars.len() && chars[pos].is_whitespace() {
pos += 1;
}
// If we reached end, return it
if pos >= chars.len() {
return chars.len();
}
// Find end of current big_word (last non-whitespace char)
while pos < chars.len() && !chars[pos].is_whitespace() {
pos += 1;
}
// Return position of last character in big_word
pos.saturating_sub(1)
}
/// Find the end of the previous big_word (whitespace-separated)
pub fn find_prev_big_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 && chars[pos].is_whitespace() {
pos -= 1;
}
// If we hit start of text and it's whitespace, return 0
if pos == 0 && chars[0].is_whitespace() {
return 0;
}
// Skip back to start of current big_word, then forward to end
while pos > 0 && !chars[pos - 1].is_whitespace() {
pos -= 1;
}
// Now find end of this big_word
while pos < chars.len() && !chars[pos].is_whitespace() {
pos += 1;
}
// Return position of last character in big_word
pos.saturating_sub(1)
}
// ============================================================================
// FIELD BOUNDARY HELPER FUNCTIONS (for cross-field movement)
// ============================================================================
/// Find the start of the last word in a field (for cross-field 'b' movement)
pub fn find_last_word_start_in_field(text: &str) -> usize {
if text.is_empty() {
return 0;
}
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
let mut pos = chars.len().saturating_sub(1);
// Skip trailing whitespace
while pos > 0 && chars[pos].is_whitespace() {
pos -= 1;
}
// If the whole field is whitespace, return 0
if pos == 0 && chars[0].is_whitespace() {
return 0;
}
// Now we're on a non-whitespace character
// Find the start of this word by going backwards while chars are the same type
let char_type = if chars[pos].is_alphanumeric() { "alnum" } else { "punct" };
while pos > 0 {
let prev_char = chars[pos - 1];
let prev_type = if prev_char.is_alphanumeric() {
"alnum"
} else if prev_char.is_whitespace() {
"space"
} else {
"punct"
};
// Stop if we hit whitespace or different word type
if prev_type == "space" || prev_type != char_type {
break;
}
pos -= 1;
}
pos
}
/// Find the end of the last word in a field (for cross-field 'ge' movement)
pub fn find_last_word_end_in_field(text: &str) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
// Start from the end and find the last non-whitespace character
let mut pos = chars.len() - 1;
// Skip trailing whitespace
while pos > 0 && chars[pos].is_whitespace() {
pos -= 1;
}
// If the whole field is whitespace, return 0
if chars[pos].is_whitespace() {
return 0;
}
// We're now at the end of the last word
pos
}
/// Find the start of the last big_word in a field (for cross-field 'B' movement)
pub fn find_last_big_word_start_in_field(text: &str) -> usize {
if text.is_empty() {
return 0;
}
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
let mut pos = chars.len().saturating_sub(1);
// Skip trailing whitespace
while pos > 0 && chars[pos].is_whitespace() {
pos -= 1;
}
// If the whole field is whitespace, return 0
if pos == 0 && chars[0].is_whitespace() {
return 0;
}
// Now we're on a non-whitespace character
// Find the start of this big_word by going backwards while chars are non-whitespace
while pos > 0 {
let prev_char = chars[pos - 1];
// Stop if we hit whitespace (big_word boundary)
if prev_char.is_whitespace() {
break;
}
pos -= 1;
}
pos
}
/// Find the end of the last big_word in a field (for cross-field 'gE' movement)
pub fn find_last_big_word_end_in_field(text: &str) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
let mut pos = chars.len().saturating_sub(1);
// Skip trailing whitespace
while pos > 0 && chars[pos].is_whitespace() {
pos -= 1;
}
// If the whole field is whitespace, return 0
if pos == 0 && chars[0].is_whitespace() {
return 0;
}
// We're now at the end of the last big_word
pos
}

View File

@@ -1,215 +0,0 @@
// src/canvas/actions/types.rs
//! Core action types and result handling for canvas operations.
/// All available canvas actions.
///
/// This enum lists high-level actions that can be performed on the canvas.
/// Consumers can match on variants to implement custom handling or map input
/// events to these canonical actions.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub enum CanvasAction {
// Movement actions
/// Move the cursor left by one character (or logical unit).
MoveLeft,
/// Move the cursor right by one character (or logical unit).
MoveRight,
/// Move the cursor up a visual line/field.
MoveUp,
/// Move the cursor down a visual line/field.
MoveDown,
// Word movement
/// Move to the start of the next word.
MoveWordNext,
/// Move to the start of the previous word.
MoveWordPrev,
/// Move to the end of the current/next word.
MoveWordEnd,
/// Move to the previous word end (vim `ge`).
MoveWordEndPrev,
// Line movement
/// Move to the start of the current line.
MoveLineStart,
/// Move to the end of the current line.
MoveLineEnd,
// Field movement
/// Move to the next field.
NextField,
/// Move to the previous field.
PrevField,
/// Move to the first field.
MoveFirstLine,
/// Move to the last field.
MoveLastLine,
// Editing actions
/// Insert a character at the cursor.
InsertChar(char),
/// Delete character before the cursor.
DeleteBackward,
/// Delete character under/after the cursor.
DeleteForward,
// Suggestions actions
/// Trigger suggestions dropdown (e.g. Tab).
TriggerSuggestions,
/// Move selection up in suggestions dropdown.
SuggestionUp,
/// Move selection down in suggestions dropdown.
SuggestionDown,
/// Accept the selected suggestion.
SelectSuggestion,
/// Exit suggestions UI.
ExitSuggestions,
// Custom actions
/// Custom named action for application-specific behavior.
Custom(String),
}
/// Result type for canvas actions.
///
/// Action handlers return an ActionResult to indicate success, user-facing
/// messages, or errors. The enum is non-exhaustive to allow extension.
#[non_exhaustive]
#[derive(Debug, Clone)]
pub enum ActionResult {
/// Action completed successfully.
Success,
/// Action completed with a user-facing message.
Message(String),
/// Action was handled by the application with an associated message.
HandledByApp(String),
/// Action was handled by a feature with an associated message.
HandledByFeature(String), // Keep for compatibility
/// An error occurred while handling the action.
Error(String),
}
impl ActionResult {
/// Convenience constructor for Success.
pub fn success() -> Self {
Self::Success
}
/// Convenience constructor for Message.
pub fn success_with_message(msg: &str) -> Self {
Self::Message(msg.to_string())
}
/// Convenience constructor for HandledByApp.
pub fn handled_by_app(msg: &str) -> Self {
Self::HandledByApp(msg.to_string())
}
/// Convenience constructor for Error.
pub fn error(msg: &str) -> Self {
Self::Error(msg.to_string())
}
/// Returns true for any variant representing a success-like outcome.
pub fn is_success(&self) -> bool {
matches!(self, Self::Success | Self::Message(_) | Self::HandledByApp(_) | Self::HandledByFeature(_))
}
/// Extract a message from the result when present.
pub fn message(&self) -> Option<&str> {
match self {
Self::Message(msg) | Self::HandledByApp(msg) | Self::HandledByFeature(msg) | Self::Error(msg) => Some(msg),
Self::Success => None,
}
}
}
impl CanvasAction {
/// Get a human-readable description of this action.
pub fn description(&self) -> &'static str {
match self {
Self::MoveLeft => "move left",
Self::MoveRight => "move right",
Self::MoveUp => "move up",
Self::MoveDown => "move down",
Self::MoveWordNext => "next word",
Self::MoveWordPrev => "previous word",
Self::MoveWordEnd => "word end",
Self::MoveWordEndPrev => "previous word end",
Self::MoveLineStart => "line start",
Self::MoveLineEnd => "line end",
Self::NextField => "next field",
Self::PrevField => "previous field",
Self::MoveFirstLine => "first field",
Self::MoveLastLine => "last field",
Self::InsertChar(_c) => "insert character",
Self::DeleteBackward => "delete backward",
Self::DeleteForward => "delete forward",
Self::TriggerSuggestions => "trigger suggestions",
Self::SuggestionUp => "suggestion up",
Self::SuggestionDown => "suggestion down",
Self::SelectSuggestion => "select suggestion",
Self::ExitSuggestions => "exit suggestions",
Self::Custom(_name) => "custom action",
}
}
/// Get all movement-related actions.
pub fn movement_actions() -> Vec<CanvasAction> {
vec![
Self::MoveLeft,
Self::MoveRight,
Self::MoveUp,
Self::MoveDown,
Self::MoveWordNext,
Self::MoveWordPrev,
Self::MoveWordEnd,
Self::MoveWordEndPrev,
Self::MoveLineStart,
Self::MoveLineEnd,
Self::NextField,
Self::PrevField,
Self::MoveFirstLine,
Self::MoveLastLine,
]
}
/// Get all editing-related actions.
pub fn editing_actions() -> Vec<CanvasAction> {
vec![
Self::InsertChar(' '), // Example char
Self::DeleteBackward,
Self::DeleteForward,
]
}
/// Get all suggestions-related actions.
pub fn suggestions_actions() -> Vec<CanvasAction> {
vec![
Self::TriggerSuggestions,
Self::SuggestionUp,
Self::SuggestionDown,
Self::SelectSuggestion,
Self::ExitSuggestions,
]
}
/// Check if this action modifies text content.
pub fn is_editing_action(&self) -> bool {
matches!(self,
Self::InsertChar(_) |
Self::DeleteBackward |
Self::DeleteForward
)
}
/// Check if this action moves the cursor.
pub fn is_movement_action(&self) -> bool {
matches!(self,
Self::MoveLeft | Self::MoveRight | Self::MoveUp | Self::MoveDown |
Self::MoveWordNext | Self::MoveWordPrev | Self::MoveWordEnd | Self::MoveWordEndPrev |
Self::MoveLineStart | Self::MoveLineEnd | Self::NextField | Self::PrevField |
Self::MoveFirstLine | Self::MoveLastLine
)
}
}

View File

@@ -1,64 +0,0 @@
// src/canvas/cursor.rs
//! Cursor style management for different canvas modes
//!
//! Provides helpers to update and reset terminal cursor style when the
//! `cursor-style` feature is enabled. When the feature is disabled the
//! functions are no-ops.
#[cfg(feature = "cursor-style")]
use crossterm::{cursor::SetCursorStyle, execute};
#[cfg(feature = "cursor-style")]
use std::io;
use crate::canvas::modes::AppMode;
/// Manages cursor styles based on canvas modes
pub struct CursorManager;
impl CursorManager {
/// Update cursor style based on current mode.
///
/// When the `textmode-normal` feature is enabled a fixed style is applied.
/// Otherwise, the cursor style is mapped to the provided AppMode.
#[cfg(feature = "cursor-style")]
pub fn update_for_mode(mode: AppMode) -> io::Result<()> {
// NORMALMODE: force underscore for every mode
#[cfg(feature = "textmode-normal")]
{
let style = SetCursorStyle::SteadyBar;
execute!(io::stdout(), style)
}
// Default (not normal): original mapping
#[cfg(not(feature = "textmode-normal"))]
{
let style = match mode {
AppMode::Edit => SetCursorStyle::SteadyBar, // Thin line for insert
AppMode::ReadOnly => SetCursorStyle::SteadyBlock, // Block for normal
AppMode::Highlight => SetCursorStyle::BlinkingBlock, // Blinking for visual
AppMode::General => SetCursorStyle::SteadyBlock, // Block for general
AppMode::Command => SetCursorStyle::SteadyUnderScore, // Underscore for command
};
return execute!(io::stdout(), style);
}
}
/// No-op when cursor-style feature is disabled.
#[cfg(not(feature = "cursor-style"))]
pub fn update_for_mode(_mode: AppMode) -> io::Result<()> {
Ok(())
}
/// Reset cursor to default on cleanup.
#[cfg(feature = "cursor-style")]
pub fn reset() -> io::Result<()> {
execute!(io::stdout(), SetCursorStyle::DefaultUserShape)
}
/// Reset is a no-op when the cursor-style feature is disabled.
#[cfg(not(feature = "cursor-style"))]
pub fn reset() -> io::Result<()> {
Ok(())
}
}

View File

@@ -1,729 +0,0 @@
// src/canvas/gui.rs
//! Canvas GUI updated to work with FormEditor
//!
//! This module provides rendering helpers for the canvas UI when the `gui`
//! feature is enabled. It exposes high-level functions to render the canvas
//! and convenience types for display options.
#[cfg(feature = "gui")]
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, BorderType, Paragraph, Wrap},
Frame,
};
#[cfg(feature = "gui")]
use crate::canvas::theme::{CanvasTheme, DefaultCanvasTheme};
use crate::canvas::modes::HighlightState;
use crate::data_provider::DataProvider;
use crate::editor::FormEditor;
use unicode_width::UnicodeWidthChar;
#[cfg(feature = "gui")]
use std::cmp::{max, min};
#[cfg(feature = "gui")]
#[derive(Debug, Clone, Copy)]
/// How to handle overflow when rendering a field's content.
pub enum OverflowMode {
/// Show an indicator character at the left/right when text is truncated.
/// Common default is '$'.
Indicator(char),
/// Wrap content into multiple visual lines.
Wrap,
}
#[cfg(feature = "gui")]
#[derive(Debug, Clone, Copy)]
/// Display options controlling canvas rendering behavior.
pub struct CanvasDisplayOptions {
/// How to handle horizontal overflow for fields.
pub overflow: OverflowMode,
}
#[cfg(feature = "gui")]
impl Default for CanvasDisplayOptions {
fn default() -> Self {
Self {
overflow: OverflowMode::Indicator('$'),
}
}
}
/// Utility: measure display width of a string
#[cfg(feature = "gui")]
fn display_width(s: &str) -> u16 {
s.chars()
.map(|c| UnicodeWidthChar::width(c).unwrap_or(0) as u16)
.sum()
}
/// Utility: clip a string to fit width, append indicator if overflow
#[cfg(feature = "gui")]
fn clip_with_indicator_line<'a>(s: &'a str, width: u16, indicator: char) -> Line<'a> {
if width == 0 {
return Line::from("");
}
if display_width(s) <= width {
return Line::from(Span::raw(s));
}
let budget = width.saturating_sub(1);
let mut out = String::new();
let mut used: u16 = 0;
for ch in s.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
if used + w > budget {
break;
}
out.push(ch);
used = used.saturating_add(w);
}
Line::from(vec![Span::raw(out), Span::raw(indicator.to_string())])
}
#[cfg(feature = "gui")]
const RIGHT_PAD: u16 = 3;
#[cfg(feature = "gui")]
fn slice_by_display_cols(s: &str, start_cols: u16, max_cols: u16) -> String {
if max_cols == 0 {
return String::new();
}
let mut cols: u16 = 0;
let mut out = String::new();
let mut taken: u16 = 0;
let mut started = false;
for ch in s.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
let next = cols.saturating_add(w);
if !started {
if next <= start_cols {
cols = next;
continue;
} else {
started = true;
}
}
if taken.saturating_add(w) > max_cols {
break;
}
out.push(ch);
taken = taken.saturating_add(w);
cols = next;
}
out
}
#[cfg(feature = "gui")]
fn compute_h_scroll_with_padding(cursor_cols: u16, width: u16) -> (u16, u16) {
let mut h = 0u16;
for _ in 0..2 {
let left_cols = if h > 0 { 1 } else { 0 };
let max_x_visible = width.saturating_sub(1 + RIGHT_PAD + left_cols);
let needed = cursor_cols.saturating_sub(max_x_visible);
if needed <= h {
return (h, left_cols);
}
h = needed;
}
let left_cols = if h > 0 { 1 } else { 0 };
(h, left_cols)
}
#[cfg(feature = "gui")]
fn render_active_line_with_indicator<T: CanvasTheme>(
typed_text: &str,
completion: Option<&str>,
width: u16,
indicator: char,
cursor_chars: usize,
theme: &T,
) -> (Line<'static>, u16, u16) {
if width == 0 {
return (Line::from(""), 0, 0);
}
// Cursor display column
let mut cursor_cols: u16 = 0;
for (i, ch) in typed_text.chars().enumerate() {
if i >= cursor_chars {
break;
}
cursor_cols = cursor_cols
.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16);
}
let (h_scroll, left_cols) = compute_h_scroll_with_padding(cursor_cols, width);
let total_cols = display_width(typed_text);
let content_budget = width.saturating_sub(left_cols);
let show_right = total_cols.saturating_sub(h_scroll) > content_budget;
let right_cols: u16 = if show_right { 1 } else { 0 };
let visible_cols = width.saturating_sub(left_cols + right_cols);
let visible_typed = slice_by_display_cols(typed_text, h_scroll, visible_cols);
let used_typed_cols = display_width(&visible_typed);
let mut remaining_cols = visible_cols.saturating_sub(used_typed_cols);
let mut visible_completion = String::new();
if let Some(comp) = completion {
if !comp.is_empty() && remaining_cols > 0 {
visible_completion = slice_by_display_cols(comp, 0, remaining_cols);
}
}
let mut spans: Vec<Span> = Vec::with_capacity(3);
if left_cols == 1 {
spans.push(Span::raw(indicator.to_string()));
}
spans.push(Span::styled(
visible_typed,
Style::default().fg(theme.fg()),
));
if !visible_completion.is_empty() {
spans.push(Span::styled(
visible_completion,
Style::default().fg(theme.suggestion_gray()),
));
}
if show_right {
spans.push(Span::raw(indicator.to_string()));
}
(Line::from(spans), h_scroll, left_cols)
}
#[cfg(feature = "gui")]
/// Render the canvas into the provided frame using default display options.
///
/// Returns the rectangle of the active input field if present.
pub fn render_canvas<T: CanvasTheme, D: DataProvider>(
f: &mut Frame,
area: Rect,
editor: &FormEditor<D>,
theme: &T,
) -> Option<Rect> {
let opts = CanvasDisplayOptions::default();
render_canvas_with_options(f, area, editor, theme, opts)
}
#[cfg(feature = "gui")]
/// Render the canvas into the provided frame with explicit display options.
///
/// This is the more configurable entrypoint for rendering and is useful for
/// tests or when callers need to override overflow handling.
pub fn render_canvas_with_options<T: CanvasTheme, D: DataProvider>(
f: &mut Frame,
area: Rect,
editor: &FormEditor<D>,
theme: &T,
opts: CanvasDisplayOptions,
) -> Option<Rect> {
let highlight_state =
convert_selection_to_highlight(editor.ui_state().selection_state());
#[cfg(feature = "suggestions")]
let active_completion = if editor.ui_state().is_suggestions_active()
&& editor.ui_state().suggestions.active_field
== Some(editor.ui_state().current_field())
{
editor.ui_state().suggestions.completion_text.clone()
} else {
None
};
#[cfg(not(feature = "suggestions"))]
let active_completion: Option<String> = None;
render_canvas_with_highlight_and_options(
f,
area,
editor,
theme,
&highlight_state,
active_completion,
opts,
)
}
#[cfg(feature = "gui")]
fn render_canvas_with_highlight_and_options<T: CanvasTheme, D: DataProvider>(
f: &mut Frame,
area: Rect,
editor: &FormEditor<D>,
theme: &T,
highlight_state: &HighlightState,
active_completion: Option<String>,
opts: CanvasDisplayOptions,
) -> Option<Rect> {
let ui_state = editor.ui_state();
let data_provider = editor.data_provider();
let field_count = data_provider.field_count();
let mut fields: Vec<&str> = Vec::with_capacity(field_count);
let mut inputs: Vec<String> = Vec::with_capacity(field_count);
for i in 0..field_count {
fields.push(data_provider.field_name(i));
#[cfg(feature = "validation")]
{
inputs.push(editor.display_text_for_field(i));
}
#[cfg(not(feature = "validation"))]
{
inputs.push(data_provider.field_value(i).to_string());
}
}
let current_field_idx = ui_state.current_field();
let is_edit_mode = matches!(ui_state.mode(), crate::canvas::modes::AppMode::Edit);
render_canvas_fields_with_options(
f,
area,
&fields,
&current_field_idx,
&inputs,
theme,
is_edit_mode,
highlight_state,
editor.display_cursor_position(),
false,
#[cfg(feature = "validation")]
|field_idx| editor.display_text_for_field(field_idx),
#[cfg(not(feature = "validation"))]
|field_idx| data_provider.field_value(field_idx).to_string(),
#[cfg(feature = "validation")]
|field_idx| {
editor
.ui_state()
.validation_state()
.get_field_config(field_idx)
.map(|cfg| cfg.custom_formatter.is_some() || cfg.display_mask.is_some())
.unwrap_or(false)
},
#[cfg(not(feature = "validation"))]
|_field_idx| false,
active_completion,
opts,
)
}
#[cfg(feature = "gui")]
fn convert_selection_to_highlight(
selection: &crate::canvas::state::SelectionState,
) -> HighlightState {
use crate::canvas::state::SelectionState;
match selection {
SelectionState::None => HighlightState::Off,
SelectionState::Characterwise { anchor } => {
HighlightState::Characterwise { anchor: *anchor }
}
SelectionState::Linewise { anchor_field } => {
HighlightState::Linewise {
anchor_line: *anchor_field,
}
}
}
}
/// Core canvas field rendering with options
#[cfg(feature = "gui")]
fn render_canvas_fields_with_options<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,
active_completion: Option<String>,
opts: CanvasDisplayOptions,
) -> Option<Rect>
where
F1: Fn(usize) -> String,
F2: Fn(usize) -> bool,
{
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(area);
let border_style = if has_unsaved_changes {
Style::default().fg(theme.warning())
} else if is_edit_mode {
Style::default().fg(theme.accent())
} else {
Style::default().fg(theme.secondary())
};
let input_container = Block::default()
.borders(Borders::ALL)
.border_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);
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(f, columns[0], input_block, fields, theme);
let mut active_field_input_rect = None;
for i in 0..inputs.len() {
let is_active = i == *current_field_idx;
let typed_text = get_display_value(i);
let inner_width = input_rows[i].width;
// ---- BEGIN MODIFIED SECTION ----
let mut h_scroll_for_cursor: u16 = 0;
let mut left_offset_for_cursor: u16 = 0;
let line = match highlight_state {
// Selection highlighting active: always use highlighting, even for the active field
HighlightState::Characterwise { .. } | HighlightState::Linewise { .. } => {
apply_highlighting(
&typed_text,
i,
current_field_idx,
current_cursor_pos,
highlight_state,
theme,
is_active,
)
}
// No selection highlighting
HighlightState::Off => match opts.overflow {
// Indicator mode: special-case the active field to preserve h-scroll + indicators
OverflowMode::Indicator(ind) => {
if is_active {
let (l, hs, left_cols) = render_active_line_with_indicator(
&typed_text,
active_completion.as_deref(),
inner_width,
ind,
current_cursor_pos,
theme,
);
h_scroll_for_cursor = hs;
left_offset_for_cursor = left_cols;
l
} else if display_width(&typed_text) <= inner_width {
Line::from(Span::raw(typed_text.clone()))
} else {
clip_with_indicator_line(&typed_text, inner_width, ind)
}
}
// Wrap mode: keep active completion for active line
OverflowMode::Wrap => {
if is_active {
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(
typed_text.clone(),
Style::default().fg(theme.fg()),
));
if let Some(completion) = &active_completion {
if !completion.is_empty() {
spans.push(Span::styled(
completion.clone(),
Style::default().fg(theme.suggestion_gray()),
));
}
}
Line::from(spans)
} else {
Line::from(Span::raw(typed_text.clone()))
}
}
},
};
// ---- END MODIFIED SECTION ----
let mut p = Paragraph::new(line).alignment(Alignment::Left);
if matches!(opts.overflow, OverflowMode::Wrap) {
p = p.wrap(Wrap { trim: false });
}
f.render_widget(p, input_rows[i]);
if is_active {
active_field_input_rect = Some(input_rows[i]);
set_cursor_position_scrolled(
f,
input_rows[i],
&typed_text,
current_cursor_pos,
has_display_override(i),
h_scroll_for_cursor,
left_offset_for_cursor,
);
}
}
active_field_input_rect
}
/// 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,
},
);
}
}
/// 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, 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 (unchanged)
#[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 = 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),
Span::styled(highlighted, highlight_style),
Span::styled(after, normal_style),
])
} else if field_index == anchor_field {
if anchor_field < *current_field_idx {
let clamped_start = anchor_char.min(text_len);
let before: String = text.chars().take(clamped_start).collect();
let highlighted: String = text.chars().skip(clamped_start).collect();
Line::from(vec![
Span::styled(before, normal_style),
Span::styled(highlighted, highlight_style),
])
} else {
let clamped_end = anchor_char.min(text_len);
let highlighted: String = text.chars().take(clamped_end + 1).collect();
let after: String = text.chars().skip(clamped_end + 1).collect();
Line::from(vec![
Span::styled(highlighted, highlight_style),
Span::styled(after, normal_style),
])
}
} else if field_index == *current_field_idx {
if anchor_field < *current_field_idx {
let clamped_end = current_cursor_pos.min(text_len);
let highlighted: String = text.chars().take(clamped_end + 1).collect();
let after: String = text.chars().skip(clamped_end + 1).collect();
Line::from(vec![
Span::styled(highlighted, highlight_style),
Span::styled(after, normal_style),
])
} else {
let clamped_start = current_cursor_pos.min(text_len);
let before: String = text.chars().take(clamped_start).collect();
let highlighted: String = text.chars().skip(clamped_start).collect();
Line::from(vec![
Span::styled(before, normal_style),
Span::styled(highlighted, highlight_style),
])
}
} else {
Line::from(Span::styled(text, highlight_style))
}
} else {
Line::from(Span::styled(text, normal_style))
}
}
/// Apply linewise highlighting (unchanged)
#[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 = 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, normal_style))
}
}
/// Set cursor position (x clamp only; no Y offset with wrap in this version)
#[cfg(feature = "gui")]
fn set_cursor_position_scrolled(
f: &mut Frame,
field_rect: Rect,
text: &str,
current_cursor_pos: usize,
_has_display_override: bool,
h_scroll: u16,
left_offset: u16,
) {
let mut cols: u16 = 0;
for (i, ch) in text.chars().enumerate() {
if i >= current_cursor_pos {
break;
}
cols = cols.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16);
}
let mut visible_x = cols.saturating_sub(h_scroll).saturating_add(left_offset);
let limit = field_rect.width.saturating_sub(1 + RIGHT_PAD);
if visible_x > limit {
visible_x = limit;
}
let cursor_x = field_rect.x.saturating_add(visible_x);
let cursor_y = field_rect.y;
f.set_cursor_position((cursor_x, cursor_y));
}
/// Default theme
#[cfg(feature = "gui")]
pub fn render_canvas_default<D: DataProvider>(
f: &mut Frame,
area: Rect,
editor: &FormEditor<D>,
) -> Option<Rect> {
let theme = DefaultCanvasTheme;
render_canvas(f, area, editor, &theme)
}

View File

@@ -1,23 +0,0 @@
// src/canvas/mod.rs
//! Top-level canvas module.
//!
//! Re-exports commonly used canvas types and modules so that downstream
//! consumers can import them from `crate::canvas`.
pub mod actions;
pub mod state;
pub mod modes;
#[cfg(feature = "gui")]
pub mod gui;
#[cfg(feature = "gui")]
pub mod theme;
#[cfg(feature = "cursor-style")]
pub mod cursor;
// Keep these exports for current functionality
pub use modes::{AppMode, ModeManager, HighlightState};
#[cfg(feature = "cursor-style")]
pub use cursor::CursorManager;

View File

@@ -1,18 +0,0 @@
// src/canvas/modes/highlight.rs
//! Highlight state definitions for canvas visual/selection modes.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Default)]
/// Represents the current highlight/visual selection state.
///
/// This enum is used by the GUI and selection logic to track whether a visual
/// selection is active and its anchor position.
pub enum HighlightState {
/// No highlighting active.
#[default]
Off,
/// Characterwise selection with an anchor (field_index, char_position).
Characterwise { anchor: (usize, usize) }, // (field_index, char_position)
/// Linewise selection anchored at a field index.
Linewise { anchor_line: usize }, // field_index
}

View File

@@ -1,106 +0,0 @@
// src/modes/handlers/mode_manager.rs
// canvas/src/modes/manager.rs
//! Mode manager utilities and the AppMode enum.
//!
//! This module defines the available canvas modes and provides helper
//! functions to validate mode transitions and perform required side-effects
//! such as updating cursor style when enabled.
#[cfg(feature = "cursor-style")]
use crate::canvas::CursorManager;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Top-level application modes used by the canvas UI.
///
/// These modes control input handling, cursor behavior, and how the UI should
/// respond to user actions.
pub enum AppMode {
/// For intro and admin screens
General,
/// Canvas read-only mode (navigation)
ReadOnly,
/// Canvas edit mode (insertion/modification)
Edit,
/// Canvas highlight/visual mode (selection)
Highlight,
/// Command mode overlay (for commands)
Command,
}
pub struct ModeManager;
impl ModeManager {
// Mode transition rules
/// Return true if the system can enter Command mode from the given current mode.
pub fn can_enter_command_mode(current_mode: AppMode) -> bool {
!matches!(current_mode, AppMode::Edit)
}
/// Return true if the system can enter Edit mode from the given current mode.
pub fn can_enter_edit_mode(current_mode: AppMode) -> bool {
matches!(current_mode, AppMode::ReadOnly)
}
/// Return true if the system can enter ReadOnly mode from the given current mode.
pub fn can_enter_read_only_mode(current_mode: AppMode) -> bool {
matches!(current_mode, AppMode::Edit | AppMode::Command | AppMode::Highlight)
}
/// Return true if the system can enter Highlight mode from the given current mode.
pub fn can_enter_highlight_mode(current_mode: AppMode) -> bool {
matches!(current_mode, AppMode::ReadOnly)
}
/// Transition to new mode with automatic cursor update (when cursor-style feature enabled).
///
/// Returns the resulting mode or an I/O error if cursor style update fails.
pub fn transition_to_mode(current_mode: AppMode, new_mode: AppMode) -> std::io::Result<AppMode> {
#[cfg(feature = "textmode-normal")]
{
// Always force Edit in normalmode
Ok(AppMode::Edit)
}
#[cfg(not(feature = "textmode-normal"))]
{
if current_mode != new_mode {
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(new_mode);
}
}
Ok(new_mode)
}
}
/// Enter highlight mode with cursor styling.
///
/// Returns Ok(true) if the transition succeeded (and cursor style was updated
/// when enabled), otherwise Ok(false) if the transition is not allowed.
pub fn enter_highlight_mode_with_cursor(current_mode: AppMode) -> std::io::Result<bool> {
if Self::can_enter_highlight_mode(current_mode) {
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(AppMode::Highlight);
}
Ok(true)
} else {
Ok(false)
}
}
/// Exit highlight mode with cursor styling and return the next mode.
///
/// This helper returns the mode to switch to (ReadOnly) and updates cursor
/// style if the feature is enabled.
pub fn exit_highlight_mode_with_cursor() -> std::io::Result<AppMode> {
let new_mode = AppMode::ReadOnly;
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(new_mode);
}
Ok(new_mode)
}
}

View File

@@ -1,7 +0,0 @@
// canvas/src/modes/mod.rs
pub mod highlight;
pub mod manager;
pub use highlight::HighlightState;
pub use manager::{AppMode, ModeManager};

View File

@@ -1,219 +0,0 @@
// src/canvas/state.rs
//! Library-owned UI state - user never directly modifies this
//!
//! This module exposes the EditorState type (and related selection and
//! suggestions types) which represent the internal UI state maintained by the
//! canvas library. These types are intended for read-only access by callers
//! and are mutated only through the library's APIs.
use crate::canvas::modes::AppMode;
/// Library-owned UI state - user never directly modifies this
#[derive(Debug, Clone)]
/// Internal editor UI state managed by the canvas library.
///
/// The fields are `pub(crate)` because they should only be modified by the
/// library's internal action handlers. Consumers can use the provided getter
/// methods to observe the state.
pub struct EditorState {
// Navigation state
pub(crate) current_field: usize,
pub(crate) cursor_pos: usize,
pub(crate) ideal_cursor_column: usize,
// Mode state
pub(crate) current_mode: AppMode,
// Suggestions dropdown state (only available with suggestions feature)
#[cfg(feature = "suggestions")]
pub(crate) suggestions: SuggestionsUIState,
// Selection state (for vim visual mode)
pub(crate) selection: SelectionState,
// Validation state (only available with validation feature)
#[cfg(feature = "validation")]
pub(crate) validation: crate::validation::ValidationState,
/// Computed fields state (only when computed feature is enabled)
#[cfg(feature = "computed")]
pub(crate) computed: Option<crate::computed::ComputedState>,
}
#[cfg(feature = "suggestions")]
#[derive(Debug, Clone)]
/// Internal suggestions UI state used to manage the suggestions dropdown.
pub struct SuggestionsUIState {
pub(crate) is_active: bool,
pub(crate) is_loading: bool,
pub(crate) selected_index: Option<usize>,
pub(crate) active_field: Option<usize>,
pub(crate) active_query: Option<String>,
pub(crate) completion_text: Option<String>,
}
#[derive(Debug, Clone)]
/// SelectionState represents the current selection/visual mode state used by
/// the canvas (for example, Vim-like visual modes).
pub enum SelectionState {
/// No selection is active.
None,
/// Characterwise selection: (field_index, char_position)
Characterwise { anchor: (usize, usize) },
/// Linewise selection anchored at a field (field index).
Linewise { anchor_field: usize },
}
impl EditorState {
/// Create a new EditorState with default initial values.
pub fn new() -> Self {
Self {
current_field: 0,
cursor_pos: 0,
ideal_cursor_column: 0,
// NORMALMODE: always start in Edit
#[cfg(feature = "textmode-normal")]
current_mode: AppMode::Edit,
// Default (vim): start in ReadOnly
#[cfg(not(feature = "textmode-normal"))]
current_mode: AppMode::ReadOnly,
#[cfg(feature = "suggestions")]
suggestions: SuggestionsUIState {
is_active: false,
is_loading: false,
selected_index: None,
active_field: None,
active_query: None,
completion_text: None,
},
selection: SelectionState::None,
#[cfg(feature = "validation")]
validation: crate::validation::ValidationState::new(),
#[cfg(feature = "computed")]
computed: None,
}
}
// ===================================================================
// READ-ONLY ACCESS: User can fetch UI state for compatibility
// ===================================================================
/// Get current field index (for user's business logic)
pub fn current_field(&self) -> usize {
self.current_field
}
/// Check if field is computed
#[cfg(feature = "computed")]
pub fn is_computed_field(&self, field_index: usize) -> bool {
self.computed
.as_ref()
.map(|state| state.is_computed_field(field_index))
.unwrap_or(false)
}
/// Get current cursor position (for user's business logic)
pub fn cursor_position(&self) -> usize {
self.cursor_pos
}
/// Get ideal cursor column (for vim-like behavior)
pub fn ideal_cursor_column(&self) -> usize {
self.ideal_cursor_column
}
/// Get current mode (for user's business logic)
pub fn mode(&self) -> AppMode {
self.current_mode
}
/// Check if suggestions dropdown is active (for user's business logic)
#[cfg(feature = "suggestions")]
pub fn is_suggestions_active(&self) -> bool {
self.suggestions.is_active
}
/// Check if suggestions dropdown is loading (for user's business logic)
#[cfg(feature = "suggestions")]
pub fn is_suggestions_loading(&self) -> bool {
self.suggestions.is_loading
}
/// Get selection state (for user's business logic)
pub fn selection_state(&self) -> &SelectionState {
&self.selection
}
/// Get validation state (for user's business logic)
/// Only available when the 'validation' feature is enabled
#[cfg(feature = "validation")]
pub fn validation_state(&self) -> &crate::validation::ValidationState {
&self.validation
}
// ===================================================================
// INTERNAL MUTATIONS: Only library modifies these
// ===================================================================
/// Move internal pointer to another field index.
///
/// This method is intended for internal library use to change the current
/// field and reset the cursor to a safe value.
pub(crate) fn move_to_field(&mut self, field_index: usize, field_count: usize) {
if field_index < field_count {
self.current_field = field_index;
// Reset cursor to safe position - will be clamped by movement logic
self.cursor_pos = 0;
}
}
/// Set the cursor position with appropriate clamping depending on mode.
///
/// If `for_edit_mode` is true the cursor may be positioned at the end of
/// the text (allowing insertion); otherwise it will be kept within the
/// bounds of the existing text for read-only/highlight modes.
pub(crate) fn set_cursor(
&mut self,
position: usize,
max_position: usize,
for_edit_mode: bool,
) {
if for_edit_mode {
// Edit mode: can go past end for insertion
self.cursor_pos = position.min(max_position);
} else {
// ReadOnly/Highlight: stay within text bounds
self.cursor_pos = position.min(max_position.saturating_sub(1));
}
self.ideal_cursor_column = self.cursor_pos;
}
/// Explicitly open suggestions — should only be called on Tab
#[cfg(feature = "suggestions")]
pub(crate) fn open_suggestions(&mut self, field_index: usize) {
self.suggestions.is_active = true;
self.suggestions.is_loading = true;
self.suggestions.active_field = Some(field_index);
self.suggestions.active_query = None;
self.suggestions.selected_index = None;
self.suggestions.completion_text = None;
}
/// Explicitly close suggestions — should be called on Esc or field change
#[cfg(feature = "suggestions")]
pub(crate) fn close_suggestions(&mut self) {
self.suggestions.is_active = false;
self.suggestions.is_loading = false;
self.suggestions.active_field = None;
self.suggestions.active_query = None;
self.suggestions.selected_index = None;
self.suggestions.completion_text = None;
}
}
impl Default for EditorState {
fn default() -> Self {
Self::new()
}
}

View File

@@ -1,54 +0,0 @@
// 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;
fn suggestion_gray(&self) -> Color;
}
#[cfg(feature = "gui")]
#[derive(Debug, Clone, Default)]
pub struct DefaultCanvasTheme;
#[cfg(feature = "gui")]
impl CanvasTheme for DefaultCanvasTheme {
fn bg(&self) -> Color {
Color::Black
}
fn fg(&self) -> Color {
Color::White
}
fn border(&self) -> Color {
Color::DarkGray
}
fn accent(&self) -> Color {
Color::Cyan
}
fn secondary(&self) -> Color {
Color::Gray
}
fn highlight(&self) -> Color {
Color::Yellow
}
fn highlight_bg(&self) -> Color {
Color::Blue
}
fn warning(&self) -> Color {
Color::Red
}
fn suggestion_gray(&self) -> Color {
Color::DarkGray
}
}

View File

@@ -1,11 +0,0 @@
//! Computed fields subsystem.
//!
//! This module exposes the provider trait and the internal state management
//! for computed (display-only) fields. Computed fields are values derived
//! from other fields in the form and are not directly editable by the user.
pub mod provider;
pub mod state;
pub use provider::{ComputedContext, ComputedProvider};
pub use state::ComputedState;

View File

@@ -1,32 +0,0 @@
//! Provider interface and context for computed/display-only fields.
//!
//! Implementors provide logic to compute a field's display value from the
//! other field values in the form.
/// Context information provided to computed field calculations
#[derive(Debug, Clone)]
pub struct ComputedContext<'a> {
/// All field values in the form (index -> value)
pub field_values: &'a [&'a str],
/// The field index being computed
pub target_field: usize,
/// Current field that user is editing (if any)
pub current_field: Option<usize>,
}
/// User implements this to provide computed field logic
pub trait ComputedProvider {
/// Compute value for a field based on other field values.
/// Called automatically when any field changes.
fn compute_field(&mut self, context: ComputedContext) -> String;
/// Check if this provider handles the given field.
fn handles_field(&self, field_index: usize) -> bool;
/// Get list of field dependencies for optimization.
/// If field A depends on fields [1, 3], only recompute A when fields 1 or 3 change.
/// Default: depend on all fields (always recompute) with a reasonable upper bound.
fn field_dependencies(&self, _field_index: usize) -> Vec<usize> {
(0..100).collect()
}
}

View File

@@ -1,87 +0,0 @@
// src/computed/state.rs
//! Computed field state: caching and dependency graph.
//!
//! This module holds the internal state necessary to track which fields are
//! computed, their dependencies, and cached computed values. It is used by the
//! editor to avoid unnecessary recomputation and to present computed fields as
//! read-only.
use std::collections::{HashMap, HashSet};
/// Internal state for computed field management
#[derive(Debug, Clone)]
pub struct ComputedState {
/// Cached computed values (field_index -> computed_value)
computed_values: HashMap<usize, String>,
/// Field dependency graph (field_index -> depends_on_fields)
dependencies: HashMap<usize, Vec<usize>>,
/// Track which fields are computed (display-only)
computed_fields: HashSet<usize>,
}
impl ComputedState {
/// Create a new, empty computed state
pub fn new() -> Self {
Self {
computed_values: HashMap::new(),
dependencies: HashMap::new(),
computed_fields: HashSet::new(),
}
}
/// Register a field as computed with its dependencies
///
/// - `field_index`: the field that is computed (display-only)
/// - `dependencies`: indices of fields this computed field depends on
pub fn register_computed_field(&mut self, field_index: usize, mut dependencies: Vec<usize>) {
// Deduplicate dependencies to keep graph lean
dependencies.sort_unstable();
dependencies.dedup();
self.computed_fields.insert(field_index);
self.dependencies.insert(field_index, dependencies);
}
/// Check if a field is computed (read-only, skip editing/navigation)
pub fn is_computed_field(&self, field_index: usize) -> bool {
self.computed_fields.contains(&field_index)
}
/// Get cached computed value for a field, if available
pub fn get_computed_value(&self, field_index: usize) -> Option<&String> {
self.computed_values.get(&field_index)
}
/// Update cached computed value for a field
pub fn set_computed_value(&mut self, field_index: usize, value: String) {
self.computed_values.insert(field_index, value);
}
/// Get fields that should be recomputed when `changed_field` changed
///
/// This scans the dependency graph and returns all computed fields
/// that list `changed_field` as a dependency.
pub fn fields_to_recompute(&self, changed_field: usize) -> Vec<usize> {
self.dependencies
.iter()
.filter_map(|(field, deps)| {
if deps.contains(&changed_field) {
Some(*field)
} else {
None
}
})
.collect()
}
/// Iterator over all computed field indices
pub fn computed_fields(&self) -> impl Iterator<Item = usize> + '_ {
self.computed_fields.iter().copied()
}
}
impl Default for ComputedState {
fn default() -> Self {
Self::new()
}
}

View File

@@ -1,69 +0,0 @@
// src/data_provider.rs
//! Simplified user interface - only business data, no UI state
#[cfg(feature = "suggestions")]
use anyhow::Result;
#[cfg(feature = "suggestions")]
use async_trait::async_trait;
/// User implements this - only business data, no UI state
pub trait DataProvider {
/// How many fields in the form
fn field_count(&self) -> usize;
/// Get field label/name
fn field_name(&self, index: usize) -> &str;
/// Get field value
fn field_value(&self, index: usize) -> &str;
/// Set field value (library calls this when text changes)
fn set_field_value(&mut self, index: usize, value: String);
/// Check if field supports suggestions (optional)
fn supports_suggestions(&self, _field_index: usize) -> bool {
false
}
/// Get display value (for password masking, etc.) - optional
fn display_value(&self, _index: usize) -> Option<&str> {
None // Default: use actual value
}
/// Get validation configuration for a field (optional)
/// Only available when the 'validation' feature is enabled
#[cfg(feature = "validation")]
fn validation_config(&self, _field_index: usize) -> Option<crate::validation::ValidationConfig> {
None
}
/// Check if field is computed (display-only, skip in navigation)
/// Default: not computed
#[cfg(feature = "computed")]
fn is_computed_field(&self, _field_index: usize) -> bool {
false
}
/// Get computed field value if this is a computed field.
/// Returns None for regular fields. Default: not computed.
#[cfg(feature = "computed")]
fn computed_field_value(&self, _field_index: usize) -> Option<String> {
None
}
}
/// Optional: User implements this for suggestions data
#[cfg(feature = "suggestions")]
#[async_trait]
pub trait SuggestionsProvider {
/// Fetch suggestions (user's business logic)
async fn fetch_suggestions(&mut self, field_index: usize, query: &str)
-> Result<Vec<SuggestionItem>>;
}
#[cfg(feature = "suggestions")]
#[derive(Debug, Clone)]
pub struct SuggestionItem {
pub display_text: String,
pub value_to_store: String,
}

View File

@@ -1,111 +0,0 @@
// src/editor/computed_helpers.rs
use crate::computed::{ComputedContext, ComputedProvider, ComputedState};
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
#[cfg(feature = "computed")]
pub fn set_computed_provider<C>(&mut self, mut provider: C)
where
C: ComputedProvider,
{
self.ui_state.computed = Some(ComputedState::new());
let field_count = self.data_provider.field_count();
for field_index in 0..field_count {
if provider.handles_field(field_index) {
let deps = provider.field_dependencies(field_index);
if let Some(computed_state) = &mut self.ui_state.computed {
computed_state.register_computed_field(field_index, deps);
}
}
}
self.recompute_all_fields(&mut provider);
}
#[cfg(feature = "computed")]
pub fn recompute_fields<C>(
&mut self,
provider: &mut C,
field_indices: &[usize],
) where
C: ComputedProvider,
{
if let Some(computed_state) = &mut self.ui_state.computed {
let field_values: Vec<String> = (0..self.data_provider.field_count())
.map(|i| {
if computed_state.is_computed_field(i) {
computed_state
.get_computed_value(i)
.cloned()
.unwrap_or_default()
} else {
self.data_provider.field_value(i).to_string()
}
})
.collect();
let field_refs: Vec<&str> =
field_values.iter().map(|s| s.as_str()).collect();
for &field_index in field_indices {
if provider.handles_field(field_index) {
let context = ComputedContext {
field_values: &field_refs,
target_field: field_index,
current_field: Some(self.ui_state.current_field),
};
let computed_value = provider.compute_field(context);
computed_state.set_computed_value(
field_index,
computed_value,
);
}
}
}
}
#[cfg(feature = "computed")]
pub fn recompute_all_fields<C>(&mut self, provider: &mut C)
where
C: ComputedProvider,
{
if let Some(computed_state) = &self.ui_state.computed {
let computed_fields: Vec<usize> =
computed_state.computed_fields().collect();
self.recompute_fields(provider, &computed_fields);
}
}
#[cfg(feature = "computed")]
pub fn on_field_changed<C>(
&mut self,
provider: &mut C,
changed_field: usize,
) where
C: ComputedProvider,
{
if let Some(computed_state) = &self.ui_state.computed {
let fields_to_update =
computed_state.fields_to_recompute(changed_field);
if !fields_to_update.is_empty() {
self.recompute_fields(provider, &fields_to_update);
}
}
}
#[cfg(feature = "computed")]
pub fn effective_field_value(&self, field_index: usize) -> String {
if let Some(computed_state) = &self.ui_state.computed {
if let Some(computed_value) =
computed_state.get_computed_value(field_index)
{
return computed_value.clone();
}
}
self.data_provider.field_value(field_index).to_string()
}
}

View File

@@ -1,131 +0,0 @@
// src/editor/core.rs
#[cfg(feature = "cursor-style")]
use crate::canvas::CursorManager;
use crate::canvas::modes::AppMode;
use crate::canvas::state::EditorState;
use crate::DataProvider;
#[cfg(feature = "suggestions")]
use crate::SuggestionItem;
pub struct FormEditor<D: DataProvider> {
pub(crate) ui_state: EditorState,
pub(crate) data_provider: D,
#[cfg(feature = "suggestions")]
pub(crate) suggestions: Vec<SuggestionItem>,
#[cfg(feature = "validation")]
pub(crate) external_validation_callback: Option<
Box<
dyn FnMut(usize, &str) -> crate::validation::ExternalValidationState
+ Send
+ Sync,
>,
>,
}
impl<D: DataProvider> FormEditor<D> {
// Make helpers visible to sibling modules in this crate
pub(crate) fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
s.char_indices()
.nth(char_idx)
.map(|(byte_idx, _)| byte_idx)
.unwrap_or_else(|| s.len())
}
#[allow(dead_code)]
pub(crate) fn byte_to_char_index(s: &str, byte_idx: usize) -> usize {
s[..byte_idx].chars().count()
}
pub fn new(data_provider: D) -> Self {
let editor = Self {
ui_state: EditorState::new(),
data_provider,
#[cfg(feature = "suggestions")]
suggestions: Vec::new(),
#[cfg(feature = "validation")]
external_validation_callback: None,
};
#[cfg(feature = "validation")]
{
let mut editor = editor;
editor.initialize_validation();
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(editor.ui_state.current_mode);
}
editor
}
#[cfg(not(feature = "validation"))]
{
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(editor.ui_state.current_mode);
}
editor
}
}
// Library-internal, used by multiple modules
pub(crate) fn current_text(&self) -> &str {
let field_index = self.ui_state.current_field;
if field_index < self.data_provider.field_count() {
self.data_provider.field_value(field_index)
} else {
""
}
}
// Read-only getters
pub fn current_field(&self) -> usize {
self.ui_state.current_field()
}
pub fn cursor_position(&self) -> usize {
self.ui_state.cursor_position()
}
pub fn mode(&self) -> AppMode {
self.ui_state.mode()
}
#[cfg(feature = "suggestions")]
pub fn is_suggestions_active(&self) -> bool {
self.ui_state.is_suggestions_active()
}
pub fn ui_state(&self) -> &EditorState {
&self.ui_state
}
pub fn data_provider(&self) -> &D {
&self.data_provider
}
pub fn data_provider_mut(&mut self) -> &mut D {
&mut self.data_provider
}
#[cfg(feature = "suggestions")]
pub fn suggestions(&self) -> &[SuggestionItem] {
&self.suggestions
}
#[cfg(feature = "validation")]
pub fn validation_state(&self) -> &crate::validation::ValidationState {
self.ui_state.validation_state()
}
// Cursor cleanup
#[cfg(feature = "cursor-style")]
pub fn cleanup_cursor(&self) -> std::io::Result<()> {
CursorManager::reset()
}
#[cfg(not(feature = "cursor-style"))]
pub fn cleanup_cursor(&self) -> std::io::Result<()> {
Ok(())
}
}
impl<D: DataProvider> Drop for FormEditor<D> {
fn drop(&mut self) {
let _ = self.cleanup_cursor();
}
}

View File

@@ -1,123 +0,0 @@
// src/editor/display.rs
use crate::canvas::modes::AppMode;
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
/// Get current field text for display.
/// Policies documented in original file.
#[cfg(feature = "validation")]
pub fn current_display_text(&self) -> String {
let field_index = self.ui_state.current_field;
let raw = if field_index < self.data_provider.field_count() {
self.data_provider.field_value(field_index)
} else {
""
};
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
if cfg.custom_formatter.is_none() {
if let Some(mask) = &cfg.display_mask {
return mask.apply_to_display(raw);
}
}
if cfg.custom_formatter.is_some() {
if matches!(self.ui_state.current_mode, AppMode::Edit) {
return raw.to_string();
}
if let Some((formatted, _mapper, _warning)) =
cfg.run_custom_formatter(raw)
{
return formatted;
}
}
if let Some(mask) = &cfg.display_mask {
return mask.apply_to_display(raw);
}
}
raw.to_string()
}
/// Get effective display text for any field index (Feature 4 + masks).
#[cfg(feature = "validation")]
pub fn display_text_for_field(&self, field_index: usize) -> String {
let raw = if field_index < self.data_provider.field_count() {
self.data_provider.field_value(field_index)
} else {
""
};
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
if cfg.custom_formatter.is_none() {
if let Some(mask) = &cfg.display_mask {
return mask.apply_to_display(raw);
}
}
if cfg.custom_formatter.is_some() {
if field_index == self.ui_state.current_field
&& matches!(self.ui_state.current_mode, AppMode::Edit)
{
return raw.to_string();
}
if let Some((formatted, _mapper, _warning)) =
cfg.run_custom_formatter(raw)
{
return formatted;
}
}
if let Some(mask) = &cfg.display_mask {
return mask.apply_to_display(raw);
}
}
raw.to_string()
}
/// Map raw cursor to display position (formatter/mask aware).
pub fn display_cursor_position(&self) -> usize {
let current_text = self.current_text();
let char_count = current_text.chars().count();
let raw_pos = match self.ui_state.current_mode {
AppMode::Edit => self.ui_state.cursor_pos.min(char_count),
_ => {
if char_count == 0 {
0
} else {
self.ui_state
.cursor_pos
.min(char_count.saturating_sub(1))
}
}
};
#[cfg(feature = "validation")]
{
let field_index = self.ui_state.current_field;
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
if !matches!(self.ui_state.current_mode, AppMode::Edit) {
if let Some((formatted, mapper, _)) =
cfg.run_custom_formatter(current_text)
{
return mapper.raw_to_formatted(
current_text,
&formatted,
raw_pos,
);
}
}
if let Some(mask) = &cfg.display_mask {
return mask.raw_pos_to_display_pos(raw_pos);
}
}
}
raw_pos
}
}

View File

@@ -1,348 +0,0 @@
// src/editor/editing.rs
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
/// Open new line below (vim o)
pub fn open_line_below(&mut self) -> anyhow::Result<()> {
// paste the method body unchanged from editor.rs
// (exact code from your VIM COMMANDS: o and O section)
let field_count = self.data_provider.field_count();
if field_count == 0 {
return Ok(());
}
let next_field = (self.ui_state.current_field + 1)
.min(field_count.saturating_sub(1));
self.transition_to_field(next_field)?;
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
self.enter_edit_mode();
Ok(())
}
/// Open new line above (vim O)
pub fn open_line_above(&mut self) -> anyhow::Result<()> {
let prev_field = self.ui_state.current_field.saturating_sub(1);
self.transition_to_field(prev_field)?;
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
self.enter_edit_mode();
Ok(())
}
/// Handle character insertion (mask/limit-aware)
pub fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
// paste entire insert_char body unchanged
if self.ui_state.current_mode != crate::canvas::modes::AppMode::Edit
{
return Ok(());
}
#[cfg(feature = "validation")]
let field_index = self.ui_state.current_field;
#[cfg(feature = "validation")]
let raw_cursor_pos = self.ui_state.cursor_pos;
#[cfg(feature = "validation")]
let current_raw_text = self.data_provider.field_value(field_index);
#[cfg(not(feature = "validation"))]
let field_index = self.ui_state.current_field;
#[cfg(not(feature = "validation"))]
let raw_cursor_pos = self.ui_state.cursor_pos;
#[cfg(not(feature = "validation"))]
let current_raw_text = self.data_provider.field_value(field_index);
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(
field_index,
) {
if let Some(mask) = &cfg.display_mask {
let display_cursor_pos =
mask.raw_pos_to_display_pos(raw_cursor_pos);
let pattern_char_len = mask.pattern().chars().count();
if display_cursor_pos >= pattern_char_len {
return Ok(());
}
if !mask.is_input_position(display_cursor_pos) {
return Ok(());
}
let input_slots = (0..pattern_char_len)
.filter(|&pos| mask.is_input_position(pos))
.count();
if current_raw_text.chars().count() >= input_slots {
return Ok(());
}
}
}
}
#[cfg(feature = "validation")]
{
let vr = self.ui_state.validation.validate_char_insertion(
field_index,
current_raw_text,
raw_cursor_pos,
ch,
);
if !vr.is_acceptable() {
return Ok(());
}
}
let new_raw_text = {
let mut temp = current_raw_text.to_string();
let byte_pos = Self::char_to_byte_index(
current_raw_text,
raw_cursor_pos,
);
temp.insert(byte_pos, ch);
temp
};
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(
field_index,
) {
if let Some(limits) = &cfg.character_limits {
if let Some(result) = limits.validate_content(&new_raw_text)
{
if !result.is_acceptable() {
return Ok(());
}
}
}
if let Some(mask) = &cfg.display_mask {
let pattern_char_len = mask.pattern().chars().count();
let input_slots = (0..pattern_char_len)
.filter(|&pos| mask.is_input_position(pos))
.count();
if new_raw_text.chars().count() > input_slots {
return Ok(());
}
}
}
}
self.data_provider
.set_field_value(field_index, new_raw_text.clone());
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(
field_index,
) {
if let Some(mask) = &cfg.display_mask {
let new_raw_pos = raw_cursor_pos + 1;
let display_pos = mask.raw_pos_to_display_pos(new_raw_pos);
let next_input_display =
mask.next_input_position(display_pos);
let next_raw_pos =
mask.display_pos_to_raw_pos(next_input_display);
let max_raw = new_raw_text.chars().count();
self.ui_state.cursor_pos = next_raw_pos.min(max_raw);
self.ui_state.ideal_cursor_column =
self.ui_state.cursor_pos;
return Ok(());
}
}
}
self.ui_state.cursor_pos = raw_cursor_pos + 1;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
Ok(())
}
/// Delete backward (backspace)
pub fn delete_backward(&mut self) -> anyhow::Result<()> {
// paste entire delete_backward body unchanged
if self.ui_state.current_mode != crate::canvas::modes::AppMode::Edit
{
return Ok(());
}
if self.ui_state.cursor_pos == 0 {
return Ok(());
}
let field_index = self.ui_state.current_field;
let mut current_text =
self.data_provider.field_value(field_index).to_string();
let new_cursor = self.ui_state.cursor_pos.saturating_sub(1);
let start = Self::char_to_byte_index(
&current_text,
self.ui_state.cursor_pos - 1,
);
let end =
Self::char_to_byte_index(&current_text, self.ui_state.cursor_pos);
current_text.replace_range(start..end, "");
self.data_provider
.set_field_value(field_index, current_text.clone());
#[cfg(feature = "validation")]
let mut target_cursor = new_cursor;
#[cfg(not(feature = "validation"))]
let target_cursor = new_cursor;
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(
field_index,
) {
if let Some(mask) = &cfg.display_mask {
let display_pos =
mask.raw_pos_to_display_pos(new_cursor);
if let Some(prev_input) =
mask.prev_input_position(display_pos)
{
target_cursor =
mask.display_pos_to_raw_pos(prev_input);
}
}
}
}
self.ui_state.cursor_pos = target_cursor;
self.ui_state.ideal_cursor_column = target_cursor;
#[cfg(feature = "validation")]
{
let _ = self.ui_state.validation.validate_field_content(
field_index,
&current_text,
);
}
Ok(())
}
/// Delete forward (Delete key)
pub fn delete_forward(&mut self) -> anyhow::Result<()> {
// paste entire delete_forward body unchanged
if self.ui_state.current_mode != crate::canvas::modes::AppMode::Edit
{
return Ok(());
}
let field_index = self.ui_state.current_field;
let mut current_text =
self.data_provider.field_value(field_index).to_string();
if self.ui_state.cursor_pos < current_text.chars().count() {
let start = Self::char_to_byte_index(
&current_text,
self.ui_state.cursor_pos,
);
let end = Self::char_to_byte_index(
&current_text,
self.ui_state.cursor_pos + 1,
);
current_text.replace_range(start..end, "");
self.data_provider
.set_field_value(field_index, current_text.clone());
#[cfg(feature = "validation")]
let mut target_cursor = self.ui_state.cursor_pos;
#[cfg(not(feature = "validation"))]
let target_cursor = self.ui_state.cursor_pos;
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(
field_index,
) {
if let Some(mask) = &cfg.display_mask {
let display_pos =
mask.raw_pos_to_display_pos(
self.ui_state.cursor_pos,
);
let next_input =
mask.next_input_position(display_pos);
target_cursor = mask
.display_pos_to_raw_pos(next_input)
.min(current_text.chars().count());
}
}
}
self.ui_state.cursor_pos = target_cursor;
self.ui_state.ideal_cursor_column = target_cursor;
#[cfg(feature = "validation")]
{
let _ = self.ui_state.validation.validate_field_content(
field_index,
&current_text,
);
}
}
Ok(())
}
/// Enter edit mode with cursor positioned for append (vim 'a')
pub fn enter_append_mode(&mut self) {
// paste body unchanged
let current_text = self.current_text();
let char_len = current_text.chars().count();
let append_pos = if current_text.is_empty() {
0
} else {
(self.ui_state.cursor_pos + 1).min(char_len)
};
self.ui_state.cursor_pos = append_pos;
self.ui_state.ideal_cursor_column = append_pos;
self.set_mode(crate::canvas::modes::AppMode::Edit);
}
/// Set current field value (validates under feature flag)
pub fn set_current_field_value(&mut self, value: String) {
let field_index = self.ui_state.current_field;
self.data_provider.set_field_value(field_index, value.clone());
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
#[cfg(feature = "validation")]
{
let _ = self
.ui_state
.validation
.validate_field_content(field_index, &value);
}
}
/// Set specific field value by index (validates under feature flag)
pub fn set_field_value(&mut self, field_index: usize, value: String) {
if field_index < self.data_provider.field_count() {
self.data_provider
.set_field_value(field_index, value.clone());
if field_index == self.ui_state.current_field {
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
}
#[cfg(feature = "validation")]
{
let _ = self
.ui_state
.validation
.validate_field_content(field_index, &value);
}
}
}
/// Clear the current field
pub fn clear_current_field(&mut self) {
self.set_current_field_value(String::new());
}
}

View File

@@ -1,25 +0,0 @@
// src/editor/mod.rs
//! Editor submodule exports.
//!
//! This module exposes the internal editor pieces (core, editing, movement,
//! navigation, mode, and optional features like suggestions, validation, and
//! computed field helpers). Only module declarations and re-exports live here.
pub mod core;
pub mod display;
pub mod editing;
pub mod movement;
pub mod navigation;
pub mod mode;
#[cfg(feature = "suggestions")]
pub mod suggestions;
#[cfg(feature = "validation")]
pub mod validation_helpers;
#[cfg(feature = "computed")]
pub mod computed_helpers;
// Re-export the main type
pub use core::FormEditor;

View File

@@ -1,305 +0,0 @@
// src/editor/mode.rs
#[cfg(feature = "cursor-style")]
use crate::canvas::CursorManager;
use crate::canvas::modes::AppMode;
use crate::canvas::state::SelectionState;
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
/// Change mode
pub fn set_mode(&mut self, mode: AppMode) {
// Avoid unused param warning in normalmode
#[cfg(feature = "textmode-normal")]
let _ = mode;
// NORMALMODE: force Edit, ignore requested mode
#[cfg(feature = "textmode-normal")]
{
self.ui_state.current_mode = AppMode::Edit;
self.ui_state.selection = SelectionState::None;
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(AppMode::Edit);
}
}
// Default (not normal): original vim behavior
#[cfg(not(feature = "textmode-normal"))]
match (self.ui_state.current_mode, mode) {
(AppMode::ReadOnly, AppMode::Highlight) => {
self.enter_highlight_mode();
}
(AppMode::Highlight, AppMode::ReadOnly) => {
self.exit_highlight_mode();
}
(_, new_mode) => {
self.ui_state.current_mode = new_mode;
if new_mode != AppMode::Highlight {
self.ui_state.selection = SelectionState::None;
}
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(new_mode);
}
}
}
}
/// Exit edit mode to read-only mode
pub fn exit_edit_mode(&mut self) -> anyhow::Result<()> {
#[cfg(feature = "validation")]
{
let current_text = self.current_text();
if !self.ui_state.validation.allows_field_switch(
self.ui_state.current_field,
current_text,
) {
if let Some(reason) = self
.ui_state
.validation
.field_switch_block_reason(
self.ui_state.current_field,
current_text,
)
{
self.ui_state
.validation
.set_last_switch_block(reason.clone());
return Err(anyhow::anyhow!(
"Cannot exit edit mode: {}",
reason
));
}
}
}
let current_text = self.current_text();
if !current_text.is_empty() {
let max_normal_pos =
current_text.chars().count().saturating_sub(1);
if self.ui_state.cursor_pos > max_normal_pos {
self.ui_state.cursor_pos = max_normal_pos;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
}
}
#[cfg(feature = "validation")]
{
let field_index = self.ui_state.current_field;
if let Some(cfg) =
self.ui_state.validation.get_field_config(field_index)
{
if cfg.external_validation_enabled {
let text = self.current_text().to_string();
if !text.is_empty() {
self.set_external_validation(
field_index,
crate::validation::ExternalValidationState::Validating,
);
if let Some(cb) =
self.external_validation_callback.as_mut()
{
let final_state = cb(field_index, &text);
self.set_external_validation(field_index, final_state);
}
}
}
}
}
// NORMALMODE: stay in Edit (do not switch to ReadOnly)
#[cfg(feature = "textmode-normal")]
{
#[cfg(feature = "suggestions")]
{
self.close_suggestions();
}
Ok(())
}
// Default (not normal): original vim behavior
#[cfg(not(feature = "textmode-normal"))]
{
self.set_mode(AppMode::ReadOnly);
#[cfg(feature = "suggestions")]
{
self.close_suggestions();
}
Ok(())
}
}
/// Enter edit mode
pub fn enter_edit_mode(&mut self) {
#[cfg(feature = "computed")]
{
if let Some(computed_state) = &self.ui_state.computed {
if computed_state.is_computed_field(self.ui_state.current_field)
{
return;
}
}
}
// NORMALMODE: already in Edit, but enforce it
#[cfg(feature = "textmode-normal")]
{
self.ui_state.current_mode = AppMode::Edit;
self.ui_state.selection = SelectionState::None;
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(AppMode::Edit);
}
}
// Default (not normal): vim behavior
#[cfg(not(feature = "textmode-normal"))]
self.set_mode(AppMode::Edit);
}
// -------------------- Highlight/Visual mode -------------------------
pub fn enter_highlight_mode(&mut self) {
// NORMALMODE: ignore request (stay in Edit)
#[cfg(feature = "textmode-normal")]
{
}
// Default (not normal): original vim
#[cfg(not(feature = "textmode-normal"))]
{
if self.ui_state.current_mode == AppMode::ReadOnly {
self.ui_state.current_mode = AppMode::Highlight;
self.ui_state.selection = SelectionState::Characterwise {
anchor: (self.ui_state.current_field, self.ui_state.cursor_pos),
};
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(AppMode::Highlight);
}
}
}
}
pub fn enter_highlight_line_mode(&mut self) {
// NORMALMODE: ignore
#[cfg(feature = "textmode-normal")]
{
}
// Default (not normal): original vim
#[cfg(not(feature = "textmode-normal"))]
{
if self.ui_state.current_mode == AppMode::ReadOnly {
self.ui_state.current_mode = AppMode::Highlight;
self.ui_state.selection =
SelectionState::Linewise { anchor_field: self.ui_state.current_field };
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(AppMode::Highlight);
}
}
}
}
pub fn exit_highlight_mode(&mut self) {
// NORMALMODE: ignore
#[cfg(feature = "textmode-normal")]
{
}
// Default (not normal): original vim
#[cfg(not(feature = "textmode-normal"))]
{
if self.ui_state.current_mode == AppMode::Highlight {
self.ui_state.current_mode = AppMode::ReadOnly;
self.ui_state.selection = SelectionState::None;
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(AppMode::ReadOnly);
}
}
}
}
pub fn is_highlight_mode(&self) -> bool {
#[cfg(feature = "textmode-normal")]
{
false
}
#[cfg(not(feature = "textmode-normal"))]
{
return self.ui_state.current_mode == AppMode::Highlight;
}
}
pub fn selection_state(&self) -> &SelectionState {
&self.ui_state.selection
}
// Visual-mode movements reuse existing movement methods
// These keep calling the movement methods; in normalmode selection is never enabled,
// so these just move without creating a selection.
pub fn move_left_with_selection(&mut self) {
let _ = self.move_left();
}
pub fn move_right_with_selection(&mut self) {
let _ = self.move_right();
}
pub fn move_up_with_selection(&mut self) {
let _ = self.move_up();
}
pub fn move_down_with_selection(&mut self) {
let _ = self.move_down();
}
pub fn move_word_next_with_selection(&mut self) {
self.move_word_next();
}
pub fn move_word_end_with_selection(&mut self) {
self.move_word_end();
}
pub fn move_word_prev_with_selection(&mut self) {
self.move_word_prev();
}
pub fn move_word_end_prev_with_selection(&mut self) {
self.move_word_end_prev();
}
pub fn move_big_word_next_with_selection(&mut self) {
self.move_big_word_next();
}
pub fn move_big_word_end_with_selection(&mut self) {
self.move_big_word_end();
}
pub fn move_big_word_prev_with_selection(&mut self) {
self.move_big_word_prev();
}
pub fn move_big_word_end_prev_with_selection(&mut self) {
self.move_big_word_end_prev();
}
pub fn move_line_start_with_selection(&mut self) {
self.move_line_start();
}
pub fn move_line_end_with_selection(&mut self) {
self.move_line_end();
}
}

View File

@@ -1,687 +0,0 @@
// src/editor/movement.rs
use crate::canvas::actions::movement::line::{
line_end_position, line_start_position,
};
use crate::canvas::modes::AppMode;
use crate::editor::FormEditor;
use crate::DataProvider;
use crate::canvas::actions::movement::word::{
find_last_big_word_start_in_field, find_last_word_start_in_field,
};
impl<D: DataProvider> FormEditor<D> {
/// Move cursor left within current field (mask-aware)
pub fn move_left(&mut self) -> anyhow::Result<()> {
#[cfg(feature = "validation")]
let mut moved = false;
#[cfg(not(feature = "validation"))]
let moved = false;
#[cfg(feature = "validation")]
{
let field_index = self.ui_state.current_field;
if let Some(cfg) =
self.ui_state.validation.get_field_config(field_index)
{
if let Some(mask) = &cfg.display_mask {
let display_pos =
mask.raw_pos_to_display_pos(self.ui_state.cursor_pos);
if let Some(prev_input) =
mask.prev_input_position(display_pos)
{
let raw_pos =
mask.display_pos_to_raw_pos(prev_input);
let max_pos = self.current_text().chars().count();
self.ui_state.cursor_pos = raw_pos.min(max_pos);
self.ui_state.ideal_cursor_column =
self.ui_state.cursor_pos;
moved = true;
} else {
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
moved = true;
}
}
}
}
if !moved
&& self.ui_state.cursor_pos > 0 {
self.ui_state.cursor_pos -= 1;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
}
Ok(())
}
/// Move cursor right within current field (mask-aware)
pub fn move_right(&mut self) -> anyhow::Result<()> {
#[cfg(feature = "validation")]
let mut moved = false;
#[cfg(not(feature = "validation"))]
let moved = false;
#[cfg(feature = "validation")]
{
let field_index = self.ui_state.current_field;
if let Some(cfg) =
self.ui_state.validation.get_field_config(field_index)
{
if let Some(mask) = &cfg.display_mask {
let display_pos =
mask.raw_pos_to_display_pos(self.ui_state.cursor_pos);
let next_display_pos = mask.next_input_position(display_pos);
let next_pos =
mask.display_pos_to_raw_pos(next_display_pos);
let max_pos = self.current_text().chars().count();
self.ui_state.cursor_pos = next_pos.min(max_pos);
self.ui_state.ideal_cursor_column =
self.ui_state.cursor_pos;
moved = true;
}
}
}
if !moved {
let max_pos = self.current_text().chars().count();
if self.ui_state.cursor_pos < max_pos {
self.ui_state.cursor_pos += 1;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
}
}
Ok(())
}
/// Move to start of current field (vim 0)
pub fn move_line_start(&mut self) {
let new_pos = line_start_position();
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
}
/// Move to end of current field (vim $)
pub fn move_line_end(&mut self) {
let current_text = self.current_text();
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let new_pos = line_end_position(current_text, is_edit_mode);
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
}
/// Set cursor to exact position (for f/F/t/T etc.)
pub fn set_cursor_position(&mut self, position: usize) {
let current_text = self.current_text();
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = current_text.chars().count();
let max_pos = if is_edit_mode {
char_len
} else {
char_len.saturating_sub(1)
};
let clamped_pos = position.min(max_pos);
self.ui_state.cursor_pos = clamped_pos;
self.ui_state.ideal_cursor_column = clamped_pos;
}
}
impl<D: DataProvider> FormEditor<D> {
/// Move to start of next word (vim w) - can cross field boundaries
pub fn move_word_next(&mut self) {
use crate::canvas::actions::movement::word::find_next_word_start;
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to next field
if self.move_down().is_ok() {
// Successfully moved to next field, try to find first word
let new_text = self.current_text();
if !new_text.is_empty() {
let first_word_pos = if new_text.chars().next().is_some_and(|c| !c.is_whitespace()) {
// Field starts with non-whitespace, go to position 0
0
} else {
// Field starts with whitespace, find first word
find_next_word_start(new_text, 0)
};
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_word_pos.min(char_len)
} else {
first_word_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
let new_pos = find_next_word_start(current_text, current_pos);
// Check if we've hit the end of the current field
if new_pos >= current_text.chars().count() {
// At end of field - jump to next field and start from beginning
if self.move_down().is_ok() {
// Successfully moved to next field
let new_text = self.current_text();
if new_text.is_empty() {
// New field is empty, cursor stays at 0
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
} else {
// Find first word in new field
let first_word_pos = if new_text.chars().next().is_some_and(|c| !c.is_whitespace()) {
// Field starts with non-whitespace, go to position 0
0
} else {
// Field starts with whitespace, find first word
find_next_word_start(new_text, 0)
};
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_word_pos.min(char_len)
} else {
first_word_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
// If move_down() failed, we stay where we are (at end of last field)
} else {
// Normal word movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = current_text.chars().count();
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
/// Move to start of previous word (vim b) - can cross field boundaries
pub fn move_word_prev(&mut self) {
use crate::canvas::actions::movement::word::find_prev_word_start;
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to previous field and find last word
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_word_start = find_last_word_start_in_field(new_text);
self.ui_state.cursor_pos = last_word_start;
self.ui_state.ideal_cursor_column = last_word_start;
}
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
// Special case: if we're at position 0, jump to previous field
if current_pos == 0 {
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_word_start = find_last_word_start_in_field(new_text);
self.ui_state.cursor_pos = last_word_start;
self.ui_state.ideal_cursor_column = last_word_start;
}
}
}
return;
}
// Try to find previous word in current field
let new_pos = find_prev_word_start(current_text, current_pos);
// Check if we actually moved
if new_pos < current_pos {
// Normal word movement within current field - we found a previous word
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
} else {
// We didn't move (probably at start of first word), try previous field
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_word_start = find_last_word_start_in_field(new_text);
self.ui_state.cursor_pos = last_word_start;
self.ui_state.ideal_cursor_column = last_word_start;
}
}
}
}
}
/// Move to end of current/next word (vim e) - can cross field boundaries
pub fn move_word_end(&mut self) {
use crate::canvas::actions::movement::word::find_word_end;
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to next field
if self.move_down().is_ok() {
// Recursively call move_word_end in the new field
self.move_word_end();
}
return;
}
let current_pos = self.ui_state.cursor_pos;
let char_len = current_text.chars().count();
let new_pos = find_word_end(current_text, current_pos);
// Check if we didn't move or hit the end of the field
if new_pos == current_pos && current_pos + 1 < char_len {
// Try next character and find word end from there
let next_pos = find_word_end(current_text, current_pos + 1);
if next_pos < char_len {
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let final_pos = if is_edit_mode {
next_pos.min(char_len)
} else {
next_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
return;
}
}
// If we're at or near the end of the field, try next field
if new_pos >= char_len.saturating_sub(1) {
if self.move_down().is_ok() {
// Position at start and find first word end
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
self.move_word_end();
}
} else {
// Normal word end movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
/// Move to end of previous word (vim ge) - can cross field boundaries
pub fn move_word_end_prev(&mut self) {
use crate::canvas::actions::movement::word::{find_prev_word_end, find_last_word_end_in_field};
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to previous field (but don't recurse)
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
// Find end of last word in the field
let last_word_end = find_last_word_end_in_field(new_text);
self.ui_state.cursor_pos = last_word_end;
self.ui_state.ideal_cursor_column = last_word_end;
}
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
// Special case: if we're at position 0, jump to previous field (but don't recurse)
if current_pos == 0 {
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_word_end = find_last_word_end_in_field(new_text);
self.ui_state.cursor_pos = last_word_end;
self.ui_state.ideal_cursor_column = last_word_end;
}
}
}
return;
}
let new_pos = find_prev_word_end(current_text, current_pos);
// Only try to cross fields if we didn't move at all (stayed at same position)
if new_pos == current_pos {
// We didn't move within the current field, try previous field
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_word_end = find_last_word_end_in_field(new_text);
self.ui_state.cursor_pos = last_word_end;
self.ui_state.ideal_cursor_column = last_word_end;
}
}
}
} else {
// Normal word movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = current_text.chars().count();
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
/// Move to start of next big_word (vim W) - can cross field boundaries
pub fn move_big_word_next(&mut self) {
use crate::canvas::actions::movement::word::find_next_big_word_start;
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to next field
if self.move_down().is_ok() {
// Successfully moved to next field, try to find first big_word
let new_text = self.current_text();
if !new_text.is_empty() {
let first_big_word_pos = if new_text.chars().next().is_some_and(|c| !c.is_whitespace()) {
// Field starts with non-whitespace, go to position 0
0
} else {
// Field starts with whitespace, find first big_word
find_next_big_word_start(new_text, 0)
};
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_big_word_pos.min(char_len)
} else {
first_big_word_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
let new_pos = find_next_big_word_start(current_text, current_pos);
// Check if we've hit the end of the current field
if new_pos >= current_text.chars().count() {
// At end of field - jump to next field and start from beginning
if self.move_down().is_ok() {
// Successfully moved to next field
let new_text = self.current_text();
if new_text.is_empty() {
// New field is empty, cursor stays at 0
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
} else {
// Find first big_word in new field
let first_big_word_pos = if new_text.chars().next().is_some_and(|c| !c.is_whitespace()) {
// Field starts with non-whitespace, go to position 0
0
} else {
// Field starts with whitespace, find first big_word
find_next_big_word_start(new_text, 0)
};
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_big_word_pos.min(char_len)
} else {
first_big_word_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
// If move_down() failed, we stay where we are (at end of last field)
} else {
// Normal big_word movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = current_text.chars().count();
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
/// Move to start of previous big_word (vim B) - can cross field boundaries
pub fn move_big_word_prev(&mut self) {
use crate::canvas::actions::movement::word::find_prev_big_word_start;
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to previous field and find last big_word
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_big_word_start = find_last_big_word_start_in_field(new_text);
self.ui_state.cursor_pos = last_big_word_start;
self.ui_state.ideal_cursor_column = last_big_word_start;
}
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
// Special case: if we're at position 0, jump to previous field
if current_pos == 0 {
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_big_word_start = find_last_big_word_start_in_field(new_text);
self.ui_state.cursor_pos = last_big_word_start;
self.ui_state.ideal_cursor_column = last_big_word_start;
}
}
}
return;
}
// Try to find previous big_word in current field
let new_pos = find_prev_big_word_start(current_text, current_pos);
// Check if we actually moved
if new_pos < current_pos {
// Normal big_word movement within current field - we found a previous big_word
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
} else {
// We didn't move (probably at start of first big_word), try previous field
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_big_word_start = find_last_big_word_start_in_field(new_text);
self.ui_state.cursor_pos = last_big_word_start;
self.ui_state.ideal_cursor_column = last_big_word_start;
}
}
}
}
}
/// Move to end of current/next big_word (vim E) - can cross field boundaries
pub fn move_big_word_end(&mut self) {
use crate::canvas::actions::movement::word::find_big_word_end;
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to next field (but don't recurse)
if self.move_down().is_ok() {
let new_text = self.current_text();
if !new_text.is_empty() {
// Find first big_word end in new field
let first_big_word_end = find_big_word_end(new_text, 0);
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_big_word_end.min(char_len)
} else {
first_big_word_end.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
let char_len = current_text.chars().count();
let new_pos = find_big_word_end(current_text, current_pos);
// Check if we didn't move or hit the end of the field
if new_pos == current_pos && current_pos + 1 < char_len {
// Try next character and find big_word end from there
let next_pos = find_big_word_end(current_text, current_pos + 1);
if next_pos < char_len {
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let final_pos = if is_edit_mode {
next_pos.min(char_len)
} else {
next_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
return;
}
}
// If we're at or near the end of the field, try next field (but don't recurse)
if new_pos >= char_len.saturating_sub(1) {
if self.move_down().is_ok() {
// Find first big_word end in new field
let new_text = self.current_text();
if !new_text.is_empty() {
let first_big_word_end = find_big_word_end(new_text, 0);
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let new_char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_big_word_end.min(new_char_len)
} else {
first_big_word_end.min(new_char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
} else {
// Normal big_word end movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
/// Move to end of previous big_word (vim gE) - can cross field boundaries
pub fn move_big_word_end_prev(&mut self) {
use crate::canvas::actions::movement::word::{
find_prev_big_word_end, find_big_word_end,
};
let current_text = self.current_text();
if current_text.is_empty() {
let current_field = self.ui_state.current_field;
if self.move_up().is_ok()
&& self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
// Find first big_word end in new field
let last_big_word_end = find_big_word_end(new_text, 0);
self.ui_state.cursor_pos = last_big_word_end;
self.ui_state.ideal_cursor_column = last_big_word_end;
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
let new_pos = find_prev_big_word_end(current_text, current_pos);
// Only try to cross fields if we didn't move at all (stayed at same position)
if new_pos == current_pos {
let current_field = self.ui_state.current_field;
if self.move_up().is_ok()
&& self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_big_word_end = find_big_word_end(new_text, 0);
self.ui_state.cursor_pos = last_big_word_end;
self.ui_state.ideal_cursor_column = last_big_word_end;
}
}
} else {
// Normal big_word movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = current_text.chars().count();
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
}

View File

@@ -1,177 +0,0 @@
// src/editor/navigation.rs
use crate::canvas::modes::AppMode;
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
/// Centralized field transition logic (unchanged).
pub fn transition_to_field(&mut self, new_field: usize) -> anyhow::Result<()> {
let field_count = self.data_provider.field_count();
if field_count == 0 {
return Ok(());
}
let prev_field = self.ui_state.current_field;
#[cfg(feature = "computed")]
let mut target_field = new_field.min(field_count - 1);
#[cfg(not(feature = "computed"))]
let target_field = new_field.min(field_count - 1);
#[cfg(feature = "computed")]
{
if let Some(computed_state) = &self.ui_state.computed {
if computed_state.is_computed_field(target_field) {
if target_field >= prev_field {
for i in (target_field + 1)..field_count {
if !computed_state.is_computed_field(i) {
target_field = i;
break;
}
}
} else {
let mut i = target_field;
loop {
if !computed_state.is_computed_field(i) {
target_field = i;
break;
}
if i == 0 {
break;
}
i -= 1;
}
}
}
}
}
if target_field == prev_field {
return Ok(());
}
#[cfg(feature = "validation")]
self.ui_state.validation.clear_last_switch_block();
#[cfg(feature = "validation")]
{
let current_text = self.current_text();
if !self
.ui_state
.validation
.allows_field_switch(prev_field, current_text)
{
if let Some(reason) = self
.ui_state
.validation
.field_switch_block_reason(prev_field, current_text)
{
self.ui_state
.validation
.set_last_switch_block(reason.clone());
tracing::debug!("Field switch blocked: {}", reason);
return Err(anyhow::anyhow!(
"Cannot switch fields: {}",
reason
));
}
}
}
#[cfg(feature = "validation")]
{
let text =
self.data_provider.field_value(prev_field).to_string();
let _ = self
.ui_state
.validation
.validate_field_content(prev_field, &text);
if let Some(cfg) =
self.ui_state.validation.get_field_config(prev_field)
{
if cfg.external_validation_enabled && !text.is_empty() {
self.set_external_validation(
prev_field,
crate::validation::ExternalValidationState::Validating,
);
if let Some(cb) =
self.external_validation_callback.as_mut()
{
let final_state = cb(prev_field, &text);
self.set_external_validation(prev_field, final_state);
}
}
}
}
#[cfg(feature = "computed")]
{
// Placeholder for recompute hook if needed later
}
self.ui_state.move_to_field(target_field, field_count);
let current_text = self.current_text();
let max_pos = current_text.chars().count();
self.ui_state.set_cursor(
self.ui_state.ideal_cursor_column,
max_pos,
self.ui_state.current_mode == AppMode::Edit,
);
// Automatically close suggestions on field switch
#[cfg(feature = "suggestions")]
{
self.close_suggestions();
}
Ok(())
}
/// Move to first line (vim gg)
pub fn move_first_line(&mut self) -> anyhow::Result<()> {
self.transition_to_field(0)
}
/// Move to last line (vim G)
pub fn move_last_line(&mut self) -> anyhow::Result<()> {
let last_field =
self.data_provider.field_count().saturating_sub(1);
self.transition_to_field(last_field)
}
/// Move to previous field (vim k / up)
pub fn move_up(&mut self) -> anyhow::Result<()> {
let new_field = self.ui_state.current_field.saturating_sub(1);
self.transition_to_field(new_field)
}
/// Move to next field (vim j / down)
pub fn move_down(&mut self) -> anyhow::Result<()> {
let new_field = (self.ui_state.current_field + 1)
.min(self.data_provider.field_count().saturating_sub(1));
self.transition_to_field(new_field)
}
/// Move to next field cyclic
pub fn move_to_next_field(&mut self) -> anyhow::Result<()> {
let field_count = self.data_provider.field_count();
if field_count == 0 {
return Ok(());
}
let new_field = (self.ui_state.current_field + 1) % field_count;
self.transition_to_field(new_field)
}
/// Aliases
pub fn prev_field(&mut self) -> anyhow::Result<()> {
self.move_up()
}
pub fn next_field(&mut self) -> anyhow::Result<()> {
self.move_down()
}
}

View File

@@ -1,166 +0,0 @@
// src/editor/suggestions.rs
use crate::editor::FormEditor;
use crate::{DataProvider, SuggestionItem};
impl<D: DataProvider> FormEditor<D> {
/// Compute inline completion for current selection and text
fn compute_current_completion(&self) -> Option<String> {
let typed = self.current_text();
let idx = self.ui_state.suggestions.selected_index?;
let sugg = self.suggestions.get(idx)?;
if let Some(rest) = sugg.value_to_store.strip_prefix(typed) {
if !rest.is_empty() {
return Some(rest.to_string());
}
}
None
}
/// Update UI state's completion text from current selection
pub fn update_inline_completion(&mut self) {
self.ui_state.suggestions.completion_text =
self.compute_current_completion();
}
/// Open the suggestions UI for `field_index`
pub fn open_suggestions(&mut self, field_index: usize) {
self.ui_state.open_suggestions(field_index);
}
/// Close suggestions UI and clear current suggestion results
pub fn close_suggestions(&mut self) {
self.ui_state.close_suggestions();
self.suggestions.clear();
}
/// Handle Escape key in ReadOnly mode (closes suggestions if active)
pub fn handle_escape_readonly(&mut self) {
if self.ui_state.suggestions.is_active {
self.close_suggestions();
}
}
// ----------------- Non-blocking suggestions API --------------------
#[cfg(feature = "suggestions")]
pub fn start_suggestions(&mut self, field_index: usize) -> Option<String> {
if !self.data_provider.supports_suggestions(field_index) {
return None;
}
let query = self.current_text().to_string();
self.ui_state.open_suggestions(field_index);
self.ui_state.suggestions.is_loading = true;
self.ui_state.suggestions.active_query = Some(query.clone());
self.suggestions.clear();
Some(query)
}
#[cfg(not(feature = "suggestions"))]
pub fn start_suggestions(&mut self, _field_index: usize) -> Option<String> {
None
}
#[cfg(feature = "suggestions")]
pub fn apply_suggestions_result(
&mut self,
field_index: usize,
query: &str,
results: Vec<SuggestionItem>,
) -> bool {
if self.ui_state.suggestions.active_field != Some(field_index) {
return false;
}
if self.ui_state.suggestions.active_query.as_deref() != Some(query) {
return false;
}
self.ui_state.suggestions.is_loading = false;
self.suggestions = results;
if !self.suggestions.is_empty() {
self.ui_state.suggestions.selected_index = Some(0);
self.update_inline_completion();
} else {
self.ui_state.suggestions.selected_index = None;
self.ui_state.suggestions.completion_text = None;
}
true
}
#[cfg(not(feature = "suggestions"))]
pub fn apply_suggestions_result(
&mut self,
_field_index: usize,
_query: &str,
_results: Vec<SuggestionItem>,
) -> bool {
false
}
#[cfg(feature = "suggestions")]
pub fn pending_suggestions_query(&self) -> Option<(usize, String)> {
if self.ui_state.suggestions.is_loading {
if let (Some(field), Some(query)) = (
self.ui_state.suggestions.active_field,
&self.ui_state.suggestions.active_query,
) {
return Some((field, query.clone()));
}
}
None
}
#[cfg(not(feature = "suggestions"))]
pub fn pending_suggestions_query(&self) -> Option<(usize, String)> {
None
}
pub fn cancel_suggestions(&mut self) {
self.close_suggestions();
}
pub fn suggestions_next(&mut self) {
if !self.ui_state.suggestions.is_active || self.suggestions.is_empty()
{
return;
}
let current = self.ui_state.suggestions.selected_index.unwrap_or(0);
let next = (current + 1) % self.suggestions.len();
self.ui_state.suggestions.selected_index = Some(next);
self.update_inline_completion();
}
pub fn apply_suggestion(&mut self) -> Option<String> {
if let Some(selected_index) = self.ui_state.suggestions.selected_index {
if let Some(suggestion) = self.suggestions.get(selected_index).cloned()
{
let field_index = self.ui_state.current_field;
self.data_provider.set_field_value(
field_index,
suggestion.value_to_store.clone(),
);
self.ui_state.cursor_pos = suggestion.value_to_store.len();
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
self.close_suggestions();
self.suggestions.clear();
#[cfg(feature = "validation")]
{
let _ = self.ui_state.validation.validate_field_content(
field_index,
&suggestion.value_to_store,
);
}
return Some(suggestion.display_text);
}
}
None
}
}

View File

@@ -1,23 +0,0 @@
// src/editor/suggestions_stub.rs
// Crate-private no-op methods so internal calls compile when feature is off.
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
pub(crate) fn open_suggestions(&mut self, _field_index: usize) {
// no-op
}
pub(crate) fn close_suggestions(&mut self) {
// no-op
}
pub(crate) fn handle_escape_readonly(&mut self) {
// no-op
}
pub(crate) fn cancel_suggestions(&mut self) {
// no-op
}
}

View File

@@ -1,178 +0,0 @@
// src/editor/validation_helpers.rs
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
#[cfg(feature = "validation")]
pub fn set_validation_enabled(&mut self, enabled: bool) {
self.ui_state.validation.set_enabled(enabled);
}
#[cfg(feature = "validation")]
pub fn is_validation_enabled(&self) -> bool {
self.ui_state.validation.is_enabled()
}
#[cfg(feature = "validation")]
pub fn set_field_validation(
&mut self,
field_index: usize,
config: crate::validation::ValidationConfig,
) {
self.ui_state
.validation
.set_field_config(field_index, config);
}
#[cfg(feature = "validation")]
pub fn remove_field_validation(&mut self, field_index: usize) {
self.ui_state.validation.remove_field_config(field_index);
}
#[cfg(feature = "validation")]
pub fn validate_current_field(
&mut self,
) -> crate::validation::ValidationResult {
let field_index = self.ui_state.current_field;
let current_text = self.current_text().to_string();
self.ui_state
.validation
.validate_field_content(field_index, &current_text)
}
#[cfg(feature = "validation")]
pub fn validate_field(
&mut self,
field_index: usize,
) -> Option<crate::validation::ValidationResult> {
if field_index < self.data_provider.field_count() {
let text =
self.data_provider.field_value(field_index).to_string();
Some(
self.ui_state
.validation
.validate_field_content(field_index, &text),
)
} else {
None
}
}
#[cfg(feature = "validation")]
pub fn clear_validation_results(&mut self) {
self.ui_state.validation.clear_all_results();
}
#[cfg(feature = "validation")]
pub fn validation_summary(
&self,
) -> crate::validation::ValidationSummary {
self.ui_state.validation.summary()
}
#[cfg(feature = "validation")]
pub fn can_switch_fields(&self) -> bool {
let current_text = self.current_text();
self.ui_state.validation.allows_field_switch(
self.ui_state.current_field,
current_text,
)
}
#[cfg(feature = "validation")]
pub fn field_switch_block_reason(&self) -> Option<String> {
let current_text = self.current_text();
self.ui_state.validation.field_switch_block_reason(
self.ui_state.current_field,
current_text,
)
}
#[cfg(feature = "validation")]
pub fn last_switch_block(&self) -> Option<&str> {
self.ui_state.validation.last_switch_block()
}
#[cfg(feature = "validation")]
pub fn current_limits_status_text(&self) -> Option<String> {
let idx = self.ui_state.current_field;
if let Some(cfg) = self.ui_state.validation.get_field_config(idx) {
if let Some(limits) = &cfg.character_limits {
return limits.status_text(self.current_text());
}
}
None
}
#[cfg(feature = "validation")]
pub fn current_formatter_warning(&self) -> Option<String> {
let idx = self.ui_state.current_field;
if let Some(cfg) = self.ui_state.validation.get_field_config(idx) {
if let Some((_fmt, _mapper, warn)) =
cfg.run_custom_formatter(self.current_text())
{
return warn;
}
}
None
}
#[cfg(feature = "validation")]
pub fn external_validation_of(
&self,
field_index: usize,
) -> crate::validation::ExternalValidationState {
self.ui_state
.validation
.get_external_validation(field_index)
}
#[cfg(feature = "validation")]
pub fn clear_all_external_validation(&mut self) {
self.ui_state.validation.clear_all_external_validation();
}
#[cfg(feature = "validation")]
pub fn clear_external_validation(&mut self, field_index: usize) {
self.ui_state
.validation
.clear_external_validation(field_index);
}
#[cfg(feature = "validation")]
pub fn set_external_validation(
&mut self,
field_index: usize,
state: crate::validation::ExternalValidationState,
) {
self.ui_state
.validation
.set_external_validation(field_index, state);
}
#[cfg(feature = "validation")]
pub fn set_external_validation_callback<F>(&mut self, callback: F)
where
F: FnMut(usize, &str) -> crate::validation::ExternalValidationState
+ Send
+ Sync
+ 'static,
{
self.external_validation_callback = Some(Box::new(callback));
}
#[cfg(feature = "validation")]
pub(crate) fn initialize_validation(&mut self) {
let field_count = self.data_provider.field_count();
for field_index in 0..field_count {
if let Some(config) =
self.data_provider.validation_config(field_index)
{
self.ui_state
.validation
.set_field_config(field_index, config);
}
}
}
}

View File

@@ -1,76 +0,0 @@
// src/lib.rs
pub mod canvas;
pub mod editor;
pub mod data_provider;
// Only include suggestions module if feature is enabled
#[cfg(feature = "suggestions")]
pub mod suggestions;
// Only include validation module if feature is enabled
#[cfg(feature = "validation")]
pub mod validation;
// First-class textarea module and exports
#[cfg(feature = "textarea")]
pub mod textarea;
// Only include computed module if feature is enabled
#[cfg(feature = "computed")]
pub mod computed;
#[cfg(feature = "cursor-style")]
pub use canvas::CursorManager;
// ===================================================================
// NEW API: Library-owned state pattern
// ===================================================================
// Main API exports
pub use editor::FormEditor;
pub use data_provider::DataProvider;
#[cfg(feature = "suggestions")]
pub use data_provider::{SuggestionsProvider, SuggestionItem};
// UI state (read-only access for users)
pub use canvas::state::EditorState;
pub use canvas::modes::AppMode;
// Actions and results (for users who want to handle actions manually)
pub use canvas::actions::{CanvasAction, ActionResult};
// Validation exports (only when validation feature is enabled)
#[cfg(feature = "validation")]
pub use validation::{
ValidationConfig, ValidationResult, ValidationError,
CharacterLimits, ValidationConfigBuilder, ValidationState,
ValidationSummary, PatternFilters, PositionFilter, PositionRange, CharacterFilter,
DisplayMask, // Simple display mask instead of complex ReservedCharacters
// Feature 4: custom formatting exports
CustomFormatter, FormattingResult, PositionMapper, DefaultPositionMapper,
};
// Computed exports (only when computed feature is enabled)
#[cfg(feature = "computed")]
pub use computed::{ComputedProvider, ComputedContext, ComputedState};
// Theming and GUI
#[cfg(feature = "gui")]
pub use canvas::theme::{CanvasTheme, DefaultCanvasTheme};
#[cfg(feature = "gui")]
pub use canvas::gui::{render_canvas, render_canvas_default};
#[cfg(feature = "gui")]
pub use canvas::gui::render_canvas_with_options;
#[cfg(feature = "gui")]
pub use canvas::gui::{CanvasDisplayOptions, OverflowMode};
#[cfg(all(feature = "gui", feature = "suggestions"))]
pub use suggestions::gui::render_suggestions_dropdown;
#[cfg(feature = "textarea")]
pub use textarea::{TextArea, TextAreaProvider, TextAreaState, TextAreaEditor};

View File

@@ -1,191 +0,0 @@
// src/suggestions/gui.rs
//! Suggestions dropdown GUI (not inline autocomplete) updated to work with FormEditor
#[cfg(feature = "gui")]
use ratatui::{
layout::{Alignment, Rect},
style::{Modifier, Style},
widgets::{Block, List, ListItem, ListState, Paragraph}, // Removed Borders
Frame,
};
#[cfg(feature = "gui")]
use crate::canvas::theme::CanvasTheme;
use crate::data_provider::{DataProvider, SuggestionItem};
use crate::editor::FormEditor;
#[cfg(feature = "gui")]
use unicode_width::UnicodeWidthStr;
/// Render suggestions dropdown for FormEditor - call this AFTER rendering canvas
#[cfg(feature = "gui")]
pub fn render_suggestions_dropdown<T: CanvasTheme, D: DataProvider>(
f: &mut Frame,
frame_area: Rect,
input_rect: Rect,
theme: &T,
editor: &FormEditor<D>,
) {
let ui_state = editor.ui_state();
if !ui_state.is_suggestions_active() {
return;
}
if ui_state.suggestions.is_loading {
render_loading_indicator(f, frame_area, input_rect, theme);
} else if !editor.suggestions().is_empty() {
render_suggestions_dropdown_list(f, frame_area, input_rect, theme, editor.suggestions(), ui_state.suggestions.selected_index);
}
}
/// 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_list<T: CanvasTheme>(
f: &mut Frame,
frame_area: Rect,
input_rect: Rect,
theme: &T,
suggestions: &[SuggestionItem], // Fixed: Removed <String> generic parameter
selected_index: Option<usize>,
) {
let display_texts: Vec<&str> = 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,
selected_index,
dropdown_dimensions.width,
theme,
);
let list = List::new(items).block(dropdown_block);
let mut list_state = ListState::default();
list_state.select(selected_index);
f.render_stateful_widget(list, dropdown_area, &mut list_state);
}
/// Calculate dropdown size based on suggestions
#[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;
let width = (max_width + horizontal_padding).max(10);
let height = (display_texts.len() as u16).min(5);
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
}
/// Create styled list items
#[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 available_width = dropdown_width;
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,
}

View File

@@ -1,16 +0,0 @@
// src/suggestions/mod.rs
//! Suggestions subsystem - provider and optional GUI.
//!
//! Contains the suggestion provider types used by the editor and, when the GUI
//! feature is enabled, the rendering helpers for the suggestions dropdown.
pub mod state;
#[cfg(feature = "gui")]
pub mod gui;
// Re-export the main suggestion types
pub use state::{SuggestionsProvider, SuggestionItem};
// Re-export GUI functions if available
#[cfg(feature = "gui")]
pub use gui::render_suggestions_dropdown;

View File

@@ -1,5 +0,0 @@
// src/suggestions/state.rs
//! Suggestions provider types (for dropdown suggestions, not real inline autocomplete)
// Re-export the main types from data_provider
pub use crate::data_provider::{SuggestionsProvider, SuggestionItem};

View File

@@ -1,183 +0,0 @@
// src/textarea/highlight/chunks.rs
use ratatui::text::{Line, Span};
use ratatui::style::Style;
use unicode_width::UnicodeWidthChar;
#[derive(Debug, Clone)]
pub struct StyledChunk {
pub text: String,
pub style: Style,
}
pub fn display_width_chunks(chunks: &[StyledChunk]) -> u16 {
chunks
.iter()
.map(|c| {
c.text
.chars()
.map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0) as u16)
.sum::<u16>()
})
.sum()
}
pub fn slice_chunks_by_display_cols(
chunks: &[StyledChunk],
start_cols: u16,
max_cols: u16,
) -> Vec<StyledChunk> {
if max_cols == 0 {
return Vec::new();
}
let mut skipped: u16 = 0;
let mut taken: u16 = 0;
let mut out: Vec<StyledChunk> = Vec::new();
for ch in chunks {
if taken >= max_cols {
break;
}
let mut acc = String::new();
for c in ch.text.chars() {
let w = UnicodeWidthChar::width(c).unwrap_or(0) as u16;
if skipped + w <= start_cols {
skipped += w;
continue;
}
if taken + w > max_cols {
break;
}
acc.push(c);
taken = taken.saturating_add(w);
if taken >= max_cols {
break;
}
}
if !acc.is_empty() {
out.push(StyledChunk {
text: acc,
style: ch.style,
});
}
}
out
}
pub fn clip_chunks_window_with_indicator_padded(
chunks: &[StyledChunk],
view_width: u16,
indicator: char,
start_cols: u16,
) -> Line<'static> {
if view_width == 0 {
return Line::from("");
}
let total = display_width_chunks(chunks);
let show_left = start_cols > 0;
let left_cols: u16 = if show_left { 1 } else { 0 };
let cap_with_right = view_width.saturating_sub(left_cols + 1);
let remaining = total.saturating_sub(start_cols);
let show_right = remaining > cap_with_right;
let max_visible = if show_right {
cap_with_right
} else {
view_width.saturating_sub(left_cols)
};
let visible = slice_chunks_by_display_cols(chunks, start_cols, max_visible);
let used_cols = left_cols + display_width_chunks(&visible);
let mut spans: Vec<Span> = Vec::new();
if show_left {
spans.push(Span::raw(indicator.to_string()));
}
for v in visible {
spans.push(Span::styled(v.text, v.style));
}
if show_right {
let right_pos = view_width.saturating_sub(1);
let filler = right_pos.saturating_sub(used_cols);
if filler > 0 {
spans.push(Span::raw(" ".repeat(filler as usize)));
}
spans.push(Span::raw(indicator.to_string()));
}
Line::from(spans)
}
pub fn wrap_chunks_indented(
chunks: &[StyledChunk],
width: u16,
indent: u16,
) -> Vec<Line<'static>> {
if width == 0 {
return vec![Line::from("")];
}
let indent = indent.min(width.saturating_sub(1));
let cont_cap = width.saturating_sub(indent);
let indent_str = " ".repeat(indent as usize);
let mut lines: Vec<Line> = Vec::new();
let mut current_spans: Vec<Span> = Vec::new();
let mut used: u16 = 0;
let mut first_line = true;
// Fixed: Restructure to avoid borrow checker issues
for chunk in chunks {
let mut buf = String::new();
let mut buf_style = chunk.style;
for ch in chunk.text.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
let cap = if first_line { width } else { cont_cap };
if used > 0 && used.saturating_add(w) >= cap {
if !buf.is_empty() {
current_spans.push(Span::styled(buf.clone(), buf_style));
buf.clear();
}
lines.push(Line::from(current_spans));
current_spans = Vec::new();
first_line = false;
used = 0;
// Add indent directly instead of using closure
if !first_line && indent > 0 {
current_spans.push(Span::raw(indent_str.clone()));
used = indent;
}
}
if !buf.is_empty() && buf_style != chunk.style {
current_spans.push(Span::styled(buf.clone(), buf_style));
buf.clear();
}
buf_style = chunk.style;
// Add indent if needed
if used == 0 && !first_line && indent > 0 {
current_spans.push(Span::raw(indent_str.clone()));
used = indent;
}
buf.push(ch);
used = used.saturating_add(w);
}
if !buf.is_empty() {
current_spans.push(Span::styled(buf, buf_style));
}
}
lines.push(Line::from(current_spans));
lines
}

View File

@@ -1,294 +0,0 @@
// src/textarea/highlight/engine.rs
#![allow(dead_code)]
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use ratatui::style::{Modifier, Style};
use syntect::{
highlighting::{
HighlightIterator, HighlightState, Highlighter, Style as SynStyle, Theme, ThemeSet,
},
parsing::{ParseState, ScopeStack, SyntaxReference, SyntaxSet},
};
use crate::data_provider::DataProvider;
use super::chunks::StyledChunk;
#[derive(Debug)]
pub struct SyntectEngine {
ps: SyntaxSet,
ts: ThemeSet,
theme_name: String,
syntax_name: Option<String>,
// Cached parser state (after line i)
parse_after: Vec<ParseState>,
// Cached scope stack (after line i)
stack_after: Vec<ScopeStack>,
// Hash of line contents to detect edits
line_hashes: Vec<u64>,
}
impl Default for SyntectEngine {
fn default() -> Self {
Self::new()
}
}
impl SyntectEngine {
pub fn new() -> Self {
let ps = SyntaxSet::load_defaults_newlines();
let ts = ThemeSet::load_defaults();
Self {
ps,
ts,
theme_name: "InspiredGitHub".to_string(),
syntax_name: None,
parse_after: Vec::new(),
stack_after: Vec::new(),
line_hashes: Vec::new(),
}
}
pub fn clear(&mut self) {
self.parse_after.clear();
self.stack_after.clear();
self.line_hashes.clear();
}
pub fn set_theme(&mut self, theme_name: &str) -> bool {
if self.ts.themes.contains_key(theme_name) {
self.theme_name = theme_name.to_string();
true
} else {
false
}
}
pub fn set_syntax_by_name(&mut self, name: &str) -> bool {
if self.ps.find_syntax_by_name(name).is_some() {
self.syntax_name = Some(name.to_string());
self.clear();
true
} else {
false
}
}
pub fn set_syntax_by_extension(&mut self, ext: &str) -> bool {
if let Some(s) = self.ps.find_syntax_by_extension(ext) {
self.syntax_name = Some(s.name.clone());
self.clear();
true
} else {
false
}
}
pub fn invalidate_from(&mut self, line_idx: usize) {
if line_idx < self.parse_after.len() {
self.parse_after.truncate(line_idx);
}
if line_idx < self.stack_after.len() {
self.stack_after.truncate(line_idx);
}
if line_idx < self.line_hashes.len() {
self.line_hashes.truncate(line_idx);
}
}
pub fn on_insert_line(&mut self, at: usize) {
self.invalidate_from(at);
}
pub fn on_delete_line(&mut self, at: usize) {
self.invalidate_from(at);
}
fn theme(&self) -> &Theme {
self.ts
.themes
.get(&self.theme_name)
.expect("theme exists")
}
fn syntax_ref(&self) -> &SyntaxReference {
if let Some(name) = &self.syntax_name {
if let Some(s) = self.ps.find_syntax_by_name(name) {
return s;
}
}
self.ps.find_syntax_plain_text()
}
fn map_syntect_style(s: SynStyle) -> Style {
let fg =
ratatui::style::Color::Rgb(s.foreground.r, s.foreground.g, s.foreground.b);
let mut st = Style::default().fg(fg);
use syntect::highlighting::FontStyle;
if s.font_style.contains(FontStyle::BOLD) {
st = st.add_modifier(Modifier::BOLD);
}
if s.font_style.contains(FontStyle::UNDERLINE) {
st = st.add_modifier(Modifier::UNDERLINED);
}
if s.font_style.contains(FontStyle::ITALIC) {
st = st.add_modifier(Modifier::ITALIC);
}
st
}
fn hash_line(s: &str) -> u64 {
let mut h = DefaultHasher::new();
s.hash(&mut h);
h.finish()
}
// Verify cached chain up to the nearest trusted predecessor of line_idx,
// using the provider to fetch the current lines.
fn verify_and_truncate_before(&mut self, line_idx: usize, provider: &dyn DataProvider) {
let mut k = std::cmp::min(line_idx, self.parse_after.len());
while k > 0 {
let j = k - 1;
let curr = Self::hash_line(provider.field_value(j));
if self.line_hashes.get(j) == Some(&curr) {
break;
}
self.invalidate_from(j);
k = j;
}
}
// Ensure we have parser + stack for lines [0..line_idx)
fn ensure_state_before(&mut self, line_idx: usize, provider: &dyn DataProvider) {
if line_idx == 0 || self.parse_after.len() >= line_idx {
return;
}
let syntax = self.syntax_ref();
let theme = self.theme().clone(); // Clone to avoid borrow conflicts
let highlighter = Highlighter::new(&theme);
let mut ps = if self.parse_after.is_empty() {
ParseState::new(syntax)
} else {
self.parse_after[self.parse_after.len() - 1].clone()
};
let mut stack = if self.stack_after.is_empty() {
ScopeStack::new()
} else {
self.stack_after[self.stack_after.len() - 1].clone()
};
let start = self.parse_after.len();
for i in start..line_idx {
let s = provider.field_value(i);
// Fix: parse_line takes 2 arguments: line and &SyntaxSet
let ops = ps.parse_line(s, &self.ps).unwrap_or_default();
// Fix: HighlightState::new requires &Highlighter and ScopeStack
let mut highlight_state = HighlightState::new(&highlighter, stack.clone());
// Fix: HighlightIterator::new expects &mut HighlightState as first parameter
let it = HighlightIterator::new(&mut highlight_state, &ops[..], s, &highlighter);
for (_style, _text) in it {
// Iterate to apply ops; we don't need the tokens here.
}
// Update the stack from the highlight state
stack = highlight_state.path.clone();
let h = Self::hash_line(s);
self.parse_after.push(ps.clone());
self.stack_after.push(stack.clone());
if i >= self.line_hashes.len() {
self.line_hashes.push(h);
} else {
self.line_hashes[i] = h;
}
}
}
// Highlight a single line using cached state; update caches for this line.
pub fn highlight_line_cached(
&mut self,
line_idx: usize,
line: &str,
provider: &dyn DataProvider,
) -> Vec<StyledChunk> {
// Auto-detect prior changes and truncate cache if needed
self.verify_and_truncate_before(line_idx, provider);
// Precompute states up to line_idx
self.ensure_state_before(line_idx, provider);
let syntax = self.syntax_ref();
let theme = self.theme().clone(); // Clone to avoid borrow conflicts
let highlighter = Highlighter::new(&theme);
let mut ps = if line_idx == 0 {
ParseState::new(syntax)
} else if self.parse_after.len() >= line_idx {
self.parse_after[line_idx - 1].clone()
} else {
ParseState::new(syntax)
};
let stack = if line_idx == 0 {
ScopeStack::new()
} else if self.stack_after.len() >= line_idx {
self.stack_after[line_idx - 1].clone()
} else {
ScopeStack::new()
};
// Fix: parse_line takes 2 arguments: line and &SyntaxSet
let ops = ps.parse_line(line, &self.ps).unwrap_or_default();
// Fix: HighlightState::new requires &Highlighter and ScopeStack
let mut highlight_state = HighlightState::new(&highlighter, stack);
// Fix: HighlightIterator::new expects &mut HighlightState as first parameter
let iter = HighlightIterator::new(&mut highlight_state, &ops[..], line, &highlighter);
let mut out: Vec<StyledChunk> = Vec::new();
for (syn_style, slice) in iter {
if slice.is_empty() {
continue;
}
let text = slice.trim_end_matches('\n').to_string();
if text.is_empty() {
continue;
}
out.push(StyledChunk {
text,
style: Self::map_syntect_style(syn_style),
});
}
// Update caches for this line (state after this line)
let h = Self::hash_line(line);
if line_idx >= self.parse_after.len() {
self.parse_after.push(ps);
} else {
self.parse_after[line_idx] = ps;
}
// Update stack from highlight state
let final_stack = highlight_state.path.clone();
if line_idx >= self.stack_after.len() {
self.stack_after.push(final_stack);
} else {
self.stack_after[line_idx] = final_stack;
}
if line_idx >= self.line_hashes.len() {
self.line_hashes.push(h);
} else {
self.line_hashes[line_idx] = h;
}
out
}
}

View File

@@ -1,18 +0,0 @@
// src/textarea/highlight/mod.rs
#[cfg(all(feature = "syntect", feature = "gui"))]
pub mod engine;
#[cfg(all(feature = "syntect", feature = "gui"))]
pub mod chunks;
#[cfg(all(feature = "syntect", feature = "gui"))]
pub mod state;
#[cfg(all(feature = "syntect", feature = "gui"))]
pub mod widget;
#[cfg(all(feature = "syntect", feature = "gui"))]
pub use engine::SyntectEngine;
#[cfg(all(feature = "syntect", feature = "gui"))]
pub use chunks::StyledChunk;
#[cfg(all(feature = "syntect", feature = "gui"))]
pub use state::TextAreaSyntaxState;
#[cfg(all(feature = "syntect", feature = "gui"))]
pub use widget::TextAreaSyntax;

View File

@@ -1,45 +0,0 @@
// src/textarea/highlight/state.rs
use std::ops::{Deref, DerefMut};
use super::engine::SyntectEngine;
use crate::textarea::state::TextAreaState;
// Remove Debug derive since TextAreaState doesn't implement Debug
#[derive(Default)]
pub struct TextAreaSyntaxState {
pub textarea: TextAreaState,
pub engine: SyntectEngine,
}
impl TextAreaSyntaxState {
pub fn from_text<S: Into<String>>(text: S) -> Self {
let mut s = Self::default();
s.textarea.set_text(text);
s
}
// Optional: convenience setters
pub fn set_syntax_theme(&mut self, theme: &str) -> bool {
self.engine.set_theme(theme)
}
pub fn set_syntax_by_name(&mut self, name: &str) -> bool {
self.engine.set_syntax_by_name(name)
}
pub fn set_syntax_by_extension(&mut self, ext: &str) -> bool {
self.engine.set_syntax_by_extension(ext)
}
}
impl Deref for TextAreaSyntaxState {
type Target = TextAreaState;
fn deref(&self) -> &Self::Target {
&self.textarea
}
}
impl DerefMut for TextAreaSyntaxState {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.textarea
}
}

View File

@@ -1,211 +0,0 @@
// src/textarea/highlight/widget.rs
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
style::Style,
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph, StatefulWidget, Widget},
};
use unicode_width::UnicodeWidthChar;
use super::chunks::{
clip_chunks_window_with_indicator_padded,
wrap_chunks_indented,
};
use super::state::TextAreaSyntaxState;
use crate::data_provider::DataProvider;
use crate::textarea::state::{
compute_h_scroll_with_padding, count_wrapped_rows_indented, TextOverflowMode,
};
#[derive(Debug, Clone)]
pub struct TextAreaSyntax<'a> {
pub block: Option<Block<'a>>,
pub style: Style,
pub border_type: BorderType,
}
impl<'a> Default for TextAreaSyntax<'a> {
fn default() -> Self {
Self {
block: Some(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded),
),
style: Style::default(),
border_type: BorderType::Rounded,
}
}
}
impl<'a> TextAreaSyntax<'a> {
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
pub fn border_type(mut self, ty: BorderType) -> Self {
self.border_type = ty;
if let Some(b) = &mut self.block {
*b = b.clone().border_type(ty);
}
self
}
}
fn display_width(s: &str) -> u16 {
s.chars()
.map(|c| UnicodeWidthChar::width(c).unwrap_or(0) as u16)
.sum()
}
fn display_cols_up_to(s: &str, char_count: usize) -> u16 {
let mut cols: u16 = 0;
for (i, ch) in s.chars().enumerate() {
if i >= char_count {
break;
}
cols = cols.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16);
}
cols
}
fn resolve_start_line_and_intra_indented(
state: &TextAreaSyntaxState,
inner: Rect,
) -> (usize, u16) {
let provider = state.textarea.editor.data_provider();
let total = provider.line_count();
if total == 0 {
return (0, 0);
}
let wrap = matches!(state.textarea.overflow_mode, TextOverflowMode::Wrap);
let width = inner.width;
let target_vis = state.textarea.scroll_y;
if !wrap {
let start = (target_vis as usize).min(total);
return (start, 0);
}
let indent = state.textarea.wrap_indent_cols;
let mut acc: u16 = 0;
for i in 0..total {
let s = provider.field_value(i);
let rows = count_wrapped_rows_indented(s, width, indent);
if acc.saturating_add(rows) > target_vis {
let intra = target_vis.saturating_sub(acc);
return (i, intra);
}
acc = acc.saturating_add(rows);
}
(total.saturating_sub(1), 0)
}
impl<'a> StatefulWidget for TextAreaSyntax<'a> {
type State = TextAreaSyntaxState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
// Reuse existing scroll logic
state.textarea.ensure_visible(area, self.block.as_ref());
let inner = if let Some(b) = &self.block {
b.clone().render(area, buf);
b.inner(area)
} else {
area
};
let edited_now = state.textarea.take_edited_flag();
let wrap_mode = matches!(state.textarea.overflow_mode, TextOverflowMode::Wrap);
let provider = state.textarea.editor.data_provider();
let total = provider.line_count();
let (start, intra) = resolve_start_line_and_intra_indented(state, inner);
let mut display_lines: Vec<Line> = Vec::new();
if total == 0 || start >= total {
if let Some(ph) = &state.textarea.placeholder {
display_lines.push(Line::from(Span::raw(ph.clone())));
}
} else if wrap_mode {
let mut rows_left = inner.height;
let indent = state.textarea.wrap_indent_cols;
let mut i = start;
while i < total && rows_left > 0 {
let s = provider.field_value(i);
let chunks = state
.engine
.highlight_line_cached(i, s, provider);
let lines = wrap_chunks_indented(&chunks, inner.width, indent);
let skip = if i == start { intra as usize } else { 0 };
for l in lines.into_iter().skip(skip) {
display_lines.push(l);
rows_left = rows_left.saturating_sub(1);
if rows_left == 0 {
break;
}
}
i += 1;
}
} else {
let end = (start.saturating_add(inner.height as usize)).min(total);
for i in start..end {
let s = provider.field_value(i);
let chunks = state.engine.highlight_line_cached(i, s, provider);
let fits = display_width(s) <= inner.width;
let start_cols = if i == state.textarea.current_field() {
let col_idx = state.textarea.display_cursor_position();
let cursor_cols = display_cols_up_to(s, col_idx);
let (target_h, _left_cols) =
compute_h_scroll_with_padding(cursor_cols, inner.width);
if fits {
if edited_now {
target_h
} else {
0
}
} else {
target_h.max(state.textarea.h_scroll)
}
} else {
0
};
if let TextOverflowMode::Indicator { ch } = state.textarea.overflow_mode {
display_lines.push(clip_chunks_window_with_indicator_padded(
&chunks,
inner.width,
ch,
start_cols,
));
}
}
}
let p = Paragraph::new(display_lines)
.alignment(Alignment::Left)
.style(self.style);
p.render(inner, buf);
}
}

View File

@@ -1,17 +0,0 @@
// src/textarea/mod.rs
//! Text area convenience exports.
pub mod provider;
pub mod state;
#[cfg(feature = "gui")]
pub mod widget;
#[cfg(all(feature = "syntect", feature = "gui"))]
pub mod highlight;
pub use provider::TextAreaProvider;
pub use state::{TextAreaEditor, TextAreaState, TextOverflowMode};
#[cfg(feature = "gui")]
pub use widget::TextArea;

View File

@@ -1,250 +0,0 @@
// src/textarea/provider.rs
use crate::DataProvider;
use once_cell::unsync::OnceCell;
use ropey::Rope;
use std::io::{self, BufReader, Read};
use std::path::Path;
#[derive(Debug)] // Clone removed: OnceCell<String> is not Clone
pub struct TextAreaProvider {
rope: Rope,
name: String,
// Lazy per-line cache; only lines that are actually used get materialized.
// This keeps memory low even for very large files.
line_cache: Vec<OnceCell<String>>,
}
impl Default for TextAreaProvider {
fn default() -> Self {
let rope = Rope::from_str("");
Self {
rope,
name: "Text".to_string(),
line_cache: vec![OnceCell::new()], // at least 1 logical line
}
}
}
impl TextAreaProvider {
pub fn from_text<S: Into<String>>(text: S) -> Self {
let s = text.into();
let rope = Rope::from_str(&s);
let lines = rope.len_lines().max(1);
Self {
rope,
name: "Text".to_string(),
line_cache: vec![(); lines].into_iter().map(|_| OnceCell::new()).collect(),
}
}
pub fn to_text(&self) -> String {
self.rope.to_string()
}
pub fn from_file<P: AsRef<Path>>(path: P) -> io::Result<Self> {
let f = std::fs::File::open(path)?;
let mut reader = BufReader::new(f);
Self::from_reader(&mut reader)
}
pub fn from_reader<R: Read>(reader: &mut R) -> io::Result<Self> {
let rope = Rope::from_reader(reader)?;
let lines = rope.len_lines().max(1);
Ok(Self {
rope,
name: "Text".to_string(),
line_cache: vec![(); lines].into_iter().map(|_| OnceCell::new()).collect(),
})
}
pub fn set_text<S: Into<String>>(&mut self, text: S) {
let s = text.into();
self.rope = Rope::from_str(&s);
self.resize_cache();
self.invalidate_cache_from(0);
}
pub fn line_count(&self) -> usize {
self.rope.len_lines().max(1)
}
fn resize_cache(&mut self) {
let want = self.line_count();
if self.line_cache.len() < want {
self.line_cache
.extend((0..(want - self.line_cache.len())).map(|_| OnceCell::new()));
} else if self.line_cache.len() > want {
self.line_cache.truncate(want);
}
}
fn invalidate_cache_from(&mut self, line_idx: usize) {
self.resize_cache();
if line_idx < self.line_cache.len() {
for cell in &mut self.line_cache[line_idx..] {
let _ = cell.take();
}
}
}
#[inline]
fn line_bounds_chars(&self, line_idx: usize) -> (usize, usize) {
// Returns [start, end) in char indices for content only (excluding newline).
let total_lines = self.line_count();
let start = self.rope.line_to_char(line_idx);
let end_exclusive = if line_idx + 1 < total_lines {
// Next line start is at the char index right after the newline.
// Exclude the newline itself by not including it in the range.
self.rope.line_to_char(line_idx + 1) - 1
} else {
self.rope.len_chars()
};
(start, end_exclusive)
}
fn line_content_len_chars(&self, line_idx: usize) -> usize {
let slice = self.rope.line(line_idx);
let mut len = slice.len_chars();
if line_idx + 1 < self.line_count() && len > 0 {
// Non-final lines include a trailing '\n' char in rope; exclude it.
len -= 1;
}
len
}
fn compute_line_string(&self, index: usize) -> String {
let mut s = self.rope.line(index).to_string();
// Trim trailing newline/CR if present (for non-final lines)
if s.ends_with('\n') {
s.pop();
if s.ends_with('\r') {
s.pop();
}
}
s
}
// --------------------------
// Editing helpers for TextAreaState (unchanged API)
// --------------------------
/// Split line at a character offset (within that line).
/// Returns the index of the newly created line (line_idx + 1).
pub fn split_line_at(&mut self, line_idx: usize, at_char: usize) -> usize {
let lines = self.line_count();
let clamped_line = line_idx.min(lines.saturating_sub(1));
let (start, end) = self.line_bounds_chars(clamped_line);
let line_len = end.saturating_sub(start);
let at = at_char.min(line_len);
let insert_at = start + at;
self.rope.insert(insert_at, "\n"); // rope insert at char index
self.resize_cache();
self.invalidate_cache_from(clamped_line);
clamped_line + 1
}
/// Join current line with the next by removing the newline.
/// Returns Some(new_cursor_col_on_merged_line) or None if no next line.
pub fn join_with_next(&mut self, line_idx: usize) -> Option<usize> {
if line_idx + 1 >= self.line_count() {
return None;
}
let newline_pos = self.rope.line_to_char(line_idx + 1) - 1; // index of '\n'
let left_len = self.line_content_len_chars(line_idx);
self.rope.remove(newline_pos..newline_pos + 1); // remove the newline
self.resize_cache();
self.invalidate_cache_from(line_idx);
Some(left_len)
}
/// Join current line with the previous by removing the previous newline.
/// Returns Some((new_prev_index, cursor_col)) or None if at line 0.
pub fn join_with_prev(&mut self, line_idx: usize) -> Option<(usize, usize)> {
if line_idx == 0 || line_idx >= self.line_count() {
return None;
}
let prev_idx = line_idx - 1;
let prev_len = self.line_content_len_chars(prev_idx);
let newline_pos = self.rope.line_to_char(line_idx) - 1; // index of '\n' before current line
self.rope.remove(newline_pos..newline_pos + 1);
self.resize_cache();
self.invalidate_cache_from(prev_idx);
Some((prev_idx, prev_len))
}
/// Insert an empty line after given index.
/// Returns the index of the inserted blank line (line_idx + 1).
pub fn insert_blank_line_after(&mut self, line_idx: usize) -> usize {
let lines = self.line_count();
let clamped = line_idx.min(lines.saturating_sub(1));
let pos = if clamped + 1 < lines {
self.rope.line_to_char(clamped + 1)
} else {
self.rope.len_chars()
};
self.rope.insert(pos, "\n");
self.resize_cache();
self.invalidate_cache_from(clamped);
clamped + 1
}
/// Insert an empty line before given index.
/// Returns the index of the inserted blank line (line_idx).
pub fn insert_blank_line_before(&mut self, line_idx: usize) -> usize {
let clamped = line_idx.min(self.line_count());
let pos = if clamped < self.line_count() {
self.rope.line_to_char(clamped)
} else {
self.rope.len_chars()
};
self.rope.insert(pos, "\n");
self.resize_cache();
self.invalidate_cache_from(clamped);
clamped
}
}
impl DataProvider for TextAreaProvider {
fn field_count(&self) -> usize {
self.line_count()
}
fn field_name(&self, _index: usize) -> &str {
&self.name
}
fn field_value(&self, index: usize) -> &str {
if index >= self.line_cache.len() {
return "";
}
let cell = &self.line_cache[index];
// Fill lazily on first read, from &self (no &mut needed).
let s_ref = cell.get_or_init(|| self.compute_line_string(index));
s_ref.as_str()
}
fn set_field_value(&mut self, index: usize, value: String) {
if index >= self.line_count() {
return;
}
// Enforce single-line invariant: strip embedded newlines
let clean = value.replace('\n', "");
let (start, end) = self.line_bounds_chars(index);
self.rope.remove(start..end);
self.rope.insert(start, &clean);
self.resize_cache();
if index < self.line_cache.len() {
// Replace this lines cached string only; other lines unchanged
let _ = self.line_cache[index].take();
let _ = self.line_cache[index].set(clean);
}
}
}

View File

@@ -1,518 +0,0 @@
// src/textarea/state.rs
use std::ops::{Deref, DerefMut};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crate::editor::FormEditor;
use crate::textarea::provider::TextAreaProvider;
use crate::data_provider::DataProvider;
#[cfg(feature = "gui")]
use ratatui::{layout::Rect, widgets::Block};
#[cfg(feature = "gui")]
use unicode_width::UnicodeWidthChar;
#[cfg(feature = "gui")]
pub(crate) const RIGHT_PAD: u16 = 3;
#[cfg(feature = "gui")]
pub(crate) fn compute_h_scroll_with_padding(
cursor_cols: u16,
width: u16,
) -> (u16, u16) {
let mut h = 0u16;
for _ in 0..2 {
let left_cols = if h > 0 { 1 } else { 0 };
let max_x_visible = width.saturating_sub(1 + RIGHT_PAD + left_cols);
let needed = cursor_cols.saturating_sub(max_x_visible);
if needed <= h {
return (h, left_cols);
}
h = needed;
}
let left_cols = if h > 0 { 1 } else { 0 };
(h, left_cols)
}
#[cfg(feature = "gui")]
fn normalize_indent(width: u16, indent: u16) -> u16 {
indent.min(width.saturating_sub(1))
}
#[cfg(feature = "gui")]
pub(crate) fn count_wrapped_rows_indented(
s: &str,
width: u16,
indent: u16,
) -> u16 {
if width == 0 {
return 1;
}
let indent = normalize_indent(width, indent);
let cont_cap = width.saturating_sub(indent);
let mut rows: u16 = 1;
let mut used: u16 = 0;
let mut first = true;
for ch in s.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
let cap = if first { width } else { cont_cap };
if used > 0 && used.saturating_add(w) >= cap {
rows = rows.saturating_add(1);
first = false;
used = indent;
}
used = used.saturating_add(w);
}
rows
}
#[cfg(feature = "gui")]
fn wrapped_rows_to_cursor_indented(
s: &str,
width: u16,
indent: u16,
cursor_chars: usize,
) -> (u16, u16) {
if width == 0 {
return (0, 0);
}
let indent = normalize_indent(width, indent);
let cont_cap = width.saturating_sub(indent);
let mut row: u16 = 0;
let mut used: u16 = 0;
let mut first = true;
for (i, ch) in s.chars().enumerate() {
if i >= cursor_chars {
break;
}
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
let cap = if first { width } else { cont_cap };
if used > 0 && used.saturating_add(w) >= cap {
row = row.saturating_add(1);
first = false;
used = indent;
}
used = used.saturating_add(w);
}
(row, used.min(width.saturating_sub(1)))
}
pub type TextAreaEditor = FormEditor<TextAreaProvider>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextOverflowMode {
Indicator { ch: char },
Wrap,
}
pub struct TextAreaState {
pub(crate) editor: TextAreaEditor,
pub(crate) scroll_y: u16,
pub(crate) placeholder: Option<String>,
pub(crate) overflow_mode: TextOverflowMode,
pub(crate) h_scroll: u16,
#[cfg(feature = "gui")]
pub(crate) wrap_indent_cols: u16,
#[cfg(feature = "gui")]
pub(crate) edited_this_frame: bool,
}
impl Default for TextAreaState {
fn default() -> Self {
Self {
editor: FormEditor::new(TextAreaProvider::default()),
scroll_y: 0,
placeholder: None,
overflow_mode: TextOverflowMode::Indicator { ch: '$' },
h_scroll: 0,
#[cfg(feature = "gui")]
wrap_indent_cols: 0,
#[cfg(feature = "gui")]
edited_this_frame: false,
}
}
}
impl Deref for TextAreaState {
type Target = TextAreaEditor;
fn deref(&self) -> &Self::Target {
&self.editor
}
}
impl DerefMut for TextAreaState {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.editor
}
}
impl TextAreaState {
pub fn from_text<S: Into<String>>(text: S) -> Self {
let provider = TextAreaProvider::from_text(text);
Self {
editor: FormEditor::new(provider),
scroll_y: 0,
placeholder: None,
overflow_mode: TextOverflowMode::Indicator { ch: '$' },
h_scroll: 0,
#[cfg(feature = "gui")]
wrap_indent_cols: 0,
#[cfg(feature = "gui")]
edited_this_frame: false,
}
}
pub fn text(&self) -> String {
self.editor.data_provider().to_text()
}
pub fn set_text<S: Into<String>>(&mut self, text: S) {
self.editor.data_provider_mut().set_text(text);
self.editor.ui_state.current_field = 0;
self.editor.ui_state.cursor_pos = 0;
self.editor.ui_state.ideal_cursor_column = 0;
}
pub fn set_placeholder<S: Into<String>>(&mut self, s: S) {
self.placeholder = Some(s.into());
}
pub fn use_overflow_indicator(&mut self, ch: char) {
self.overflow_mode = TextOverflowMode::Indicator { ch };
}
pub fn use_wrap(&mut self) {
self.overflow_mode = TextOverflowMode::Wrap;
}
pub fn set_wrap_indent_cols(&mut self, cols: u16) {
#[cfg(feature = "gui")]
{
self.wrap_indent_cols = cols;
}
}
pub fn insert_newline(&mut self) {
#[cfg(feature = "gui")]
{
self.edited_this_frame = true;
}
let line_idx = self.current_field();
let col = self.cursor_position();
let new_idx = self
.editor
.data_provider_mut()
.split_line_at(line_idx, col);
let _ = self.transition_to_field(new_idx);
self.move_line_start();
self.enter_edit_mode();
}
pub fn backspace(&mut self) {
let col = self.cursor_position();
if col > 0 {
#[cfg(feature = "gui")]
{
self.edited_this_frame = true;
}
let _ = self.delete_backward();
return;
}
let line_idx = self.current_field();
if line_idx == 0 {
return;
}
if let Some((prev_idx, new_col)) =
self.editor.data_provider_mut().join_with_prev(line_idx)
{
#[cfg(feature = "gui")]
{
self.edited_this_frame = true;
}
let _ = self.transition_to_field(prev_idx);
self.set_cursor_position(new_col);
self.enter_edit_mode();
}
}
pub fn delete_forward_or_join(&mut self) {
let line_idx = self.current_field();
let line_len = self.current_text().chars().count();
let col = self.cursor_position();
if col < line_len {
#[cfg(feature = "gui")]
{
self.edited_this_frame = true;
}
let _ = self.delete_forward();
return;
}
if let Some(new_col) =
self.editor.data_provider_mut().join_with_next(line_idx)
{
#[cfg(feature = "gui")]
{
self.edited_this_frame = true;
}
self.set_cursor_position(new_col);
self.enter_edit_mode();
}
}
pub fn input(&mut self, key: KeyEvent) {
if key.kind != KeyEventKind::Press {
return;
}
match (key.code, key.modifiers) {
(KeyCode::Enter, _) => self.insert_newline(),
(KeyCode::Backspace, _) => self.backspace(),
(KeyCode::Delete, _) => self.delete_forward_or_join(),
(KeyCode::Left, _) => {
let _ = self.move_left();
}
(KeyCode::Right, _) => {
let _ = self.move_right();
}
(KeyCode::Up, _) => {
let _ = self.move_up();
}
(KeyCode::Down, _) => {
let _ = self.move_down();
}
(KeyCode::Home, _)
| (KeyCode::Char('a'), KeyModifiers::CONTROL) => {
self.move_line_start();
}
(KeyCode::End, _)
| (KeyCode::Char('e'), KeyModifiers::CONTROL) => {
self.move_line_end();
}
(KeyCode::Char('b'), KeyModifiers::ALT) => self.move_word_prev(),
(KeyCode::Char('f'), KeyModifiers::ALT) => self.move_word_next(),
(KeyCode::Char('e'), KeyModifiers::ALT) => self.move_word_end(),
(KeyCode::Char(c), m) if m.is_empty() => {
self.enter_edit_mode();
#[cfg(feature = "gui")]
{
self.edited_this_frame = true;
}
let _ = self.insert_char(c);
}
(KeyCode::Tab, _) => {
self.enter_edit_mode();
#[cfg(feature = "gui")]
{
self.edited_this_frame = true;
}
for _ in 0..4 {
let _ = self.insert_char(' ');
}
}
_ => {}
}
}
#[cfg(feature = "gui")]
fn visual_rows_before_line_and_intra_indented(
&self,
width: u16,
line_idx: usize,
) -> u16 {
let provider = self.editor.data_provider();
let mut acc: u16 = 0;
let indent = self.wrap_indent_cols;
for i in 0..line_idx {
let s = provider.field_value(i);
acc = acc.saturating_add(count_wrapped_rows_indented(s, width, indent));
}
acc
}
#[cfg(feature = "gui")]
pub fn cursor(&self, area: Rect, block: Option<&Block<'_>>) -> (u16, u16) {
let inner = if let Some(b) = block { b.inner(area) } else { area };
let line_idx = self.current_field();
match self.overflow_mode {
TextOverflowMode::Wrap => {
let width = inner.width;
let y_top = inner.y;
let indent = self.wrap_indent_cols;
if width == 0 {
let prefix = self.visual_rows_before_line_and_intra_indented(1, line_idx);
let y = y_top.saturating_add(prefix.saturating_sub(self.scroll_y));
return (inner.x, y);
}
let prefix_rows =
self.visual_rows_before_line_and_intra_indented(width, line_idx);
let current_line = self.current_text();
let col_chars = self.display_cursor_position();
let (subrow, x_cols) = wrapped_rows_to_cursor_indented(
current_line,
width,
indent,
col_chars,
);
let caret_vis_row = prefix_rows.saturating_add(subrow);
let y = y_top.saturating_add(caret_vis_row.saturating_sub(self.scroll_y));
let x = inner.x.saturating_add(x_cols);
(x, y)
}
TextOverflowMode::Indicator { .. } => {
let y = inner.y + (line_idx as u16).saturating_sub(self.scroll_y);
let current_line = self.current_text();
let col = self.display_cursor_position();
let mut x_cols: u16 = 0;
let mut total_cols: u16 = 0;
for (i, ch) in current_line.chars().enumerate() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
if i < col {
x_cols = x_cols.saturating_add(w);
}
total_cols = total_cols.saturating_add(w);
}
let left_cols = if self.h_scroll > 0 { 1 } else { 0 };
let mut x_off_visible = x_cols
.saturating_sub(self.h_scroll)
.saturating_add(left_cols);
let limit = inner.width.saturating_sub(1 + RIGHT_PAD);
if x_off_visible > limit {
x_off_visible = limit;
}
let x = inner.x.saturating_add(x_off_visible);
(x, y)
}
}
}
#[cfg(feature = "gui")]
pub(crate) fn ensure_visible(&mut self, area: Rect, block: Option<&Block<'_>>) {
let inner = if let Some(b) = block { b.inner(area) } else { area };
if inner.height == 0 {
return;
}
match self.overflow_mode {
TextOverflowMode::Indicator { .. } => {
let line_idx_u16 = self.current_field() as u16;
if line_idx_u16 < self.scroll_y {
self.scroll_y = line_idx_u16;
} else if line_idx_u16 >= self.scroll_y + inner.height {
self.scroll_y = line_idx_u16.saturating_sub(inner.height - 1);
}
let width = inner.width;
if width == 0 {
return;
}
let current_line = self.current_text();
let mut total_cols: u16 = 0;
for ch in current_line.chars() {
total_cols = total_cols
.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16);
}
if total_cols <= width {
self.h_scroll = 0;
return;
}
let col = self.display_cursor_position();
let mut cursor_cols: u16 = 0;
for (i, ch) in current_line.chars().enumerate() {
if i >= col {
break;
}
cursor_cols = cursor_cols
.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16);
}
let (target_h, _left_cols) =
compute_h_scroll_with_padding(cursor_cols, width);
if target_h > self.h_scroll {
self.h_scroll = target_h;
} else if cursor_cols < self.h_scroll {
self.h_scroll = cursor_cols;
}
}
TextOverflowMode::Wrap => {
let width = inner.width;
if width == 0 {
self.h_scroll = 0;
return;
}
let indent = self.wrap_indent_cols;
let line_idx = self.current_field();
let prefix_rows =
self.visual_rows_before_line_and_intra_indented(width, line_idx);
let current_line = self.current_text();
let col = self.display_cursor_position();
let (subrow, _x_cols) =
wrapped_rows_to_cursor_indented(current_line, width, indent, col);
let caret_vis_row = prefix_rows.saturating_add(subrow);
let top = self.scroll_y;
let height = inner.height;
if caret_vis_row < top {
self.scroll_y = caret_vis_row;
} else {
let bottom = top.saturating_add(height.saturating_sub(1));
if caret_vis_row > bottom {
let shift = caret_vis_row.saturating_sub(bottom);
self.scroll_y = top.saturating_add(shift);
}
}
self.h_scroll = 0;
}
}
}
#[cfg(feature = "gui")]
pub(crate) fn take_edited_flag(&mut self) -> bool {
let v = self.edited_this_frame;
self.edited_this_frame = false;
v
}
}

View File

@@ -1,352 +0,0 @@
// src/textarea/widget.rs
#[cfg(feature = "gui")]
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
style::Style,
text::{Line, Span},
widgets::{
Block, BorderType, Borders, Paragraph, StatefulWidget, Widget,
},
};
#[cfg(feature = "gui")]
use crate::data_provider::DataProvider;
#[cfg(feature = "gui")]
use crate::textarea::state::{
compute_h_scroll_with_padding,
count_wrapped_rows_indented,
TextAreaState,
TextOverflowMode,
};
#[cfg(feature = "gui")]
use unicode_width::UnicodeWidthChar;
#[cfg(feature = "gui")]
#[derive(Debug, Clone)]
pub struct TextArea<'a> {
pub(crate) block: Option<Block<'a>>,
pub(crate) style: Style,
pub(crate) border_type: BorderType,
}
#[cfg(feature = "gui")]
impl<'a> Default for TextArea<'a> {
fn default() -> Self {
Self {
block: Some(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded),
),
style: Style::default(),
border_type: BorderType::Rounded,
}
}
}
#[cfg(feature = "gui")]
impl<'a> TextArea<'a> {
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
pub fn border_type(mut self, ty: BorderType) -> Self {
self.border_type = ty;
if let Some(b) = &mut self.block {
*b = b.clone().border_type(ty);
}
self
}
}
#[cfg(feature = "gui")]
fn display_width(s: &str) -> u16 {
s.chars()
.map(|c| UnicodeWidthChar::width(c).unwrap_or(0) as u16)
.sum()
}
#[cfg(feature = "gui")]
fn display_cols_up_to(s: &str, char_count: usize) -> u16 {
let mut cols: u16 = 0;
for (i, ch) in s.chars().enumerate() {
if i >= char_count {
break;
}
cols = cols.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16);
}
cols
}
#[cfg(feature = "gui")]
fn slice_by_display_cols(s: &str, start_cols: u16, max_cols: u16) -> String {
if max_cols == 0 {
return String::new();
}
let mut current_cols: u16 = 0;
let mut output = String::new();
let mut taken: u16 = 0;
let mut started = false;
for ch in s.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
if !started {
if current_cols.saturating_add(w) <= start_cols {
current_cols = current_cols.saturating_add(w);
continue;
} else {
started = true;
}
}
if taken.saturating_add(w) > max_cols {
break;
}
output.push(ch);
taken = taken.saturating_add(w);
current_cols = current_cols.saturating_add(w);
}
output
}
#[cfg(feature = "gui")]
fn clip_window_with_indicator_padded(
text: &str,
view_width: u16,
indicator: char,
start_cols: u16,
) -> Line<'static> {
if view_width == 0 {
return Line::from("");
}
let total = display_width(text);
// Left indicator if we scrolled
let show_left = start_cols > 0;
let left_cols: u16 = if show_left { 1 } else { 0 };
// Capacity for text if we also need a right indicator
let cap_with_right = view_width.saturating_sub(left_cols + 1);
// Do we still have content beyond this window?
let remaining = total.saturating_sub(start_cols);
let show_right = remaining > cap_with_right;
// Final capacity for visible text
let max_visible = if show_right {
cap_with_right
} else {
view_width.saturating_sub(left_cols)
};
let visible = slice_by_display_cols(text, start_cols, max_visible);
let mut spans: Vec<Span> = Vec::new();
if show_left {
spans.push(Span::raw(indicator.to_string()));
}
// Visible text
spans.push(Span::raw(visible.clone()));
// Place $ flush-right
if show_right {
let used_cols = left_cols + display_width(&visible);
let right_pos = view_width.saturating_sub(1);
let filler = right_pos.saturating_sub(used_cols);
if filler > 0 {
spans.push(Span::raw(" ".repeat(filler as usize)));
}
spans.push(Span::raw(indicator.to_string()));
}
Line::from(spans)
}
#[cfg(feature = "gui")]
fn wrap_segments_with_indent(
s: &str,
width: u16,
indent: u16,
) -> Vec<String> {
let mut segments: Vec<String> = Vec::new();
if width == 0 {
segments.push(String::new());
return segments;
}
let indent = indent.min(width.saturating_sub(1));
let cont_cap = width.saturating_sub(indent);
let indent_str = " ".repeat(indent as usize);
let mut buf = String::new();
let mut used: u16 = 0;
let mut first = true;
for ch in s.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
let cap = if first { width } else { cont_cap };
// Early-wrap: wrap before filling the last cell (and avoid empty segment)
if used > 0 && used.saturating_add(w) >= cap {
segments.push(buf);
buf = String::new();
used = 0;
first = false;
if indent > 0 {
buf.push_str(&indent_str);
used = indent;
}
}
buf.push(ch);
used = used.saturating_add(w);
}
segments.push(buf);
segments
}
// Map visual row offset to (logical line, intra segment)
#[cfg(feature = "gui")]
fn resolve_start_line_and_intra_indented(
state: &TextAreaState,
inner: Rect,
) -> (usize, u16) {
let provider = state.editor.data_provider();
let total = provider.line_count();
if total == 0 {
return (0, 0);
}
let wrap = matches!(state.overflow_mode, TextOverflowMode::Wrap);
let width = inner.width;
let target_vis = state.scroll_y;
if !wrap {
let start = (target_vis as usize).min(total);
return (start, 0);
}
let indent = state.wrap_indent_cols;
let mut acc: u16 = 0;
for i in 0..total {
let s = provider.field_value(i);
let rows = count_wrapped_rows_indented(s, width, indent);
if acc.saturating_add(rows) > target_vis {
let intra = target_vis.saturating_sub(acc);
return (i, intra);
}
acc = acc.saturating_add(rows);
}
(total.saturating_sub(1), 0)
}
#[cfg(feature = "gui")]
impl<'a> StatefulWidget for TextArea<'a> {
type State = TextAreaState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
state.ensure_visible(area, self.block.as_ref());
let inner = if let Some(b) = &self.block {
b.clone().render(area, buf);
b.inner(area)
} else {
area
};
let edited_now = state.take_edited_flag();
let wrap_mode = matches!(state.overflow_mode, TextOverflowMode::Wrap);
let provider = state.editor.data_provider();
let total = provider.line_count();
let (start, intra) = resolve_start_line_and_intra_indented(state, inner);
let mut display_lines: Vec<Line> = Vec::new();
if total == 0 || start >= total {
if let Some(ph) = &state.placeholder {
display_lines.push(Line::from(Span::raw(ph.clone())));
}
} else if wrap_mode {
// manual pre-wrap path (unchanged)
let mut rows_left = inner.height;
let indent = state.wrap_indent_cols;
let mut i = start;
while i < total && rows_left > 0 {
let s = provider.field_value(i);
let segments = wrap_segments_with_indent(s, inner.width, indent);
let skip = if i == start { intra as usize } else { 0 };
for seg in segments.into_iter().skip(skip) {
display_lines.push(Line::from(Span::raw(seg)));
rows_left = rows_left.saturating_sub(1);
if rows_left == 0 {
break;
}
}
i += 1;
}
} else {
// Indicator mode: full inner width; RIGHT_PAD only affects cursor clamp and h-scroll
let end = (start.saturating_add(inner.height as usize)).min(total);
for i in start..end {
let s = provider.field_value(i);
match state.overflow_mode {
TextOverflowMode::Wrap => unreachable!(),
TextOverflowMode::Indicator { ch } => {
let fits = display_width(s) <= inner.width;
let start_cols = if i == state.current_field() {
let col_idx = state.display_cursor_position();
let cursor_cols = display_cols_up_to(s, col_idx);
let (target_h, _left_cols) =
compute_h_scroll_with_padding(cursor_cols, inner.width);
if fits {
if edited_now { target_h } else { 0 }
} else {
target_h.max(state.h_scroll)
}
} else {
0
};
display_lines.push(clip_window_with_indicator_padded(
s,
inner.width,
ch,
start_cols,
));
}
}
}
}
let p = Paragraph::new(display_lines)
.alignment(Alignment::Left)
.style(self.style);
// No Paragraph::wrap/scroll in wrap mode — we pre-wrap.
p.render(inner, buf);
}
}

View File

@@ -1,447 +0,0 @@
// src/validation/config.rs
//! Validation configuration types and builders
use crate::validation::{CharacterLimits, PatternFilters, DisplayMask};
#[cfg(feature = "validation")]
use crate::validation::{CustomFormatter, FormattingResult, PositionMapper};
use std::sync::Arc;
/// Main validation configuration for a field
#[derive(Clone, Default)]
pub struct ValidationConfig {
/// Character limit configuration
pub character_limits: Option<CharacterLimits>,
/// Pattern filtering configuration
pub pattern_filters: Option<PatternFilters>,
/// User-defined display mask for visual formatting
pub display_mask: Option<DisplayMask>,
/// Optional: user-provided custom formatter (feature 4)
#[cfg(feature = "validation")]
pub custom_formatter: Option<Arc<dyn CustomFormatter + Send + Sync>>,
/// Enable external validation indicator UI (feature 5)
pub external_validation_enabled: bool,
/// Future: External validation
pub external_validation: Option<()>, // Placeholder for future implementation
}
/// Manual Debug to avoid requiring Debug on dyn CustomFormatter
impl std::fmt::Debug for ValidationConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut ds = f.debug_struct("ValidationConfig");
ds.field("character_limits", &self.character_limits)
.field("pattern_filters", &self.pattern_filters)
.field("display_mask", &self.display_mask)
// Do not print the formatter itself to avoid requiring Debug
.field(
"custom_formatter",
&{
#[cfg(feature = "validation")]
{
if self.custom_formatter.is_some() { &"Some(<CustomFormatter>)" } else { &"None" }
}
#[cfg(not(feature = "validation"))]
{
&"N/A"
}
},
)
.field("external_validation_enabled", &self.external_validation_enabled)
.field("external_validation", &self.external_validation)
.finish()
}
}
// ✅ FIXED: Move function from struct definition to impl block
impl ValidationConfig {
/// If a custom formatter is configured, run it and return the formatted text,
/// the position mapper and an optional warning message.
///
/// Returns None when no custom formatter is configured.
#[cfg(feature = "validation")]
pub fn run_custom_formatter(
&self,
raw: &str,
) -> Option<(String, Arc<dyn PositionMapper>, Option<String>)> {
let formatter = self.custom_formatter.as_ref()?;
match formatter.format(raw) {
FormattingResult::Success { formatted, mapper } => {
Some((formatted, mapper, None))
}
FormattingResult::Warning { formatted, message, mapper } => {
Some((formatted, mapper, Some(message)))
}
FormattingResult::Error { .. } => None, // Fall back to raw display
}
}
/// Create a new empty validation configuration
pub fn new() -> Self {
Self::default()
}
/// Create a configuration with just character limits
pub fn with_max_length(max_length: usize) -> Self {
ValidationConfigBuilder::new()
.with_max_length(max_length)
.build()
}
/// Create a configuration with pattern filters
pub fn with_patterns(patterns: PatternFilters) -> Self {
ValidationConfigBuilder::new()
.with_pattern_filters(patterns)
.build()
}
/// Create a configuration with user-defined display mask
///
/// # Examples
/// ```
/// use canvas::{ValidationConfig, DisplayMask};
///
/// let phone_mask = DisplayMask::new("(###) ###-####", '#');
/// let config = ValidationConfig::with_mask(phone_mask);
/// ```
pub fn with_mask(mask: DisplayMask) -> Self {
ValidationConfigBuilder::new()
.with_display_mask(mask)
.build()
}
/// Validate a character insertion at a specific position (raw text space).
///
/// Note: Display masks are visual-only and do not participate in validation.
/// Editor logic is responsible for skipping mask separator positions; here we
/// only validate the raw insertion against limits and patterns.
pub fn validate_char_insertion(
&self,
current_text: &str,
position: usize,
character: char,
) -> ValidationResult {
// Character limits validation
if let Some(ref limits) = self.character_limits {
// ✅ FIXED: Explicit return type annotation
if let Some(result) = limits.validate_insertion(current_text, position, character) {
if !result.is_acceptable() {
return result;
}
}
}
// Pattern filters validation
if let Some(ref patterns) = self.pattern_filters {
// ✅ FIXED: Explicit error handling
if let Err(message) = patterns.validate_char_at_position(position, character) {
return ValidationResult::error(message);
}
}
// Future: Add other validation types here
ValidationResult::Valid
}
/// Validate the current text content (raw text space)
pub fn validate_content(&self, text: &str) -> ValidationResult {
// Character limits validation
if let Some(ref limits) = self.character_limits {
// ✅ FIXED: Explicit return type annotation
if let Some(result) = limits.validate_content(text) {
if !result.is_acceptable() {
return result;
}
}
}
// Pattern filters validation
if let Some(ref patterns) = self.pattern_filters {
// ✅ FIXED: Explicit error handling
if let Err(message) = patterns.validate_text(text) {
return ValidationResult::error(message);
}
}
// Future: Add other validation types here
ValidationResult::Valid
}
/// Check if any validation rules are configured
pub fn has_validation(&self) -> bool {
self.character_limits.is_some()
|| self.pattern_filters.is_some()
|| self.display_mask.is_some()
|| {
#[cfg(feature = "validation")]
{ self.custom_formatter.is_some() }
#[cfg(not(feature = "validation"))]
{ false }
}
}
pub fn allows_field_switch(&self, text: &str) -> bool {
// Character limits validation
if let Some(ref limits) = self.character_limits {
// ✅ FIXED: Direct boolean return
if !limits.allows_field_switch(text) {
return false;
}
}
// Future: Add other validation types here
true
}
/// Get reason why field switching is blocked (if any)
pub fn field_switch_block_reason(&self, text: &str) -> Option<String> {
// Character limits validation
if let Some(ref limits) = self.character_limits {
// ✅ FIXED: Direct option return
if let Some(reason) = limits.field_switch_block_reason(text) {
return Some(reason);
}
}
// Future: Add other validation types here
None
}
}
/// Builder for creating validation configurations
#[derive(Debug, Default)]
pub struct ValidationConfigBuilder {
config: ValidationConfig,
}
impl ValidationConfigBuilder {
/// Create a new validation config builder
pub fn new() -> Self {
Self::default()
}
/// Set character limits for the field
pub fn with_character_limits(mut self, limits: CharacterLimits) -> Self {
self.config.character_limits = Some(limits);
self
}
/// Set pattern filters for the field
pub fn with_pattern_filters(mut self, filters: PatternFilters) -> Self {
self.config.pattern_filters = Some(filters);
self
}
/// Set user-defined display mask for visual formatting
///
/// # Examples
/// ```
/// use canvas::{ValidationConfigBuilder, DisplayMask};
///
/// // Phone number with dynamic formatting
/// let phone_mask = DisplayMask::new("(###) ###-####", '#');
/// let config = ValidationConfigBuilder::new()
/// .with_display_mask(phone_mask)
/// .build();
///
/// // Date with template formatting
/// let date_mask = DisplayMask::new("##/##/####", '#')
/// .with_template('_');
/// let config = ValidationConfigBuilder::new()
/// .with_display_mask(date_mask)
/// .build();
///
/// // Custom business format
/// let employee_id = DisplayMask::new("EMP-####-##", '#')
/// .with_template('•');
/// let config = ValidationConfigBuilder::new()
/// .with_display_mask(employee_id)
/// .with_max_length(6) // Only store the 6 digits
/// .build();
/// ```
pub fn with_display_mask(mut self, mask: DisplayMask) -> Self {
self.config.display_mask = Some(mask);
self
}
/// Set optional custom formatter (feature 4)
#[cfg(feature = "validation")]
pub fn with_custom_formatter<F>(mut self, formatter: Arc<F>) -> Self
where
F: CustomFormatter + Send + Sync + 'static,
{
self.config.custom_formatter = Some(formatter);
// When custom formatter is present, it takes precedence over display mask.
self.config.display_mask = None;
self
}
/// Set maximum number of characters (convenience method)
pub fn with_max_length(mut self, max_length: usize) -> Self {
self.config.character_limits = Some(CharacterLimits::new(max_length));
self
}
/// Enable or disable external validation indicator UI (feature 5)
pub fn with_external_validation_enabled(mut self, enabled: bool) -> Self {
self.config.external_validation_enabled = enabled;
self
}
/// Build the final validation configuration
pub fn build(self) -> ValidationConfig {
self.config
}
}
/// Result of a validation operation
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationResult {
/// Validation passed
Valid,
/// Validation failed with warning (input still accepted)
Warning { message: String },
/// Validation failed with error (input rejected)
Error { message: String },
}
impl ValidationResult {
/// Check if the validation result allows the input
pub fn is_acceptable(&self) -> bool {
matches!(self, ValidationResult::Valid | ValidationResult::Warning { .. })
}
/// Check if the validation result is an error
pub fn is_error(&self) -> bool {
matches!(self, ValidationResult::Error { .. })
}
/// Get the message if there is one
pub fn message(&self) -> Option<&str> {
match self {
ValidationResult::Valid => None,
ValidationResult::Warning { message } => Some(message),
ValidationResult::Error { message } => Some(message),
}
}
/// Create a warning result
pub fn warning(message: impl Into<String>) -> Self {
ValidationResult::Warning { message: message.into() }
}
/// Create an error result
pub fn error(message: impl Into<String>) -> Self {
ValidationResult::Error { message: message.into() }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_with_user_defined_mask() {
// User creates their own phone mask
let phone_mask = DisplayMask::new("(###) ###-####", '#');
let config = ValidationConfig::with_mask(phone_mask);
// has_validation should be true because mask is configured
assert!(config.has_validation());
// Display mask is visual only; validation still focuses on raw content
let result = config.validate_char_insertion("123", 3, '4');
assert!(result.is_acceptable());
// Content validation unaffected by mask
let result = config.validate_content("1234567890");
assert!(result.is_acceptable());
}
#[test]
fn test_validation_config_builder() {
let config = ValidationConfigBuilder::new()
.with_max_length(10)
.build();
assert!(config.character_limits.is_some());
assert_eq!(config.character_limits.unwrap().max_length(), Some(10));
}
#[test]
fn test_config_builder_with_user_mask() {
// User defines custom format
let custom_mask = DisplayMask::new("##-##-##", '#').with_template('_');
let config = ValidationConfigBuilder::new()
.with_display_mask(custom_mask)
.with_max_length(6)
.build();
assert!(config.has_validation());
assert!(config.character_limits.is_some());
assert!(config.display_mask.is_some());
}
#[test]
fn test_validation_result() {
let valid = ValidationResult::Valid;
assert!(valid.is_acceptable());
assert!(!valid.is_error());
assert_eq!(valid.message(), None);
let warning = ValidationResult::warning("Too long");
assert!(warning.is_acceptable());
assert!(!warning.is_error());
assert_eq!(warning.message(), Some("Too long"));
let error = ValidationResult::error("Invalid");
assert!(!error.is_acceptable());
assert!(error.is_error());
assert_eq!(error.message(), Some("Invalid"));
}
#[test]
fn test_config_with_max_length() {
let config = ValidationConfig::with_max_length(5);
assert!(config.has_validation());
// Test valid insertion
let result = config.validate_char_insertion("test", 4, 'x');
assert!(result.is_acceptable());
// Test invalid insertion (would exceed limit)
let result = config.validate_char_insertion("tests", 5, 'x');
assert!(!result.is_acceptable());
}
#[test]
fn test_config_with_patterns() {
use crate::validation::{PatternFilters, PositionFilter, PositionRange, CharacterFilter};
let patterns = PatternFilters::new()
.add_filter(PositionFilter::new(
PositionRange::Range(0, 1),
CharacterFilter::Alphabetic,
));
let config = ValidationConfig::with_patterns(patterns);
assert!(config.has_validation());
// Test valid pattern insertion
let result = config.validate_char_insertion("", 0, 'A');
assert!(result.is_acceptable());
// Test invalid pattern insertion
let result = config.validate_char_insertion("", 0, '1');
assert!(!result.is_acceptable());
}
}

View File

@@ -1,222 +0,0 @@
// src/validation/formatting.rs
//! Custom formatting and position mapping for validation/display.
//!
//! This module defines the CustomFormatter trait along with helpers to map
//! cursor positions between the raw stored text and the formatted display
//! representation. Implementors may provide a custom PositionMapper to handle
//! advanced formatting scenarios.
use std::sync::Arc;
/// Bidirectional mapping between raw input positions and formatted display positions.
///
/// The library uses this to keep cursor/selection behavior intuitive when the UI
/// shows a formatted transformation (e.g., "01001" -> "010 01") while the editor
/// still stores raw text.
pub trait PositionMapper: Send + Sync {
/// Map a raw cursor position to a formatted cursor position.
///
/// raw_pos is an index into the raw text (0..=raw.len() in char positions).
/// Implementations should return a position within 0..=formatted.len() (in char positions).
fn raw_to_formatted(&self, raw: &str, formatted: &str, raw_pos: usize) -> usize;
/// Map a formatted cursor position to a raw cursor position.
///
/// formatted_pos is an index into the formatted text (0..=formatted.len()).
/// Implementations should return a position within 0..=raw.len() (in char positions).
fn formatted_to_raw(&self, raw: &str, formatted: &str, formatted_pos: usize) -> usize;
}
/// A reasonable default mapper that works for "insert separators" style formatting,
/// such as grouping digits or adding dashes/spaces.
///
/// Heuristic:
/// - Treat letters and digits (is_alphanumeric) in the formatted string as user-entered characters
/// corresponding to raw characters, in order.
/// - Treat any non-alphanumeric characters as purely visual separators.
/// - Raw positions are mapped by counting alphanumeric characters in the formatted string.
/// - If the formatted contains fewer alphanumeric characters than the raw (shouldn't happen
/// for plain grouping), we cap at the end of the formatted string.
#[derive(Clone, Default)]
pub struct DefaultPositionMapper;
impl PositionMapper for DefaultPositionMapper {
fn raw_to_formatted(&self, raw: &str, formatted: &str, raw_pos: usize) -> usize {
// Convert to char indices for correctness in presence of UTF-8
let raw_len = raw.chars().count();
let clamped_raw_pos = raw_pos.min(raw_len);
// Count alphanumerics in formatted, find the index where we've seen `clamped_raw_pos` of them.
let mut seen_user_chars = 0usize;
for (idx, ch) in formatted.char_indices() {
if ch.is_alphanumeric() {
if seen_user_chars == clamped_raw_pos {
// Cursor is positioned before this user character in the formatted view
return idx;
}
seen_user_chars += 1;
}
}
// If we consumed all alphanumeric chars and still haven't reached clamped_raw_pos,
// place cursor at the end of the formatted string.
formatted.len()
}
fn formatted_to_raw(&self, raw: &str, formatted: &str, formatted_pos: usize) -> usize {
let clamped_fmt_pos = formatted_pos.min(formatted.len());
// Count alphanumerics in formatted up to formatted_pos.
let mut seen_user_chars = 0usize;
for (idx, ch) in formatted.char_indices() {
if idx >= clamped_fmt_pos {
break;
}
if ch.is_alphanumeric() {
seen_user_chars += 1;
}
}
// Map to raw position by clamping to raw char count
let raw_len = raw.chars().count();
seen_user_chars.min(raw_len)
}
}
/// Result of invoking a custom formatter on the raw input.
///
/// Success variants carry the formatted string and a position mapper to translate
/// between raw and formatted cursor positions. If you don't provide a custom mapper,
/// the library will fall back to DefaultPositionMapper.
pub enum FormattingResult {
/// Successfully produced a formatted display value and a position mapper.
Success {
formatted: String,
/// Mapper to convert cursor positions between raw and formatted representations.
mapper: Arc<dyn PositionMapper>,
},
/// Successfully produced a formatted value, but with a non-fatal warning message
/// that can be shown in the UI (e.g., "incomplete value").
Warning {
formatted: String,
message: String,
mapper: Arc<dyn PositionMapper>,
},
/// Failed to produce a formatted display. The library will typically fall back to raw.
Error {
message: String,
},
}
impl FormattingResult {
/// Convenience to create a success result using the default mapper.
pub fn success(formatted: impl Into<String>) -> Self {
FormattingResult::Success {
formatted: formatted.into(),
mapper: Arc::new(DefaultPositionMapper),
}
}
/// Convenience to create a warning result using the default mapper.
pub fn warning(formatted: impl Into<String>, message: impl Into<String>) -> Self {
FormattingResult::Warning {
formatted: formatted.into(),
message: message.into(),
mapper: Arc::new(DefaultPositionMapper),
}
}
/// Convenience to create a success result with a custom mapper.
pub fn success_with_mapper(
formatted: impl Into<String>,
mapper: Arc<dyn PositionMapper>,
) -> Self {
FormattingResult::Success {
formatted: formatted.into(),
mapper,
}
}
/// Convenience to create a warning result with a custom mapper.
pub fn warning_with_mapper(
formatted: impl Into<String>,
message: impl Into<String>,
mapper: Arc<dyn PositionMapper>,
) -> Self {
FormattingResult::Warning {
formatted: formatted.into(),
message: message.into(),
mapper,
}
}
/// Convenience to create an error result.
pub fn error(message: impl Into<String>) -> Self {
FormattingResult::Error {
message: message.into(),
}
}
}
/// A user-implemented formatter that turns raw input into a formatted display string,
/// optionally providing a custom cursor position mapper.
///
/// Notes:
/// - The library will keep raw input authoritative for editing and validation.
/// - The formatted value is only used for display.
/// - If formatting fails, return Error; the library will show the raw value.
/// - For common grouping (spaces/dashes), you can return Success/Warning and rely
/// on DefaultPositionMapper, or provide your own mapper for advanced cases
/// (reordering, compression, locale-specific rules, etc.).
pub trait CustomFormatter: Send + Sync {
fn format(&self, raw: &str) -> FormattingResult;
}
#[cfg(test)]
mod tests {
use super::*;
struct GroupEvery3;
impl CustomFormatter for GroupEvery3 {
fn format(&self, raw: &str) -> FormattingResult {
let mut out = String::new();
for (i, ch) in raw.chars().enumerate() {
if i > 0 && i % 3 == 0 {
out.push(' ');
}
out.push(ch);
}
FormattingResult::success(out)
}
}
#[test]
fn default_mapper_roundtrip_basic() {
let mapper = DefaultPositionMapper;
let raw = "01001";
let formatted = "010 01";
// raw_to_formatted monotonicity and bounds
for rp in 0..=raw.chars().count() {
let fp = mapper.raw_to_formatted(raw, formatted, rp);
assert!(fp <= formatted.len());
}
// formatted_to_raw bounds
for fp in 0..=formatted.len() {
let rp = mapper.formatted_to_raw(raw, formatted, fp);
assert!(rp <= raw.chars().count());
}
}
#[test]
fn formatter_groups_every_3() {
let f = GroupEvery3;
match f.format("1234567") {
FormattingResult::Success { formatted, .. } => {
assert_eq!(formatted, "123 456 7");
}
_ => panic!("expected success"),
}
}
}

View File

@@ -1,423 +0,0 @@
// src/validation/limits.rs
//! Character limits validation implementation
use crate::validation::ValidationResult;
use serde::{Deserialize, Serialize};
use unicode_width::UnicodeWidthStr;
/// Character limits configuration for a field
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CharacterLimits {
/// Maximum number of characters allowed (None = unlimited)
max_length: Option<usize>,
/// Minimum number of characters required (None = no minimum)
min_length: Option<usize>,
/// Warning threshold (warn when approaching max limit)
warning_threshold: Option<usize>,
/// Count mode: characters vs display width
count_mode: CountMode,
}
/// How to count characters for limit checking
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[derive(Default)]
pub enum CountMode {
/// Count actual characters (default)
#[default]
Characters,
/// Count display width (useful for CJK characters)
DisplayWidth,
/// Count bytes (rarely used, but available)
Bytes,
}
/// Result of a character limit check
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LimitCheckResult {
/// Within limits
Ok,
/// Approaching limit (warning)
Warning { current: usize, max: usize },
/// At or exceeding limit (error)
Exceeded { current: usize, max: usize },
/// Below minimum length
TooShort { current: usize, min: usize },
}
impl CharacterLimits {
/// Create new character limits with just max length
pub fn new(max_length: usize) -> Self {
Self {
max_length: Some(max_length),
min_length: None,
warning_threshold: None,
count_mode: CountMode::default(),
}
}
/// Create new character limits with min and max
pub fn new_range(min_length: usize, max_length: usize) -> Self {
Self {
max_length: Some(max_length),
min_length: Some(min_length),
warning_threshold: None,
count_mode: CountMode::default(),
}
}
/// Set warning threshold (when to show warning before hitting limit)
pub fn with_warning_threshold(mut self, threshold: usize) -> Self {
self.warning_threshold = Some(threshold);
self
}
/// Set count mode (characters vs display width vs bytes)
pub fn with_count_mode(mut self, mode: CountMode) -> Self {
self.count_mode = mode;
self
}
/// Get maximum length
pub fn max_length(&self) -> Option<usize> {
self.max_length
}
/// Get minimum length
pub fn min_length(&self) -> Option<usize> {
self.min_length
}
/// Get warning threshold
pub fn warning_threshold(&self) -> Option<usize> {
self.warning_threshold
}
/// Get count mode
pub fn count_mode(&self) -> CountMode {
self.count_mode
}
/// Count characters/width/bytes according to the configured mode
fn count(&self, text: &str) -> usize {
match self.count_mode {
CountMode::Characters => text.chars().count(),
CountMode::DisplayWidth => text.width(),
CountMode::Bytes => text.len(),
}
}
/// Check if inserting a character would exceed limits
pub fn validate_insertion(
&self,
current_text: &str,
position: usize,
character: char,
) -> Option<ValidationResult> {
// FIX: Actually simulate the insertion at the specified position
// This makes the `position` parameter essential to the logic
// 1. Create the new string by inserting the character at the correct position
let mut new_text = String::with_capacity(current_text.len() + character.len_utf8());
let mut chars = current_text.chars();
// Append characters from the original string that come before the insertion point
// We clamp the position to be safe
let clamped_pos = position.min(current_text.chars().count());
for _ in 0..clamped_pos {
if let Some(ch) = chars.next() {
new_text.push(ch);
}
}
// Insert the new character
new_text.push(character);
// Append the rest of the original string
for ch in chars {
new_text.push(ch);
}
// 2. Now perform all validation on the *actual* resulting text
let new_count = self.count(&new_text);
let current_count = self.count(current_text);
// Check max length
if let Some(max) = self.max_length {
if new_count > max {
return Some(ValidationResult::error(format!(
"Character limit exceeded: {new_count}/{max}"
)));
}
// Check warning threshold
if let Some(warning_threshold) = self.warning_threshold {
if new_count >= warning_threshold && current_count < warning_threshold {
return Some(ValidationResult::warning(format!(
"Approaching character limit: {new_count}/{max}"
)));
}
}
}
None // No validation issues
}
/// Validate the current content
pub fn validate_content(&self, text: &str) -> Option<ValidationResult> {
let count = self.count(text);
// Check minimum length
if let Some(min) = self.min_length {
if count < min {
return Some(ValidationResult::warning(format!(
"Minimum length not met: {count}/{min}"
)));
}
}
// Check maximum length
if let Some(max) = self.max_length {
if count > max {
return Some(ValidationResult::error(format!(
"Character limit exceeded: {count}/{max}"
)));
}
// Check warning threshold
if let Some(warning_threshold) = self.warning_threshold {
if count >= warning_threshold {
return Some(ValidationResult::warning(format!(
"Approaching character limit: {count}/{max}"
)));
}
}
}
None // No validation issues
}
/// Get the current status of the text against limits
pub fn check_limits(&self, text: &str) -> LimitCheckResult {
let count = self.count(text);
// Check max length first
if let Some(max) = self.max_length {
if count > max {
return LimitCheckResult::Exceeded { current: count, max };
}
// Check warning threshold
if let Some(warning_threshold) = self.warning_threshold {
if count >= warning_threshold {
return LimitCheckResult::Warning { current: count, max };
}
}
}
// Check min length
if let Some(min) = self.min_length {
if count < min {
return LimitCheckResult::TooShort { current: count, min };
}
}
LimitCheckResult::Ok
}
/// Get a human-readable status string
pub fn status_text(&self, text: &str) -> Option<String> {
match self.check_limits(text) {
LimitCheckResult::Ok => {
// Show current/max if we have a max limit
self.max_length.map(|max| format!("{}/{}", self.count(text), max))
},
LimitCheckResult::Warning { current, max } => {
Some(format!("{current}/{max} (approaching limit)"))
},
LimitCheckResult::Exceeded { current, max } => {
Some(format!("{current}/{max} (exceeded)"))
},
LimitCheckResult::TooShort { current, min } => {
Some(format!("{current}/{min} minimum"))
},
}
}
pub fn allows_field_switch(&self, text: &str) -> bool {
if let Some(min) = self.min_length {
let count = self.count(text);
// Allow switching if field is empty OR meets minimum requirement
count == 0 || count >= min
} else {
true // No minimum requirement, always allow switching
}
}
/// Get reason why field switching is not allowed (if any)
pub fn field_switch_block_reason(&self, text: &str) -> Option<String> {
if let Some(min) = self.min_length {
let count = self.count(text);
if count > 0 && count < min {
return Some(format!(
"Field must be empty or have at least {min} characters (currently: {count})"
));
}
}
None
}
}
impl Default for CharacterLimits {
fn default() -> Self {
Self {
max_length: Some(30), // Default 30 character limit as specified
min_length: None,
warning_threshold: None,
count_mode: CountMode::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_character_limits_creation() {
let limits = CharacterLimits::new(10);
assert_eq!(limits.max_length(), Some(10));
assert_eq!(limits.min_length(), None);
let range_limits = CharacterLimits::new_range(5, 15);
assert_eq!(range_limits.min_length(), Some(5));
assert_eq!(range_limits.max_length(), Some(15));
}
#[test]
fn test_default_limits() {
let limits = CharacterLimits::default();
assert_eq!(limits.max_length(), Some(30));
}
#[test]
fn test_character_counting() {
let limits = CharacterLimits::new(5);
// Test character mode (default)
assert_eq!(limits.count("hello"), 5);
assert_eq!(limits.count("héllo"), 5); // Accented character counts as 1
// Test display width mode
let limits = limits.with_count_mode(CountMode::DisplayWidth);
assert_eq!(limits.count("hello"), 5);
// Test bytes mode
let limits = limits.with_count_mode(CountMode::Bytes);
assert_eq!(limits.count("hello"), 5);
assert_eq!(limits.count("héllo"), 6); // é takes 2 bytes in UTF-8
}
#[test]
fn test_insertion_validation() {
let limits = CharacterLimits::new(5);
// Valid insertion
let result = limits.validate_insertion("test", 4, 'x');
assert!(result.is_none()); // No validation issues
// Invalid insertion (would exceed limit)
let result = limits.validate_insertion("tests", 5, 'x');
assert!(result.is_some());
assert!(!result.unwrap().is_acceptable());
}
#[test]
fn test_content_validation() {
let limits = CharacterLimits::new_range(3, 10);
// Too short
let result = limits.validate_content("hi");
assert!(result.is_some());
assert!(result.unwrap().is_acceptable()); // Warning, not error
// Just right
let result = limits.validate_content("hello");
assert!(result.is_none());
// Too long
let result = limits.validate_content("hello world!");
assert!(result.is_some());
assert!(!result.unwrap().is_acceptable()); // Error
}
#[test]
fn test_warning_threshold() {
let limits = CharacterLimits::new(10).with_warning_threshold(8);
// Below warning threshold
let result = limits.validate_insertion("1234567", 7, 'x');
assert!(result.is_none());
// At warning threshold
let result = limits.validate_insertion("1234567", 7, 'x');
assert!(result.is_none()); // This brings us to 8 chars
let result = limits.validate_insertion("12345678", 8, 'x');
assert!(result.is_some());
assert!(result.unwrap().is_acceptable()); // Warning, not error
}
#[test]
fn test_status_text() {
let limits = CharacterLimits::new(10);
assert_eq!(limits.status_text("hello"), Some("5/10".to_string()));
let limits = limits.with_warning_threshold(8);
assert_eq!(limits.status_text("12345678"), Some("8/10 (approaching limit)".to_string()));
assert_eq!(limits.status_text("1234567890x"), Some("11/10 (exceeded)".to_string()));
}
#[test]
fn test_field_switch_blocking() {
let limits = CharacterLimits::new_range(3, 10);
// Empty field: should allow switching
assert!(limits.allows_field_switch(""));
assert!(limits.field_switch_block_reason("").is_none());
// Field with content below minimum: should block switching
assert!(!limits.allows_field_switch("hi"));
assert!(limits.field_switch_block_reason("hi").is_some());
assert!(limits.field_switch_block_reason("hi").unwrap().contains("at least 3 characters"));
// Field meeting minimum: should allow switching
assert!(limits.allows_field_switch("hello"));
assert!(limits.field_switch_block_reason("hello").is_none());
// Field exceeding maximum: should still allow switching (validation shows error but doesn't block)
assert!(limits.allows_field_switch("this is way too long"));
assert!(limits.field_switch_block_reason("this is way too long").is_none());
}
#[test]
fn test_field_switch_no_minimum() {
let limits = CharacterLimits::new(10); // Only max, no minimum
// Should always allow switching when there's no minimum
assert!(limits.allows_field_switch(""));
assert!(limits.allows_field_switch("a"));
assert!(limits.allows_field_switch("hello"));
assert!(limits.field_switch_block_reason("").is_none());
assert!(limits.field_switch_block_reason("a").is_none());
}
}

View File

@@ -1,330 +0,0 @@
// src/validation/mask.rs
//! Pure display mask system - user-defined patterns only
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Default)]
pub enum MaskDisplayMode {
/// Only show separators as user types
/// Example: "" → "", "123" → "123", "12345" → "(123) 45"
#[default]
Dynamic,
/// Show full template with placeholders from start
/// Example: "" → "(___) ___-____", "123" → "(123) ___-____"
Template {
/// Character to use as placeholder for empty input positions
placeholder: char
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DisplayMask {
/// Mask pattern like "##-##-####" where # = input position, others are visual separators
pattern: String,
/// Character used to represent input positions (usually '#')
input_char: char,
/// How to display the mask (dynamic vs template)
display_mode: MaskDisplayMode,
}
impl DisplayMask {
/// Create a new display mask with dynamic mode (current behavior)
///
/// # Arguments
/// * `pattern` - The mask pattern (e.g., "##-##-####", "(###) ###-####")
/// * `input_char` - Character representing input positions (usually '#')
///
/// # Examples
/// ```
/// // Phone number format
/// let phone_mask = DisplayMask::new("(###) ###-####", '#');
///
/// // Date format
/// let date_mask = DisplayMask::new("##/##/####", '#');
///
/// // Custom business format
/// let employee_id = DisplayMask::new("EMP-####-##", '#');
/// ```
pub fn new(pattern: impl Into<String>, input_char: char) -> Self {
Self {
pattern: pattern.into(),
input_char,
display_mode: MaskDisplayMode::Dynamic,
}
}
/// Set the display mode for this mask
///
/// # Examples
/// ```
/// let dynamic_mask = DisplayMask::new("##-##", '#')
/// .with_mode(MaskDisplayMode::Dynamic);
///
/// let template_mask = DisplayMask::new("##-##", '#')
/// .with_mode(MaskDisplayMode::Template { placeholder: '_' });
/// ```
pub fn with_mode(mut self, mode: MaskDisplayMode) -> Self {
self.display_mode = mode;
self
}
/// Set template mode with custom placeholder
///
/// # Examples
/// ```
/// let phone_template = DisplayMask::new("(###) ###-####", '#')
/// .with_template('_'); // Shows "(___) ___-____" when empty
///
/// let date_dots = DisplayMask::new("##/##/####", '#')
/// .with_template('•'); // Shows "••/••/••••" when empty
/// ```
pub fn with_template(self, placeholder: char) -> Self {
self.with_mode(MaskDisplayMode::Template { placeholder })
}
/// Apply mask to raw input, showing visual separators and handling display mode
pub fn apply_to_display(&self, raw_input: &str) -> String {
match &self.display_mode {
MaskDisplayMode::Dynamic => self.apply_dynamic(raw_input),
MaskDisplayMode::Template { placeholder } => self.apply_template(raw_input, *placeholder),
}
}
/// Dynamic mode - only show separators as user types
fn apply_dynamic(&self, raw_input: &str) -> String {
let mut result = String::new();
let mut raw_chars = raw_input.chars();
for pattern_char in self.pattern.chars() {
if pattern_char == self.input_char {
// Input position - take from raw input
if let Some(input_char) = raw_chars.next() {
result.push(input_char);
} else {
// No more input - stop here in dynamic mode
break;
}
} else {
// Visual separator - always show
result.push(pattern_char);
}
}
// Append any remaining raw characters that don't fit the pattern
for remaining_char in raw_chars {
result.push(remaining_char);
}
result
}
/// Template mode - show full pattern with placeholders
fn apply_template(&self, raw_input: &str, placeholder: char) -> String {
let mut result = String::new();
let mut raw_chars = raw_input.chars().peekable();
for pattern_char in self.pattern.chars() {
if pattern_char == self.input_char {
// Input position - take from raw input or use placeholder
if let Some(input_char) = raw_chars.next() {
result.push(input_char);
} else {
// No more input - use placeholder to show template
result.push(placeholder);
}
} else {
// Visual separator - always show in template mode
result.push(pattern_char);
}
}
// In template mode, we don't append extra characters beyond the pattern
// This keeps the template consistent
result
}
/// Check if a display position should accept cursor/input
pub fn is_input_position(&self, display_position: usize) -> bool {
self.pattern.chars()
.nth(display_position)
.map(|c| c == self.input_char)
.unwrap_or(true) // Beyond pattern = accept input
}
/// Map display position to raw position
pub fn display_pos_to_raw_pos(&self, display_pos: usize) -> usize {
let mut raw_pos = 0;
for (i, pattern_char) in self.pattern.chars().enumerate() {
if i >= display_pos {
break;
}
if pattern_char == self.input_char {
raw_pos += 1;
}
}
raw_pos
}
/// Map raw position to display position
pub fn raw_pos_to_display_pos(&self, raw_pos: usize) -> usize {
let mut input_positions_seen = 0;
for (display_pos, pattern_char) in self.pattern.chars().enumerate() {
if pattern_char == self.input_char {
if input_positions_seen == raw_pos {
return display_pos;
}
input_positions_seen += 1;
}
}
// Beyond pattern, return position after pattern
self.pattern.len() + (raw_pos - input_positions_seen)
}
/// Find next input position at or after the given display position
pub fn next_input_position(&self, display_pos: usize) -> usize {
for (i, pattern_char) in self.pattern.chars().enumerate().skip(display_pos) {
if pattern_char == self.input_char {
return i;
}
}
// Beyond pattern = all positions are input positions
display_pos.max(self.pattern.len())
}
/// Find previous input position at or before the given display position
pub fn prev_input_position(&self, display_pos: usize) -> Option<usize> {
// Collect pattern chars with indices first, then search backwards
let pattern_chars: Vec<(usize, char)> = self.pattern.chars().enumerate().collect();
// Search backwards from display_pos
for &(i, pattern_char) in pattern_chars.iter().rev() {
if i <= display_pos && pattern_char == self.input_char {
return Some(i);
}
}
None
}
/// Get the display mode
pub fn display_mode(&self) -> &MaskDisplayMode {
&self.display_mode
}
/// Check if this mask uses template mode
pub fn is_template_mode(&self) -> bool {
matches!(self.display_mode, MaskDisplayMode::Template { .. })
}
/// Get the pattern string
pub fn pattern(&self) -> &str {
&self.pattern
}
/// Get the position of the first input character in the pattern
pub fn first_input_position(&self) -> usize {
for (pos, ch) in self.pattern.chars().enumerate() {
if ch == self.input_char {
return pos;
}
}
0
}
}
impl Default for DisplayMask {
fn default() -> Self {
Self::new("", '#')
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_user_defined_phone_mask() {
// User creates their own phone mask
let dynamic = DisplayMask::new("(###) ###-####", '#');
let template = DisplayMask::new("(###) ###-####", '#').with_template('_');
// Dynamic mode
assert_eq!(dynamic.apply_to_display(""), "");
assert_eq!(dynamic.apply_to_display("1234567890"), "(123) 456-7890");
// Template mode
assert_eq!(template.apply_to_display(""), "(___) ___-____");
assert_eq!(template.apply_to_display("123"), "(123) ___-____");
}
#[test]
fn test_user_defined_date_mask() {
// User creates their own date formats
let us_date = DisplayMask::new("##/##/####", '#');
let eu_date = DisplayMask::new("##.##.####", '#');
let iso_date = DisplayMask::new("####-##-##", '#');
assert_eq!(us_date.apply_to_display("12252024"), "12/25/2024");
assert_eq!(eu_date.apply_to_display("25122024"), "25.12.2024");
assert_eq!(iso_date.apply_to_display("20241225"), "2024-12-25");
}
#[test]
fn test_user_defined_business_formats() {
// User creates custom business formats
let employee_id = DisplayMask::new("EMP-####-##", '#');
let product_code = DisplayMask::new("###-###-###", '#');
let invoice = DisplayMask::new("INV####/##", '#');
assert_eq!(employee_id.apply_to_display("123456"), "EMP-1234-56");
assert_eq!(product_code.apply_to_display("123456789"), "123-456-789");
assert_eq!(invoice.apply_to_display("123456"), "INV1234/56");
}
#[test]
fn test_custom_input_characters() {
// User can define their own input character
let mask_with_x = DisplayMask::new("XXX-XX-XXXX", 'X');
let mask_with_hash = DisplayMask::new("###-##-####", '#');
let mask_with_n = DisplayMask::new("NNN-NN-NNNN", 'N');
assert_eq!(mask_with_x.apply_to_display("123456789"), "123-45-6789");
assert_eq!(mask_with_hash.apply_to_display("123456789"), "123-45-6789");
assert_eq!(mask_with_n.apply_to_display("123456789"), "123-45-6789");
}
#[test]
fn test_custom_placeholders() {
// User can define custom placeholder characters
let underscores = DisplayMask::new("##-##", '#').with_template('_');
let dots = DisplayMask::new("##-##", '#').with_template('•');
let dashes = DisplayMask::new("##-##", '#').with_template('-');
assert_eq!(underscores.apply_to_display(""), "__-__");
assert_eq!(dots.apply_to_display(""), "••-••");
assert_eq!(dashes.apply_to_display(""), "---"); // Note: dashes blend with separator
}
#[test]
fn test_position_mapping_user_patterns() {
let custom = DisplayMask::new("ABC-###-XYZ", '#');
// Position mapping should work correctly with any pattern
assert_eq!(custom.raw_pos_to_display_pos(0), 4); // First # at position 4
assert_eq!(custom.raw_pos_to_display_pos(1), 5); // Second # at position 5
assert_eq!(custom.raw_pos_to_display_pos(2), 6); // Third # at position 6
assert_eq!(custom.display_pos_to_raw_pos(4), 0); // Position 4 -> first input
assert_eq!(custom.display_pos_to_raw_pos(5), 1); // Position 5 -> second input
assert_eq!(custom.display_pos_to_raw_pos(6), 2); // Position 6 -> third input
assert!(!custom.is_input_position(0)); // A
assert!(!custom.is_input_position(3)); // -
assert!(custom.is_input_position(4)); // #
assert!(!custom.is_input_position(8)); // Y
}
}

View File

@@ -1,45 +0,0 @@
// src/validation/mod.rs
//! Validation subsystem re-exports and helpers.
//!
//! This module collects validation-related modules (limits, masks, patterns,
//! formatting, and state) and re-exports the most commonly used types so that
//! callers can import them from `crate::validation`.
// Core validation modules
pub mod config;
pub mod limits;
pub mod state;
pub mod patterns;
pub mod mask; // Simple display mask instead of complex reserved chars
pub mod formatting; // Custom formatter and position mapping (feature 4)
// Re-export main types
pub use config::{ValidationConfig, ValidationResult, ValidationConfigBuilder};
pub use limits::{CharacterLimits, LimitCheckResult};
pub use state::{ValidationState, ValidationSummary};
pub use patterns::{PatternFilters, PositionFilter, PositionRange, CharacterFilter};
pub use mask::DisplayMask; // Simple mask instead of ReservedCharacters
pub use formatting::{CustomFormatter, FormattingResult, PositionMapper, DefaultPositionMapper};
/// External validation UI state (Feature 5)
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExternalValidationState {
NotValidated,
Validating,
Valid(Option<String>),
Invalid { message: String, suggestion: Option<String> },
Warning { message: String },
}
/// Validation error types
#[derive(Debug, Clone, thiserror::Error)]
pub enum ValidationError {
#[error("Character limit exceeded: {message}")]
LimitExceeded { message: String },
#[error("Pattern validation failed: {message}")]
PatternFailed { message: String },
#[error("Custom validation failed: {message}")]
CustomFailed { message: String },
}

View File

@@ -1,324 +0,0 @@
// src/validation/patterns.rs
//! Position-based pattern filtering for validation
use serde::{Deserialize, Serialize};
use std::sync::Arc;
/// A filter that applies to specific character positions in a field
#[derive(Debug, Clone)]
pub struct PositionFilter {
/// Which positions this filter applies to
pub positions: PositionRange,
/// What type of character filter to apply
pub filter: CharacterFilter,
}
/// Defines which character positions a filter applies to
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PositionRange {
/// Single position (e.g., position 3 only)
Single(usize),
/// Range of positions (e.g., positions 0-2, inclusive)
Range(usize, usize),
/// From position onwards (e.g., position 4 and beyond)
From(usize),
/// Multiple specific positions (e.g., positions 0, 2, 5)
Multiple(Vec<usize>),
}
/// Types of character filters that can be applied
pub enum CharacterFilter {
/// Allow only alphabetic characters (a-z, A-Z)
Alphabetic,
/// Allow only numeric characters (0-9)
Numeric,
/// Allow alphanumeric characters (a-z, A-Z, 0-9)
Alphanumeric,
/// Allow only exact character match
Exact(char),
/// Allow any character from the provided set
OneOf(Vec<char>),
/// Custom user-defined filter function
Custom(Arc<dyn Fn(char) -> bool + Send + Sync>),
}
// Manual implementations for Debug and Clone
impl std::fmt::Debug for CharacterFilter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CharacterFilter::Alphabetic => write!(f, "Alphabetic"),
CharacterFilter::Numeric => write!(f, "Numeric"),
CharacterFilter::Alphanumeric => write!(f, "Alphanumeric"),
CharacterFilter::Exact(ch) => write!(f, "Exact('{ch}')"),
CharacterFilter::OneOf(chars) => write!(f, "OneOf({chars:?})"),
CharacterFilter::Custom(_) => write!(f, "Custom(<function>)"),
}
}
}
impl Clone for CharacterFilter {
fn clone(&self) -> Self {
match self {
CharacterFilter::Alphabetic => CharacterFilter::Alphabetic,
CharacterFilter::Numeric => CharacterFilter::Numeric,
CharacterFilter::Alphanumeric => CharacterFilter::Alphanumeric,
CharacterFilter::Exact(ch) => CharacterFilter::Exact(*ch),
CharacterFilter::OneOf(chars) => CharacterFilter::OneOf(chars.clone()),
CharacterFilter::Custom(func) => CharacterFilter::Custom(Arc::clone(func)),
}
}
}
impl PositionRange {
/// Check if a position is included in this range
pub fn contains(&self, position: usize) -> bool {
match self {
PositionRange::Single(pos) => position == *pos,
PositionRange::Range(start, end) => position >= *start && position <= *end,
PositionRange::From(start) => position >= *start,
PositionRange::Multiple(positions) => positions.contains(&position),
}
}
/// Get all positions up to a given length that this range covers
pub fn positions_up_to(&self, max_length: usize) -> Vec<usize> {
match self {
PositionRange::Single(pos) => {
if *pos < max_length { vec![*pos] } else { vec![] }
},
PositionRange::Range(start, end) => {
let actual_end = (*end).min(max_length.saturating_sub(1));
if *start <= actual_end {
(*start..=actual_end).collect()
} else {
vec![]
}
},
PositionRange::From(start) => {
if *start < max_length {
(*start..max_length).collect()
} else {
vec![]
}
},
PositionRange::Multiple(positions) => {
positions.iter()
.filter(|&&pos| pos < max_length)
.copied()
.collect()
},
}
}
}
impl CharacterFilter {
/// Test if a character passes this filter
pub fn accepts(&self, ch: char) -> bool {
match self {
CharacterFilter::Alphabetic => ch.is_alphabetic(),
CharacterFilter::Numeric => ch.is_numeric(),
CharacterFilter::Alphanumeric => ch.is_alphanumeric(),
CharacterFilter::Exact(expected) => ch == *expected,
CharacterFilter::OneOf(chars) => chars.contains(&ch),
CharacterFilter::Custom(func) => func(ch),
}
}
/// Get a human-readable description of this filter
pub fn description(&self) -> String {
match self {
CharacterFilter::Alphabetic => "alphabetic characters (a-z, A-Z)".to_string(),
CharacterFilter::Numeric => "numeric characters (0-9)".to_string(),
CharacterFilter::Alphanumeric => "alphanumeric characters (a-z, A-Z, 0-9)".to_string(),
CharacterFilter::Exact(ch) => format!("exactly '{ch}'"),
CharacterFilter::OneOf(chars) => {
let char_list: String = chars.iter().collect();
format!("one of: {char_list}")
},
CharacterFilter::Custom(_) => "custom filter".to_string(),
}
}
}
impl PositionFilter {
/// Create a new position filter
pub fn new(positions: PositionRange, filter: CharacterFilter) -> Self {
Self { positions, filter }
}
/// Validate a character at a specific position
pub fn validate_position(&self, position: usize, character: char) -> bool {
if self.positions.contains(position) {
self.filter.accepts(character)
} else {
true // Position not covered by this filter, allow any character
}
}
/// Get error message for invalid character at position
pub fn error_message(&self, position: usize, character: char) -> Option<String> {
if self.positions.contains(position) && !self.filter.accepts(character) {
Some(format!(
"Position {} requires {} but got '{}'",
position,
self.filter.description(),
character
))
} else {
None
}
}
}
/// A collection of position filters for a field
#[derive(Debug, Clone, Default)]
pub struct PatternFilters {
filters: Vec<PositionFilter>,
}
impl PatternFilters {
/// Create empty pattern filters
pub fn new() -> Self {
Self::default()
}
/// Add a position filter
pub fn add_filter(mut self, filter: PositionFilter) -> Self {
self.filters.push(filter);
self
}
/// Add multiple filters
pub fn add_filters(mut self, filters: Vec<PositionFilter>) -> Self {
self.filters.extend(filters);
self
}
/// Validate a character at a specific position against all applicable filters
pub fn validate_char_at_position(&self, position: usize, character: char) -> Result<(), String> {
for filter in &self.filters {
if let Some(error) = filter.error_message(position, character) {
return Err(error);
}
}
Ok(())
}
/// Validate entire text against all filters
pub fn validate_text(&self, text: &str) -> Result<(), String> {
for (position, character) in text.char_indices() {
self.validate_char_at_position(position, character)?
}
Ok(())
}
/// Check if any filters are configured
pub fn has_filters(&self) -> bool {
!self.filters.is_empty()
}
/// Get all configured filters
pub fn filters(&self) -> &[PositionFilter] {
&self.filters
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_position_range_contains() {
assert!(PositionRange::Single(3).contains(3));
assert!(!PositionRange::Single(3).contains(2));
assert!(PositionRange::Range(1, 4).contains(3));
assert!(!PositionRange::Range(1, 4).contains(5));
assert!(PositionRange::From(2).contains(5));
assert!(!PositionRange::From(2).contains(1));
assert!(PositionRange::Multiple(vec![0, 2, 5]).contains(2));
assert!(!PositionRange::Multiple(vec![0, 2, 5]).contains(3));
}
#[test]
fn test_position_range_positions_up_to() {
assert_eq!(PositionRange::Single(3).positions_up_to(5), vec![3]);
assert_eq!(PositionRange::Single(5).positions_up_to(3), vec![]);
assert_eq!(PositionRange::Range(1, 3).positions_up_to(5), vec![1, 2, 3]);
assert_eq!(PositionRange::Range(1, 5).positions_up_to(3), vec![1, 2]);
assert_eq!(PositionRange::From(2).positions_up_to(5), vec![2, 3, 4]);
assert_eq!(PositionRange::Multiple(vec![0, 2, 5]).positions_up_to(4), vec![0, 2]);
}
#[test]
fn test_character_filter_accepts() {
assert!(CharacterFilter::Alphabetic.accepts('a'));
assert!(CharacterFilter::Alphabetic.accepts('Z'));
assert!(!CharacterFilter::Alphabetic.accepts('1'));
assert!(CharacterFilter::Numeric.accepts('5'));
assert!(!CharacterFilter::Numeric.accepts('a'));
assert!(CharacterFilter::Alphanumeric.accepts('a'));
assert!(CharacterFilter::Alphanumeric.accepts('5'));
assert!(!CharacterFilter::Alphanumeric.accepts('-'));
assert!(CharacterFilter::Exact('x').accepts('x'));
assert!(!CharacterFilter::Exact('x').accepts('y'));
assert!(CharacterFilter::OneOf(vec!['a', 'b', 'c']).accepts('b'));
assert!(!CharacterFilter::OneOf(vec!['a', 'b', 'c']).accepts('d'));
}
#[test]
fn test_position_filter_validation() {
let filter = PositionFilter::new(
PositionRange::Range(0, 1),
CharacterFilter::Alphabetic,
);
assert!(filter.validate_position(0, 'A'));
assert!(filter.validate_position(1, 'b'));
assert!(!filter.validate_position(0, '1'));
assert!(filter.validate_position(2, '1')); // Position 2 not covered, allow anything
}
#[test]
fn test_pattern_filters_validation() {
let patterns = PatternFilters::new()
.add_filter(PositionFilter::new(
PositionRange::Range(0, 1),
CharacterFilter::Alphabetic,
))
.add_filter(PositionFilter::new(
PositionRange::Range(2, 4),
CharacterFilter::Numeric,
));
// Valid pattern: AB123
assert!(patterns.validate_text("AB123").is_ok());
// Invalid: number in alphabetic position
assert!(patterns.validate_text("A1123").is_err());
// Invalid: letter in numeric position
assert!(patterns.validate_text("AB1A3").is_err());
}
#[test]
fn test_custom_filter() {
let pattern = PatternFilters::new()
.add_filter(PositionFilter::new(
PositionRange::From(0),
CharacterFilter::Custom(Arc::new(|c| c.is_lowercase())),
));
assert!(pattern.validate_text("hello").is_ok());
assert!(pattern.validate_text("Hello").is_err()); // Uppercase not allowed
}
}

View File

@@ -1,460 +0,0 @@
// src/validation/state.rs
//! Validation state management
use crate::validation::{ValidationConfig, ValidationResult, ExternalValidationState};
use std::collections::HashMap;
/// Validation state for all fields in a form
#[derive(Debug, Clone, Default)]
pub struct ValidationState {
/// Validation configurations per field index
field_configs: HashMap<usize, ValidationConfig>,
/// Current validation results per field index
field_results: HashMap<usize, ValidationResult>,
/// Track which fields have been validated
validated_fields: std::collections::HashSet<usize>,
/// Global validation enabled/disabled
enabled: bool,
/// External validation results per field (Feature 5)
external_results: HashMap<usize, ExternalValidationState>,
last_switch_block: Option<String>,
}
impl ValidationState {
/// Create a new validation state
pub fn new() -> Self {
Self {
field_configs: HashMap::new(),
field_results: HashMap::new(),
validated_fields: std::collections::HashSet::new(),
enabled: true,
external_results: HashMap::new(),
last_switch_block: None,
}
}
/// Enable or disable validation globally
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
if !enabled {
// Clear all validation results when disabled
self.field_results.clear();
self.validated_fields.clear();
self.external_results.clear(); // Also clear external results
}
}
/// Check if validation is enabled
pub fn is_enabled(&self) -> bool {
self.enabled
}
/// Set validation configuration for a field
pub fn set_field_config(&mut self, field_index: usize, config: ValidationConfig) {
if config.has_validation() || config.external_validation_enabled {
self.field_configs.insert(field_index, config);
} else {
self.field_configs.remove(&field_index);
self.field_results.remove(&field_index);
self.validated_fields.remove(&field_index);
self.external_results.remove(&field_index);
}
}
/// Get validation configuration for a field
pub fn get_field_config(&self, field_index: usize) -> Option<&ValidationConfig> {
self.field_configs.get(&field_index)
}
/// Remove validation configuration for a field
pub fn remove_field_config(&mut self, field_index: usize) {
self.field_configs.remove(&field_index);
self.field_results.remove(&field_index);
self.validated_fields.remove(&field_index);
self.external_results.remove(&field_index);
}
/// Set external validation state for a field (Feature 5)
pub fn set_external_validation(&mut self, field_index: usize, state: ExternalValidationState) {
self.external_results.insert(field_index, state);
}
/// Get current external validation state for a field
pub fn get_external_validation(&self, field_index: usize) -> ExternalValidationState {
self.external_results
.get(&field_index)
.cloned()
.unwrap_or(ExternalValidationState::NotValidated)
}
/// Clear external validation state for a field
pub fn clear_external_validation(&mut self, field_index: usize) {
self.external_results.remove(&field_index);
}
/// Clear all external validation states
pub fn clear_all_external_validation(&mut self) {
self.external_results.clear();
}
/// Validate character insertion for a field
pub fn validate_char_insertion(
&mut self,
field_index: usize,
current_text: &str,
position: usize,
character: char,
) -> ValidationResult {
if !self.enabled {
return ValidationResult::Valid;
}
if let Some(config) = self.field_configs.get(&field_index) {
let result = config.validate_char_insertion(current_text, position, character);
// Store the validation result
self.field_results.insert(field_index, result.clone());
self.validated_fields.insert(field_index);
result
} else {
ValidationResult::Valid
}
}
/// Validate field content
pub fn validate_field_content(
&mut self,
field_index: usize,
text: &str,
) -> ValidationResult {
if !self.enabled {
return ValidationResult::Valid;
}
if let Some(config) = self.field_configs.get(&field_index) {
let result = config.validate_content(text);
// Store the validation result
self.field_results.insert(field_index, result.clone());
self.validated_fields.insert(field_index);
result
} else {
ValidationResult::Valid
}
}
/// Get current validation result for a field
pub fn get_field_result(&self, field_index: usize) -> Option<&ValidationResult> {
self.field_results.get(&field_index)
}
/// Get formatted display for a field if a custom formatter is configured.
/// Returns (formatted_text, position_mapper, optional_warning_message).
#[cfg(feature = "validation")]
pub fn formatted_for(
&self,
field_index: usize,
raw: &str,
) -> Option<(String, std::sync::Arc<dyn crate::validation::PositionMapper>, Option<String>)> {
let config = self.field_configs.get(&field_index)?;
config.run_custom_formatter(raw)
}
/// Check if a field has been validated
pub fn is_field_validated(&self, field_index: usize) -> bool {
self.validated_fields.contains(&field_index)
}
/// Clear validation result for a field
pub fn clear_field_result(&mut self, field_index: usize) {
self.field_results.remove(&field_index);
self.validated_fields.remove(&field_index);
}
/// Clear all validation results
pub fn clear_all_results(&mut self) {
self.field_results.clear();
self.validated_fields.clear();
}
/// Get all field indices that have validation configured
pub fn validated_field_indices(&self) -> impl Iterator<Item = usize> + '_ {
self.field_configs.keys().copied()
}
/// Get all field indices with validation errors
pub fn fields_with_errors(&self) -> impl Iterator<Item = usize> + '_ {
self.field_results
.iter()
.filter(|(_, result)| result.is_error())
.map(|(index, _)| *index)
}
/// Get all field indices with validation warnings
pub fn fields_with_warnings(&self) -> impl Iterator<Item = usize> + '_ {
self.field_results
.iter()
.filter(|(_, result)| matches!(result, ValidationResult::Warning { .. }))
.map(|(index, _)| *index)
}
/// Check if any field has validation errors
pub fn has_errors(&self) -> bool {
self.field_results.values().any(|result| result.is_error())
}
/// Check if any field has validation warnings
pub fn has_warnings(&self) -> bool {
self.field_results.values().any(|result| matches!(result, ValidationResult::Warning { .. }))
}
/// Get total count of fields with validation configured
pub fn validated_field_count(&self) -> usize {
self.field_configs.len()
}
/// Check if field switching is allowed for a specific field
pub fn allows_field_switch(&self, field_index: usize, text: &str) -> bool {
if !self.enabled {
return true;
}
if let Some(config) = self.field_configs.get(&field_index) {
config.allows_field_switch(text)
} else {
true // No validation configured, allow switching
}
}
/// Get reason why field switching is blocked (if any)
pub fn field_switch_block_reason(&self, field_index: usize, text: &str) -> Option<String> {
if !self.enabled {
return None;
}
if let Some(config) = self.field_configs.get(&field_index) {
config.field_switch_block_reason(text)
} else {
None // No validation configured
}
}
pub fn summary(&self) -> ValidationSummary {
let total_validated = self.validated_fields.len();
let errors = self.fields_with_errors().count();
let warnings = self.fields_with_warnings().count();
let valid = total_validated - errors - warnings;
ValidationSummary {
total_fields: self.field_configs.len(),
validated_fields: total_validated,
valid_fields: valid,
warning_fields: warnings,
error_fields: errors,
}
}
/// Set the last switch block reason (for UI convenience)
pub fn set_last_switch_block<S: Into<String>>(&mut self, reason: S) {
self.last_switch_block = Some(reason.into());
}
/// Clear the last switch block reason
pub fn clear_last_switch_block(&mut self) {
self.last_switch_block = None;
}
/// Get the last switch block reason (if any)
pub fn last_switch_block(&self) -> Option<&str> {
self.last_switch_block.as_deref()
}
}
/// Summary of validation state across all fields
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationSummary {
/// Total number of fields with validation configured
pub total_fields: usize,
/// Number of fields that have been validated
pub validated_fields: usize,
/// Number of fields with valid validation results
pub valid_fields: usize,
/// Number of fields with warnings
pub warning_fields: usize,
/// Number of fields with errors
pub error_fields: usize,
}
impl ValidationSummary {
/// Check if all configured fields are valid
pub fn is_all_valid(&self) -> bool {
self.error_fields == 0 && self.validated_fields == self.total_fields
}
/// Check if there are any errors
pub fn has_errors(&self) -> bool {
self.error_fields > 0
}
/// Check if there are any warnings
pub fn has_warnings(&self) -> bool {
self.warning_fields > 0
}
/// Get completion percentage (validated fields / total fields)
pub fn completion_percentage(&self) -> f32 {
if self.total_fields == 0 {
1.0
} else {
self.validated_fields as f32 / self.total_fields as f32
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::validation::{CharacterLimits, ValidationConfigBuilder};
#[test]
fn test_validation_state_creation() {
let state = ValidationState::new();
assert!(state.is_enabled());
assert_eq!(state.validated_field_count(), 0);
}
#[test]
fn test_enable_disable() {
let mut state = ValidationState::new();
// Add some validation config
let config = ValidationConfigBuilder::new()
.with_max_length(10)
.build();
state.set_field_config(0, config);
// Validate something
let result = state.validate_field_content(0, "test");
assert!(result.is_acceptable());
assert!(state.is_field_validated(0));
// Disable validation
state.set_enabled(false);
assert!(!state.is_enabled());
assert!(!state.is_field_validated(0)); // Should be cleared
// Validation should now return valid regardless
let result = state.validate_field_content(0, "this is way too long for the limit");
assert!(result.is_acceptable());
}
#[test]
fn test_field_config_management() {
let mut state = ValidationState::new();
let config = ValidationConfigBuilder::new()
.with_max_length(5)
.build();
// Set config
state.set_field_config(0, config);
assert_eq!(state.validated_field_count(), 1);
assert!(state.get_field_config(0).is_some());
// Remove config
state.remove_field_config(0);
assert_eq!(state.validated_field_count(), 0);
assert!(state.get_field_config(0).is_none());
}
#[test]
fn test_character_insertion_validation() {
let mut state = ValidationState::new();
let config = ValidationConfigBuilder::new()
.with_max_length(5)
.build();
state.set_field_config(0, config);
// Valid insertion
let result = state.validate_char_insertion(0, "test", 4, 'x');
assert!(result.is_acceptable());
// Invalid insertion
let result = state.validate_char_insertion(0, "tests", 5, 'x');
assert!(!result.is_acceptable());
// Check that result was stored
assert!(state.is_field_validated(0));
let stored_result = state.get_field_result(0);
assert!(stored_result.is_some());
assert!(!stored_result.unwrap().is_acceptable());
}
#[test]
fn test_validation_summary() {
let mut state = ValidationState::new();
// Configure two fields
let config1 = ValidationConfigBuilder::new().with_max_length(5).build();
let config2 = ValidationConfigBuilder::new().with_max_length(10).build();
state.set_field_config(0, config1);
state.set_field_config(1, config2);
// Validate field 0 (valid)
state.validate_field_content(0, "test");
// Validate field 1 (error)
state.validate_field_content(1, "this is too long");
let summary = state.summary();
assert_eq!(summary.total_fields, 2);
assert_eq!(summary.validated_fields, 2);
assert_eq!(summary.valid_fields, 1);
assert_eq!(summary.error_fields, 1);
assert_eq!(summary.warning_fields, 0);
assert!(!summary.is_all_valid());
assert!(summary.has_errors());
assert!(!summary.has_warnings());
assert_eq!(summary.completion_percentage(), 1.0);
}
#[test]
fn test_error_and_warning_tracking() {
let mut state = ValidationState::new();
let config = ValidationConfigBuilder::new()
.with_character_limits(
CharacterLimits::new_range(3, 10).with_warning_threshold(8)
)
.build();
state.set_field_config(0, config);
// Too short (warning)
state.validate_field_content(0, "hi");
assert!(state.has_warnings());
assert!(!state.has_errors());
// Just right
state.validate_field_content(0, "hello");
assert!(!state.has_warnings());
assert!(!state.has_errors());
// Too long (error)
state.validate_field_content(0, "hello world!");
assert!(!state.has_warnings());
assert!(state.has_errors());
}
}

View File

@@ -1,55 +0,0 @@
#!/bin/bash
# Enhanced documentation viewer for your canvas library
echo "=========================================="
echo "CANVAS LIBRARY DOCUMENTATION"
echo "=========================================="
# Function to display module docs with colors
show_module() {
local module=$1
local title=$2
echo -e "\n\033[1;34m=== $title ===\033[0m"
echo -e "\033[33mFiles in $module:\033[0m"
find src/$module -name "*.rs" 2>/dev/null | sort
echo
# Show doc comments for this module
find src/$module -name "*.rs" 2>/dev/null | while read file; do
if grep -q "///" "$file"; then
echo -e "\033[32m--- $file ---\033[0m"
grep -n "^\s*///" "$file" | sed 's/^\([0-9]*:\)\s*\/\/\/ /\1 /' | head -10
echo
fi
done
}
# Main modules
show_module "canvas" "CANVAS SYSTEM"
show_module "suggestions" "SUGGESTIONS SYSTEM"
show_module "config" "CONFIGURATION SYSTEM"
# Show lib.rs and other root files
echo -e "\n\033[1;34m=== ROOT DOCUMENTATION ===\033[0m"
if [ -f "src/lib.rs" ]; then
echo -e "\033[32m--- src/lib.rs ---\033[0m"
grep -n "^\s*///" src/lib.rs | sed 's/^\([0-9]*:\)\s*\/\/\/ /\1 /' 2>/dev/null
fi
if [ -f "src/dispatcher.rs" ]; then
echo -e "\033[32m--- src/dispatcher.rs ---\033[0m"
grep -n "^\s*///" src/dispatcher.rs | sed 's/^\([0-9]*:\)\s*\/\/\/ /\1 /' 2>/dev/null
fi
echo -e "\n\033[1;36m=========================================="
echo "To view specific module documentation:"
echo " ./view_canvas_docs.sh canvas"
echo " ./view_canvas_docs.sh suggestions"
echo " ./view_canvas_docs.sh config"
echo "==========================================\033[0m"
# If specific module requested
if [ $# -eq 1 ]; then
show_module "$1" "$(echo $1 | tr '[:lower:]' '[:upper:]') MODULE DETAILS"
fi

1
client Submodule

Submodule client added at c1839bd960

1
client/.gitignore vendored
View File

@@ -1 +0,0 @@
canvas_config.toml.txt

View File

@@ -1,40 +0,0 @@
[package]
name = "client"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
anyhow = { workspace = true }
async-trait = "0.1.88"
common = { path = "../common" }
canvas = { path = "../canvas", features = ["gui"] }
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"
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"

View File

@@ -1,134 +0,0 @@
# config.toml
[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"]
move_down = ["j", "Down"]
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"]
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
[keybindings.read_only]
enter_edit_mode_before = ["i"]
enter_edit_mode_after = ["a"]
previous_entry = ["left","q"]
next_entry = ["right","1"]
enter_highlight_mode = ["v"]
enter_highlight_mode_linewise = ["ctrl+v"]
### AUTOGENERATED CANVAS CONFIG
# Required
move_up = ["k", "Up"]
move_left = ["h", "Left"]
move_right = ["l", "Right"]
move_down = ["j", "Down"]
# Optional
move_line_end = ["$"]
# move_word_next = ["w"]
next_field = ["Tab"]
move_word_prev = ["b"]
move_word_end = ["e"]
move_last_line = ["shift+g"]
move_word_end_prev = ["ge"]
move_line_start = ["0"]
move_first_line = ["g+g"]
prev_field = ["Shift+Tab"]
[keybindings.highlight]
exit_highlight_mode = ["esc"]
enter_highlight_mode_linewise = ["ctrl+v"]
### AUTOGENERATED CANVAS CONFIG
# Required
move_left = ["h", "Left"]
move_right = ["l", "Right"]
move_up = ["k", "Up"]
move_down = ["j", "Down"]
# Optional
move_word_next = ["w"]
move_line_start = ["0"]
move_line_end = ["$"]
move_word_prev = ["b"]
move_word_end = ["e"]
[keybindings.edit]
# BIG CHANGES NOW EXIT HANDLES EITHER IF THOSE
# exit_edit_mode = ["esc","ctrl+e"]
# 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"]
command_execute = ["enter"]
command_backspace = ["backspace"]
save = ["w"]
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"

View File

@@ -1,124 +0,0 @@
## 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`

View File

@@ -1,49 +0,0 @@
client/
├── Cargo.toml
├── config.toml
└── src/
├── main.rs # Entry point with minimal code
├── lib.rs # Core exports
├── app.rs # Application lifecycle and main loop
├── ui/ # UI components and rendering
│ ├── mod.rs
│ ├── theme.rs # Theme definitions (from colors.rs)
│ ├── layout.rs # Layout definitions
│ ├── render.rs # Main render coordinator
│ └── components/ # UI components
│ ├── mod.rs
│ ├── command_line.rs
│ ├── form.rs
│ ├── preview_card.rs
│ └── status_line.rs
├── input/ # Input handling
│ ├── mod.rs
│ ├── handler.rs # Main input handler (lightweight coordinator)
│ ├── commands.rs # Command processing
│ ├── navigation.rs # Navigation between entries and fields
│ └── edit.rs # Edit mode logic
├── editor/ # Text editing functionality
│ ├── mod.rs
│ ├── cursor.rs # Cursor movement
│ └── text.rs # Text manipulation (word movements, etc.)
├── state/ # Application state
│ ├── mod.rs
│ ├── app_state.rs # Main application state
│ └── form_state.rs # Form state
├── model/ # Data models
│ ├── mod.rs
│ └── entry.rs # Entry model with business logic
├── service/ # External services
│ ├── mod.rs
│ ├── terminal.rs # Terminal setup and management
│ └── grpc.rs # gRPC client (extracted from terminal.rs)
└── config/ # Configuration
├── mod.rs
└── keybindings.rs # Keybinding definitions and matching

View File

@@ -1,10 +0,0 @@
// src/components/admin.rs
pub mod admin_panel;
pub mod admin_panel_admin;
pub mod add_table;
pub mod add_logic;
pub use admin_panel::*;
pub use admin_panel_admin::*;
pub use add_table::*;
pub use add_logic::*;

View File

@@ -1,318 +0,0 @@
// 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,
);
}
}

View File

@@ -1,577 +0,0 @@
// 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,
);
}
}

View File

@@ -1,131 +0,0 @@
// 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::{
layout::{Constraint, Direction, Layout, Rect},
style::Style,
text::{Line, Span, Text},
widgets::{Block, BorderType, Borders, List, ListItem, Paragraph, Wrap},
Frame,
};
use super::admin_panel_admin::render_admin_panel_admin;
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,
);
}
}
/// 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),
),
Span::styled(p, Style::default().fg(theme.fg)),
]))
})
.collect();
let list = List::new(items)
.block(Block::default().title("Profiles"))
.highlight_style(Style::default().bg(theme.highlight).fg(theme.bg));
let mut profile_list_state_clone = admin_state.profile_list_state.clone();
f.render_stateful_widget(list, content_chunks[0], &mut profile_list_state_clone);
// 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),
));
}
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]);
}
}

View File

@@ -1,180 +0,0 @@
// 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]);
}

View File

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

View File

@@ -1,155 +0,0 @@
// src/components/auth/login.rs
use crate::{
config::colors::themes::Theme,
state::pages::auth::LoginState,
components::common::dialog,
state::app::state::AppState,
};
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
// 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,
);
}
}

View File

@@ -1,178 +0,0 @@
// 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,
);
}
}

View File

@@ -1,18 +0,0 @@
// 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::*;

View File

@@ -1,153 +0,0 @@
// 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);
}

View File

@@ -1,15 +0,0 @@
// src/components/handlers/background.rs
use ratatui::{
widgets::{Block},
layout::Rect,
style::Style,
Frame,
};
use crate::config::colors::themes::Theme;
pub fn render_background(f: &mut Frame, area: Rect, theme: &Theme) {
let background = Block::default()
.style(Style::default().bg(theme.bg));
f.render_widget(background, area);
}

View File

@@ -1,69 +0,0 @@
// src/components/common/command_line.rs
use ratatui::{
widgets::{Block, Paragraph},
style::Style,
layout::Rect,
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, // 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)
}
}
};
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 {
// 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
};
// 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_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);
}

View File

@@ -1,183 +0,0 @@
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],
);
}
}
}
}

View File

@@ -1,142 +0,0 @@
// 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);
}

View File

@@ -1,121 +0,0 @@
// 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);
}
}

View File

@@ -1,155 +0,0 @@
// 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,
area: Rect,
current_dir: &str,
theme: &Theme,
is_edit_mode: bool,
current_fps: f64,
app_state: &AppState,
) {
#[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());
// 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.
}
// --- 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()
};
let available_width = area.width as usize;
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 {
let dir_name = Path::new(current_dir)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(current_dir);
if UnicodeWidthStr::width(dir_name) <= remaining_width_for_dir {
dir_name.to_string()
} else {
dir_name
.chars()
.take(remaining_width_for_dir)
.collect::<String>()
}
};
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;
}
let mut line_spans = vec![
Span::styled(mode_text, Style::default().fg(theme.accent)),
Span::styled(separator, Style::default().fg(theme.border)),
Span::styled(
dir_display_text_str.as_str(),
Style::default().fg(theme.fg),
),
Span::styled(separator, Style::default().fg(theme.border)),
Span::styled(
program_info.as_str(),
Style::default().fg(theme.secondary),
),
];
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);
}

View File

@@ -1,331 +0,0 @@
// 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)
}
}

View File

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

View File

@@ -1,98 +0,0 @@
// 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::{
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
style::Style,
widgets::{Block, Borders, Paragraph},
Frame,
};
pub fn render_form(
f: &mut Frame,
area: Rect,
form_state: &FormState,
fields: &[&str],
current_field_idx: &usize,
inputs: &[&String],
table_name: &str,
theme: &Theme,
is_edit_mode: bool,
highlight_state: &HighlightState,
total_count: u64,
current_position: u64,
) {
let card_title = format!(" {} ", table_name);
let adresar_card = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border))
.title(card_title)
.style(Style::default().bg(theme.bg).fg(theme.fg));
f.render_widget(adresar_card, area);
let inner_area = area.inner(Margin {
horizontal: 1,
vertical: 1,
});
let main_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(inner_area);
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]);
// Use the canvas library's render_canvas function
let active_field_rect = render_canvas(
f,
main_layout[1],
form_state,
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!
}
}
}

View File

@@ -1,6 +0,0 @@
// src/components/handlers.rs
pub mod sidebar;
pub mod buffer_list;
pub use sidebar::*;
pub use buffer_list::*;

View File

@@ -1,80 +0,0 @@
// 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);
}

View File

@@ -1,150 +0,0 @@
// src/components/handlers/sidebar.rs
use ratatui::{
widgets::{Block, List, ListItem},
layout::{Rect, Direction, Layout, Constraint},
style::Style,
Frame,
};
use crate::config::colors::themes::Theme;
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 = 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()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(SIDEBAR_WIDTH),
Constraint::Min(0),
])
.split(main_content_area);
(Some(chunks[0]), chunks[1])
} else {
(None, main_content_area)
}
}
pub fn render_sidebar(
f: &mut Frame,
area: Rect,
theme: &Theme,
profile_tree: &ProfileTreeResponse,
selected_profile: &Option<String>,
) {
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 {
// 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(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;
let is_last_profile = profile_idx == profile_tree.profiles.len() - 1;
// Shorter prefix characters
let prefix = match (is_last_profile, is_last_table) {
(true, true) => "",
(true, false) => "",
(false, true) => "│└",
(false, false) => "│├",
};
// 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
};
// 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
};
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)));
}
// Compact separator between profiles
if profile_idx < profile_tree.profiles.len() - 1 {
items.push(ListItem::new(Line::from(
Span::styled("", Style::default().fg(theme.secondary))
)));
}
}
if profile_tree.profiles.is_empty() {
items.push(ListItem::new(Span::styled(
"No profiles",
Style::default().fg(theme.secondary)
)));
}
}
let list = List::new(items)
.block(sidebar_block)
.highlight_style(Style::default().fg(theme.highlight))
.highlight_symbol(">");
f.render_widget(list, area);
}

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