Compare commits

..

28 Commits

Author SHA1 Message Date
Priec
4c8cfd4f80 feature3 cursor bug fixed WARNING MIGHT BE BREAKING IF PROBLEMS, CHECK THIS COMMIT but it should be safe imo 2025-08-06 22:19:07 +02:00
Priec
85c5d7ccf9 feature3 with bug, needs a fix immidiately 2025-08-06 22:05:10 +02:00
Priec
46a0d2b9db better example for feature2 being implemented and integrated into the codebase 2025-08-05 21:15:25 +02:00
Priec
c9b4841f67 validation2 example now working and displaying the full potential of the feature2 being implemented 2025-08-05 21:11:31 +02:00
Priec
d62cc2add6 feature2 implemented bug needs to be addressed 2025-08-05 19:22:30 +02:00
Priec
9c36e76eaa validation of characters length is finished 2025-08-05 18:27:16 +02:00
Priec
abd8cba7a5 forgotten cargo lock 2025-08-05 00:12:25 +02:00
Priec
e6c4cb7e75 validation passed to the canvas library now compiled 2025-08-04 23:38:44 +02:00
filipriec
3d4435bac5 working colors in vim mode 2025-08-03 22:08:52 +02:00
filipriec
4146d0820b line different color changed 2025-08-03 21:09:58 +02:00
filipriec
dbaa32f589 Merge branch 'main' of gitlab.com:filipriec/komp_ac 2025-08-03 07:53:36 +02:00
Priec
2b8eae67b9 highlight is now finally working 2025-08-02 23:31:03 +02:00
Priec
225bdc2bb6 Merge branch 'main' of gitlab.com:filipriec/komp_ac 2025-08-02 22:11:16 +02:00
Priec
8605ed1547 fixing issues in the edit/normal mode 2025-08-02 22:08:43 +02:00
filipriec
91cecabaca append at the end of the line is being fully fixed now 2025-08-02 16:56:16 +02:00
filipriec
d4922233ae Merge branch 'canvas' of gitlab.com:filipriec/komp_ac 2025-08-02 15:46:51 +02:00
filipriec
c00a214a0f Merge branch 'main' of gitlab.com:filipriec/komp_ac 2025-08-02 15:42:56 +02:00
Priec
0baf152c3e automatic cursor style handled by the library 2025-08-02 15:06:29 +02:00
Priec
c92c617314 exposed api to full vim mode 2025-08-02 13:41:21 +02:00
Priec
8c8ba53668 better example 2025-08-02 10:45:21 +02:00
Priec
2b08e64db8 fixed generics 2025-08-02 00:19:45 +02:00
Priec
643db8e586 removed deprecantions 2025-08-01 23:38:24 +02:00
Priec
5c39386a3a completely redesign philosofy of this library 2025-08-01 22:54:05 +02:00
filipriec
a4e94878e7 enter decider should be taken care of next, suggestions works in register now also 2025-07-26 22:30:45 +02:00
filipriec
c7353ac81e email is now required 2025-07-26 20:34:02 +02:00
filipriec
1fbc720620 updated 2025-07-26 19:05:08 +02:00
filipriec
263ccc3260 updated system 2025-07-26 08:49:09 +02:00
filipriec
00c0a399cd sql search2 added 2025-07-25 22:38:34 +02:00
47 changed files with 8256 additions and 2030 deletions

171
Cargo.lock generated
View File

@@ -384,9 +384,9 @@ dependencies = [
[[package]]
name = "bon"
version = "3.6.4"
version = "3.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f61138465baf186c63e8d9b6b613b508cd832cba4ce93cf37ce5f096f91ac1a6"
checksum = "33d9ef19ae5263a138da9a86871eca537478ab0332a7770bac7e3f08b801f89f"
dependencies = [
"bon-macros",
"rustversion",
@@ -394,11 +394,11 @@ dependencies = [
[[package]]
name = "bon-macros"
version = "3.6.4"
version = "3.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40d1dad34aa19bf02295382f08d9bc40651585bd497266831d40ee6296fb49ca"
checksum = "577ae008f2ca11ca7641bd44601002ee5ab49ef0af64846ce1ab6057218a5cc1"
dependencies = [
"darling",
"darling 0.21.0",
"ident_case",
"prettyplease",
"proc-macro2",
@@ -479,6 +479,7 @@ dependencies = [
"common",
"crossterm",
"ratatui",
"regex",
"serde",
"thiserror",
"tokio",
@@ -497,18 +498,18 @@ checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "castaway"
version = "0.2.3"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cc"
version = "1.2.29"
version = "1.2.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1599538de2394445747c8cf7935946e3cc27e9625f889d979bfb2aaf569362"
checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7"
dependencies = [
"jobserver",
"libc",
@@ -653,9 +654,9 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const_panic"
version = "0.2.12"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2459fc9262a1aa204eb4b5764ad4f189caec88aea9634389c0a25f8be7f6265e"
checksum = "b98d1483e98c9d67f341ab4b3915cfdc54740bd6f5cccc9226ee0535d86aa8fb"
[[package]]
name = "core-foundation"
@@ -714,9 +715,9 @@ checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.4.2"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
@@ -811,8 +812,18 @@ version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core",
"darling_macro",
"darling_core 0.20.11",
"darling_macro 0.20.11",
]
[[package]]
name = "darling"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a79c4acb1fd5fa3d9304be4c76e031c54d2e92d172a393e24b19a14fe8532fe9"
dependencies = [
"darling_core 0.21.0",
"darling_macro 0.21.0",
]
[[package]]
@@ -829,13 +840,38 @@ dependencies = [
"syn 2.0.104",
]
[[package]]
name = "darling_core"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74875de90daf30eb59609910b84d4d368103aaec4c924824c6799b28f77d6a1d"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.104",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"darling_core 0.20.11",
"quote",
"syn 2.0.104",
]
[[package]]
name = "darling_macro"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e79f8e61677d5df9167cd85265f8e5f64b215cdea3fb55eebc3e622e44c7a146"
dependencies = [
"darling_core 0.21.0",
"quote",
"syn 2.0.104",
]
@@ -959,7 +995,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -1430,9 +1466,9 @@ dependencies = [
[[package]]
name = "hyper-util"
version = "0.1.15"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df"
checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e"
dependencies = [
"bytes",
"futures-channel",
@@ -1443,7 +1479,7 @@ dependencies = [
"hyper",
"libc",
"pin-project-lite",
"socket2",
"socket2 0.6.0",
"tokio",
"tower-service",
"tracing",
@@ -1612,9 +1648,9 @@ dependencies = [
[[package]]
name = "im-lists"
version = "0.9.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88485149c4fcec01ebce4e4b8284a3c75b3d8a4749169f5481144e6433e9bcd2"
checksum = "8b971d2652e5700514cc92ca020dba64c790352af0ff2b9acb7514868a32d6aa"
dependencies = [
"smallvec",
]
@@ -1671,11 +1707,11 @@ dependencies = [
[[package]]
name = "instability"
version = "0.3.7"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d"
checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a"
dependencies = [
"darling",
"darling 0.20.11",
"indoc",
"proc-macro2",
"quote",
@@ -1684,9 +1720,9 @@ dependencies = [
[[package]]
name = "io-uring"
version = "0.7.8"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013"
checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4"
dependencies = [
"bitflags",
"cfg-if",
@@ -1803,9 +1839,9 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
[[package]]
name = "libredox"
version = "0.1.4"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638"
checksum = "4488594b9328dee448adb906d8b126d9b7deb7cf5c22161ee591610bb1be83c0"
dependencies = [
"bitflags",
"libc",
@@ -1903,9 +1939,9 @@ checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
[[package]]
name = "memmap2"
version = "0.9.5"
version = "0.9.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f"
checksum = "483758ad303d734cec05e5c12b41d7e93e6a6390c5e9dae6bdeb7c1259012d28"
dependencies = [
"libc",
]
@@ -2293,17 +2329,16 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "polling"
version = "3.8.0"
version = "3.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b53a684391ad002dd6a596ceb6c74fd004fdce75f4be2e3f615068abbea5fd50"
checksum = "8ee9b2fa7a4517d2c91ff5bc6c297a427a96749d15f98fcdbb22c05571a4d4b7"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix 1.0.7",
"tracing",
"windows-sys 0.59.0",
"rustix 1.0.8",
"windows-sys 0.60.2",
]
[[package]]
@@ -2343,9 +2378,9 @@ dependencies = [
[[package]]
name = "prettyplease"
version = "0.2.35"
version = "0.2.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "061c1221631e079b26479d25bbf2275bfe5917ae8419cd7e34f13bfc2aa7539a"
checksum = "ff24dfcda44452b9816fff4cd4227e1bb73ff5a2f1bc1105aa92fb8565ce44d2"
dependencies = [
"proc-macro2",
"syn 2.0.104",
@@ -2507,9 +2542,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.9.1"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.3",
@@ -2615,9 +2650,9 @@ dependencies = [
[[package]]
name = "redox_syscall"
version = "0.5.13"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6"
checksum = "7e8af0dde094006011e6a740d4879319439489813bd0bcdc7d821beaeeff48ec"
dependencies = [
"bitflags",
]
@@ -2846,20 +2881,20 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
name = "rustix"
version = "1.0.7"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266"
checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys 0.9.4",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -2963,9 +2998,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.140"
version = "1.0.141"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3"
dependencies = [
"itoa",
"memchr",
@@ -3009,7 +3044,7 @@ dependencies = [
"lazy_static",
"prost",
"prost-types",
"rand 0.9.1",
"rand 0.9.2",
"regex",
"rstest",
"rust-stemmers",
@@ -3173,6 +3208,16 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "spin"
version = "0.9.8"
@@ -3447,7 +3492,7 @@ dependencies = [
"parking_lot",
"polling",
"quickscope",
"rand 0.9.1",
"rand 0.9.2",
"serde",
"serde_json",
"smallvec",
@@ -3462,9 +3507,9 @@ dependencies = [
[[package]]
name = "steel-decimal"
version = "1.0.0"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c43950a3eed43f3e9765a51f5dc1b0de5e1687ba824b8589990747d9ba241187"
checksum = "4cd8a6d1a41d2146705b29292cac75c78a3e32d7b6cabb72d808209546615f37"
dependencies = [
"regex",
"rust_decimal",
@@ -3599,9 +3644,9 @@ dependencies = [
[[package]]
name = "tantivy"
version = "0.24.1"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca2374a21157427c5faff2d90930f035b6c22a5d7b0e5b0b7f522e988ef33c06"
checksum = "64a966cb0e76e311f09cf18507c9af192f15d34886ee43d7ba7c7e3803660c43"
dependencies = [
"aho-corasick",
"arc-swap",
@@ -3758,8 +3803,8 @@ dependencies = [
"fastrand",
"getrandom 0.3.3",
"once_cell",
"rustix 1.0.7",
"windows-sys 0.59.0",
"rustix 1.0.8",
"windows-sys 0.52.0",
]
[[package]]
@@ -3873,7 +3918,7 @@ dependencies = [
"pin-project-lite",
"signal-hook-registry",
"slab",
"socket2",
"socket2 0.5.10",
"tokio-macros",
"windows-sys 0.52.0",
]
@@ -3987,7 +4032,7 @@ dependencies = [
"percent-encoding",
"pin-project",
"prost",
"socket2",
"socket2 0.5.10",
"tokio",
"tokio-stream",
"tower",
@@ -4276,7 +4321,7 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca"
dependencies = [
"darling",
"darling 0.20.11",
"once_cell",
"proc-macro-error2",
"proc-macro2",
@@ -4404,7 +4449,7 @@ checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762"
dependencies = [
"either",
"env_home",
"rustix 1.0.7",
"rustix 1.0.8",
"winsafe",
]
@@ -4440,7 +4485,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.48.0",
]
[[package]]
@@ -4731,9 +4776,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
[[package]]
name = "winnow"
version = "0.7.11"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd"
checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95"
dependencies = [
"memchr",
]

1
canvas/.gitignore vendored Normal file
View File

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

View File

@@ -12,17 +12,18 @@ categories.workspace = true
[dependencies]
common = { path = "../common" }
ratatui = { workspace = true, optional = true }
crossterm = { workspace = true }
anyhow = { workspace = true }
crossterm = { workspace = true, optional = true }
anyhow.workspace = true
tokio = { workspace = true, optional = true }
toml = { workspace = true }
serde = { workspace = true }
serde.workspace = true
unicode-width.workspace = true
thiserror = { workspace = true }
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
async-trait = { workspace = true, optional = true }
async-trait.workspace = true
regex = { workspace = true, optional = true }
[dev-dependencies]
tokio-test = "0.4.4"
@@ -30,7 +31,9 @@ tokio-test = "0.4.4"
[features]
default = []
gui = ["ratatui"]
autocomplete = ["tokio", "async-trait"]
autocomplete = ["tokio"]
cursor-style = ["crossterm"]
validation = ["regex"]
[[example]]
name = "autocomplete"
@@ -41,3 +44,7 @@ path = "examples/autocomplete.rs"
name = "canvas_gui_demo"
required-features = ["gui"]
path = "examples/canvas_gui_demo.rs"
[[example]]
name = "validation_1"
required-features = ["gui", "validation"]

View File

@@ -1,77 +0,0 @@
git status
On branch main
Your branch is ahead of 'origin/main' by 1 commit.
(use "git push" to publish your local commits)
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: src/canvas/actions/handlers/edit.rs
modified: src/canvas/actions/types.rs
no changes added to commit (use "git add" and/or "git commit -a")
git --no-pager diff
diff --git a/canvas/src/canvas/actions/handlers/edit.rs b/canvas/src/canvas/actions/handlers/edit.rs
index a26fe6f..fa1becb 100644
--- a/canvas/src/canvas/actions/handlers/edit.rs
+++ b/canvas/src/canvas/actions/handlers/edit.rs
@@ -29,6 +29,21 @@ pub async fn handle_edit_action<S: CanvasState>(
Ok(ActionResult::success())
}
+ CanvasAction::SelectAll => {
+ // Select all text in current field
+ let current_input = state.get_current_input();
+ let text_length = current_input.len();
+
+ // Set cursor to start and select all
+ state.set_current_cursor_pos(0);
+ // TODO: You'd need to add selection state to CanvasState trait
+ // For now, just move cursor to end to "select" all
+ state.set_current_cursor_pos(text_length);
+ *ideal_cursor_column = text_length;
+
+ Ok(ActionResult::success_with_message(&format!("Selected all {} characters", text_length)))
+ }
+
CanvasAction::DeleteBackward => {
let cursor_pos = state.current_cursor_pos();
if cursor_pos > 0 {
@@ -323,6 +338,13 @@ impl ActionHandlerIntrospection for EditHandler {
is_required: false,
});
+ actions.push(ActionSpec {
+ name: "select_all".to_string(),
+ description: "Select all text in current field".to_string(),
+ examples: vec!["Ctrl+a".to_string()],
+ is_required: false, // Optional action
+ });
+
HandlerCapabilities {
mode_name: "edit".to_string(),
actions,
diff --git a/canvas/src/canvas/actions/types.rs b/canvas/src/canvas/actions/types.rs
index 433a4d5..3794596 100644
--- a/canvas/src/canvas/actions/types.rs
+++ b/canvas/src/canvas/actions/types.rs
@@ -31,6 +31,8 @@ pub enum CanvasAction {
NextField,
PrevField,
+ SelectAll,
+
// Autocomplete actions
TriggerAutocomplete,
SuggestionUp,
@@ -62,6 +64,7 @@ impl CanvasAction {
"move_word_end_prev" => Self::MoveWordEndPrev,
"next_field" => Self::NextField,
"prev_field" => Self::PrevField,
+ "select_all" => Self::SelectAll,
"trigger_autocomplete" => Self::TriggerAutocomplete,
"suggestion_up" => Self::SuggestionUp,
"suggestion_down" => Self::SuggestionDown,
╭─    ~/Doc/p/komp_ac/canvas  on   main ⇡1 !2 
╰─

View File

@@ -20,21 +20,14 @@ use canvas::{
canvas::{
gui::render_canvas,
modes::AppMode,
state::{ActionContext, CanvasState},
theme::CanvasTheme,
},
autocomplete::{
AutocompleteCanvasState,
AutocompleteState,
SuggestionItem,
execute_with_autocomplete,
handle_autocomplete_feature_action,
},
CanvasAction,
autocomplete::gui::render_autocomplete_dropdown,
FormEditor, DataProvider, AutocompleteProvider, SuggestionItem,
};
// Add the async_trait import
use async_trait::async_trait;
use anyhow::Result;
// Simple theme implementation
#[derive(Clone)]
@@ -58,150 +51,94 @@ struct EmailSuggestion {
provider: String,
}
// Demo form state with autocomplete
struct AutocompleteFormState {
fields: Vec<String>,
field_names: Vec<String>,
current_field: usize,
cursor_pos: usize,
mode: AppMode,
has_changes: bool,
debug_message: String,
// ===================================================================
// SIMPLE DATA PROVIDER - Only business data, no UI concerns!
// ===================================================================
// Autocomplete state
autocomplete: AutocompleteState<EmailSuggestion>,
struct ContactForm {
// Only business data - no UI state!
name: String,
email: String,
phone: String,
city: String,
}
impl AutocompleteFormState {
impl ContactForm {
fn new() -> Self {
Self {
fields: vec![
"John Doe".to_string(),
"john@".to_string(), // Partial email to demonstrate autocomplete
"+1 234 567 8900".to_string(),
"San Francisco".to_string(),
],
field_names: vec![
"Name".to_string(),
"Email".to_string(),
"Phone".to_string(),
"City".to_string(),
],
current_field: 1, // Start on email field
cursor_pos: 5, // Position after "john@"
mode: AppMode::Edit,
has_changes: false,
debug_message: "Type in email field, Tab to trigger autocomplete, Enter to select, Esc to cancel".to_string(),
autocomplete: AutocompleteState::new(),
name: "John Doe".to_string(),
email: "john@".to_string(), // Partial email for demo
phone: "+1 234 567 8900".to_string(),
city: "San Francisco".to_string(),
}
}
}
impl CanvasState for AutocompleteFormState {
fn current_field(&self) -> usize { self.current_field }
fn current_cursor_pos(&self) -> usize { self.cursor_pos }
fn set_current_field(&mut self, index: usize) {
self.current_field = index.min(self.fields.len().saturating_sub(1));
// Clear autocomplete when changing fields
if self.is_autocomplete_active() {
self.clear_autocomplete_suggestions();
// Simple trait implementation - only 4 methods!
impl DataProvider for ContactForm {
fn field_count(&self) -> usize { 4 }
fn field_name(&self, index: usize) -> &str {
match index {
0 => "Name",
1 => "Email",
2 => "Phone",
3 => "City",
_ => "",
}
}
fn set_current_cursor_pos(&mut self, pos: usize) {
let max_pos = if self.mode == AppMode::Edit {
self.fields[self.current_field].len()
} else {
self.fields[self.current_field].len().saturating_sub(1)
};
self.cursor_pos = pos.min(max_pos);
}
fn current_mode(&self) -> AppMode { self.mode }
fn get_current_input(&self) -> &str { &self.fields[self.current_field] }
fn get_current_input_mut(&mut self) -> &mut String { &mut self.fields[self.current_field] }
fn inputs(&self) -> Vec<&String> { self.fields.iter().collect() }
fn fields(&self) -> Vec<&str> { self.field_names.iter().map(|s| s.as_str()).collect() }
fn has_unsaved_changes(&self) -> bool { self.has_changes }
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
// Handle autocomplete actions first
if let Some(result) = handle_autocomplete_feature_action(action, self) {
return Some(result);
}
// Handle other custom actions
match action {
CanvasAction::Custom(cmd) => {
match cmd.as_str() {
"toggle_mode" => {
self.mode = match self.mode {
AppMode::Edit => AppMode::ReadOnly,
AppMode::ReadOnly => AppMode::Edit,
_ => AppMode::Edit,
};
Some(format!("Switched to {:?} mode", self.mode))
}
_ => None,
}
}
_ => None,
fn field_value(&self, index: usize) -> &str {
match index {
0 => &self.name,
1 => &self.email,
2 => &self.phone,
3 => &self.city,
_ => "",
}
}
}
// Add the #[async_trait] attribute to the implementation
#[async_trait]
impl AutocompleteCanvasState for AutocompleteFormState {
type SuggestionData = EmailSuggestion;
fn set_field_value(&mut self, index: usize, value: String) {
match index {
0 => self.name = value,
1 => self.email = value,
2 => self.phone = value,
3 => self.city = value,
_ => {}
}
}
fn supports_autocomplete(&self, field_index: usize) -> bool {
// Only enable autocomplete for email field (index 1)
field_index == 1
field_index == 1 // Only email field
}
}
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> {
Some(&self.autocomplete)
}
// ===================================================================
// SIMPLE AUTOCOMPLETE PROVIDER - Only data fetching!
// ===================================================================
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> {
Some(&mut self.autocomplete)
}
struct EmailAutocomplete;
fn should_trigger_autocomplete(&self) -> bool {
let current_input = self.get_current_input();
let current_field = self.current_field();
// Trigger for email field when we have "@" and at least 1 more character
self.supports_autocomplete(current_field) &&
current_input.contains('@') &&
current_input.len() > current_input.find('@').unwrap_or(0) + 1 &&
!self.is_autocomplete_active()
}
/// This is where the magic happens - user implements their own async fetching
async fn trigger_autocomplete_suggestions(&mut self) {
// 1. Activate UI (shows loading spinner)
self.activate_autocomplete();
self.set_autocomplete_loading(true);
// 2. Get current input for querying
let query = self.get_current_input().to_string();
// 3. Extract domain part from email
let domain_part = if let Some(at_pos) = query.find('@') {
query[at_pos + 1..].to_string()
#[async_trait]
impl AutocompleteProvider for EmailAutocomplete {
type SuggestionData = EmailSuggestion;
async fn fetch_suggestions(&mut self, _field_index: usize, query: &str)
-> Result<Vec<SuggestionItem<Self::SuggestionData>>>
{
// Extract domain part from email
let (email_prefix, domain_part) = if let Some(at_pos) = query.find('@') {
(query[..at_pos].to_string(), query[at_pos + 1..].to_string())
} else {
self.set_autocomplete_loading(false);
return; // No @ symbol, can't suggest
return Ok(Vec::new()); // No @ symbol
};
// 4. SIMULATE ASYNC API CALL (in real code, this would be HTTP request)
let email_prefix = query[..query.find('@').unwrap()].to_string();
// Simulate async API call
let suggestions = tokio::task::spawn_blocking(move || {
// Simulate network delay
std::thread::sleep(std::time::Duration::from_millis(200));
// Create mock suggestions based on domain input
// Mock email suggestions
let popular_domains = vec![
("gmail.com", "Gmail"),
("yahoo.com", "Yahoo Mail"),
@@ -212,110 +149,148 @@ impl AutocompleteCanvasState for AutocompleteFormState {
];
let mut results = Vec::new();
for (domain, provider) in popular_domains {
if domain.starts_with(&domain_part) || domain_part.is_empty() {
let full_email = format!("{}@{}", email_prefix, domain);
results.push(SuggestionItem::new(
EmailSuggestion {
results.push(SuggestionItem {
data: EmailSuggestion {
email: full_email.clone(),
provider: provider.to_string(),
},
format!("{} ({})", full_email, provider), // display text
full_email, // value to store
));
display_text: format!("{} ({})", full_email, provider),
value_to_store: full_email,
});
}
}
results
}).await.unwrap_or_default();
// 5. Provide suggestions back to library
self.set_autocomplete_suggestions(suggestions);
Ok(suggestions)
}
}
async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut AutocompleteFormState) -> bool {
// ===================================================================
// APPLICATION STATE - Much simpler!
// ===================================================================
struct AppState {
editor: FormEditor<ContactForm>,
autocomplete: EmailAutocomplete,
debug_message: String,
}
impl AppState {
fn new() -> Self {
let contact_form = ContactForm::new();
let mut editor = FormEditor::new(contact_form);
// Start on email field (index 1) at end of existing text
editor.set_mode(AppMode::Edit);
// TODO: Add method to set initial field/cursor position
Self {
editor,
autocomplete: EmailAutocomplete,
debug_message: "Type in email field, Tab to trigger autocomplete, Enter to select, Esc to cancel".to_string(),
}
}
}
// ===================================================================
// INPUT HANDLING - Much cleaner!
// ===================================================================
async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut AppState) -> bool {
if key == KeyCode::F(10) || (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) {
return false; // Quit
}
let action = match key {
// Handle input based on key
let result = match key {
// === AUTOCOMPLETE KEYS ===
KeyCode::Tab => {
if state.is_autocomplete_active() {
Some(CanvasAction::SuggestionDown) // Navigate suggestions
} else if state.supports_autocomplete(state.current_field()) {
Some(CanvasAction::TriggerAutocomplete) // Manual trigger
if state.editor.is_autocomplete_active() {
state.editor.autocomplete_next();
Ok("Navigated to next suggestion".to_string())
} else if state.editor.data_provider().supports_autocomplete(state.editor.current_field()) {
state.editor.trigger_autocomplete(&mut state.autocomplete).await
.map(|_| "Triggered autocomplete".to_string())
} else {
Some(CanvasAction::NextField) // Normal tab
}
}
KeyCode::BackTab => {
if state.is_autocomplete_active() {
Some(CanvasAction::SuggestionUp)
} else {
Some(CanvasAction::PrevField)
state.editor.move_to_next_field();
Ok("Moved to next field".to_string())
}
}
KeyCode::Enter => {
if state.is_autocomplete_active() {
Some(CanvasAction::SelectSuggestion) // Apply suggestion
if state.editor.is_autocomplete_active() {
if let Some(applied) = state.editor.apply_autocomplete() {
Ok(format!("Applied: {}", applied))
} else {
Ok("No suggestion to apply".to_string())
}
} else {
Some(CanvasAction::NextField)
state.editor.move_to_next_field();
Ok("Moved to next field".to_string())
}
}
KeyCode::Esc => {
if state.is_autocomplete_active() {
Some(CanvasAction::ExitSuggestions) // Close autocomplete
if state.editor.is_autocomplete_active() {
// Autocomplete will be cleared automatically by mode change
Ok("Cancelled autocomplete".to_string())
} else {
Some(CanvasAction::Custom("toggle_mode".to_string()))
// Toggle between edit and readonly mode
let new_mode = match state.editor.mode() {
AppMode::Edit => AppMode::ReadOnly,
_ => AppMode::Edit,
};
state.editor.set_mode(new_mode);
Ok(format!("Switched to {:?} mode", new_mode))
}
}
// === STANDARD CANVAS KEYS ===
KeyCode::Left => Some(CanvasAction::MoveLeft),
KeyCode::Right => Some(CanvasAction::MoveRight),
KeyCode::Up => Some(CanvasAction::MoveUp),
KeyCode::Down => Some(CanvasAction::MoveDown),
KeyCode::Home => Some(CanvasAction::MoveLineStart),
KeyCode::End => Some(CanvasAction::MoveLineEnd),
KeyCode::Backspace => Some(CanvasAction::DeleteBackward),
KeyCode::Delete => Some(CanvasAction::DeleteForward),
// Character input
KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => {
Some(CanvasAction::InsertChar(c))
// === MOVEMENT KEYS ===
KeyCode::Left => {
state.editor.move_left();
Ok("Moved left".to_string())
}
KeyCode::Right => {
state.editor.move_right();
Ok("Moved right".to_string())
}
KeyCode::Up => {
state.editor.move_to_next_field(); // TODO: Add move_up method
Ok("Moved up".to_string())
}
KeyCode::Down => {
state.editor.move_to_next_field(); // TODO: Add move_down method
Ok("Moved down".to_string())
}
_ => None,
// === TEXT INPUT ===
KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => {
state.editor.insert_char(c)
.map(|_| format!("Inserted '{}'", c))
}
KeyCode::Backspace => {
// TODO: Add delete_backward method to FormEditor
Ok("Backspace (not implemented yet)".to_string())
}
_ => Ok(format!("Unhandled key: {:?}", key)),
};
if let Some(action) = action {
match execute_with_autocomplete(action.clone(), state).await {
Ok(result) => {
if let Some(msg) = result.message() {
state.debug_message = msg.to_string();
} else {
state.debug_message = format!("Executed: {:?}", action);
}
true
}
Err(e) => {
state.debug_message = format!("Error: {}", e);
true
}
}
} else {
state.debug_message = format!("Unhandled key: {:?}", key);
true
// Update debug message
match result {
Ok(msg) => state.debug_message = msg,
Err(e) => state.debug_message = format!("Error: {}", e),
}
true
}
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut state: AutocompleteFormState) -> io::Result<()> {
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut state: AppState) -> io::Result<()> {
let theme = DemoTheme;
loop {
@@ -332,7 +307,7 @@ async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut state: Autocomplete
Ok(())
}
fn ui(f: &mut Frame, state: &AutocompleteFormState, theme: &DemoTheme) {
fn ui(f: &mut Frame, state: &AppState, theme: &DemoTheme) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
@@ -341,33 +316,31 @@ fn ui(f: &mut Frame, state: &AutocompleteFormState, theme: &DemoTheme) {
])
.split(f.area());
// Render the canvas form
// Render the canvas form - much simpler!
let active_field_rect = render_canvas(
f,
chunks[0],
state,
&state.editor,
theme,
state.mode == AppMode::Edit,
&canvas::HighlightState::Off,
);
// Render autocomplete dropdown on top if active
// Render autocomplete dropdown if active
if let Some(input_rect) = active_field_rect {
canvas::render_autocomplete_dropdown(
render_autocomplete_dropdown(
f,
chunks[0],
input_rect,
theme,
&state.autocomplete,
&state.editor,
);
}
// Status info
let autocomplete_status = if state.is_autocomplete_active() {
if state.autocomplete.is_loading {
let autocomplete_status = if state.editor.is_autocomplete_active() {
if state.editor.ui_state().is_autocomplete_loading() {
"Loading suggestions..."
} else if state.has_autocomplete_suggestions() {
"Use Tab/Shift+Tab to navigate, Enter to select, Esc to cancel"
} else if !state.editor.suggestions().is_empty() {
"Use Tab to navigate, Enter to select, Esc to cancel"
} else {
"No suggestions found"
}
@@ -377,7 +350,10 @@ fn ui(f: &mut Frame, state: &AutocompleteFormState, theme: &DemoTheme) {
let status_lines = vec![
Line::from(Span::raw(format!("Mode: {:?} | Field: {}/{} | Cursor: {}",
state.mode, state.current_field + 1, state.fields.len(), state.cursor_pos))),
state.editor.mode(),
state.editor.current_field() + 1,
state.editor.data_provider().field_count(),
state.editor.cursor_position()))),
Line::from(Span::raw(format!("Autocomplete: {}", autocomplete_status))),
Line::from(Span::raw(state.debug_message.clone())),
Line::from(Span::raw("F10: Quit | Tab: Trigger/Navigate autocomplete | Enter: Select | Esc: Cancel/Toggle mode")),
@@ -397,8 +373,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let state = AutocompleteFormState::new();
let state = AppState::new();
let res = run_app(&mut terminal, state).await;
disable_raw_mode()?;

View File

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

View File

@@ -1,388 +0,0 @@
// examples/canvas_gui_demo.rs
use std::io;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame, Terminal,
};
use canvas::{
canvas::{
gui::render_canvas,
modes::{AppMode, HighlightState, ModeManager},
state::{ActionContext, CanvasState},
theme::CanvasTheme,
},
CanvasAction, execute,
};
// Simple theme implementation
#[derive(Clone)]
struct DemoTheme;
impl CanvasTheme for DemoTheme {
fn bg(&self) -> Color { Color::Reset }
fn fg(&self) -> Color { Color::White }
fn accent(&self) -> Color { Color::Cyan }
fn secondary(&self) -> Color { Color::Gray }
fn highlight(&self) -> Color { Color::Yellow }
fn highlight_bg(&self) -> Color { Color::DarkGray }
fn warning(&self) -> Color { Color::Red }
fn border(&self) -> Color { Color::Gray }
}
// Demo form state
struct DemoFormState {
fields: Vec<String>,
field_names: Vec<String>,
current_field: usize,
cursor_pos: usize,
mode: AppMode,
highlight_state: HighlightState,
has_changes: bool,
debug_message: String,
}
impl DemoFormState {
fn new() -> Self {
Self {
fields: vec![
"John Doe".to_string(),
"john.doe@example.com".to_string(),
"+1 234 567 8900".to_string(),
"123 Main Street Apt 4B".to_string(),
"San Francisco".to_string(),
"This is a test comment with multiple words".to_string(),
],
field_names: vec![
"Name".to_string(),
"Email".to_string(),
"Phone".to_string(),
"Address".to_string(),
"City".to_string(),
"Comments".to_string(),
],
current_field: 0,
cursor_pos: 0,
mode: AppMode::ReadOnly,
highlight_state: HighlightState::Off,
has_changes: false,
debug_message: "Ready - Use hjkl to move, w for next word, i to edit".to_string(),
}
}
fn enter_edit_mode(&mut self) {
if ModeManager::can_enter_edit_mode(self.mode) {
self.mode = AppMode::Edit;
self.debug_message = "Entered EDIT mode".to_string();
}
}
fn enter_readonly_mode(&mut self) {
if ModeManager::can_enter_read_only_mode(self.mode) {
self.mode = AppMode::ReadOnly;
self.highlight_state = HighlightState::Off;
self.debug_message = "Entered READ-ONLY mode".to_string();
}
}
fn enter_highlight_mode(&mut self) {
if ModeManager::can_enter_highlight_mode(self.mode) {
self.mode = AppMode::Highlight;
self.highlight_state = HighlightState::Characterwise {
anchor: (self.current_field, self.cursor_pos),
};
self.debug_message = "Entered VISUAL mode".to_string();
}
}
}
impl CanvasState for DemoFormState {
fn current_field(&self) -> usize {
self.current_field
}
fn current_cursor_pos(&self) -> usize {
self.cursor_pos
}
fn set_current_field(&mut self, index: usize) {
self.current_field = index.min(self.fields.len().saturating_sub(1));
self.cursor_pos = self.fields[self.current_field].len();
}
fn set_current_cursor_pos(&mut self, pos: usize) {
let max_pos = self.fields[self.current_field].len();
self.cursor_pos = pos.min(max_pos);
}
fn current_mode(&self) -> AppMode {
self.mode
}
fn get_current_input(&self) -> &str {
&self.fields[self.current_field]
}
fn get_current_input_mut(&mut self) -> &mut String {
&mut self.fields[self.current_field]
}
fn inputs(&self) -> Vec<&String> {
self.fields.iter().collect()
}
fn fields(&self) -> Vec<&str> {
self.field_names.iter().map(|s| s.as_str()).collect()
}
fn has_unsaved_changes(&self) -> bool {
self.has_changes
}
fn set_has_unsaved_changes(&mut self, changed: bool) {
self.has_changes = changed;
}
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
match action {
CanvasAction::Custom(cmd) => {
match cmd.as_str() {
"enter_edit_mode" => {
self.enter_edit_mode();
Some("Entered edit mode".to_string())
}
"enter_readonly_mode" => {
self.enter_readonly_mode();
Some("Entered read-only mode".to_string())
}
"enter_highlight_mode" => {
self.enter_highlight_mode();
Some("Entered highlight mode".to_string())
}
_ => None,
}
}
_ => None,
}
}
}
/// Simple key mapping - users have full control!
async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut DemoFormState) -> bool {
let is_edit_mode = state.mode == AppMode::Edit;
// Handle quit first
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL)) ||
(key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) ||
key == KeyCode::F(10) {
return false; // Signal to quit
}
// Users directly map keys to actions - no configuration needed!
let action = match (state.mode, key, modifiers) {
// === READ-ONLY MODE KEYS ===
(AppMode::ReadOnly, KeyCode::Char('h'), _) => Some(CanvasAction::MoveLeft),
(AppMode::ReadOnly, KeyCode::Char('j'), _) => Some(CanvasAction::MoveDown),
(AppMode::ReadOnly, KeyCode::Char('k'), _) => Some(CanvasAction::MoveUp),
(AppMode::ReadOnly, KeyCode::Char('l'), _) => Some(CanvasAction::MoveRight),
(AppMode::ReadOnly, KeyCode::Char('w'), _) => Some(CanvasAction::MoveWordNext),
(AppMode::ReadOnly, KeyCode::Char('b'), _) => Some(CanvasAction::MoveWordPrev),
(AppMode::ReadOnly, KeyCode::Char('e'), _) => Some(CanvasAction::MoveWordEnd),
(AppMode::ReadOnly, KeyCode::Char('0'), _) => Some(CanvasAction::MoveLineStart),
(AppMode::ReadOnly, KeyCode::Char('$'), _) => Some(CanvasAction::MoveLineEnd),
(AppMode::ReadOnly, KeyCode::Tab, _) => Some(CanvasAction::NextField),
(AppMode::ReadOnly, KeyCode::BackTab, _) => Some(CanvasAction::PrevField),
// === EDIT MODE KEYS ===
(AppMode::Edit, KeyCode::Left, _) => Some(CanvasAction::MoveLeft),
(AppMode::Edit, KeyCode::Right, _) => Some(CanvasAction::MoveRight),
(AppMode::Edit, KeyCode::Up, _) => Some(CanvasAction::MoveUp),
(AppMode::Edit, KeyCode::Down, _) => Some(CanvasAction::MoveDown),
(AppMode::Edit, KeyCode::Home, _) => Some(CanvasAction::MoveLineStart),
(AppMode::Edit, KeyCode::End, _) => Some(CanvasAction::MoveLineEnd),
(AppMode::Edit, KeyCode::Backspace, _) => Some(CanvasAction::DeleteBackward),
(AppMode::Edit, KeyCode::Delete, _) => Some(CanvasAction::DeleteForward),
(AppMode::Edit, KeyCode::Tab, _) => Some(CanvasAction::NextField),
(AppMode::Edit, KeyCode::BackTab, _) => Some(CanvasAction::PrevField),
// Vim-style movement in edit mode (optional)
(AppMode::Edit, KeyCode::Char('h'), m) if m.contains(KeyModifiers::CONTROL) => Some(CanvasAction::MoveLeft),
(AppMode::Edit, KeyCode::Char('l'), m) if m.contains(KeyModifiers::CONTROL) => Some(CanvasAction::MoveRight),
// Word movement with Ctrl in edit mode
(AppMode::Edit, KeyCode::Left, m) if m.contains(KeyModifiers::CONTROL) => Some(CanvasAction::MoveWordPrev),
(AppMode::Edit, KeyCode::Right, m) if m.contains(KeyModifiers::CONTROL) => Some(CanvasAction::MoveWordNext),
// === MODE TRANSITIONS ===
(AppMode::ReadOnly, KeyCode::Char('i'), _) => Some(CanvasAction::Custom("enter_edit_mode".to_string())),
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
// 'a' moves to end of line then enters edit mode
if let Ok(_) = execute(CanvasAction::MoveLineEnd, state).await {
Some(CanvasAction::Custom("enter_edit_mode".to_string()))
} else {
None
}
},
(AppMode::ReadOnly, KeyCode::Char('v'), _) => Some(CanvasAction::Custom("enter_highlight_mode".to_string())),
(_, KeyCode::Esc, _) => Some(CanvasAction::Custom("enter_readonly_mode".to_string())),
// === CHARACTER INPUT IN EDIT MODE ===
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) => {
Some(CanvasAction::InsertChar(c))
},
// === ARROW KEYS IN READ-ONLY MODE ===
(AppMode::ReadOnly, KeyCode::Left, _) => Some(CanvasAction::MoveLeft),
(AppMode::ReadOnly, KeyCode::Right, _) => Some(CanvasAction::MoveRight),
(AppMode::ReadOnly, KeyCode::Up, _) => Some(CanvasAction::MoveUp),
(AppMode::ReadOnly, KeyCode::Down, _) => Some(CanvasAction::MoveDown),
_ => None,
};
// Execute the action if we found one
if let Some(action) = action {
match execute(action.clone(), state).await {
Ok(result) => {
if result.is_success() {
// Mark as changed for editing actions
if is_edit_mode {
match action {
CanvasAction::InsertChar(_) | CanvasAction::DeleteBackward | CanvasAction::DeleteForward => {
state.set_has_unsaved_changes(true);
}
_ => {}
}
}
if let Some(msg) = result.message() {
state.debug_message = msg.to_string();
} else {
state.debug_message = format!("Executed: {}", action.description());
}
} else if let Some(msg) = result.message() {
state.debug_message = format!("Error: {}", msg);
}
}
Err(e) => {
state.debug_message = format!("Error executing action: {}", e);
}
}
} else {
state.debug_message = format!("Unhandled key: {:?} (mode: {:?})", key, state.mode);
}
true // Continue running
}
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut state: DemoFormState) -> io::Result<()> {
let theme = DemoTheme;
loop {
terminal.draw(|f| ui(f, &state, &theme))?;
if let Event::Key(key) = event::read()? {
let should_continue = handle_key_press(key.code, key.modifiers, &mut state).await;
if !should_continue {
break;
}
}
}
Ok(())
}
fn ui(f: &mut Frame, state: &DemoFormState, theme: &DemoTheme) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(8),
Constraint::Length(4),
])
.split(f.area());
// Render the canvas form
render_canvas(
f,
chunks[0],
state,
theme,
state.mode == AppMode::Edit,
&state.highlight_state,
);
// Render status bar
let mode_text = match state.mode {
AppMode::Edit => "EDIT",
AppMode::ReadOnly => "NORMAL",
AppMode::Highlight => "VISUAL",
AppMode::General => "GENERAL",
AppMode::Command => "COMMAND",
};
let status_text = if state.has_changes {
format!("-- {} -- [Modified]", mode_text)
} else {
format!("-- {} --", mode_text)
};
let position_text = format!("Field: {}/{} | Cursor: {} | Actions: {}",
state.current_field + 1,
state.fields.len(),
state.cursor_pos,
CanvasAction::movement_actions().len() + CanvasAction::editing_actions().len());
let help_text = match state.mode {
AppMode::ReadOnly => "hjkl/arrows: Move | Tab/Shift+Tab: Fields | w/b/e: Words | 0/$: Line | i/a: Edit | v: Visual | F10: Quit",
AppMode::Edit => "Type to edit | Arrows/Ctrl+arrows: Move | Tab: Next field | Backspace/Delete: Delete | Home/End: Line | Esc: Normal | F10: Quit",
AppMode::Highlight => "hjkl/arrows: Select | w/b/e: Words | 0/$: Line | Esc: Normal | F10: Quit",
_ => "Esc: Normal | F10: Quit",
};
let status = Paragraph::new(vec![
Line::from(Span::styled(status_text, Style::default().fg(theme.accent()))),
Line::from(Span::styled(position_text, Style::default().fg(theme.fg()))),
Line::from(Span::styled(state.debug_message.clone(), Style::default().fg(theme.warning()))),
Line::from(Span::styled(help_text, Style::default().fg(theme.secondary()))),
])
.block(Block::default().borders(Borders::ALL).title("Status"));
f.render_widget(status, chunks[1]);
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let state = DemoFormState::new();
let res = run_app(&mut terminal, state).await;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err);
}
Ok(())
}

View File

@@ -0,0 +1,724 @@
// examples/full_canvas_demo.rs
//! Demonstrates the FULL potential of the canvas library using the native API
use std::io;
use crossterm::{
cursor::SetCursorStyle,
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
},
execute,
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame, Terminal,
};
use canvas::{
canvas::{
gui::render_canvas_default,
modes::{AppMode, ModeManager, HighlightState},
},
DataProvider, FormEditor,
};
/// Update cursor style based on current AppMode
fn update_cursor_for_mode(mode: AppMode) -> io::Result<()> {
let style = match mode {
AppMode::Edit => SetCursorStyle::SteadyBar, // Thin line for insert mode
AppMode::ReadOnly => SetCursorStyle::SteadyBlock, // Block for normal mode
AppMode::Highlight => SetCursorStyle::BlinkingBlock, // Blinking block for visual mode
AppMode::General => SetCursorStyle::SteadyBlock, // Block for general mode
AppMode::Command => SetCursorStyle::SteadyUnderScore, // Underscore for command mode
};
execute!(io::stdout(), style)
}
// Enhanced FormEditor that adds visual mode and status tracking
struct EnhancedFormEditor<D: DataProvider> {
editor: FormEditor<D>,
highlight_state: HighlightState,
has_unsaved_changes: bool,
debug_message: String,
command_buffer: String, // For multi-key vim commands like "gg"
}
impl<D: DataProvider> EnhancedFormEditor<D> {
fn new(data_provider: D) -> Self {
Self {
editor: FormEditor::new(data_provider),
highlight_state: HighlightState::Off,
has_unsaved_changes: false,
debug_message: "Full Canvas Demo - All features enabled".to_string(),
command_buffer: String::new(),
}
}
// === COMMAND BUFFER HANDLING ===
fn clear_command_buffer(&mut self) {
self.command_buffer.clear();
}
fn add_to_command_buffer(&mut self, ch: char) {
self.command_buffer.push(ch);
}
fn get_command_buffer(&self) -> &str {
&self.command_buffer
}
fn has_pending_command(&self) -> bool {
!self.command_buffer.is_empty()
}
// === VISUAL/HIGHLIGHT MODE SUPPORT ===
fn enter_visual_mode(&mut self) {
if ModeManager::can_enter_highlight_mode(self.editor.mode()) {
self.editor.set_mode(AppMode::Highlight);
self.highlight_state = HighlightState::Characterwise {
anchor: (
self.editor.current_field(),
self.editor.cursor_position(),
),
};
self.debug_message = "-- VISUAL --".to_string();
}
}
fn enter_visual_line_mode(&mut self) {
if ModeManager::can_enter_highlight_mode(self.editor.mode()) {
self.editor.set_mode(AppMode::Highlight);
self.highlight_state =
HighlightState::Linewise { anchor_line: self.editor.current_field() };
self.debug_message = "-- VISUAL LINE --".to_string();
}
}
fn exit_visual_mode(&mut self) {
self.highlight_state = HighlightState::Off;
if self.editor.mode() == AppMode::Highlight {
self.editor.set_mode(AppMode::ReadOnly);
self.debug_message = "Visual mode exited".to_string();
}
}
fn update_visual_selection(&mut self) {
if self.editor.mode() == AppMode::Highlight {
match &self.highlight_state {
HighlightState::Characterwise { anchor: _ } => {
self.debug_message = format!(
"Visual selection: char {} in field {}",
self.editor.cursor_position(),
self.editor.current_field()
);
}
HighlightState::Linewise { anchor_line: _ } => {
self.debug_message = format!(
"Visual line selection: field {}",
self.editor.current_field()
);
}
_ => {}
}
}
}
// === ENHANCED MOVEMENT WITH VISUAL UPDATES ===
fn move_left(&mut self) {
self.editor.move_left();
self.update_visual_selection();
}
fn move_right(&mut self) {
self.editor.move_right();
self.update_visual_selection();
}
fn move_up(&mut self) {
self.editor.move_up();
self.update_visual_selection();
}
fn move_down(&mut self) {
self.editor.move_down();
self.update_visual_selection();
}
fn move_word_next(&mut self) {
self.editor.move_word_next();
self.update_visual_selection();
}
fn move_word_prev(&mut self) {
self.editor.move_word_prev();
self.update_visual_selection();
}
fn move_word_end(&mut self) {
self.editor.move_word_end();
self.update_visual_selection();
}
fn move_word_end_prev(&mut self) {
self.editor.move_word_end_prev();
self.update_visual_selection();
}
fn move_line_start(&mut self) {
self.editor.move_line_start();
self.update_visual_selection();
}
fn move_line_end(&mut self) {
self.editor.move_line_end();
self.update_visual_selection();
}
fn move_first_line(&mut self) {
self.editor.move_first_line();
self.update_visual_selection();
}
fn move_last_line(&mut self) {
self.editor.move_last_line();
self.update_visual_selection();
}
fn prev_field(&mut self) {
self.editor.prev_field();
self.update_visual_selection();
}
fn next_field(&mut self) {
self.editor.next_field();
self.update_visual_selection();
}
// === DELETE OPERATIONS ===
fn delete_backward(&mut self) -> anyhow::Result<()> {
let result = self.editor.delete_backward();
if result.is_ok() {
self.has_unsaved_changes = true;
self.debug_message = "Deleted character backward".to_string();
}
Ok(result?)
}
fn delete_forward(&mut self) -> anyhow::Result<()> {
let result = self.editor.delete_forward();
if result.is_ok() {
self.has_unsaved_changes = true;
self.debug_message = "Deleted character forward".to_string();
}
Ok(result?)
}
// === MODE TRANSITIONS ===
fn enter_edit_mode(&mut self) {
self.editor.enter_edit_mode();
self.debug_message = "-- INSERT --".to_string();
}
fn exit_edit_mode(&mut self) {
self.editor.exit_edit_mode();
self.exit_visual_mode();
self.debug_message = "".to_string();
}
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
let result = self.editor.insert_char(ch);
if result.is_ok() {
self.has_unsaved_changes = true;
}
Ok(result?)
}
// === DELEGATE TO ORIGINAL EDITOR ===
fn current_field(&self) -> usize {
self.editor.current_field()
}
fn cursor_position(&self) -> usize {
self.editor.cursor_position()
}
fn mode(&self) -> AppMode {
self.editor.mode()
}
fn current_text(&self) -> &str {
self.editor.current_text()
}
fn data_provider(&self) -> &D {
self.editor.data_provider()
}
fn ui_state(&self) -> &canvas::EditorState {
self.editor.ui_state()
}
fn set_mode(&mut self, mode: AppMode) {
self.editor.set_mode(mode);
if mode != AppMode::Highlight {
self.exit_visual_mode();
}
}
// === STATUS AND DEBUG ===
fn set_debug_message(&mut self, msg: String) {
self.debug_message = msg;
}
fn debug_message(&self) -> &str {
&self.debug_message
}
fn highlight_state(&self) -> &HighlightState {
&self.highlight_state
}
fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
}
// Demo form data with interesting text for word movement
struct FullDemoData {
fields: Vec<(String, String)>,
}
impl FullDemoData {
fn new() -> Self {
Self {
fields: vec![
("Name".to_string(), "John-Paul McDonald".to_string()),
(
"Email".to_string(),
"user@example-domain.com".to_string(),
),
("Phone".to_string(), "+1 (555) 123-4567".to_string()),
("Address".to_string(), "123 Main St, Apt 4B".to_string()),
(
"Tags".to_string(),
"urgent,important,follow-up".to_string(),
),
(
"Notes".to_string(),
"This is a sample note with multiple words, punctuation! And symbols @#$"
.to_string(),
),
],
}
}
}
impl DataProvider for FullDemoData {
fn field_count(&self) -> usize {
self.fields.len()
}
fn field_name(&self, index: usize) -> &str {
&self.fields[index].0
}
fn field_value(&self, index: usize) -> &str {
&self.fields[index].1
}
fn set_field_value(&mut self, index: usize, value: String) {
self.fields[index].1 = value;
}
fn supports_autocomplete(&self, _field_index: usize) -> bool {
false
}
fn display_value(&self, _index: usize) -> Option<&str> {
None
}
}
/// Full vim-like key handling using the native FormEditor API
fn handle_key_press(
key: KeyCode,
modifiers: KeyModifiers,
editor: &mut EnhancedFormEditor<FullDemoData>,
) -> anyhow::Result<bool> {
let old_mode = editor.mode(); // Store mode before processing
// Quit handling
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL))
|| (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL))
|| key == KeyCode::F(10)
{
return Ok(false);
}
match (old_mode, key, modifiers) {
// === MODE TRANSITIONS ===
(AppMode::ReadOnly, KeyCode::Char('i'), _) => {
editor.enter_edit_mode();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
editor.move_right(); // Move after current character
editor.enter_edit_mode();
editor.set_debug_message("-- INSERT -- (append)".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('A'), _) => {
editor.move_line_end();
editor.enter_edit_mode();
editor.set_debug_message("-- INSERT -- (end of line)".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('o'), _) => {
editor.move_line_end();
editor.enter_edit_mode();
editor.set_debug_message("-- INSERT -- (open line)".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('v'), _) => {
editor.enter_visual_mode();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('V'), _) => {
editor.enter_visual_line_mode();
editor.clear_command_buffer();
}
(_, KeyCode::Esc, _) => {
editor.exit_edit_mode();
editor.clear_command_buffer();
}
// === MOVEMENT: VIM-STYLE NAVIGATION ===
// Basic movement (hjkl and arrows)
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('h'), _)
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Left, _) => {
editor.move_left();
editor.set_debug_message("← left".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('l'), _)
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Right, _) => {
editor.move_right();
editor.set_debug_message("→ right".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('j'), _)
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Down, _) => {
editor.move_down();
editor.set_debug_message("↓ next field".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('k'), _)
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Up, _) => {
editor.move_up();
editor.set_debug_message("↑ previous field".to_string());
editor.clear_command_buffer();
}
// Word movement - Full vim word navigation
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('w'), _) => {
editor.move_word_next();
editor.set_debug_message("w: next word start".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('b'), _) => {
editor.move_word_prev();
editor.set_debug_message("b: previous word start".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('e'), _) => {
editor.move_word_end();
editor.set_debug_message("e: word end".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('W'), _) => {
editor.move_word_end_prev();
editor.set_debug_message("W: previous word end".to_string());
editor.clear_command_buffer();
}
// Line movement
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('0'), _)
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Home, _) => {
editor.move_line_start();
editor.set_debug_message("0: line start".to_string());
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('$'), _)
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::End, _) => {
editor.move_line_end();
editor.set_debug_message("$: line end".to_string());
}
// Field/document movement
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('g'), _) => {
if editor.get_command_buffer() == "g" {
// Second 'g' - execute "gg" command
editor.move_first_line();
editor.set_debug_message("gg: first field".to_string());
editor.clear_command_buffer();
} else {
// First 'g' - start command buffer
editor.clear_command_buffer();
editor.add_to_command_buffer('g');
editor.set_debug_message("g".to_string());
}
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('G'), _) => {
editor.move_last_line();
editor.set_debug_message("G: last field".to_string());
editor.clear_command_buffer();
}
// === EDIT MODE MOVEMENT ===
(AppMode::Edit, KeyCode::Left, m) if m.contains(KeyModifiers::CONTROL) => {
editor.move_word_prev();
editor.set_debug_message("Ctrl+← word back".to_string());
}
(AppMode::Edit, KeyCode::Right, m) if m.contains(KeyModifiers::CONTROL) => {
editor.move_word_next();
editor.set_debug_message("Ctrl+→ word forward".to_string());
}
(AppMode::Edit, KeyCode::Left, _) => {
editor.move_left();
}
(AppMode::Edit, KeyCode::Right, _) => {
editor.move_right();
}
(AppMode::Edit, KeyCode::Up, _) => {
editor.move_up();
}
(AppMode::Edit, KeyCode::Down, _) => {
editor.move_down();
}
(AppMode::Edit, KeyCode::Home, _) => {
editor.move_line_start();
}
(AppMode::Edit, KeyCode::End, _) => {
editor.move_line_end();
}
// === DELETE OPERATIONS ===
(AppMode::Edit, KeyCode::Backspace, _) => {
editor.delete_backward()?;
}
(AppMode::Edit, KeyCode::Delete, _) => {
editor.delete_forward()?;
}
// Delete operations in normal mode (vim x)
(AppMode::ReadOnly, KeyCode::Char('x'), _) => {
editor.delete_forward()?;
editor.set_debug_message("x: deleted character".to_string());
}
(AppMode::ReadOnly, KeyCode::Char('X'), _) => {
editor.delete_backward()?;
editor.set_debug_message("X: deleted character backward".to_string());
}
// === TAB NAVIGATION ===
(_, KeyCode::Tab, _) => {
editor.next_field();
editor.set_debug_message("Tab: next field".to_string());
}
(_, KeyCode::BackTab, _) => {
editor.prev_field();
editor.set_debug_message("Shift+Tab: previous field".to_string());
}
// === CHARACTER INPUT ===
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
editor.insert_char(c)?;
}
// === DEBUG/INFO COMMANDS ===
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
editor.set_debug_message(format!(
"Field {}/{}, Pos {}, Mode: {:?}",
editor.current_field() + 1,
editor.data_provider().field_count(),
editor.cursor_position(),
editor.mode()
));
}
_ => {
// If we have a pending command and this key doesn't complete it, clear the buffer
if editor.has_pending_command() {
editor.clear_command_buffer();
editor.set_debug_message("Invalid command sequence".to_string());
} else {
editor.set_debug_message(format!(
"Unhandled: {:?} + {:?} in {:?} mode",
key, modifiers, old_mode
));
}
}
}
// Update cursor if mode changed
let new_mode = editor.mode();
if old_mode != new_mode {
update_cursor_for_mode(new_mode)?;
}
Ok(true)
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut editor: EnhancedFormEditor<FullDemoData>,
) -> io::Result<()> {
loop {
terminal.draw(|f| ui(f, &editor))?;
if let Event::Key(key) = event::read()? {
match handle_key_press(key.code, key.modifiers, &mut editor) {
Ok(should_continue) => {
if !should_continue {
break;
}
}
Err(e) => {
editor.set_debug_message(format!("Error: {}", e));
}
}
}
}
Ok(())
}
fn ui(f: &mut Frame, editor: &EnhancedFormEditor<FullDemoData>) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(8)])
.split(f.area());
render_enhanced_canvas(f, chunks[0], editor);
render_status_and_help(f, chunks[1], editor);
}
fn render_enhanced_canvas(
f: &mut Frame,
area: ratatui::layout::Rect,
editor: &EnhancedFormEditor<FullDemoData>,
) {
render_canvas_default(f, area, &editor.editor);
}
fn render_status_and_help(
f: &mut Frame,
area: ratatui::layout::Rect,
editor: &EnhancedFormEditor<FullDemoData>,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Length(5)])
.split(area);
// Status bar
let mode_text = match editor.mode() {
AppMode::Edit => "INSERT",
AppMode::ReadOnly => "NORMAL",
AppMode::Highlight => match editor.highlight_state() {
HighlightState::Characterwise { .. } => "VISUAL",
HighlightState::Linewise { .. } => "VISUAL LINE",
_ => "VISUAL",
},
_ => "NORMAL",
};
let status_text = if editor.has_pending_command() {
format!("-- {} -- {} [{}]", mode_text, editor.debug_message(), editor.get_command_buffer())
} else if editor.has_unsaved_changes() {
format!("-- {} -- [Modified] {}", mode_text, editor.debug_message())
} else {
format!("-- {} -- {}", mode_text, editor.debug_message())
};
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("Status"));
f.render_widget(status, chunks[0]);
// Help text
let help_text = match editor.mode() {
AppMode::ReadOnly => {
if editor.has_pending_command() {
match editor.get_command_buffer() {
"g" => "Press 'g' again for first field, or any other key to cancel",
_ => "Pending command... (Esc to cancel)"
}
} else {
"Normal: hjkl/arrows=move, w/b/e=words, 0/$=line, gg/G=first/last, i/a/A=insert, v/V=visual, x/X=delete, ?=info"
}
}
AppMode::Edit => {
"Insert: arrows=move, Ctrl+arrows=words, Backspace/Del=delete, Esc=normal, Tab/Shift+Tab=fields"
}
AppMode::Highlight => {
"Visual: hjkl/arrows=extend selection, w/b/e=word selection, Esc=normal"
}
_ => "Press ? for help"
};
let help = Paragraph::new(Line::from(Span::raw(help_text)))
.block(Block::default().borders(Borders::ALL).title("Commands"))
.style(Style::default().fg(Color::Gray));
f.render_widget(help, chunks[1]);
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let data = FullDemoData::new();
let mut editor = EnhancedFormEditor::new(data);
editor.set_mode(AppMode::ReadOnly); // Start in normal mode
// Set initial cursor style
update_cursor_for_mode(editor.mode())?;
let res = run_app(&mut terminal, editor);
// Reset cursor style on exit
execute!(io::stdout(), SetCursorStyle::DefaultUserShape)?;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err);
}
Ok(())
}

View File

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

View File

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

View File

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

View File

@@ -1,170 +0,0 @@
// src/autocomplete/actions.rs
use crate::canvas::state::CanvasState;
use crate::autocomplete::state::AutocompleteCanvasState;
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::canvas::actions::execute;
use anyhow::Result;
/// Enhanced execute function for states that support autocomplete
/// This is the main entry point for autocomplete-aware canvas execution
///
/// Use this instead of canvas::execute() if you want autocomplete behavior:
/// ```rust
/// execute_with_autocomplete(action, &mut state).await?;
/// ```
pub async fn execute_with_autocomplete<S: CanvasState + AutocompleteCanvasState + Send>(
action: CanvasAction,
state: &mut S,
) -> Result<ActionResult> {
match &action {
// === AUTOCOMPLETE-SPECIFIC ACTIONS ===
CanvasAction::TriggerAutocomplete => {
if state.supports_autocomplete(state.current_field()) {
state.trigger_autocomplete_suggestions().await;
Ok(ActionResult::success_with_message("Triggered autocomplete"))
} else {
Ok(ActionResult::success_with_message("Autocomplete not supported for this field"))
}
}
CanvasAction::SuggestionUp => {
if state.has_autocomplete_suggestions() {
state.move_suggestion_selection(-1);
Ok(ActionResult::success())
} else {
Ok(ActionResult::success_with_message("No suggestions available"))
}
}
CanvasAction::SuggestionDown => {
if state.has_autocomplete_suggestions() {
state.move_suggestion_selection(1);
Ok(ActionResult::success())
} else {
Ok(ActionResult::success_with_message("No suggestions available"))
}
}
CanvasAction::SelectSuggestion => {
if let Some(message) = state.apply_selected_suggestion() {
Ok(ActionResult::success_with_message(&message))
} else {
Ok(ActionResult::success_with_message("No suggestion to select"))
}
}
CanvasAction::ExitSuggestions => {
state.clear_autocomplete_suggestions();
Ok(ActionResult::success_with_message("Closed autocomplete"))
}
// === TEXT INSERTION WITH AUTO-TRIGGER ===
CanvasAction::InsertChar(_) => {
// First, execute the character insertion normally
let result = execute(action, state).await?;
// After successful insertion, check if we should auto-trigger autocomplete
if result.is_success() && state.should_trigger_autocomplete() {
state.trigger_autocomplete_suggestions().await;
}
Ok(result)
}
// === NAVIGATION/EDITING ACTIONS (clear autocomplete first) ===
CanvasAction::MoveLeft | CanvasAction::MoveRight |
CanvasAction::MoveUp | CanvasAction::MoveDown |
CanvasAction::NextField | CanvasAction::PrevField |
CanvasAction::DeleteBackward | CanvasAction::DeleteForward => {
// Clear autocomplete when navigating/editing
if state.is_autocomplete_active() {
state.clear_autocomplete_suggestions();
}
// Execute the action normally
execute(action, state).await
}
// === ALL OTHER ACTIONS (normal execution) ===
_ => {
// For all other actions, just execute normally
execute(action, state).await
}
}
}
/// Helper function to integrate autocomplete actions with CanvasState.handle_feature_action()
///
/// Use this in your CanvasState implementation like this:
/// ```rust
/// fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
/// // Try autocomplete first
/// if let Some(result) = handle_autocomplete_feature_action(action, self) {
/// return Some(result);
/// }
///
/// // Handle your other custom actions...
/// None
/// }
/// ```
pub fn handle_autocomplete_feature_action<S: CanvasState + AutocompleteCanvasState + Send>(
action: &CanvasAction,
state: &S,
) -> Option<String> {
match action {
CanvasAction::TriggerAutocomplete => {
if state.supports_autocomplete(state.current_field()) {
if state.is_autocomplete_active() {
Some("Autocomplete already active".to_string())
} else {
None // Let execute_with_autocomplete handle it
}
} else {
Some("Autocomplete not available for this field".to_string())
}
}
CanvasAction::SuggestionUp | CanvasAction::SuggestionDown => {
if state.is_autocomplete_active() {
None // Let execute_with_autocomplete handle navigation
} else {
Some("No autocomplete suggestions to navigate".to_string())
}
}
CanvasAction::SelectSuggestion => {
if state.has_autocomplete_suggestions() {
None // Let execute_with_autocomplete handle selection
} else {
Some("No suggestion to select".to_string())
}
}
CanvasAction::ExitSuggestions => {
if state.is_autocomplete_active() {
None // Let execute_with_autocomplete handle exit
} else {
Some("No autocomplete to close".to_string())
}
}
_ => None // Not an autocomplete action
}
}
/// Legacy compatibility function - kept for backward compatibility
/// This is the old function signature, now it just wraps the new system
#[deprecated(note = "Use execute_with_autocomplete instead")]
pub async fn execute_canvas_action_with_autocomplete<S: CanvasState + AutocompleteCanvasState + Send>(
action: CanvasAction,
state: &mut S,
_ideal_cursor_column: &mut usize, // Ignored - new system manages this internally
_config: Option<&()>, // Ignored - no more config system
) -> Result<ActionResult> {
execute_with_autocomplete(action, state).await
}

View File

@@ -1,39 +1,41 @@
// src/autocomplete/gui.rs
//! Autocomplete GUI updated to work with FormEditor
#[cfg(feature = "gui")]
use ratatui::{
layout::{Alignment, Rect},
style::{Modifier, Style},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
widgets::{Block, List, ListItem, ListState, Paragraph}, // Removed Borders
Frame,
};
// Use the correct import from our types module
use crate::autocomplete::types::AutocompleteState;
#[cfg(feature = "gui")]
use crate::canvas::theme::CanvasTheme;
use crate::data_provider::{DataProvider, SuggestionItem};
use crate::editor::FormEditor;
#[cfg(feature = "gui")]
use unicode_width::UnicodeWidthStr;
/// Render autocomplete dropdown - call this AFTER rendering canvas
/// Render autocomplete dropdown for FormEditor - call this AFTER rendering canvas
#[cfg(feature = "gui")]
pub fn render_autocomplete_dropdown<T: CanvasTheme, D: Clone + Send + 'static>(
pub fn render_autocomplete_dropdown<T: CanvasTheme, D: DataProvider>(
f: &mut Frame,
frame_area: Rect,
input_rect: Rect,
theme: &T,
autocomplete_state: &AutocompleteState<D>,
editor: &FormEditor<D>,
) {
if !autocomplete_state.is_active {
let ui_state = editor.ui_state();
if !ui_state.is_autocomplete_active() {
return;
}
if autocomplete_state.is_loading {
if ui_state.autocomplete.is_loading {
render_loading_indicator(f, frame_area, input_rect, theme);
} else if !autocomplete_state.suggestions.is_empty() {
render_suggestions_dropdown(f, frame_area, input_rect, theme, autocomplete_state);
} else if !editor.suggestions().is_empty() {
render_suggestions_dropdown(f, frame_area, input_rect, theme, editor.suggestions(), ui_state.autocomplete.selected_index);
}
}
@@ -69,14 +71,15 @@ fn render_loading_indicator<T: CanvasTheme>(
/// Show actual suggestions list
#[cfg(feature = "gui")]
fn render_suggestions_dropdown<T: CanvasTheme, D: Clone + Send + 'static>(
fn render_suggestions_dropdown<T: CanvasTheme>(
f: &mut Frame,
frame_area: Rect,
input_rect: Rect,
theme: &T,
autocomplete_state: &AutocompleteState<D>,
suggestions: &[SuggestionItem], // Fixed: Removed <String> generic parameter
selected_index: Option<usize>,
) {
let display_texts: Vec<&str> = autocomplete_state.suggestions
let display_texts: Vec<&str> = suggestions
.iter()
.map(|item| item.display_text.as_str())
.collect();
@@ -96,19 +99,19 @@ fn render_suggestions_dropdown<T: CanvasTheme, D: Clone + Send + 'static>(
// List items
let items = create_suggestion_list_items(
&display_texts,
autocomplete_state.selected_index,
selected_index,
dropdown_dimensions.width,
theme,
);
let list = List::new(items).block(dropdown_block);
let mut list_state = ListState::default();
list_state.select(autocomplete_state.selected_index);
list_state.select(selected_index);
f.render_stateful_widget(list, dropdown_area, &mut list_state);
}
/// Calculate dropdown size based on suggestions - updated to match client dimensions
/// Calculate dropdown size based on suggestions
#[cfg(feature = "gui")]
fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions {
let max_width = display_texts
@@ -117,9 +120,9 @@ fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions {
.max()
.unwrap_or(0) as u16;
let horizontal_padding = 2; // Changed from 4 to 2 to match client
let width = (max_width + horizontal_padding).max(10); // Changed from 12 to 10 to match client
let height = (display_texts.len() as u16).min(5); // Removed +2 since no borders
let horizontal_padding = 2;
let width = (max_width + horizontal_padding).max(10);
let height = (display_texts.len() as u16).min(5);
DropdownDimensions { width, height }
}
@@ -152,7 +155,7 @@ fn calculate_dropdown_position(
dropdown_area
}
/// Create styled list items - updated to match client spacing
/// Create styled list items
#[cfg(feature = "gui")]
fn create_suggestion_list_items<'a, T: CanvasTheme>(
display_texts: &'a [&'a str],
@@ -160,8 +163,7 @@ fn create_suggestion_list_items<'a, T: CanvasTheme>(
dropdown_width: u16,
theme: &T,
) -> Vec<ListItem<'a>> {
let horizontal_padding = 2; // Changed from 4 to 2 to match client
let available_width = dropdown_width; // No border padding needed
let available_width = dropdown_width;
display_texts
.iter()

View File

@@ -1,21 +1,11 @@
// src/autocomplete/mod.rs
pub mod types;
pub mod state;
pub mod actions;
#[cfg(feature = "gui")]
pub mod gui;
// Re-export the main autocomplete API
pub use types::{SuggestionItem, AutocompleteState};
pub use state::AutocompleteCanvasState;
// Re-export the new action functions
pub use actions::{
execute_with_autocomplete,
handle_autocomplete_feature_action,
};
// Re-export the main autocomplete types
pub use state::{AutocompleteProvider, SuggestionItem};
// Re-export GUI functions if available
#[cfg(feature = "gui")]

View File

@@ -1,189 +1,5 @@
// src/autocomplete/state.rs
//! Autocomplete provider types
use crate::canvas::state::CanvasState;
use async_trait::async_trait;
/// OPTIONAL extension trait for states that want rich autocomplete functionality.
/// Only implement this if you need the new autocomplete features.
///
/// # User Workflow:
/// 1. User presses trigger key (Tab, Ctrl+K, etc.)
/// 2. User's key mapping calls CanvasAction::TriggerAutocomplete
/// 3. Library calls your trigger_autocomplete_suggestions() method
/// 4. You implement async fetching logic in that method
/// 5. You call set_autocomplete_suggestions() with results
/// 6. Library manages UI state and navigation
#[async_trait]
pub trait AutocompleteCanvasState: CanvasState {
/// Associated type for suggestion data (e.g., Hit, String, CustomType)
type SuggestionData: Clone + Send + 'static;
/// Check if a field supports autocomplete (user decides which fields)
fn supports_autocomplete(&self, _field_index: usize) -> bool {
false // Default: no autocomplete support
}
/// Get autocomplete state (read-only)
fn autocomplete_state(&self) -> Option<&crate::autocomplete::AutocompleteState<Self::SuggestionData>> {
None // Default: no autocomplete state
}
/// Get autocomplete state (mutable)
fn autocomplete_state_mut(&mut self) -> Option<&mut crate::autocomplete::AutocompleteState<Self::SuggestionData>> {
None // Default: no autocomplete state
}
// === PUBLIC API METHODS (called by library) ===
/// Activate autocomplete for current field (shows loading spinner)
fn activate_autocomplete(&mut self) {
let current_field = self.current_field();
if let Some(state) = self.autocomplete_state_mut() {
state.activate(current_field);
}
}
/// Deactivate autocomplete (hides dropdown)
fn deactivate_autocomplete(&mut self) {
if let Some(state) = self.autocomplete_state_mut() {
state.deactivate();
}
}
/// Set suggestions (called after your async fetch completes)
fn set_autocomplete_suggestions(&mut self, suggestions: Vec<crate::autocomplete::SuggestionItem<Self::SuggestionData>>) {
if let Some(state) = self.autocomplete_state_mut() {
state.set_suggestions(suggestions);
}
}
/// Set loading state (show/hide spinner)
fn set_autocomplete_loading(&mut self, loading: bool) {
if let Some(state) = self.autocomplete_state_mut() {
state.is_loading = loading;
}
}
// === QUERY METHODS ===
/// Check if autocomplete is currently active/visible
fn is_autocomplete_active(&self) -> bool {
self.autocomplete_state()
.map(|state| state.is_active)
.unwrap_or(false)
}
/// Check if autocomplete has suggestions ready for navigation
fn is_autocomplete_ready(&self) -> bool {
self.autocomplete_state()
.map(|state| state.is_ready())
.unwrap_or(false)
}
/// Check if there are available suggestions
fn has_autocomplete_suggestions(&self) -> bool {
self.autocomplete_state()
.map(|state| !state.suggestions.is_empty())
.unwrap_or(false)
}
// === USER-IMPLEMENTABLE METHODS ===
/// Check if autocomplete should be triggered automatically (e.g., after typing 2+ chars)
/// Override this to implement your own trigger logic
fn should_trigger_autocomplete(&self) -> bool {
let current_input = self.get_current_input();
let current_field = self.current_field();
self.supports_autocomplete(current_field) &&
current_input.len() >= 2 && // Default: trigger after 2 chars
!self.is_autocomplete_active()
}
/// **USER MUST IMPLEMENT**: Trigger autocomplete suggestions (async)
/// This is where you implement your API calls, caching, etc.
///
/// # Example Implementation:
/// ```rust
/// #[async_trait]
/// impl AutocompleteCanvasState for MyState {
/// type SuggestionData = MyData;
///
/// async fn trigger_autocomplete_suggestions(&mut self) {
/// self.activate_autocomplete(); // Show loading state
///
/// let query = self.get_current_input().to_string();
/// let suggestions = my_api.search(&query).await.unwrap_or_default();
///
/// self.set_autocomplete_suggestions(suggestions);
/// }
/// }
/// ```
async fn trigger_autocomplete_suggestions(&mut self) {
// Activate autocomplete UI
self.activate_autocomplete();
// Default: just show loading state
// User should override this to do actual async fetching
self.set_autocomplete_loading(true);
// In a real implementation, you'd:
// 1. Get current input: let query = self.get_current_input();
// 2. Make API call: let results = api.search(query).await;
// 3. Convert to suggestions: let suggestions = results.into_suggestions();
// 4. Set suggestions: self.set_autocomplete_suggestions(suggestions);
}
// === INTERNAL NAVIGATION METHODS (called by library actions) ===
/// Clear autocomplete suggestions and hide dropdown
fn clear_autocomplete_suggestions(&mut self) {
self.deactivate_autocomplete();
}
/// Move selection up/down in suggestions list
fn move_suggestion_selection(&mut self, direction: i32) {
if let Some(state) = self.autocomplete_state_mut() {
if direction > 0 {
state.select_next();
} else {
state.select_previous();
}
}
}
/// Get currently selected suggestion for display/application
fn get_selected_suggestion(&self) -> Option<crate::autocomplete::SuggestionItem<Self::SuggestionData>> {
self.autocomplete_state()?
.get_selected()
.cloned()
}
/// Apply the selected suggestion to the current field
fn apply_suggestion(&mut self, suggestion: &crate::autocomplete::SuggestionItem<Self::SuggestionData>) {
// Apply the value to current field
*self.get_current_input_mut() = suggestion.value_to_store.clone();
self.set_has_unsaved_changes(true);
// Clear autocomplete
self.clear_autocomplete_suggestions();
}
/// Apply the currently selected suggestion (convenience method)
fn apply_selected_suggestion(&mut self) -> Option<String> {
if let Some(suggestion) = self.get_selected_suggestion() {
let display_text = suggestion.display_text.clone();
self.apply_suggestion(&suggestion);
Some(format!("Applied: {}", display_text))
} else {
None
}
}
// === LEGACY COMPATIBILITY ===
/// INTERNAL: Apply selected autocomplete value to current field (legacy method)
fn apply_autocomplete_selection(&mut self) -> Option<String> {
self.apply_selected_suggestion()
}
}
// Re-export the main types from data_provider
pub use crate::data_provider::{AutocompleteProvider, SuggestionItem};

View File

@@ -1,126 +0,0 @@
// canvas/src/autocomplete.rs
/// Generic suggestion item that clients push to canvas
#[derive(Debug, Clone)]
pub struct SuggestionItem<T> {
/// The underlying data (client-specific, e.g., Hit, String, etc.)
pub data: T,
/// Text to display in the dropdown
pub display_text: String,
/// Value to store in the form field when selected
pub value_to_store: String,
}
impl<T> SuggestionItem<T> {
pub fn new(data: T, display_text: String, value_to_store: String) -> Self {
Self {
data,
display_text,
value_to_store,
}
}
/// Convenience constructor for simple string suggestions
pub fn simple(data: T, text: String) -> Self {
Self {
data,
display_text: text.clone(),
value_to_store: text,
}
}
}
/// Autocomplete state managed by canvas
#[derive(Debug, Clone)]
pub struct AutocompleteState<T> {
/// Whether autocomplete is currently active/visible
pub is_active: bool,
/// Whether suggestions are being loaded (for spinner/loading indicator)
pub is_loading: bool,
/// Current suggestions to display
pub suggestions: Vec<SuggestionItem<T>>,
/// Currently selected suggestion index
pub selected_index: Option<usize>,
/// Field index that triggered autocomplete (for context)
pub active_field: Option<usize>,
}
impl<T> Default for AutocompleteState<T> {
fn default() -> Self {
Self {
is_active: false,
is_loading: false,
suggestions: Vec::new(),
selected_index: None,
active_field: None,
}
}
}
impl<T> AutocompleteState<T> {
pub fn new() -> Self {
Self::default()
}
/// Activate autocomplete for a specific field
pub fn activate(&mut self, field_index: usize) {
self.is_active = true;
self.active_field = Some(field_index);
self.selected_index = None;
self.suggestions.clear();
self.is_loading = true;
}
/// Deactivate autocomplete and clear state
pub fn deactivate(&mut self) {
self.is_active = false;
self.is_loading = false;
self.suggestions.clear();
self.selected_index = None;
self.active_field = None;
}
/// Set suggestions and stop loading
pub fn set_suggestions(&mut self, suggestions: Vec<SuggestionItem<T>>) {
self.suggestions = suggestions;
self.is_loading = false;
self.selected_index = if self.suggestions.is_empty() {
None
} else {
Some(0)
};
}
/// Move selection down
pub fn select_next(&mut self) {
if !self.suggestions.is_empty() {
let current = self.selected_index.unwrap_or(0);
self.selected_index = Some((current + 1) % self.suggestions.len());
}
}
/// Move selection up
pub fn select_previous(&mut self) {
if !self.suggestions.is_empty() {
let current = self.selected_index.unwrap_or(0);
self.selected_index = Some(
if current == 0 {
self.suggestions.len() - 1
} else {
current - 1
}
);
}
}
/// Get currently selected suggestion
pub fn get_selected(&self) -> Option<&SuggestionItem<T>> {
self.selected_index
.and_then(|idx| self.suggestions.get(idx))
}
/// Check if autocomplete is ready for interaction (active and has suggestions)
pub fn is_ready(&self) -> bool {
self.is_active && !self.suggestions.is_empty() && !self.is_loading
}
}

View File

@@ -1,43 +0,0 @@
// src/canvas/actions/handlers/dispatcher.rs
use crate::canvas::state::{CanvasState, ActionContext};
use crate::canvas::actions::{CanvasAction, ActionResult};
use crate::canvas::modes::AppMode;
use anyhow::Result;
use super::{handle_edit_action, handle_readonly_action, handle_highlight_action};
/// Main action dispatcher - routes actions to mode-specific handlers
pub async fn dispatch_action<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<ActionResult> {
// Check if the application wants to handle this action first
let context = ActionContext {
key_code: None,
ideal_cursor_column: *ideal_cursor_column,
current_input: state.get_current_input().to_string(),
current_field: state.current_field(),
};
if let Some(result) = state.handle_feature_action(&action, &context) {
return Ok(ActionResult::HandledByFeature(result));
}
// Route to mode-specific handler
match state.current_mode() {
AppMode::Edit => {
handle_edit_action(action, state, ideal_cursor_column).await
}
AppMode::ReadOnly => {
handle_readonly_action(action, state, ideal_cursor_column).await
}
AppMode::Highlight => {
handle_highlight_action(action, state, ideal_cursor_column).await
}
AppMode::General | AppMode::Command => {
Ok(ActionResult::success_with_message("Mode does not handle canvas actions directly"))
}
}
}

View File

@@ -1,213 +0,0 @@
// src/canvas/actions/handlers/edit.rs
//! Edit mode action handler
//!
//! Handles user input when in edit mode, supporting text entry, deletion,
//! and cursor movement with edit-specific behavior (cursor can go past end of text).
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::canvas::actions::movement::*;
use crate::canvas::state::CanvasState;
use anyhow::Result;
/// Edit mode uses cursor-past-end behavior for text insertion
const FOR_EDIT_MODE: bool = true;
/// Handle actions in edit mode with edit-specific cursor behavior
///
/// Edit mode allows text modification and uses cursor positioning that can
/// go past the end of existing text to facilitate insertion.
///
/// # Arguments
/// * `action` - The action to perform
/// * `state` - Mutable canvas state
/// * `ideal_cursor_column` - Desired column for vertical movement (maintained across line changes)
pub async fn handle_edit_action<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<ActionResult> {
match action {
CanvasAction::InsertChar(c) => {
// Insert character at cursor position and advance cursor
let cursor_pos = state.current_cursor_pos();
let input = state.get_current_input_mut();
input.insert(cursor_pos, c);
state.set_current_cursor_pos(cursor_pos + 1);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = cursor_pos + 1;
Ok(ActionResult::success())
}
CanvasAction::DeleteBackward => {
// Delete character before cursor (Backspace behavior)
let cursor_pos = state.current_cursor_pos();
if cursor_pos > 0 {
let input = state.get_current_input_mut();
input.remove(cursor_pos - 1);
state.set_current_cursor_pos(cursor_pos - 1);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = cursor_pos - 1;
}
Ok(ActionResult::success())
}
CanvasAction::DeleteForward => {
// Delete character at cursor position (Delete key behavior)
let cursor_pos = state.current_cursor_pos();
let input = state.get_current_input_mut();
if cursor_pos < input.len() {
input.remove(cursor_pos);
state.set_has_unsaved_changes(true);
}
Ok(ActionResult::success())
}
// Cursor movement actions
CanvasAction::MoveLeft => {
let new_pos = move_left(state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
}
CanvasAction::MoveRight => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
}
// Field navigation (treating single-line fields as "lines")
CanvasAction::MoveUp => {
let current_field = state.current_field();
if current_field > 0 {
state.set_current_field(current_field - 1);
let current_input = state.get_current_input();
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
}
Ok(ActionResult::success())
}
CanvasAction::MoveDown => {
let current_field = state.current_field();
let total_fields = state.fields().len();
if current_field < total_fields - 1 {
state.set_current_field(current_field + 1);
let current_input = state.get_current_input();
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
}
Ok(ActionResult::success())
}
// Line-based movement
CanvasAction::MoveLineStart => {
let new_pos = line_start_position();
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
}
CanvasAction::MoveLineEnd => {
let current_input = state.get_current_input();
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
}
// Document-level movement (first/last field)
CanvasAction::MoveFirstLine => {
state.set_current_field(0);
let current_input = state.get_current_input();
let new_pos = safe_cursor_position(current_input, 0, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
}
CanvasAction::MoveLastLine => {
let last_field = state.fields().len() - 1;
state.set_current_field(last_field);
let current_input = state.get_current_input();
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
}
// Word-based movement
CanvasAction::MoveWordNext => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok(ActionResult::success())
}
CanvasAction::MoveWordEnd => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_word_end(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok(ActionResult::success())
}
CanvasAction::MoveWordPrev => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok(ActionResult::success())
}
CanvasAction::MoveWordEndPrev => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_end(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok(ActionResult::success())
}
// Field navigation with simple wrapping behavior
CanvasAction::NextField | CanvasAction::PrevField => {
let current_field = state.current_field();
let total_fields = state.fields().len();
let new_field = match action {
CanvasAction::NextField => {
(current_field + 1) % total_fields // Simple wrap
}
CanvasAction::PrevField => {
if current_field == 0 { total_fields - 1 } else { current_field - 1 } // Simple wrap
}
_ => unreachable!(),
};
state.set_current_field(new_field);
let current_input = state.get_current_input();
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
Ok(ActionResult::success())
}
CanvasAction::Custom(action_str) => {
Ok(ActionResult::success_with_message(&format!("Custom edit action: {}", action_str)))
}
_ => {
Ok(ActionResult::success_with_message("Action not implemented for edit mode"))
}
}
}

View File

@@ -1,104 +0,0 @@
// src/canvas/actions/handlers/highlight.rs
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::canvas::actions::movement::*;
use crate::canvas::state::CanvasState;
use anyhow::Result;
const FOR_EDIT_MODE: bool = false; // Highlight mode uses read-only cursor behavior
/// Handle actions in highlight/visual mode
/// TODO: Implement selection logic and highlight-specific behaviors
pub async fn handle_highlight_action<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<ActionResult> {
match action {
// Movement actions work similar to read-only mode but with selection
CanvasAction::MoveLeft => {
let new_pos = move_left(state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
// TODO: Update selection range
Ok(ActionResult::success())
}
CanvasAction::MoveRight => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
// TODO: Update selection range
Ok(ActionResult::success())
}
CanvasAction::MoveWordNext => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
// TODO: Update selection range
}
Ok(ActionResult::success())
}
CanvasAction::MoveWordEnd => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_word_end(current_input, state.current_cursor_pos());
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
// TODO: Update selection range
}
Ok(ActionResult::success())
}
CanvasAction::MoveWordPrev => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
// TODO: Update selection range
}
Ok(ActionResult::success())
}
CanvasAction::MoveLineStart => {
let new_pos = line_start_position();
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
// TODO: Update selection range
Ok(ActionResult::success())
}
CanvasAction::MoveLineEnd => {
let current_input = state.get_current_input();
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
// TODO: Update selection range
Ok(ActionResult::success())
}
// Highlight mode doesn't handle editing actions
CanvasAction::InsertChar(_) |
CanvasAction::DeleteBackward |
CanvasAction::DeleteForward => {
Ok(ActionResult::success_with_message("Action not available in highlight mode"))
}
CanvasAction::Custom(action_str) => {
Ok(ActionResult::success_with_message(&format!("Custom highlight action: {}", action_str)))
}
_ => {
Ok(ActionResult::success_with_message("Action not implemented for highlight mode"))
}
}
}

View File

@@ -1,11 +0,0 @@
// src/canvas/actions/handlers/mod.rs
pub mod edit;
pub mod readonly;
pub mod highlight;
pub mod dispatcher;
pub use edit::handle_edit_action;
pub use readonly::handle_readonly_action;
pub use highlight::handle_highlight_action;
pub use dispatcher::dispatch_action;

View File

@@ -1,183 +0,0 @@
// src/canvas/actions/handlers/readonly.rs
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::canvas::actions::movement::*;
use crate::canvas::state::CanvasState;
use anyhow::Result;
const FOR_EDIT_MODE: bool = false; // Read-only mode flag
/// Handle actions in read-only mode with read-only specific cursor behavior
pub async fn handle_readonly_action<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<ActionResult> {
match action {
CanvasAction::MoveLeft => {
let new_pos = move_left(state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
}
CanvasAction::MoveRight => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
}
CanvasAction::MoveUp => {
let current_field = state.current_field();
let new_field = current_field.saturating_sub(1);
state.set_current_field(new_field);
// Apply ideal cursor column with read-only bounds
let current_input = state.get_current_input();
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
Ok(ActionResult::success())
}
CanvasAction::MoveDown => {
let current_field = state.current_field();
let total_fields = state.fields().len();
if total_fields == 0 {
return Ok(ActionResult::success_with_message("No fields to navigate"));
}
let new_field = (current_field + 1).min(total_fields - 1);
state.set_current_field(new_field);
// Apply ideal cursor column with read-only bounds
let current_input = state.get_current_input();
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
Ok(ActionResult::success())
}
CanvasAction::MoveFirstLine => {
let total_fields = state.fields().len();
if total_fields == 0 {
return Ok(ActionResult::success_with_message("No fields to navigate"));
}
state.set_current_field(0);
let current_input = state.get_current_input();
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
}
CanvasAction::MoveLastLine => {
let total_fields = state.fields().len();
if total_fields == 0 {
return Ok(ActionResult::success_with_message("No fields to navigate"));
}
let last_field = total_fields - 1;
state.set_current_field(last_field);
let current_input = state.get_current_input();
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
}
CanvasAction::MoveLineStart => {
let new_pos = line_start_position();
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
}
CanvasAction::MoveLineEnd => {
let current_input = state.get_current_input();
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
}
CanvasAction::MoveWordNext => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
}
Ok(ActionResult::success())
}
CanvasAction::MoveWordEnd => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let current_pos = state.current_cursor_pos();
let new_pos = find_word_end(current_input, current_pos);
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
}
Ok(ActionResult::success())
}
CanvasAction::MoveWordPrev => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok(ActionResult::success())
}
CanvasAction::MoveWordEndPrev => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_end(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok(ActionResult::success())
}
CanvasAction::NextField | CanvasAction::PrevField => {
let current_field = state.current_field();
let total_fields = state.fields().len();
let new_field = match action {
CanvasAction::NextField => {
(current_field + 1) % total_fields // Simple wrap
}
CanvasAction::PrevField => {
if current_field == 0 { total_fields - 1 } else { current_field - 1 } // Simple wrap
}
_ => unreachable!(),
};
state.set_current_field(new_field);
*ideal_cursor_column = state.current_cursor_pos();
Ok(ActionResult::success())
}
// Read-only mode doesn't handle editing actions
CanvasAction::InsertChar(_) |
CanvasAction::DeleteBackward |
CanvasAction::DeleteForward => {
Ok(ActionResult::success_with_message("Action not available in read-only mode"))
}
CanvasAction::Custom(action_str) => {
Ok(ActionResult::success_with_message(&format!("Custom readonly action: {}", action_str)))
}
_ => {
Ok(ActionResult::success_with_message("Action not implemented for read-only mode"))
}
}
}

View File

@@ -1,8 +1,7 @@
// src/canvas/actions/mod.rs
pub mod types;
pub mod handlers;
pub mod movement;
// Re-export the main API
pub use types::{CanvasAction, ActionResult, execute};
pub use types::{CanvasAction, ActionResult};

View File

@@ -1,8 +1,5 @@
// src/canvas/actions/types.rs
use crate::canvas::state::CanvasState;
use anyhow::Result;
/// All available canvas actions
#[derive(Debug, Clone, PartialEq)]
pub enum CanvasAction {
@@ -11,35 +8,35 @@ pub enum CanvasAction {
MoveRight,
MoveUp,
MoveDown,
// Word movement
MoveWordNext,
MoveWordPrev,
MoveWordEnd,
MoveWordEndPrev,
// Line movement
MoveLineStart,
MoveLineEnd,
// Field movement
NextField,
PrevField,
MoveFirstLine,
MoveLastLine,
// Editing actions
InsertChar(char),
DeleteBackward,
DeleteForward,
// Autocomplete actions
TriggerAutocomplete,
SuggestionUp,
SuggestionDown,
SelectSuggestion,
ExitSuggestions,
// Custom actions
Custom(String),
}
@@ -58,23 +55,23 @@ impl ActionResult {
pub fn success() -> Self {
Self::Success
}
pub fn success_with_message(msg: &str) -> Self {
Self::Message(msg.to_string())
}
pub fn handled_by_app(msg: &str) -> Self {
Self::HandledByApp(msg.to_string())
}
pub fn error(msg: &str) -> Self {
Self::Error(msg.to_string())
}
pub fn is_success(&self) -> bool {
matches!(self, Self::Success | Self::Message(_) | Self::HandledByApp(_) | Self::HandledByFeature(_))
}
pub fn message(&self) -> Option<&str> {
match self {
Self::Message(msg) | Self::HandledByApp(msg) | Self::HandledByFeature(msg) | Self::Error(msg) => Some(msg),
@@ -83,16 +80,6 @@ impl ActionResult {
}
}
/// Execute a canvas action on the given state
pub async fn execute<S: CanvasState>(
action: CanvasAction,
state: &mut S,
) -> Result<ActionResult> {
let mut ideal_cursor_column = 0;
super::handlers::dispatch_action(action, state, &mut ideal_cursor_column).await
}
impl CanvasAction {
/// Get a human-readable description of this action
pub fn description(&self) -> &'static str {
@@ -111,7 +98,7 @@ impl CanvasAction {
Self::PrevField => "previous field",
Self::MoveFirstLine => "first field",
Self::MoveLastLine => "last field",
Self::InsertChar(c) => "insert character",
Self::InsertChar(_c) => "insert character",
Self::DeleteBackward => "delete backward",
Self::DeleteForward => "delete forward",
Self::TriggerAutocomplete => "trigger autocomplete",
@@ -119,7 +106,7 @@ impl CanvasAction {
Self::SuggestionDown => "suggestion down",
Self::SelectSuggestion => "select suggestion",
Self::ExitSuggestions => "exit suggestions",
Self::Custom(name) => "custom action",
Self::Custom(_name) => "custom action",
}
}

View File

@@ -0,0 +1,45 @@
// src/canvas/cursor.rs
//! Cursor style management for different canvas modes
#[cfg(feature = "cursor-style")]
use crossterm::{cursor::SetCursorStyle, execute};
#[cfg(feature = "cursor-style")]
use std::io;
use crate::canvas::modes::AppMode;
/// Manages cursor styles based on canvas modes
pub struct CursorManager;
impl CursorManager {
/// Update cursor style based on current mode
#[cfg(feature = "cursor-style")]
pub fn update_for_mode(mode: AppMode) -> io::Result<()> {
let style = match mode {
AppMode::Edit => SetCursorStyle::SteadyBar, // Thin line for insert
AppMode::ReadOnly => SetCursorStyle::SteadyBlock, // Block for normal
AppMode::Highlight => SetCursorStyle::BlinkingBlock, // Blinking for visual
AppMode::General => SetCursorStyle::SteadyBlock, // Block for general
AppMode::Command => SetCursorStyle::SteadyUnderScore, // Underscore for command
};
execute!(io::stdout(), style)
}
/// No-op when cursor-style feature is disabled
#[cfg(not(feature = "cursor-style"))]
pub fn update_for_mode(_mode: AppMode) -> io::Result<()> {
Ok(())
}
/// Reset cursor to default on cleanup
#[cfg(feature = "cursor-style")]
pub fn reset() -> io::Result<()> {
execute!(io::stdout(), SetCursorStyle::DefaultUserShape)
}
#[cfg(not(feature = "cursor-style"))]
pub fn reset() -> io::Result<()> {
Ok(())
}
}

View File

@@ -1,4 +1,5 @@
// canvas/src/canvas/gui.rs
// src/canvas/gui.rs
//! Canvas GUI updated to work with FormEditor
#[cfg(feature = "gui")]
use ratatui::{
@@ -9,28 +10,76 @@ use ratatui::{
Frame,
};
use crate::canvas::state::CanvasState;
use crate::canvas::modes::HighlightState;
#[cfg(feature = "gui")]
use crate::canvas::theme::CanvasTheme;
use crate::canvas::theme::{CanvasTheme, DefaultCanvasTheme};
use crate::canvas::modes::HighlightState;
use crate::data_provider::DataProvider;
use crate::editor::FormEditor;
#[cfg(feature = "gui")]
use std::cmp::{max, min};
/// Render ONLY the canvas form fields - no autocomplete
/// Updated to work with FormEditor instead of CanvasState trait
#[cfg(feature = "gui")]
pub fn render_canvas<T: CanvasTheme>(
pub fn render_canvas<T: CanvasTheme, D: DataProvider>(
f: &mut Frame,
area: Rect,
form_state: &impl CanvasState,
editor: &FormEditor<D>,
theme: &T,
) -> Option<Rect> {
// Convert SelectionState to HighlightState
let highlight_state = convert_selection_to_highlight(editor.ui_state().selection_state());
render_canvas_with_highlight(f, area, editor, theme, &highlight_state)
}
/// Render canvas with explicit highlight state (for advanced use)
#[cfg(feature = "gui")]
pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
f: &mut Frame,
area: Rect,
editor: &FormEditor<D>,
theme: &T,
is_edit_mode: bool,
highlight_state: &HighlightState,
) -> Option<Rect> {
let fields: Vec<&str> = form_state.fields();
let current_field_idx = form_state.current_field();
let inputs: Vec<&String> = form_state.inputs();
let ui_state = editor.ui_state();
let data_provider = editor.data_provider();
// Build field information
let field_count = data_provider.field_count();
let mut fields: Vec<&str> = Vec::with_capacity(field_count);
let mut inputs: Vec<String> = Vec::with_capacity(field_count);
for i in 0..field_count {
fields.push(data_provider.field_name(i));
// Use display text that applies masks if configured
#[cfg(feature = "validation")]
{
if i == editor.current_field() {
inputs.push(editor.current_display_text());
} else {
// For non-current fields, we need to apply mask manually
let raw = data_provider.field_value(i);
if let Some(cfg) = editor.ui_state().validation_state().get_field_config(i) {
if let Some(mask) = &cfg.display_mask {
inputs.push(mask.apply_to_display(raw));
} else {
inputs.push(raw.to_string());
}
} else {
inputs.push(raw.to_string());
}
}
}
#[cfg(not(feature = "validation"))]
{
inputs.push(data_provider.field_value(i).to_string());
}
}
let current_field_idx = ui_state.current_field();
let is_edit_mode = matches!(ui_state.mode(), crate::canvas::modes::AppMode::Edit);
render_canvas_fields(
f,
@@ -41,13 +90,60 @@ pub fn render_canvas<T: CanvasTheme>(
theme,
is_edit_mode,
highlight_state,
form_state.current_cursor_pos(),
form_state.has_unsaved_changes(),
|i| form_state.get_display_value_for_field(i).to_string(),
|i| form_state.has_display_override(i),
editor.display_cursor_position(), // Use display cursor position for masks
false, // TODO: track unsaved changes in editor
|i| {
// Get display value for field i
#[cfg(feature = "validation")]
{
if i == editor.current_field() {
editor.current_display_text()
} else {
let raw = data_provider.field_value(i);
if let Some(cfg) = editor.ui_state().validation_state().get_field_config(i) {
if let Some(mask) = &cfg.display_mask {
mask.apply_to_display(raw)
} else {
raw.to_string()
}
} else {
raw.to_string()
}
}
}
#[cfg(not(feature = "validation"))]
{
data_provider.field_value(i).to_string()
}
},
|i| {
// Check if field has display override (mask)
#[cfg(feature = "validation")]
{
editor.ui_state().validation_state().get_field_config(i)
.and_then(|cfg| cfg.display_mask.as_ref())
.is_some()
}
#[cfg(not(feature = "validation"))]
{
false
}
},
)
}
/// Convert SelectionState to HighlightState for rendering
#[cfg(feature = "gui")]
fn convert_selection_to_highlight(selection: &crate::canvas::state::SelectionState) -> HighlightState {
use crate::canvas::state::SelectionState;
match selection {
SelectionState::None => HighlightState::Off,
SelectionState::Characterwise { anchor } => HighlightState::Characterwise { anchor: *anchor },
SelectionState::Linewise { anchor_field } => HighlightState::Linewise { anchor_line: *anchor_field },
}
}
/// Core canvas field rendering
#[cfg(feature = "gui")]
fn render_canvas_fields<T: CanvasTheme, F1, F2>(
@@ -55,7 +151,7 @@ fn render_canvas_fields<T: CanvasTheme, F1, F2>(
area: Rect,
fields: &[&str],
current_field_idx: &usize,
inputs: &[&String],
inputs: &[String],
theme: &T,
is_edit_mode: bool,
highlight_state: &HighlightState,
@@ -112,7 +208,7 @@ where
// Render field values and return active field rect
render_field_values(
f,
input_rows.to_vec(), // Fix: Convert Rc<[Rect]> to Vec<Rect>
input_rows.to_vec(),
inputs,
current_field_idx,
theme,
@@ -154,7 +250,7 @@ fn render_field_labels<T: CanvasTheme>(
fn render_field_values<T: CanvasTheme, F1, F2>(
f: &mut Frame,
input_rows: Vec<Rect>,
inputs: &[&String],
inputs: &[String],
current_field_idx: &usize,
theme: &T,
highlight_state: &HighlightState,
@@ -171,7 +267,7 @@ where
for (i, _input) in inputs.iter().enumerate() {
let is_active = i == *current_field_idx;
let text = get_display_value(i);
// Apply highlighting
let line = apply_highlighting(
&text,
@@ -205,7 +301,7 @@ fn apply_highlighting<'a, T: CanvasTheme>(
current_cursor_pos: usize,
highlight_state: &HighlightState,
theme: &T,
is_active: bool,
_is_active: bool,
) -> Line<'a> {
let text_len = text.chars().count();
@@ -213,23 +309,19 @@ fn apply_highlighting<'a, T: CanvasTheme>(
HighlightState::Off => {
Line::from(Span::styled(
text,
if is_active {
Style::default().fg(theme.highlight())
} else {
Style::default().fg(theme.fg())
},
Style::default().fg(theme.fg())
))
}
HighlightState::Characterwise { anchor } => {
apply_characterwise_highlighting(text, text_len, field_index, current_field_idx, current_cursor_pos, anchor, theme, is_active)
apply_characterwise_highlighting(text, text_len, field_index, current_field_idx, current_cursor_pos, anchor, theme, _is_active)
}
HighlightState::Linewise { anchor_line } => {
apply_linewise_highlighting(text, field_index, current_field_idx, anchor_line, theme, is_active)
apply_linewise_highlighting(text, field_index, current_field_idx, anchor_line, theme, _is_active)
}
}
}
/// Apply characterwise highlighting
/// Apply characterwise highlighting - PROPER VIM-LIKE VERSION
#[cfg(feature = "gui")]
fn apply_characterwise_highlighting<'a, T: CanvasTheme>(
text: &'a str,
@@ -239,21 +331,25 @@ fn apply_characterwise_highlighting<'a, T: CanvasTheme>(
current_cursor_pos: usize,
anchor: &(usize, usize),
theme: &T,
is_active: bool,
_is_active: bool,
) -> Line<'a> {
let (anchor_field, anchor_char) = *anchor;
let start_field = min(anchor_field, *current_field_idx);
let end_field = max(anchor_field, *current_field_idx);
// Vim-like styling:
// - Selected text: contrasting color + background (like vim visual selection)
// - All other text: normal color (no special colors for active fields, etc.)
let highlight_style = Style::default()
.fg(theme.highlight())
.bg(theme.highlight_bg())
.fg(theme.highlight()) // ✅ Contrasting text color for selected text
.bg(theme.highlight_bg()) // ✅ Background for selected text
.add_modifier(Modifier::BOLD);
let normal_style_in_highlight = Style::default().fg(theme.highlight());
let normal_style_outside = Style::default().fg(theme.fg());
let normal_style = Style::default().fg(theme.fg()); // ✅ Normal text color everywhere else
if field_index >= start_field && field_index <= end_field {
if start_field == end_field {
// Single field selection
let (start_char, end_char) = if anchor_field == *current_field_idx {
(min(anchor_char, current_cursor_pos), max(anchor_char, current_cursor_pos))
} else if anchor_field < *current_field_idx {
@@ -273,23 +369,64 @@ fn apply_characterwise_highlighting<'a, T: CanvasTheme>(
let after: String = text.chars().skip(clamped_end + 1).collect();
Line::from(vec![
Span::styled(before, normal_style_in_highlight),
Span::styled(highlighted, highlight_style),
Span::styled(after, normal_style_in_highlight),
Span::styled(before, normal_style), // Normal text color
Span::styled(highlighted, highlight_style), // Contrasting color + background
Span::styled(after, normal_style), // Normal text color
])
} else {
// Multi-field selection
Line::from(Span::styled(text, highlight_style))
if field_index == anchor_field {
if anchor_field < *current_field_idx {
let clamped_start = anchor_char.min(text_len);
let before: String = text.chars().take(clamped_start).collect();
let highlighted: String = text.chars().skip(clamped_start).collect();
Line::from(vec![
Span::styled(before, normal_style),
Span::styled(highlighted, highlight_style),
])
} else {
let clamped_end = anchor_char.min(text_len);
let highlighted: String = text.chars().take(clamped_end + 1).collect();
let after: String = text.chars().skip(clamped_end + 1).collect();
Line::from(vec![
Span::styled(highlighted, highlight_style),
Span::styled(after, normal_style),
])
}
} else if field_index == *current_field_idx {
if anchor_field < *current_field_idx {
let clamped_end = current_cursor_pos.min(text_len);
let highlighted: String = text.chars().take(clamped_end + 1).collect();
let after: String = text.chars().skip(clamped_end + 1).collect();
Line::from(vec![
Span::styled(highlighted, highlight_style),
Span::styled(after, normal_style),
])
} else {
let clamped_start = current_cursor_pos.min(text_len);
let before: String = text.chars().take(clamped_start).collect();
let highlighted: String = text.chars().skip(clamped_start).collect();
Line::from(vec![
Span::styled(before, normal_style),
Span::styled(highlighted, highlight_style),
])
}
} else {
// Middle field: highlight entire field
Line::from(Span::styled(text, highlight_style))
}
}
} else {
Line::from(Span::styled(
text,
if is_active { normal_style_in_highlight } else { normal_style_outside }
))
// Outside selection: always normal text color (no special active field color)
Line::from(Span::styled(text, normal_style))
}
}
/// Apply linewise highlighting
/// Apply linewise highlighting - PROPER VIM-LIKE VERSION
#[cfg(feature = "gui")]
fn apply_linewise_highlighting<'a, T: CanvasTheme>(
text: &'a str,
@@ -297,25 +434,27 @@ fn apply_linewise_highlighting<'a, T: CanvasTheme>(
current_field_idx: &usize,
anchor_line: &usize,
theme: &T,
is_active: bool,
_is_active: bool,
) -> Line<'a> {
let start_field = min(*anchor_line, *current_field_idx);
let end_field = max(*anchor_line, *current_field_idx);
// Vim-like styling:
// - Selected lines: contrasting text color + background
// - All other lines: normal text color (no special active field color)
let highlight_style = Style::default()
.fg(theme.highlight())
.bg(theme.highlight_bg())
.fg(theme.highlight()) // ✅ Contrasting text color for selected text
.bg(theme.highlight_bg()) // ✅ Background for selected text
.add_modifier(Modifier::BOLD);
let normal_style_in_highlight = Style::default().fg(theme.highlight());
let normal_style_outside = Style::default().fg(theme.fg());
let normal_style = Style::default().fg(theme.fg()); // ✅ Normal text color everywhere else
if field_index >= start_field && field_index <= end_field {
// Selected line: contrasting text color + background
Line::from(Span::styled(text, highlight_style))
} else {
Line::from(Span::styled(
text,
if is_active { normal_style_in_highlight } else { normal_style_outside }
))
// Normal line: normal text color (no special active field color)
Line::from(Span::styled(text, normal_style))
}
}
@@ -328,11 +467,24 @@ fn set_cursor_position(
current_cursor_pos: usize,
has_display_override: bool,
) {
let cursor_x = if has_display_override {
field_rect.x + text.chars().count() as u16
} else {
field_rect.x + current_cursor_pos as u16
};
// BUG FIX: Use the correct display cursor position, not end of text
let cursor_x = field_rect.x + current_cursor_pos as u16;
let cursor_y = field_rect.y;
f.set_cursor_position((cursor_x, cursor_y));
// SAFETY: Ensure cursor doesn't go beyond field bounds
let max_cursor_x = field_rect.x + field_rect.width.saturating_sub(1);
let safe_cursor_x = cursor_x.min(max_cursor_x);
f.set_cursor_position((safe_cursor_x, cursor_y));
}
/// Set default theme if custom not specified
#[cfg(feature = "gui")]
pub fn render_canvas_default<D: DataProvider>(
f: &mut Frame,
area: Rect,
editor: &FormEditor<D>,
) -> Option<Rect> {
let theme = DefaultCanvasTheme::default();
render_canvas(f, area, editor, &theme)
}

View File

@@ -1,18 +1,19 @@
// src/canvas/mod.rs
pub mod actions;
pub mod gui;
pub mod modes;
pub mod state;
pub mod modes;
#[cfg(feature = "gui")]
pub mod gui;
#[cfg(feature = "gui")]
pub mod theme;
// Re-export main types for convenience
pub use actions::{CanvasAction, ActionResult};
#[cfg(feature = "cursor-style")]
pub mod cursor;
// Keep these exports for current functionality
pub use modes::{AppMode, ModeManager, HighlightState};
pub use state::{CanvasState, ActionContext};
#[cfg(feature = "gui")]
pub use theme::CanvasTheme;
#[cfg(feature = "gui")]
pub use gui::render_canvas;
#[cfg(feature = "cursor-style")]
pub use cursor::CursorManager;

View File

@@ -1,6 +1,8 @@
// src/modes/handlers/mode_manager.rs
// canvas/src/modes/manager.rs
#[cfg(feature = "cursor-style")]
use crate::canvas::CursorManager;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppMode {
@@ -30,4 +32,39 @@ impl ModeManager {
pub fn can_enter_highlight_mode(current_mode: AppMode) -> bool {
matches!(current_mode, AppMode::ReadOnly)
}
/// Transition to new mode with automatic cursor update (when cursor-style feature enabled)
pub fn transition_to_mode(current_mode: AppMode, new_mode: AppMode) -> std::io::Result<AppMode> {
if current_mode != new_mode {
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(new_mode);
}
}
Ok(new_mode)
}
/// Enter highlight mode with cursor styling
pub fn enter_highlight_mode_with_cursor(current_mode: AppMode) -> std::io::Result<bool> {
if Self::can_enter_highlight_mode(current_mode) {
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(AppMode::Highlight);
}
Ok(true)
} else {
Ok(false)
}
}
/// Exit highlight mode with cursor styling
pub fn exit_highlight_mode_with_cursor() -> std::io::Result<AppMode> {
let new_mode = AppMode::ReadOnly;
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(new_mode);
}
Ok(new_mode)
}
}

View File

@@ -1,117 +1,150 @@
// src/canvas/state.rs
//! Canvas state trait and related types
//!
//! This module defines the core trait that any form or input system must implement
//! to work with the canvas library.
//! Library-owned UI state - user never directly modifies this
use crate::canvas::actions::CanvasAction;
use crate::canvas::modes::AppMode;
/// Context information passed to feature-specific action handlers
#[derive(Debug)]
pub struct ActionContext {
/// Original key code that triggered this action (for backwards compatibility)
pub key_code: Option<crossterm::event::KeyCode>,
/// Current ideal cursor column for vertical movement
pub ideal_cursor_column: usize,
/// Current input text
pub current_input: String,
/// Current field index
pub current_field: usize,
/// Library-owned UI state - user never directly modifies this
#[derive(Debug, Clone)]
pub struct EditorState {
// Navigation state
pub(crate) current_field: usize,
pub(crate) cursor_pos: usize,
pub(crate) ideal_cursor_column: usize,
// Mode state
pub(crate) current_mode: AppMode,
// Autocomplete state
pub(crate) autocomplete: AutocompleteUIState,
// Selection state (for vim visual mode)
pub(crate) selection: SelectionState,
// Validation state (only available with validation feature)
#[cfg(feature = "validation")]
pub(crate) validation: crate::validation::ValidationState,
}
/// Core trait that any form-like state must implement to work with canvas
///
/// This trait enables the same mode behaviors (edit, read-only, highlight) to work
/// across any implementation - login forms, data entry forms, configuration screens, etc.
///
/// # Required Implementation
///
/// Your struct needs to track:
/// - Current field index and cursor position
/// - All input field values
/// - Current interaction mode
/// - Whether there are unsaved changes
///
/// # Example Implementation
///
/// ```rust
/// struct MyForm {
/// fields: Vec<String>,
/// current_field: usize,
/// cursor_pos: usize,
/// mode: AppMode,
/// dirty: bool,
/// }
///
/// impl CanvasState for MyForm {
/// fn current_field(&self) -> usize { self.current_field }
/// fn current_cursor_pos(&self) -> usize { self.cursor_pos }
/// // ... implement other required methods
/// }
/// ```
pub trait CanvasState {
// --- Core Navigation ---
/// Get current field index (0-based)
fn current_field(&self) -> usize;
/// Get current cursor position within the current field
fn current_cursor_pos(&self) -> usize;
/// Set current field index (should clamp to valid range)
fn set_current_field(&mut self, index: usize);
/// Set cursor position within current field (should clamp to valid range)
fn set_current_cursor_pos(&mut self, pos: usize);
#[derive(Debug, Clone)]
pub struct AutocompleteUIState {
pub(crate) is_active: bool,
pub(crate) is_loading: bool,
pub(crate) selected_index: Option<usize>,
pub(crate) active_field: Option<usize>,
}
// --- Mode Information ---
/// Get current interaction mode (edit, read-only, highlight, etc.)
fn current_mode(&self) -> AppMode;
#[derive(Debug, Clone)]
pub enum SelectionState {
None,
Characterwise { anchor: (usize, usize) },
Linewise { anchor_field: usize },
}
// --- Data Access ---
/// Get immutable reference to current field's text
fn get_current_input(&self) -> &str;
/// Get mutable reference to current field's text
fn get_current_input_mut(&mut self) -> &mut String;
/// Get all input values as immutable references
fn inputs(&self) -> Vec<&String>;
/// Get all field names/labels
fn fields(&self) -> Vec<&str>;
// --- State Management ---
/// Check if there are unsaved changes
fn has_unsaved_changes(&self) -> bool;
/// Mark whether there are unsaved changes
fn set_has_unsaved_changes(&mut self, changed: bool);
// --- Optional Overrides ---
/// Handle application-specific actions not covered by standard handlers
/// Return Some(message) if the action was handled, None to use standard handling
fn handle_feature_action(&mut self, _action: &CanvasAction, _context: &ActionContext) -> Option<String> {
None // Default: no custom handling
impl EditorState {
pub fn new() -> Self {
Self {
current_field: 0,
cursor_pos: 0,
ideal_cursor_column: 0,
current_mode: AppMode::Edit,
autocomplete: AutocompleteUIState {
is_active: false,
is_loading: false,
selected_index: None,
active_field: None,
},
selection: SelectionState::None,
#[cfg(feature = "validation")]
validation: crate::validation::ValidationState::new(),
}
}
/// Get display value for a field (may differ from actual value)
/// Used for things like password masking or computed display values
fn get_display_value_for_field(&self, index: usize) -> &str {
self.inputs()
.get(index)
.map(|s| s.as_str())
.unwrap_or("")
// ===================================================================
// 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 a field has a custom display value
/// Return true if get_display_value_for_field returns something different than the actual value
fn has_display_override(&self, _index: usize) -> bool {
false
/// 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 autocomplete is active (for user's business logic)
pub fn is_autocomplete_active(&self) -> bool {
self.autocomplete.is_active
}
/// Check if autocomplete is loading (for user's business logic)
pub fn is_autocomplete_loading(&self) -> bool {
self.autocomplete.is_loading
}
/// Get selection state (for user's business logic)
pub fn selection_state(&self) -> &SelectionState {
&self.selection
}
/// Get validation state (for user's business logic)
/// Only available when the 'validation' feature is enabled
#[cfg(feature = "validation")]
pub fn validation_state(&self) -> &crate::validation::ValidationState {
&self.validation
}
// ===================================================================
// INTERNAL MUTATIONS: Only library modifies these
// ===================================================================
pub(crate) fn move_to_field(&mut self, field_index: usize, field_count: usize) {
if field_index < field_count {
self.current_field = field_index;
// Reset cursor to safe position - will be clamped by movement logic
self.cursor_pos = 0;
}
}
pub(crate) fn set_cursor(&mut self, position: usize, max_position: usize, for_edit_mode: bool) {
if for_edit_mode {
// Edit mode: can go past end for insertion
self.cursor_pos = position.min(max_position);
} else {
// ReadOnly/Highlight: stay within text bounds
self.cursor_pos = position.min(max_position.saturating_sub(1));
}
self.ideal_cursor_column = self.cursor_pos;
}
pub(crate) fn activate_autocomplete(&mut self, field_index: usize) {
self.autocomplete.is_active = true;
self.autocomplete.is_loading = true;
self.autocomplete.active_field = Some(field_index);
self.autocomplete.selected_index = None;
}
pub(crate) fn deactivate_autocomplete(&mut self) {
self.autocomplete.is_active = false;
self.autocomplete.is_loading = false;
self.autocomplete.active_field = None;
self.autocomplete.selected_index = None;
}
}
impl Default for EditorState {
fn default() -> Self {
Self::new()
}
}

View File

@@ -15,3 +15,36 @@ pub trait CanvasTheme {
fn highlight_bg(&self) -> Color;
fn warning(&self) -> Color;
}
#[cfg(feature = "gui")]
#[derive(Debug, Clone, Default)]
pub struct DefaultCanvasTheme;
#[cfg(feature = "gui")]
impl CanvasTheme for DefaultCanvasTheme {
fn bg(&self) -> Color {
Color::Black
}
fn fg(&self) -> Color {
Color::White
}
fn border(&self) -> Color {
Color::DarkGray
}
fn accent(&self) -> Color {
Color::Cyan
}
fn secondary(&self) -> Color {
Color::Gray
}
fn highlight(&self) -> Color {
Color::Yellow
}
fn highlight_bg(&self) -> Color {
Color::Blue
}
fn warning(&self) -> Color {
Color::Red
}
}

View File

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

1104
canvas/src/editor.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,53 @@
// src/lib.rs
pub mod canvas;
pub mod editor;
pub mod data_provider;
// Only include autocomplete module if feature is enabled
#[cfg(feature = "autocomplete")]
pub mod autocomplete;
// Re-export the main API for easy access
pub use canvas::actions::{CanvasAction, ActionResult, execute};
pub use canvas::state::{CanvasState, ActionContext};
pub use canvas::modes::{AppMode, ModeManager, HighlightState};
// Only include validation module if feature is enabled
#[cfg(feature = "validation")]
pub mod validation;
#[cfg(feature = "cursor-style")]
pub use canvas::CursorManager;
// ===================================================================
// NEW API: Library-owned state pattern
// ===================================================================
// Main API exports
pub use editor::FormEditor;
pub use data_provider::{DataProvider, AutocompleteProvider, SuggestionItem};
// UI state (read-only access for users)
pub use canvas::state::EditorState;
pub use canvas::modes::AppMode;
// Actions and results (for users who want to handle actions manually)
pub use canvas::actions::{CanvasAction, ActionResult};
// Validation exports (only when validation feature is enabled)
#[cfg(feature = "validation")]
pub use validation::{
ValidationConfig, ValidationResult, ValidationError,
CharacterLimits, ValidationConfigBuilder, ValidationState,
ValidationSummary, PatternFilters, PositionFilter, PositionRange, CharacterFilter,
DisplayMask, // Simple display mask instead of complex ReservedCharacters
};
// Theming and GUI
#[cfg(feature = "gui")]
pub use canvas::theme::CanvasTheme;
pub use canvas::theme::{CanvasTheme, DefaultCanvasTheme};
#[cfg(feature = "gui")]
pub use canvas::gui::render_canvas;
// Re-export autocomplete API if feature is enabled
#[cfg(feature = "autocomplete")]
pub use autocomplete::{
AutocompleteCanvasState,
AutocompleteState,
SuggestionItem,
execute_with_autocomplete,
handle_autocomplete_feature_action,
};
#[cfg(feature = "gui")]
pub use canvas::gui::render_canvas_default;
#[cfg(all(feature = "gui", feature = "autocomplete"))]
pub use autocomplete::render_autocomplete_dropdown;
pub use autocomplete::gui::render_autocomplete_dropdown;

View File

@@ -0,0 +1,361 @@
// src/validation/config.rs
//! Validation configuration types and builders
use crate::validation::{CharacterLimits, PatternFilters, DisplayMask};
/// Main validation configuration for a field
#[derive(Debug, 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>,
/// Future: Custom formatting
pub custom_formatting: Option<()>, // Placeholder for future implementation
/// Future: External validation
pub external_validation: Option<()>, // Placeholder for future implementation
}
/// 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 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
}
/// 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() }
}
}
impl ValidationConfig {
/// 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 {
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 {
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 {
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 {
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()
}
pub fn allows_field_switch(&self, text: &str) -> bool {
// Character limits validation
if let Some(ref limits) = self.character_limits {
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 {
if let Some(reason) = limits.field_switch_block_reason(text) {
return Some(reason);
}
}
// Future: Add other validation types here
None
}
}
#[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

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

View File

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

View File

@@ -0,0 +1,28 @@
// src/validation/mod.rs
// Core validation modules
pub mod config;
pub mod limits;
pub mod state;
pub mod patterns;
pub mod mask; // Simple display mask instead of complex reserved chars
// 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
/// 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

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

View File

@@ -0,0 +1,399 @@
// src/validation/state.rs
//! Validation state management
use crate::validation::{ValidationConfig, ValidationResult};
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,
}
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,
}
}
/// 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();
}
}
/// 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() {
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);
}
}
/// 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);
}
/// 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)
}
/// Check if a field has been validated
pub fn is_field_validated(&self, field_index: usize) -> bool {
self.validated_fields.contains(&field_index)
}
/// Clear validation result for a field
pub fn clear_field_result(&mut self, field_index: usize) {
self.field_results.remove(&field_index);
self.validated_fields.remove(&field_index);
}
/// Clear all validation results
pub fn clear_all_results(&mut self) {
self.field_results.clear();
self.validated_fields.clear();
}
/// Get all field indices that have validation configured
pub fn validated_field_indices(&self) -> impl Iterator<Item = usize> + '_ {
self.field_configs.keys().copied()
}
/// Get all field indices with validation errors
pub fn fields_with_errors(&self) -> impl Iterator<Item = usize> + '_ {
self.field_results
.iter()
.filter(|(_, result)| result.is_error())
.map(|(index, _)| *index)
}
/// Get all field indices with validation warnings
pub fn fields_with_warnings(&self) -> impl Iterator<Item = usize> + '_ {
self.field_results
.iter()
.filter(|(_, result)| matches!(result, ValidationResult::Warning { .. }))
.map(|(index, _)| *index)
}
/// Check if any field has validation errors
pub fn has_errors(&self) -> bool {
self.field_results.values().any(|result| result.is_error())
}
/// Check if any field has validation warnings
pub fn has_warnings(&self) -> bool {
self.field_results.values().any(|result| matches!(result, ValidationResult::Warning { .. }))
}
/// Get total count of fields with validation configured
pub fn validated_field_count(&self) -> usize {
self.field_configs.len()
}
/// Check if field switching is allowed for a specific field
pub fn allows_field_switch(&self, field_index: usize, text: &str) -> bool {
if !self.enabled {
return true;
}
if let Some(config) = self.field_configs.get(&field_index) {
config.allows_field_switch(text)
} else {
true // No validation configured, allow switching
}
}
/// Get reason why field switching is blocked (if any)
pub fn field_switch_block_reason(&self, field_index: usize, text: &str) -> Option<String> {
if !self.enabled {
return None;
}
if let Some(config) = self.field_configs.get(&field_index) {
config.field_switch_block_reason(text)
} else {
None // No validation configured
}
}
pub fn summary(&self) -> ValidationSummary {
let total_validated = self.validated_fields.len();
let errors = self.fields_with_errors().count();
let warnings = self.fields_with_warnings().count();
let valid = total_validated - errors - warnings;
ValidationSummary {
total_fields: self.field_configs.len(),
validated_fields: total_validated,
valid_fields: valid,
warning_fields: warnings,
error_fields: errors,
}
}
}
/// Summary of validation state across all fields
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationSummary {
/// Total number of fields with validation configured
pub total_fields: usize,
/// Number of fields that have been validated
pub validated_fields: usize,
/// Number of fields with valid validation results
pub valid_fields: usize,
/// Number of fields with warnings
pub warning_fields: usize,
/// Number of fields with errors
pub error_fields: usize,
}
impl ValidationSummary {
/// Check if all configured fields are valid
pub fn is_all_valid(&self) -> bool {
self.error_fields == 0 && self.validated_fields == self.total_fields
}
/// Check if there are any errors
pub fn has_errors(&self) -> bool {
self.error_fields > 0
}
/// Check if there are any warnings
pub fn has_warnings(&self) -> bool {
self.warning_fields > 0
}
/// Get completion percentage (validated fields / total fields)
pub fn completion_percentage(&self) -> f32 {
if self.total_fields == 0 {
1.0
} else {
self.validated_fields as f32 / self.total_fields as f32
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::validation::{CharacterLimits, ValidationConfigBuilder};
#[test]
fn test_validation_state_creation() {
let state = ValidationState::new();
assert!(state.is_enabled());
assert_eq!(state.validated_field_count(), 0);
}
#[test]
fn test_enable_disable() {
let mut state = ValidationState::new();
// Add some validation config
let config = ValidationConfigBuilder::new()
.with_max_length(10)
.build();
state.set_field_config(0, config);
// Validate something
let result = state.validate_field_content(0, "test");
assert!(result.is_acceptable());
assert!(state.is_field_validated(0));
// Disable validation
state.set_enabled(false);
assert!(!state.is_enabled());
assert!(!state.is_field_validated(0)); // Should be cleared
// Validation should now return valid regardless
let result = state.validate_field_content(0, "this is way too long for the limit");
assert!(result.is_acceptable());
}
#[test]
fn test_field_config_management() {
let mut state = ValidationState::new();
let config = ValidationConfigBuilder::new()
.with_max_length(5)
.build();
// Set config
state.set_field_config(0, config);
assert_eq!(state.validated_field_count(), 1);
assert!(state.get_field_config(0).is_some());
// Remove config
state.remove_field_config(0);
assert_eq!(state.validated_field_count(), 0);
assert!(state.get_field_config(0).is_none());
}
#[test]
fn test_character_insertion_validation() {
let mut state = ValidationState::new();
let config = ValidationConfigBuilder::new()
.with_max_length(5)
.build();
state.set_field_config(0, config);
// Valid insertion
let result = state.validate_char_insertion(0, "test", 4, 'x');
assert!(result.is_acceptable());
// Invalid insertion
let result = state.validate_char_insertion(0, "tests", 5, 'x');
assert!(!result.is_acceptable());
// Check that result was stored
assert!(state.is_field_validated(0));
let stored_result = state.get_field_result(0);
assert!(stored_result.is_some());
assert!(!stored_result.unwrap().is_acceptable());
}
#[test]
fn test_validation_summary() {
let mut state = ValidationState::new();
// Configure two fields
let config1 = ValidationConfigBuilder::new().with_max_length(5).build();
let config2 = ValidationConfigBuilder::new().with_max_length(10).build();
state.set_field_config(0, config1);
state.set_field_config(1, config2);
// Validate field 0 (valid)
state.validate_field_content(0, "test");
// Validate field 1 (error)
state.validate_field_content(1, "this is too long");
let summary = state.summary();
assert_eq!(summary.total_fields, 2);
assert_eq!(summary.validated_fields, 2);
assert_eq!(summary.valid_fields, 1);
assert_eq!(summary.error_fields, 1);
assert_eq!(summary.warning_fields, 0);
assert!(!summary.is_all_valid());
assert!(summary.has_errors());
assert!(!summary.has_warnings());
assert_eq!(summary.completion_percentage(), 1.0);
}
#[test]
fn test_error_and_warning_tracking() {
let mut state = ValidationState::new();
let config = ValidationConfigBuilder::new()
.with_character_limits(
CharacterLimits::new_range(3, 10).with_warning_threshold(8)
)
.build();
state.set_field_config(0, config);
// Too short (warning)
state.validate_field_content(0, "hi");
assert!(state.has_warnings());
assert!(!state.has_errors());
// Just right
state.validate_field_content(0, "hello");
assert!(!state.has_warnings());
assert!(!state.has_errors());
// Too long (error)
state.validate_field_content(0, "hello world!");
assert!(!state.has_warnings());
assert!(state.has_errors());
}
}

View File

@@ -15,6 +15,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
"proto/tables_data.proto",
"proto/table_script.proto",
"proto/search.proto",
"proto/search2.proto",
],
&["proto"],
)?;

View File

@@ -0,0 +1,46 @@
// In common/proto/search2.proto
syntax = "proto3";
package komp_ac.search2;
service Search2 {
rpc SearchTable(Search2Request) returns (Search2Response);
}
message Search2Request {
string profile_name = 1;
string table_name = 2;
repeated ColumnFilter column_filters = 3;
optional string text_query = 4; // Optional fallback text search
optional int32 limit = 5;
optional string order_by = 6;
optional bool order_desc = 7;
}
message ColumnFilter {
string column_name = 1;
FilterType filter_type = 2;
string value = 3;
optional string value2 = 4; // For range queries
}
enum FilterType {
EQUALS = 0;
CONTAINS = 1;
STARTS_WITH = 2;
ENDS_WITH = 3;
RANGE = 4;
GREATER_THAN = 5;
LESS_THAN = 6;
IS_NULL = 7;
IS_NOT_NULL = 8;
}
message Search2Response {
message Hit {
int64 id = 1;
string content_json = 2; // No score - this is SQL-based
optional string match_info = 3; // Info about which columns matched
}
repeated Hit hits = 1;
int32 total_count = 2; // Total matching records (for pagination)
}

View File

@@ -31,6 +31,9 @@ pub mod proto {
pub mod search {
include!("proto/komp_ac.search.rs");
}
pub mod search2 {
include!("proto/komp_ac.search2.rs");
}
pub const FILE_DESCRIPTOR_SET: &[u8] =
include_bytes!("proto/descriptor.bin");
}

Binary file not shown.

View File

@@ -0,0 +1,394 @@
// This file is @generated by prost-build.
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct Search2Request {
#[prost(string, tag = "1")]
pub profile_name: ::prost::alloc::string::String,
#[prost(string, tag = "2")]
pub table_name: ::prost::alloc::string::String,
#[prost(message, repeated, tag = "3")]
pub column_filters: ::prost::alloc::vec::Vec<ColumnFilter>,
/// Optional fallback text search
#[prost(string, optional, tag = "4")]
pub text_query: ::core::option::Option<::prost::alloc::string::String>,
#[prost(int32, optional, tag = "5")]
pub limit: ::core::option::Option<i32>,
#[prost(string, optional, tag = "6")]
pub order_by: ::core::option::Option<::prost::alloc::string::String>,
#[prost(bool, optional, tag = "7")]
pub order_desc: ::core::option::Option<bool>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ColumnFilter {
#[prost(string, tag = "1")]
pub column_name: ::prost::alloc::string::String,
#[prost(enumeration = "FilterType", tag = "2")]
pub filter_type: i32,
#[prost(string, tag = "3")]
pub value: ::prost::alloc::string::String,
/// For range queries
#[prost(string, optional, tag = "4")]
pub value2: ::core::option::Option<::prost::alloc::string::String>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct Search2Response {
#[prost(message, repeated, tag = "1")]
pub hits: ::prost::alloc::vec::Vec<search2_response::Hit>,
/// Total matching records (for pagination)
#[prost(int32, tag = "2")]
pub total_count: i32,
}
/// Nested message and enum types in `Search2Response`.
pub mod search2_response {
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct Hit {
#[prost(int64, tag = "1")]
pub id: i64,
/// No score - this is SQL-based
#[prost(string, tag = "2")]
pub content_json: ::prost::alloc::string::String,
/// Info about which columns matched
#[prost(string, optional, tag = "3")]
pub match_info: ::core::option::Option<::prost::alloc::string::String>,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
#[repr(i32)]
pub enum FilterType {
Equals = 0,
Contains = 1,
StartsWith = 2,
EndsWith = 3,
Range = 4,
GreaterThan = 5,
LessThan = 6,
IsNull = 7,
IsNotNull = 8,
}
impl FilterType {
/// String value of the enum field names used in the ProtoBuf definition.
///
/// The values are not transformed in any way and thus are considered stable
/// (if the ProtoBuf definition does not change) and safe for programmatic use.
pub fn as_str_name(&self) -> &'static str {
match self {
Self::Equals => "EQUALS",
Self::Contains => "CONTAINS",
Self::StartsWith => "STARTS_WITH",
Self::EndsWith => "ENDS_WITH",
Self::Range => "RANGE",
Self::GreaterThan => "GREATER_THAN",
Self::LessThan => "LESS_THAN",
Self::IsNull => "IS_NULL",
Self::IsNotNull => "IS_NOT_NULL",
}
}
/// Creates an enum from field names used in the ProtoBuf definition.
pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {
match value {
"EQUALS" => Some(Self::Equals),
"CONTAINS" => Some(Self::Contains),
"STARTS_WITH" => Some(Self::StartsWith),
"ENDS_WITH" => Some(Self::EndsWith),
"RANGE" => Some(Self::Range),
"GREATER_THAN" => Some(Self::GreaterThan),
"LESS_THAN" => Some(Self::LessThan),
"IS_NULL" => Some(Self::IsNull),
"IS_NOT_NULL" => Some(Self::IsNotNull),
_ => None,
}
}
}
/// Generated client implementations.
pub mod search2_client {
#![allow(
unused_variables,
dead_code,
missing_docs,
clippy::wildcard_imports,
clippy::let_unit_value,
)]
use tonic::codegen::*;
use tonic::codegen::http::Uri;
#[derive(Debug, Clone)]
pub struct Search2Client<T> {
inner: tonic::client::Grpc<T>,
}
impl Search2Client<tonic::transport::Channel> {
/// Attempt to create a new client by connecting to a given endpoint.
pub async fn connect<D>(dst: D) -> Result<Self, tonic::transport::Error>
where
D: TryInto<tonic::transport::Endpoint>,
D::Error: Into<StdError>,
{
let conn = tonic::transport::Endpoint::new(dst)?.connect().await?;
Ok(Self::new(conn))
}
}
impl<T> Search2Client<T>
where
T: tonic::client::GrpcService<tonic::body::Body>,
T::Error: Into<StdError>,
T::ResponseBody: Body<Data = Bytes> + std::marker::Send + 'static,
<T::ResponseBody as Body>::Error: Into<StdError> + std::marker::Send,
{
pub fn new(inner: T) -> Self {
let inner = tonic::client::Grpc::new(inner);
Self { inner }
}
pub fn with_origin(inner: T, origin: Uri) -> Self {
let inner = tonic::client::Grpc::with_origin(inner, origin);
Self { inner }
}
pub fn with_interceptor<F>(
inner: T,
interceptor: F,
) -> Search2Client<InterceptedService<T, F>>
where
F: tonic::service::Interceptor,
T::ResponseBody: Default,
T: tonic::codegen::Service<
http::Request<tonic::body::Body>,
Response = http::Response<
<T as tonic::client::GrpcService<tonic::body::Body>>::ResponseBody,
>,
>,
<T as tonic::codegen::Service<
http::Request<tonic::body::Body>,
>>::Error: Into<StdError> + std::marker::Send + std::marker::Sync,
{
Search2Client::new(InterceptedService::new(inner, interceptor))
}
/// Compress requests with the given encoding.
///
/// This requires the server to support it otherwise it might respond with an
/// error.
#[must_use]
pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.inner = self.inner.send_compressed(encoding);
self
}
/// Enable decompressing responses.
#[must_use]
pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.inner = self.inner.accept_compressed(encoding);
self
}
/// Limits the maximum size of a decoded message.
///
/// Default: `4MB`
#[must_use]
pub fn max_decoding_message_size(mut self, limit: usize) -> Self {
self.inner = self.inner.max_decoding_message_size(limit);
self
}
/// Limits the maximum size of an encoded message.
///
/// Default: `usize::MAX`
#[must_use]
pub fn max_encoding_message_size(mut self, limit: usize) -> Self {
self.inner = self.inner.max_encoding_message_size(limit);
self
}
pub async fn search_table(
&mut self,
request: impl tonic::IntoRequest<super::Search2Request>,
) -> std::result::Result<
tonic::Response<super::Search2Response>,
tonic::Status,
> {
self.inner
.ready()
.await
.map_err(|e| {
tonic::Status::unknown(
format!("Service was not ready: {}", e.into()),
)
})?;
let codec = tonic::codec::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static(
"/komp_ac.search2.Search2/SearchTable",
);
let mut req = request.into_request();
req.extensions_mut()
.insert(GrpcMethod::new("komp_ac.search2.Search2", "SearchTable"));
self.inner.unary(req, path, codec).await
}
}
}
/// Generated server implementations.
pub mod search2_server {
#![allow(
unused_variables,
dead_code,
missing_docs,
clippy::wildcard_imports,
clippy::let_unit_value,
)]
use tonic::codegen::*;
/// Generated trait containing gRPC methods that should be implemented for use with Search2Server.
#[async_trait]
pub trait Search2: std::marker::Send + std::marker::Sync + 'static {
async fn search_table(
&self,
request: tonic::Request<super::Search2Request>,
) -> std::result::Result<tonic::Response<super::Search2Response>, tonic::Status>;
}
#[derive(Debug)]
pub struct Search2Server<T> {
inner: Arc<T>,
accept_compression_encodings: EnabledCompressionEncodings,
send_compression_encodings: EnabledCompressionEncodings,
max_decoding_message_size: Option<usize>,
max_encoding_message_size: Option<usize>,
}
impl<T> Search2Server<T> {
pub fn new(inner: T) -> Self {
Self::from_arc(Arc::new(inner))
}
pub fn from_arc(inner: Arc<T>) -> Self {
Self {
inner,
accept_compression_encodings: Default::default(),
send_compression_encodings: Default::default(),
max_decoding_message_size: None,
max_encoding_message_size: None,
}
}
pub fn with_interceptor<F>(
inner: T,
interceptor: F,
) -> InterceptedService<Self, F>
where
F: tonic::service::Interceptor,
{
InterceptedService::new(Self::new(inner), interceptor)
}
/// Enable decompressing requests with the given encoding.
#[must_use]
pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.accept_compression_encodings.enable(encoding);
self
}
/// Compress responses with the given encoding, if the client supports it.
#[must_use]
pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.send_compression_encodings.enable(encoding);
self
}
/// Limits the maximum size of a decoded message.
///
/// Default: `4MB`
#[must_use]
pub fn max_decoding_message_size(mut self, limit: usize) -> Self {
self.max_decoding_message_size = Some(limit);
self
}
/// Limits the maximum size of an encoded message.
///
/// Default: `usize::MAX`
#[must_use]
pub fn max_encoding_message_size(mut self, limit: usize) -> Self {
self.max_encoding_message_size = Some(limit);
self
}
}
impl<T, B> tonic::codegen::Service<http::Request<B>> for Search2Server<T>
where
T: Search2,
B: Body + std::marker::Send + 'static,
B::Error: Into<StdError> + std::marker::Send + 'static,
{
type Response = http::Response<tonic::body::Body>;
type Error = std::convert::Infallible;
type Future = BoxFuture<Self::Response, Self::Error>;
fn poll_ready(
&mut self,
_cx: &mut Context<'_>,
) -> Poll<std::result::Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, req: http::Request<B>) -> Self::Future {
match req.uri().path() {
"/komp_ac.search2.Search2/SearchTable" => {
#[allow(non_camel_case_types)]
struct SearchTableSvc<T: Search2>(pub Arc<T>);
impl<T: Search2> tonic::server::UnaryService<super::Search2Request>
for SearchTableSvc<T> {
type Response = super::Search2Response;
type Future = BoxFuture<
tonic::Response<Self::Response>,
tonic::Status,
>;
fn call(
&mut self,
request: tonic::Request<super::Search2Request>,
) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut = async move {
<T as Search2>::search_table(&inner, request).await
};
Box::pin(fut)
}
}
let accept_compression_encodings = self.accept_compression_encodings;
let send_compression_encodings = self.send_compression_encodings;
let max_decoding_message_size = self.max_decoding_message_size;
let max_encoding_message_size = self.max_encoding_message_size;
let inner = self.inner.clone();
let fut = async move {
let method = SearchTableSvc(inner);
let codec = tonic::codec::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec)
.apply_compression_config(
accept_compression_encodings,
send_compression_encodings,
)
.apply_max_message_size_config(
max_decoding_message_size,
max_encoding_message_size,
);
let res = grpc.unary(method, req).await;
Ok(res)
};
Box::pin(fut)
}
_ => {
Box::pin(async move {
let mut response = http::Response::new(
tonic::body::Body::default(),
);
let headers = response.headers_mut();
headers
.insert(
tonic::Status::GRPC_STATUS,
(tonic::Code::Unimplemented as i32).into(),
);
headers
.insert(
http::header::CONTENT_TYPE,
tonic::metadata::GRPC_CONTENT_TYPE,
);
Ok(response)
})
}
}
}
}
impl<T> Clone for Search2Server<T> {
fn clone(&self) -> Self {
let inner = self.inner.clone();
Self {
inner,
accept_compression_encodings: self.accept_compression_encodings,
send_compression_encodings: self.send_compression_encodings,
max_decoding_message_size: self.max_decoding_message_size,
max_encoding_message_size: self.max_encoding_message_size,
}
}
}
/// Generated gRPC service name
pub const SERVICE_NAME: &str = "komp_ac.search2.Search2";
impl<T> tonic::server::NamedService for Search2Server<T> {
const NAME: &'static str = SERVICE_NAME;
}
}

View File

@@ -11,6 +11,11 @@ pub async fn register(
pool: &PgPool,
payload: RegisterRequest,
) -> Result<Response<AuthResponse>, Status> {
// Validate required fields
if payload.email.trim().is_empty() {
return Err(Status::invalid_argument("Email is required"));
}
// Validate passwords match
if payload.password != payload.password_confirmation {
return Err(Status::invalid_argument(AuthError::PasswordMismatch.to_string()));
@@ -41,6 +46,15 @@ pub async fn register(
if db_err.constraint() == Some("valid_roles") {
return Status::invalid_argument(format!("Invalid role specified: '{}'", role_to_insert));
}
// Check for specific constraint violations
if let Some(constraint) = db_err.constraint() {
if constraint.contains("users_username_key") {
return Status::already_exists("Username already exists".to_string());
}
if constraint.contains("users_email_key") {
return Status::already_exists("Email already exists".to_string());
}
}
}
if e.to_string().contains("duplicate key") {
Status::already_exists(AuthError::UserExists.to_string())

View File

@@ -10,14 +10,16 @@ use crate::server::services::{
TableDefinitionService,
TablesDataService,
TableScriptService,
AuthServiceImpl
AuthServiceImpl,
Search2Service,
};
use common::proto::komp_ac::{
table_structure::table_structure_service_server::TableStructureServiceServer,
table_definition::table_definition_server::TableDefinitionServer,
tables_data::tables_data_server::TablesDataServer,
table_script::table_script_server::TableScriptServer,
auth::auth_service_server::AuthServiceServer
auth::auth_service_server::AuthServiceServer,
search2::search2_server::Search2Server,
};
use search::{SearcherService, SearcherServer};
@@ -47,9 +49,8 @@ pub async fn run_server(db_pool: sqlx::PgPool) -> Result<(), Box<dyn std::error:
};
let table_script_service = TableScriptService { db_pool: db_pool.clone() };
let auth_service = AuthServiceImpl { db_pool: db_pool.clone() };
// MODIFIED: Instantiate SearcherService with the database pool
let search_service = SearcherService { pool: db_pool.clone() };
let search2_service = Search2Service { db_pool: db_pool.clone() };
Server::builder()
.add_service(TableStructureServiceServer::new(TableStructureHandler { db_pool: db_pool.clone() }))
@@ -58,6 +59,7 @@ pub async fn run_server(db_pool: sqlx::PgPool) -> Result<(), Box<dyn std::error:
.add_service(TableScriptServer::new(table_script_service))
.add_service(AuthServiceServer::new(auth_service))
.add_service(SearcherServer::new(search_service))
.add_service(Search2Server::new(search2_service))
.add_service(reflection_service)
.serve(addr)
.await?;

View File

@@ -5,9 +5,11 @@ pub mod table_definition_service;
pub mod tables_data_service;
pub mod table_script_service;
pub mod auth_service;
pub mod search2_service;
pub use table_structure_service::TableStructureHandler;
pub use table_definition_service::TableDefinitionService;
pub use tables_data_service::TablesDataService;
pub use table_script_service::TableScriptService;
pub use auth_service::AuthServiceImpl;
pub use search2_service::*;

View File

@@ -0,0 +1,202 @@
// src/server/services/search2_service.rs
use tonic::{Request, Response, Status};
use sqlx::PgPool;
use sqlx::Row;
use common::proto::komp_ac::search2::{
search2_server::Search2,
Search2Request, Search2Response, ColumnFilter, FilterType,
search2_response::Hit,
};
use crate::shared::schema_qualifier::qualify_table_name_for_data;
pub struct Search2Service {
pub db_pool: PgPool,
}
#[tonic::async_trait]
impl Search2 for Search2Service {
async fn search_table(
&self,
request: Request<Search2Request>,
) -> Result<Response<Search2Response>, Status> {
let req = request.into_inner();
// Build SQL query - NOW PASS DB_POOL AND AWAIT
let (sql, bind_values, count_sql) = build_search_query(&self.db_pool, &req).await
.map_err(|e| Status::invalid_argument(format!("Query build error: {}", e)))?;
// Execute main query
let rows = execute_query(&self.db_pool, &sql, &bind_values).await
.map_err(|e| Status::internal(format!("Database error: {}", e)))?;
// Execute count query for pagination
let total_count = execute_count_query(&self.db_pool, &count_sql, &bind_values).await
.map_err(|e| Status::internal(format!("Count query error: {}", e)))?;
// Convert to response format
let hits = rows.into_iter().map(|row| {
Hit {
id: row.id,
content_json: row.content_json,
match_info: Some(format!("SQL search on table: {}", req.table_name)),
}
}).collect();
Ok(Response::new(Search2Response {
hits,
total_count,
}))
}
}
#[derive(Debug)]
struct QueryRow {
id: i64,
content_json: String,
}
// MAKE THIS FUNCTION ASYNC AND ADD DB_POOL PARAMETER
async fn build_search_query(
db_pool: &PgPool,
request: &Search2Request
) -> Result<(String, Vec<String>, String), String> {
// Since Search2Request doesn't have profile_name, use "default"
// You can add profile_name to the proto later if needed
let profile_name = "default";
// NOW AWAIT THE ASYNC FUNCTION CALL
let qualified_table = qualify_table_name_for_data(db_pool, profile_name, &request.table_name)
.await
.map_err(|e| format!("Invalid table name: {}", e))?;
let mut conditions = vec!["deleted = FALSE".to_string()];
let mut bind_values = Vec::new();
// Build WHERE conditions from column filters
for filter in &request.column_filters {
let condition = build_filter_condition(filter, &mut bind_values)?;
conditions.push(condition);
}
// Add text search fallback if provided
if let Some(text_query) = &request.text_query {
if !text_query.trim().is_empty() {
bind_values.push(format!("%{}%", text_query.to_lowercase()));
conditions.push(format!(
"EXISTS (SELECT 1 FROM jsonb_each_text(to_jsonb(t)) as kv(key, value) WHERE LOWER(value) LIKE ${})",
bind_values.len()
));
}
}
let where_clause = conditions.join(" AND ");
// Build ORDER BY clause
let order_clause = if let Some(order_by) = &request.order_by {
let direction = if request.order_desc.unwrap_or(false) { "DESC" } else { "ASC" };
format!("ORDER BY \"{}\" {}", order_by, direction)
} else {
"ORDER BY id DESC".to_string()
};
let limit_clause = format!("LIMIT {}", request.limit.unwrap_or(100));
// Main query
let sql = format!(
"SELECT id, to_jsonb(t) AS data FROM {} t WHERE {} {} {}",
qualified_table, where_clause, order_clause, limit_clause
);
// Count query (for pagination)
let count_sql = format!(
"SELECT COUNT(*) FROM {} t WHERE {}",
qualified_table, where_clause
);
Ok((sql, bind_values, count_sql))
}
fn build_filter_condition(filter: &ColumnFilter, bind_values: &mut Vec<String>) -> Result<String, String> {
// FIX DEPRECATED WARNING - USE TryFrom INSTEAD
let filter_type = FilterType::try_from(filter.filter_type)
.map_err(|_| "Invalid filter type".to_string())?;
let param_idx = bind_values.len() + 1;
let condition = match filter_type {
FilterType::Equals => {
bind_values.push(filter.value.clone());
format!("\"{}\" = ${}", filter.column_name, param_idx)
},
FilterType::Contains => {
bind_values.push(format!("%{}%", filter.value));
format!("\"{}\" ILIKE ${}", filter.column_name, param_idx)
},
FilterType::StartsWith => {
bind_values.push(format!("{}%", filter.value));
format!("\"{}\" ILIKE ${}", filter.column_name, param_idx)
},
FilterType::EndsWith => {
bind_values.push(format!("%{}", filter.value));
format!("\"{}\" ILIKE ${}", filter.column_name, param_idx)
},
FilterType::Range => {
bind_values.push(filter.value.clone());
bind_values.push(filter.value2.as_ref().unwrap_or(&"".to_string()).clone());
format!("\"{}\" BETWEEN ${} AND ${}", filter.column_name, param_idx, param_idx + 1)
},
FilterType::GreaterThan => {
bind_values.push(filter.value.clone());
format!("\"{}\" > ${}", filter.column_name, param_idx)
},
FilterType::LessThan => {
bind_values.push(filter.value.clone());
format!("\"{}\" < ${}", filter.column_name, param_idx)
},
FilterType::IsNull => {
format!("\"{}\" IS NULL", filter.column_name)
},
FilterType::IsNotNull => {
format!("\"{}\" IS NOT NULL", filter.column_name)
},
};
Ok(condition)
}
async fn execute_query(pool: &PgPool, sql: &str, bind_values: &[String]) -> Result<Vec<QueryRow>, sqlx::Error> {
let mut query = sqlx::query(sql);
// Bind all parameters
for value in bind_values {
query = query.bind(value);
}
let rows = query.fetch_all(pool).await?;
let results = rows.into_iter().map(|row| {
QueryRow {
id: row.try_get("id").unwrap_or(0),
content_json: row.try_get::<serde_json::Value, _>("data")
.unwrap_or(serde_json::Value::Null)
.to_string(),
}
}).collect();
Ok(results)
}
async fn execute_count_query(pool: &PgPool, sql: &str, bind_values: &[String]) -> Result<i32, sqlx::Error> {
let mut query = sqlx::query(sql);
// Bind all parameters
for value in bind_values {
query = query.bind(value);
}
let row = query.fetch_one(pool).await?;
let count: i64 = row.try_get(0).unwrap_or(0);
Ok(count as i32)
}